Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/actions/Model/formatSearchFilter.js
Original file line number Diff line number Diff line change
@@ -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
};
};
1 change: 1 addition & 0 deletions backend/actions/Model/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions backend/authorize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
70 changes: 70 additions & 0 deletions backend/helpers/formatFilterForMongoShell.js
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down Expand Up @@ -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);
},
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/models/models.html
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@
@search="search"
>
</document-search>
<button
type="button"
@click="openMongoShellFilterModal()"
:disabled="searchText.trim().length === 0"
title="View MongoDB shell syntax"
aria-label="View MongoDB shell syntax"
class="shrink-0 rounded-md p-1.5"
:class="searchText.trim().length > 0 ? 'text-content-secondary hover:text-content hover:bg-muted' : 'text-content-tertiary cursor-not-allowed opacity-50'"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1M8 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M8 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m0 0h2a2 2 0 0 1 2 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
</button>
<div>
<span v-if="numDocuments == null">Loading ...</span>
<span v-else-if="typeof numDocuments === 'number'">{{documents.length}}/{{numDocuments === 1 ? numDocuments + ' document' : numDocuments + ' documents'}}</span>
Expand Down Expand Up @@ -517,6 +530,42 @@
</div>
</div>
</div>
<modal v-if="shouldShowMongoShellFilterModal">
<template v-slot:body>
<div class="modal-exit" @click="closeMongoShellFilterModal">&times;</div>
<div class="text-xl font-bold mb-2">MongoDB Shell Syntax</div>
<div class="space-y-4">
<div>
<div class="text-sm font-semibold text-content-secondary mb-2">Original filter</div>
<pre class="rounded bg-muted p-3 text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ searchText }}</pre>
</div>
<div>
<div class="text-sm font-semibold text-content-secondary mb-2">Shell command</div>
<div v-if="mongoShellFilterLoading" class="text-content-secondary">Formatting filter...</div>
<div v-else-if="mongoShellFilterError" class="text-red-600">{{ mongoShellFilterError }}</div>
<pre v-else class="rounded bg-muted p-3 text-sm font-mono overflow-x-auto whitespace-pre-wrap">{{ mongoShellFilterCommand }}</pre>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
@click="copyOriginalFilterQuery()"
class="rounded bg-surface px-3 py-2 text-sm font-semibold text-content-secondary shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-page focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Copy original filter
</button>
<button
type="button"
@click="copyMongoShellFilterCommand()"
:disabled="mongoShellFilterLoading || !!mongoShellFilterError || !mongoShellFilterCommand"
class="rounded px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="mongoShellFilterLoading || mongoShellFilterError || !mongoShellFilterCommand ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-primary text-primary-text hover:bg-primary-hover'"
>
Copy shell command
</button>
</div>
</template>
</modal>
<modal v-if="shouldShowExportModal">
<template v-slot:body>
<div class="modal-exit" @click="shouldShowExportModal = false">&times;</div>
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/models/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ module.exports = app => app.component('models', {
dropCollectionConfirmName: '',
shouldShowUpdateMultipleModal: false,
shouldShowDeleteMultipleModal: false,
shouldShowMongoShellFilterModal: false,
mongoShellFilterCommand: '',
mongoShellFilterError: null,
mongoShellFilterLoading: false,
shouldExport: {},
sortBy: {},
query: {},
Expand Down Expand Up @@ -1534,6 +1538,46 @@ module.exports = app => app.component('models', {
this.fallbackCopyText(text);
}
},
async openMongoShellFilterModal() {
if (typeof this.searchText !== 'string' || this.searchText.trim().length === 0) {
return;
}

this.shouldShowMongoShellFilterModal = true;
this.mongoShellFilterCommand = '';
this.mongoShellFilterError = null;
this.mongoShellFilterLoading = true;

try {
const { command } = await api.Model.formatSearchFilter({
model: this.currentModel,
searchText: this.searchText
});
this.mongoShellFilterCommand = command;
} catch (err) {
this.mongoShellFilterError = err.message || 'Failed to format filter';
} finally {
this.mongoShellFilterLoading = false;
}
},
closeMongoShellFilterModal() {
this.shouldShowMongoShellFilterModal = false;
this.mongoShellFilterCommand = '';
this.mongoShellFilterError = null;
this.mongoShellFilterLoading = false;
},
copyMongoShellFilterCommand() {
if (!this.mongoShellFilterCommand) {
return;
}
this.copyCellValue(this.mongoShellFilterCommand);
},
copyOriginalFilterQuery() {
if (typeof this.searchText !== 'string' || this.searchText.trim().length === 0) {
return;
}
this.copyCellValue(this.searchText);
},
fallbackCopyText(text) {
try {
const el = document.createElement('textarea');
Expand Down
Loading