UNPKG

@mongodb-js/compass-query-bar

Version:

Renders a component for executing MongoDB queries through a GUI.

671 lines (609 loc) 21 kB
import Reflux from 'reflux'; import StateMixin from 'reflux-state-mixin'; import queryParser from 'mongodb-query-parser'; import assert from 'assert'; import diff from 'object-diff'; import { identity, get, has, pull, pick, keys, isBoolean, isFunction, isUndefined, isNull, isEqual, isArray, isPlainObject, includes, every, values, without, mapKeys, mapValues, assign, clone, contains, omit } from 'lodash'; import QueryBarActions from 'actions'; import { bsonEqual, hasDistinctValue } from 'mongodb-query-util'; import QUERY_PROPERTIES from 'constants/query-properties'; import { USER_TYPING_DEBOUNCE_MS, APPLY_STATE, DEFAULT_FILTER, DEFAULT_PROJECT, DEFAULT_SORT, DEFAULT_SKIP, DEFAULT_LIMIT, DEFAULT_SAMPLE, DEFAULT_MAX_TIME_MS, DEFAULT_SAMPLE_SIZE, DEFAULT_STATE } from 'constants/query-bar-store'; const debug = require('debug')('mongodb-compass:stores:query-bar'); /** * Query Bar store. */ const QueryBarStore = Reflux.createStore({ mixins: [StateMixin.store], listenables: QueryBarActions, onActivated(appRegistry) { this.QueryHistoryActions = appRegistry.getAction('QueryHistory.Actions'); if (isFunction(this.QueryHistoryActions)) { this.QueryHistoryActions.runQuery.listen(this.autoPopulateQuery.bind(this)); } appRegistry.on('collection-changed', this.onCollectionChanged.bind(this)); appRegistry.on('database-changed', this.onDatabaseChanged.bind(this)); }, /* * listen to Namespace store and reset if ns changes. */ onCollectionChanged(ns) { const newState = this.getInitialState(); newState.ns = ns; this.setState(newState); }, /* * listen to Namespace store and reset if ns changes. */ onDatabaseChanged(ns) { const newState = this.getInitialState(); newState.ns = ns; this.setState(newState); }, /** * Initialize the query store. * * @return {Object} the initial store state. */ getInitialState() { return { // user-facing query properties filter: DEFAULT_FILTER, project: DEFAULT_PROJECT, sort: DEFAULT_SORT, skip: DEFAULT_SKIP, limit: DEFAULT_LIMIT, sample: DEFAULT_SAMPLE, // internal query properties maxTimeMS: DEFAULT_MAX_TIME_MS, // string values for the query bar input fields filterString: '', projectString: '', sortString: '', skipString: '', limitString: '', // whether Apply or Reset was clicked last queryState: DEFAULT_STATE, // either apply or reset // validation flags valid: true, filterValid: true, projectValid: true, sortValid: true, skipValid: true, limitValid: true, sampleValid: true, // last full query (contains user-facing and internal variables above) lastExecutedQuery: null, // is the user currently typing (debounced by USER_TYPING_DEBOUNCE_MS) userTyping: false, // if the value was populated from a click in the schema view or // query history view. autoPopulated: false, // is the query bar component expanded or collapsed? expanded: false, // set the namespace ns: '', // Schema fields to use for filter autocompletion schemaFields: null }; }, /** * internal method to indicate user stopped typing. */ _stoppedTyping() { this.userTypingTimer = null; this.setState({ userTyping: false }); }, /** * toggles between expanded and collapsed query options state. * * @param {Boolean} force optional flag to force the extended options * to be open (true) or closed (false). If not * specified, the options switch to their opposite * state. */ toggleQueryOptions(force) { this.setState({ expanded: isBoolean(force) ? force : !this.state.expanded }); }, /** * toggles between sampling on/off. Also can take a value to force sampling * to be on or off directly. When sampling is turned on and there is no limit * specified, set it to the DEFAULT_SAMPLE_SIZE. * * @param {Boolean} force optional flag to force the sampling to be on or * off. If not specified, the value switches to its * opposite state. */ toggleSample(force) { const newState = { sample: isBoolean(force) ? force : !this.state.sample }; if (newState.sample && this.state.limit === 0) { newState.limit = DEFAULT_SAMPLE_SIZE; newState.limitString = String(DEFAULT_SAMPLE_SIZE); newState.limitValid = true; } this.setState(newState); }, /** * like `setQueryString()` except that it also sets the userTyping state to * true and starts a debouncing timer to detect when the user stops typing. * * This is done for performance reasons so we don't re-render all the charts * constantly while the string is still being typed. * * @param {String} label Which part of the query, e.g. `filter`, `sort` * @param {String} input The query string (i.e. manual user input) */ typeQueryString(label, input) { if (this.userTypingTimer) { clearTimeout(this.userTypingTimer); } this.userTypingTimer = setTimeout( this._stoppedTyping, USER_TYPING_DEBOUNCE_MS ); this.setQueryString(label, input, true); }, /** * Sets `queryString` and `valid`, and if it is a valid input, also set `filter`, * `sort`, `project`, `skip`, `limit`. * If it is not a valid query, only set `valid` to `false`. * * @param {String} label Which part of the query, e.g. `filter`, `sort` * @param {Object} input the query string (i.e. manual user input) * @param {Boolean} userTyping (optional) whether the user is still typing */ setQueryString(label, input, userTyping) { assert(includes(QUERY_PROPERTIES, label)); const validatedInput = this._validateInput(label, input); const state = { userTyping: Boolean(userTyping) }; state[`${label}String`] = input; state[`${label}Valid`] = validatedInput !== false; // if the input was validated, also set the corresponding state variable if (validatedInput !== false) { state[label] = validatedInput; const valid = { filter: this.state.filterValid, project: this.state.projectValid, sort: this.state.sortValid, skip: this.state.skipValid, limit: this.state.limitValid }; valid[label] = validatedInput !== false; state.valid = every(values(valid)); } else { state.valid = false; } state.autoPopulated = !userTyping; this.setState(state); }, /** * Auto populate the query. * * @param {Object} query - The query. */ autoPopulateQuery(query) { this.setQuery(query, true); }, /** * set many/all properties of a query at once. The values are converted to * strings, and xxxString is set. The values are validated, and xxxValid is * set. the properties themselves are only set for valid values. * * If `query` is null or undefined, set the default options. * * @param {Object} query a query object with some or all query properties set. * @param {Boolean} autoPopulated - flag to indicate whether the query was auto-populated or not. */ setQuery(query, autoPopulated = false) { if (isUndefined(query) || isNull(query)) { query = this._getDefaultQuery(); } // convert all query inputs into their string values and validate them const stringProperties = without(QUERY_PROPERTIES, 'sample'); let inputStrings = mapValues(pick(query, stringProperties), queryParser.stringify); let inputValids = mapValues(inputStrings, (val, label) => { return this._validateInput(label, val) !== false; }); // store all keys for which the values are true const validKeys = keys(pick(inputValids, identity)); // determine if query is valid overall with these new values const valid = every( values( assign( { filter: this.state.filterValid, project: this.state.projectValid, sort: this.state.sortValid, skip: this.state.skipValid, limit: this.state.limitValid }, inputValids ) ) ); // now rename the keys appropriately to xxxxString and xxxxValid inputStrings = mapKeys(inputStrings, (val, label) => { return `${label}String`; }); inputValids = mapKeys(inputValids, (val, label) => { return `${label}Valid`; }); // merge query, query strings, valid flags into state object const state = assign({}, pick(query, validKeys), inputStrings, inputValids); // add sample state if available if (has(query, 'sample')) { this.toggleSample(query.sample); } state.autoPopulated = autoPopulated; state.valid = valid; this.setState(state); }, /** * returns a clone of the current query. * * @return {Object} clone of the query properties. */ _cloneQuery() { return mapValues(pick(this.state, QUERY_PROPERTIES), clone); }, /** * returns the default query with all the query properties. * * @return {Object} new object consisting of all default values. */ _getDefaultQuery() { return pick(this.getInitialState(), QUERY_PROPERTIES); }, /** * routes to the correct validation function. * * @param {String} label one of `filter`, `project`, `sort`, `skip`, `limit` * @param {String} input the input to validated * * @return {Boolean|String} false if not valid, otherwise the potentially * cleaned-up string input. */ _validateInput(label, input) { return queryParser.validate(label, input); }, /** * returns true if all components of the query are not false. * (note: they can return a value 0, which should not be interpreted as * false here.) * * @return {Boolean} if the full query is valid. */ _validateQuery() { return ( queryParser.isFilterValid(this.state.filterString) !== false && queryParser.isProjectValid(this.state.projectString) !== false && queryParser.isSortValid(this.state.sortString) !== false && queryParser.isSkipValid(this.state.skipString) !== false && queryParser.isLimitValid(this.state.limitString) !== false ); }, /** * Sets the value for the given field on the filter. * * @param {Object} args arguments must include `field` and `value`, and * can optionally include `unsetIfSet`: * field the field of the query to set the value on. * value the value to set. * unsetIfSet (optional) boolean, unsets the value if an identical * value is already set. This is useful for the toggle * behavior we use on minichart bars. */ setValue(args) { const filter = clone(this.state.filter); if ( args.unsetIfSet && isEqual(filter[args.field], args.value, bsonEqual) ) { delete filter[args.field]; } else { filter[args.field] = args.value; } this.setQuery({ filter: filter }); }, /** * takes either a single value or an array of values, and sets the value on * the filter correctly as equality or $in depending on the number of values. * * @param {Object} args arguments must include `field` and `value`: * field the field of the query to set the value on. * value the value(s) to set. Can be a single value or an * array of values, in which case `$in` is used. */ setDistinctValues(args) { const filter = clone(this.state.filter); if (isArray(args.value)) { if (args.value.length > 1) { filter[args.field] = { $in: args.value }; } else if (args.value.length === 1) { filter[args.field] = args.value[0]; } else { this.clearValue(args); } this.setQuery({ filter: filter }); return; } filter[args.field] = args.value; this.setQuery({ filter: filter }); }, /** * clears a field from the filter * * @param {Object} args arguments must include `field`: * field the field of the query to set the value on. */ clearValue(args) { const filter = clone(this.state.filter); delete filter[args.field]; this.setQuery({ filter: filter }); }, /** * adds a discrete value to a field on the filter, converting primitive * values to $in lists as required. * * @param {Object} args object with a `field` and `value` key. */ addDistinctValue(args) { const filter = clone(this.state.filter); const field = get(filter, args.field, undefined); // field not present in filter yet, add primitive value if (field === undefined) { filter[args.field] = args.value; this.setQuery({ filter: filter }); return; } // field is object, could be a $in clause or a primitive value if (isPlainObject(field)) { if (has(field, '$in')) { // add value to $in array if it is not present yet const inArray = filter[args.field].$in; if (!contains(inArray, args.value)) { filter[args.field].$in.push(args.value); this.setQuery({ filter: filter }); } return; } // it is not a $in operator, replace the value filter[args.field] = args.value; this.setQuery({ filter: filter }); return; } // in all other cases, we want to turn a primitive value into a $in list filter[args.field] = { $in: [field, args.value] }; this.setQuery({ filter: filter }); }, /** * removes a distinct value from a field on the filter, converting primitive * values to $in lists as required. * * @param {Object} args object with a `field` and `value` key. */ removeDistinctValue(args) { const filter = clone(this.state.filter); const field = get(filter, args.field, undefined); if (field === undefined) { return; } if (isPlainObject(field)) { if (has(field, '$in')) { // add value to $in array if it is not present yet const inArray = filter[args.field].$in; const newArray = pull(inArray, args.value); // if $in array was reduced to single value, replace with primitive if (newArray.length > 1) { filter[args.field].$in = newArray; } else if (newArray.length === 1) { filter[args.field] = newArray[0]; } else { delete filter[args.field]; } this.setQuery({ filter: filter }); return; } } // if value to remove is the same as the primitive value, unset field if (isEqual(field, args.value, bsonEqual)) { delete filter[args.field]; this.setQuery({ filter: filter }); return; } // else do nothing return; }, /** * adds distinct value (equality or $in) from filter if not yet present, * otherwise removes it. * * @param {Object} args object with a `field` and `value` key. */ toggleDistinctValue(args) { const field = get(this.state.filter, args.field, undefined); const actionFn = hasDistinctValue(field, args.value) ? this.removeDistinctValue : this.addDistinctValue; actionFn(args); }, /** * Sets a range with minimum and/or maximum, and determines inclusive/exclusive * upper and lower bounds. If neither `min` nor `max` are set, clears the field * on the filter. * * @param {Object} args arguments must include `field`, and can optionally * include `min`, `max`, `minInclusive`, `maxInclusive` * and `unsetIfSet`: * field the field of the query to set the value on. * min (optional) the minimum value (lower bound) * minInclusive (optional) boolean, true uses $gte, false uses $gt * default is true. * max (optional) the maximum value (upper bound) * maxInclusive (optional) boolean, true uses $lte, false uses $lt * default is false. * unsetIfSet (optional) boolean, unsets the value if an identical * value is already set. This is useful for the toggle * behavior we use on minichart bars. */ setRangeValues(args) { const filter = clone(this.state.filter); const value = {}; let op; // without min and max, clear the field const minValue = get(args, 'min', undefined); const maxValue = get(args, 'max', undefined); if (minValue === undefined && maxValue === undefined) { this.clearValue({ field: args.field }); return; } if (minValue !== undefined) { op = get(args, 'minInclusive', true) ? '$gte' : '$gt'; value[op] = minValue; } if (maxValue !== undefined) { op = get(args, 'maxInclusive', false) ? '$lte' : '$lt'; value[op] = maxValue; } // if `args.unsetIfSet` is true, then unset the value if it's already set if (args.unsetIfSet && isEqual(filter[args.field], value, bsonEqual)) { delete filter[args.field]; } else { filter[args.field] = value; } this.setQuery({ filter: filter }); }, /** * takes a center coordinate [lng, lat] and a radius in miles and constructs * a circular geoWithin query for the filter. * * @param {Object} args arguments must include `field` and `value`: * field the field of the query to set the value on. * center array of two numeric values: longitude and latitude * radius radius in miles of the circle * * @see https://docs.mongodb.com/manual/tutorial/calculate-distances-using-spherical-geometry-with-2d-geospatial-indexes/ */ setGeoWithinValue(args) { const filter = clone(this.state.filter); const value = {}; const radius = get(args, 'radius', 0); const center = get(args, 'center', null); if (radius && center) { value.$geoWithin = { $centerSphere: [[center[0], center[1]], radius] }; filter[args.field] = value; this.setQuery({ filter: filter }); return; } // else if center or radius are not set, or radius is 0, clear field this.clearValue({ field: args.field }); }, /** * apply the current (valid) query, and store it in `lastExecutedQuery`. */ apply() { if (this._validateQuery()) { const registry = global.hadronApp.appRegistry; if (registry) { const newState = { filter: this.state.filter, project: this.state.project, sort: this.state.sort, skip: this.state.skip, limit: this.state.limit, ns: this.state.ns }; registry.emit('query-applied', newState); } this.setState({ valid: true, queryState: APPLY_STATE, lastExecutedQuery: this._cloneQuery() }); } }, /** * dismiss current changes to the query and restore `{}` as the query. * @note The wacky logic here is because the ampersand app is not * loaded in the unit test environment and the validation tests fail since * not app registry is found. Once we get rid of the ampersand app we can * put the store set back into the init once we've sorted out the proper * test strategy. Same as collection-stats and collections-store. */ reset() { // if the current query is the same as the default, nothing happens if (isEqual(this._cloneQuery(), this._getDefaultQuery())) { return; } // if the last executed query is the default query, we don't need to // change lastExecuteQuery and trigger a change in the QueryChangedStore. if (isEqual(this.state.lastExecutedQuery, this._getDefaultQuery())) { this.setQuery(); return; } // otherwise we do need to trigger the QueryChangedStore and let all other // components in the app know about the change so they can re-render. if (this.state.valid) { let namespace = ''; if (this.NamespaceStore) { namespace = this.NamespaceStore.ns; } else if (global.hadronApp.appRegistry) { this.NamespaceStore = global.hadronApp.appRegistry.getStore('App.NamespaceStore'); namespace = this.NamespaceStore.ns; } const newState = this.getInitialState(); newState.ns = namespace; this.setState(omit(newState, 'expanded')); } }, storeDidUpdate(prevState) { debug('query store changed', diff(prevState, this.state)); } }); export default QueryBarStore; export { QueryBarStore };