diff --git a/backend/actions/Model/formatSearchFilter.js b/backend/actions/Model/formatSearchFilter.js new file mode 100644 index 0000000..bb0cf86 --- /dev/null +++ b/backend/actions/Model/formatSearchFilter.js @@ -0,0 +1,41 @@ +'use strict'; + +const Archetype = require('archetype'); +const authorize = require('../../authorize'); +const evaluateFilter = require('../../helpers/evaluateFilter'); +const formatFilterForMongoShell = require('../../helpers/formatFilterForMongoShell'); + +const FormatSearchFilterParams = new Archetype({ + model: { + $type: 'string', + $required: true + }, + searchText: { + $type: 'string', + $required: true + }, + roles: { + $type: ['string'] + } +}).compile('FormatSearchFilterParams'); + +module.exports = ({ db }) => async function formatSearchFilter(params) { + const { model, searchText, roles } = new FormatSearchFilterParams(params); + await authorize('Model.formatSearchFilter', roles); + + const Model = db.models[model]; + if (Model == null) { + throw new Error(`Model ${model} not found`); + } + + const parsedFilter = evaluateFilter(searchText); + const filter = parsedFilter == null ? {} : parsedFilter; + const filterSyntax = formatFilterForMongoShell(filter); + const collectionName = Model.collection.collectionName; + const command = `db.getCollection(${JSON.stringify(collectionName)}).find(${filterSyntax})`; + + return { + filter: filterSyntax, + command + }; +}; diff --git a/backend/actions/Model/index.js b/backend/actions/Model/index.js index c090e00..dc0355f 100644 --- a/backend/actions/Model/index.js +++ b/backend/actions/Model/index.js @@ -10,6 +10,7 @@ exports.dropCollection = require('./dropCollection'); exports.dropIndex = require('./dropIndex'); exports.executeDocumentScript = require('./executeDocumentScript'); exports.exportQueryResults = require('./exportQueryResults'); +exports.formatSearchFilter = require('./formatSearchFilter'); exports.getDocument = require('./getDocument'); exports.getDocuments = require('./getDocuments'); exports.getDocumentsStream = require('./getDocumentsStream'); diff --git a/backend/authorize.js b/backend/authorize.js index 600ca60..7a1b703 100644 --- a/backend/authorize.js +++ b/backend/authorize.js @@ -23,6 +23,7 @@ const actionsToRequiredRoles = { 'Model.dropIndex': ['owner', 'admin'], 'Model.executeDocumentScript': ['owner', 'admin', 'member'], 'Model.exportQueryResults': ['owner', 'admin', 'member', 'readonly'], + 'Model.formatSearchFilter': ['owner', 'admin', 'member', 'readonly'], 'Model.getDocument': ['owner', 'admin', 'member', 'readonly'], 'Model.getDocuments': ['owner', 'admin', 'member', 'readonly'], 'Model.getDocumentsStream': ['owner', 'admin', 'member', 'readonly'], diff --git a/backend/helpers/formatFilterForMongoShell.js b/backend/helpers/formatFilterForMongoShell.js new file mode 100644 index 0000000..b57bf3c --- /dev/null +++ b/backend/helpers/formatFilterForMongoShell.js @@ -0,0 +1,70 @@ +'use strict'; + +const mongoose = require('mongoose'); + +const ObjectId = mongoose.Types.ObjectId; + +function isPlainObject(value) { + if (value == null || typeof value !== 'object') { + return false; + } + if (Array.isArray(value) || isObjectId(value) || value instanceof Date || value instanceof RegExp) { + return false; + } + return true; +} + +function isObjectId(value) { + if (value instanceof ObjectId) { + return true; + } + return value != null && + typeof value === 'object' && + value._bsontype === 'ObjectId' && + typeof value.toString === 'function'; +} + +function formatKey(key) { + if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) { + return key; + } + return JSON.stringify(key); +} + +function formatFilterForMongoShell(value) { + if (value === null) { + return 'null'; + } + if (value === undefined) { + return 'undefined'; + } + if (typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (isObjectId(value)) { + return `ObjectId("${value.toString()}")`; + } + if (value instanceof Date) { + return `ISODate("${value.toISOString()}")`; + } + if (value instanceof RegExp) { + return value.toString(); + } + if (Array.isArray(value)) { + const items = value.map(item => formatFilterForMongoShell(item)); + return `[${items.join(', ')}]`; + } + if (isPlainObject(value)) { + const entries = Object.entries(value).map(([key, val]) => { + return `${formatKey(key)}: ${formatFilterForMongoShell(val)}`; + }); + return `{ ${entries.join(', ')} }`; + } + + throw new Error(`Unsupported filter value type: ${value?.constructor?.name || typeof value}`); +} + +module.exports = formatFilterForMongoShell; diff --git a/frontend/src/api.js b/frontend/src/api.js index b0e8068..ae85300 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -217,6 +217,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { window.URL.revokeObjectURL(blobURL); }); }, + formatSearchFilter(params) { + return client.post('', { action: 'Model.formatSearchFilter', ...params }).then(res => res.data); + }, getDocument: function getDocument(params) { return client.post('', { action: 'Model.getDocument', ...params }).then(res => res.data); }, @@ -409,6 +412,9 @@ if (window.MONGOOSE_STUDIO_CONFIG.isLambda) { window.URL.revokeObjectURL(blobURL); }); }, + formatSearchFilter(params) { + return client.post('/Model/formatSearchFilter', params).then(res => res.data); + }, getDocument: function getDocument(params) { return client.post('/Model/getDocument', params).then(res => res.data); }, diff --git a/frontend/src/models/models.html b/frontend/src/models/models.html index 34b1120..ec96293 100644 --- a/frontend/src/models/models.html +++ b/frontend/src/models/models.html @@ -109,6 +109,19 @@ @search="search" > +
{{ searchText }}
+ {{ mongoShellFilterCommand }}
+