diff --git a/app/client/aclui/ACLFormulaEditor.ts b/app/client/aclui/ACLFormulaEditor.ts index 02e54c8a..9e504b89 100644 --- a/app/client/aclui/ACLFormulaEditor.ts +++ b/app/client/aclui/ACLFormulaEditor.ts @@ -41,7 +41,7 @@ export function aclFormulaEditor(options: ACLFormulaOptions) { ); editor.on("change", () => showPlaceholder.set(!editor.getValue().length)); - async function getSuggestions(prefix: string) { + async function getSuggestions(prefix: string): Promise> { return [ // The few Python keywords and constants we support. 'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None', @@ -51,7 +51,7 @@ export function aclFormulaEditor(options: ACLFormulaOptions) { 'user', 'rec', 'newRec', // Other completions that depend on doc schema or other rules. ...options.getSuggestions(prefix), - ]; + ].map(suggestion => [suggestion, null]); // null means no example value } setupAceEditorCompletions(editor, {getSuggestions}); diff --git a/app/client/components/AceEditor.css b/app/client/components/AceEditor.css index 58132d99..5d26f55b 100644 --- a/app/client/components/AceEditor.css +++ b/app/client/components/AceEditor.css @@ -8,6 +8,10 @@ cursor: pointer; } +.ace_grist_example { + color: #8f8f8f; +} + .ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link { color: var(--grist-color-dark-green); } @@ -16,3 +20,8 @@ z-index: 7; pointer-events: auto; } + +.ace_editor.ace_autocomplete { + width: 500px !important; /* the default in language_tools.js is 280px */ + max-width: 80%; /* of the screen, for hypothetical mobile support */ +} diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js index 3f1fa561..0fe8c980 100644 --- a/app/client/components/AceEditor.js +++ b/app/client/components/AceEditor.js @@ -189,9 +189,11 @@ AceEditor.prototype._setup = function() { this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom)); if (this.gristDoc && this.column) { const getSuggestions = (prefix) => { - const tableId = this.gristDoc.viewModel.activeSection().table().tableId(); + const section = this.gristDoc.viewModel.activeSection(); + const tableId = section.table().tableId(); const columnId = this.column.colId(); - return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId); + const rowId = section.activeRowId(); + return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId, rowId); }; setupAceEditorCompletions(this.editor, {getSuggestions}); } diff --git a/app/client/components/AceEditorCompletions.ts b/app/client/components/AceEditorCompletions.ts index 1cef17e5..16f49d8e 100644 --- a/app/client/components/AceEditorCompletions.ts +++ b/app/client/components/AceEditorCompletions.ts @@ -1,14 +1,8 @@ +import {ISuggestionWithValue} from 'app/common/ActiveDocAPI'; import * as ace from 'brace'; -// Suggestion may be a string, or a tuple [funcname, argSpec, isGrist], where: -// - funcname (e.g. "DATEADD") will be auto-completed with "(", AND linked to Grist -// documentation. -// - argSpec (e.g. "(start_date, days=0, ...)") is to be shown as autocomplete caption. -// - isGrist determines whether to tag this suggestion as "grist" or "python". -export type ISuggestion = string | [string, string, boolean]; - export interface ICompletionOptions { - getSuggestions(prefix: string): Promise; + getSuggestions(prefix: string): Promise; } const completionOptions = new WeakMap(); @@ -27,18 +21,29 @@ export function setupAceEditorCompletions(editor: ace.Editor, options: ICompleti completer.autoSelect = false; (editor as any).completer = completer; - // Patch updateCompletions and insertMatch so that fresh completions are fetched when the user types '.' or '(' + // Used in the patches below. Returns true if the client should fetch fresh completions from the server, + // as it may have new suggestions that aren't currently shown. + completer._gristShouldRefreshCompletions = function(this: any, start: any) { + // These two lines are based on updateCompletions() in the ace autocomplete source code. + const end = this.editor.getCursorPosition(); + const prefix: string = this.editor.session.getTextRange({start, end}).toLowerCase(); + + return ( + prefix.endsWith(".") || // to get fresh attributes of references + prefix.endsWith(".lookupone(") || // to get initial argument suggestions + prefix.endsWith(".lookuprecords(") + ); + }.bind(completer); + + // Patch updateCompletions and insertMatch so that fresh completions are fetched when appropriate. const originalUpdate = completer.updateCompletions.bind(completer); completer.updateCompletions = function(this: any, keepPopupPosition: boolean) { - // The next three lines are copied from updateCompletions() in the ace autocomplete source code. + // This next line is copied from updateCompletions() in the ace autocomplete source code. if (keepPopupPosition && this.base && this.completions) { - const pos = this.editor.getCursorPosition(); - const prefix = this.editor.session.getTextRange({start: this.base, end: pos}); - // If the cursor is just after '.' or '(', prevent this same block from running + // When we need fresh completions, prevent this same block from running // in the original updateCompletions() function. Otherwise it will just keep any remaining completions that match, // or not show any completions at all. - // But the last character implies that the set of completions is likely to have changed. - if (prefix.endsWith(".") || prefix.endsWith("(")) { + if (this._gristShouldRefreshCompletions(this.base)) { this.completions = null; } } @@ -50,12 +55,7 @@ export function setupAceEditorCompletions(editor: ace.Editor, options: ICompleti completer.insertMatch = function(this: any) { const base = this.base; // this.base may become null after the next line, save it now. const result = originalInsertMatch.apply(...arguments); - // Like in the above patch, get the current text in the editor to be completed. - const pos = this.editor.getCursorPosition(); - const prefix = this.editor.session.getTextRange({start: base, end: pos}); - // This patch is specifically for when a previous completion is inserted by pressing Enter/Tab, - // and such completions may end in '(', which can lead to more completions, e.g. for `.lookupRecords(`. - if (prefix.endsWith("(")) { + if (this._gristShouldRefreshCompletions(base)) { this.showPopup(this.editor); } return result; @@ -100,19 +100,73 @@ function initCustomCompleter() { const suggestions = await options.getSuggestions(prefix); // ACE autocompletions are very poorly documented. This is somewhat helpful: // https://prog.world/implementing-code-completion-in-ace-editor/ - callback(null, suggestions.map(suggestion => { + const completions: AceSuggestion[] = suggestions.map(suggestionWithValue => { + const [suggestion, example] = suggestionWithValue; if (Array.isArray(suggestion)) { - const [funcname, argSpec, isGrist] = suggestion; - const meta = isGrist ? 'grist' : 'python'; - return {value: funcname + '(', caption: funcname + argSpec, score: 1, meta, funcname}; + const [funcname, argSpec] = suggestion; + return { + value: funcname + '(', + caption: funcname + argSpec, + score: 1, + example, + funcname, + }; } else { - return {value: suggestion, score: 1, meta: "python"}; + return { + value: suggestion, + caption: suggestion, + score: 1, + example, + funcname: '', + }; } - })); + }); + + // For suggestions with example values, calculate the 'shared padding', i.e. + // the minimum width in characters that all suggestions should fill + // (before adding 'base padding') so that the examples are aligned. + const captionLengths = completions.filter(c => c.example).map(c => c.caption.length); + const sharedPadding = Math.min( + Math.min(...captionLengths) + MAX_RELATIVE_SHARED_PADDING, + Math.max(...captionLengths), + MAX_ABSOLUTE_SHARED_PADDING, + ); + + // Add the padding spaces and example values to the captions. + for (const c of completions) { + if (!c.example) { continue; } + const numSpaces = Math.max(0, sharedPadding - c.caption.length) + BASE_PADDING; + c.caption = c.caption + ' '.repeat(numSpaces) + c.example; + } + + callback(null, completions); }, }); } +// Regardless of other suggestions, always add this many spaces between the caption and the example. +const BASE_PADDING = 8; +// In addition to the base padding, there's shared padding, which is the minimum number of spaces +// that all suggestions should fill so that the examples are aligned. +// However, one extremely long suggestion shouldn't result in huge padding for all suggestions. +// To mitigate this, there are two limits on the shared padding. +// The first limit is relative to the shortest caption in the suggestions. +// So if all the suggestions are similarly long, there will still be some shared padding. +const MAX_RELATIVE_SHARED_PADDING = 15; +// The second limit is absolute, so that even if all suggestions are long, we don't run out of popup space. +const MAX_ABSOLUTE_SHARED_PADDING = 40; + +// Suggestion objects that are passed to ace. +interface AceSuggestion { + value: string; // the actual value inserted by the autocomplete + caption: string; // the value displayed in the popup + score: number; + + // Custom attributes used only by us + example: string | null; // example value of the suggestion to show on the right + funcname: string; // name of a function to link to in documentation +} + /** * When autocompleting a known function (with funcname received from the server call), turn the * function name into a link to Grist documentation. @@ -159,8 +213,8 @@ interface TokenInfo extends ace.TokenInfo { type: string; } -function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo[] { - if (!rowData.funcname) { +function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]): TokenInfo[] { + if (!(rowData.funcname || rowData.example)) { // Not a special completion, pass through the result of ACE's original tokenizing. return tokens; } @@ -186,23 +240,50 @@ function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo rowData.funcname.slice(linkStart, linkEnd).toLowerCase(); newTokens.push({value: href, type: 'grist_link_hidden'}); + // Find where the example value (if any) starts, so that it can be shown in grey. + let exampleStart: number | undefined; + if (rowData.example) { + if (!rowData.caption.endsWith(rowData.example)) { + // Just being cautious, this shouldn't happen. + console.warn(`Example "${rowData.example}" does not match caption "${rowData.caption}"`); + } else { + exampleStart = rowData.caption.length - rowData.example.length; + } + } + // Go through tokens, splitting them if needed, and modifying those that form the link part. let position = 0; for (const t of tokens) { - // lStart/lEnd are indices of the link within the token, possibly negative. - const lStart = linkStart - position, lEnd = linkEnd - position; - if (lStart > 0) { - const beforeLink = t.value.slice(0, lStart); - newTokens.push({value: beforeLink, type: t.type}); - } - if (lEnd > 0) { - const inLink = t.value.slice(Math.max(0, lStart), lEnd); - const newType = t.type + (t.type ? '.' : '') + 'grist_link'; - newTokens.push({value: inLink, type: newType}); - } - if (lEnd < t.value.length) { - const afterLink = t.value.slice(lEnd); - newTokens.push({value: afterLink, type: t.type}); + if (exampleStart && position + t.value.length > exampleStart) { + // Ensure that all text after `exampleStart` has the type 'grist_example'. + // Don't combine that type with the existing type, because ace highlights weirdly sometimes + // and it's best to just override that. + const end = exampleStart - position; + if (end > 0) { + newTokens.push({value: t.value.slice(0, end), type: t.type}); + newTokens.push({value: t.value.slice(end), type: 'grist_example'}); + } else { + newTokens.push({value: t.value, type: 'grist_example'}); + } + } else { + // Handle links to documentation. + // lStart/lEnd are indices of the link within the token, possibly negative. + const lStart = linkStart - position, lEnd = linkEnd - position; + if (lStart > 0) { + const beforeLink = t.value.slice(0, lStart); + newTokens.push({value: beforeLink, type: t.type}); + } + if (lEnd > 0) { + const inLink = t.value.slice(Math.max(0, lStart), lEnd); + const newType = t.type + (t.type ? '.' : '') + 'grist_link'; + newTokens.push({value: inLink, type: newType}); + if (lEnd < t.value.length) { + const afterLink = t.value.slice(lEnd); + newTokens.push({value: afterLink, type: t.type}); + } + } else { + newTokens.push(t); + } } position += t.value.length; } diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index 2f1f955b..87a86194 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -1,6 +1,7 @@ import {ActionGroup} from 'app/common/ActionGroup'; import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions'; import {FormulaProperties} from 'app/common/GranularAccessClause'; +import {UIRowId} from 'app/common/UIRowId'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI'; import {ParseOptions} from 'app/plugin/FileParserAPI'; @@ -178,6 +179,18 @@ export function summaryGroupByDescription(groupByColumnLabels: string[]): string return `[${groupByColumnLabels.length ? 'by ' + groupByColumnLabels.join(", ") : "Totals"}]`; } +//// Types for autocomplete suggestions + +// Suggestion may be a string, or a tuple [funcname, argSpec, isGrist], where: +// - funcname (e.g. "DATEADD") will be auto-completed with "(", AND linked to Grist +// documentation. +// - argSpec (e.g. "(start_date, days=0, ...)") is to be shown as autocomplete caption. +// - isGrist is no longer used +type ISuggestion = string | [string, string, boolean]; + +// Suggestion paired with an optional example value to show on the right +export type ISuggestionWithValue = [ISuggestion, string | null]; + export interface ActiveDocAPI { /** * Closes a document, and unsubscribes from its userAction events. @@ -269,7 +282,7 @@ export interface ActiveDocAPI { * Find and return a list of auto-complete suggestions that start with `txt`, when editing a * formula in table `tableId` and column `columnId`. */ - autocomplete(txt: string, tableId: string, columnId: string): Promise; + autocomplete(txt: string, tableId: string, columnId: string, rowId: UIRowId): Promise; /** * Removes the current instance from the doc. diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 8388fbc2..d56a6325 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -21,6 +21,7 @@ import { ForkResult, ImportOptions, ImportResult, + ISuggestionWithValue, MergeOptions, PermissionDataWithExtraUsers, QueryResult, @@ -64,6 +65,7 @@ import {Interval} from 'app/common/Interval'; import * as roles from 'app/common/roles'; import {schema, SCHEMA_VERSION} from 'app/common/schema'; import {MetaRowRecord, SingleCell} from 'app/common/TableData'; +import {UIRowId} from 'app/common/UIRowId'; import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI'; import {convertFromColumn} from 'app/common/ValueConverter'; @@ -1272,12 +1274,14 @@ export class ActiveDoc extends EventEmitter { docSession.linkId = 0; } - public async autocomplete(docSession: DocSession, txt: string, tableId: string, columnId: string): Promise { + public async autocomplete( + docSession: DocSession, txt: string, tableId: string, columnId: string, rowId: UIRowId + ): Promise { // Autocompletion can leak names of tables and columns. if (!await this._granularAccess.canScanData(docSession)) { return []; } await this.waitForInitialization(); const user = await this._granularAccess.getCachedUser(docSession); - return this._pyCall('autocomplete', txt, tableId, columnId, user.toJSON()); + return this._pyCall('autocomplete', txt, tableId, columnId, rowId, user.toJSON()); } public fetchURL(docSession: DocSession, url: string, options?: FetchUrlOptions): Promise { diff --git a/sandbox/grist/autocomplete_context.py b/sandbox/grist/autocomplete_context.py index cc1a1fd0..be69418a 100644 --- a/sandbox/grist/autocomplete_context.py +++ b/sandbox/grist/autocomplete_context.py @@ -7,7 +7,7 @@ lowercase searches, and adds function usage information to some results. import inspect import re from collections import namedtuple, defaultdict -from six.moves import builtins +from six.moves import builtins, reprlib import six import column @@ -145,12 +145,14 @@ def lookup_autocomplete_options(lookup_table, formula_table, reverse_only): for col_id, col in formula_table.all_columns.items(): # Note that we can't support reflist columns in the current table, # as there is no `IN()` function to do the opposite of the `CONTAINS()` function. - if isinstance(col, column.ReferenceColumn) and column.is_user_column(col_id): + if isinstance(col, column.ReferenceColumn) and column.is_visible_column(col_id): ref_cols[col._target_table].append(col_id) # Find referencing columns in the lookup table that target tables in ref_cols. results = [] for lookup_col_id, lookup_col in lookup_table.all_columns.items(): + if not column.is_visible_column(lookup_col_id): + continue if isinstance(lookup_col, column.ReferenceColumn): value_template = "${}" elif isinstance(lookup_col, column.ReferenceListColumn): @@ -162,3 +164,78 @@ def lookup_autocomplete_options(lookup_table, formula_table, reverse_only): value = value_template.format(ref_col_id) results.append("{}={})".format(lookup_col_id, value)) return results + + +def eval_suggestion(suggestion, rec, user): + """ + Evaluate a simple string of Python code, + and return a limited string representation of the result, + or None if this isn't possible. + Only supports code starting with `rec` or `user`, + followed by any number of attribute accesses, nothing else. + """ + + if not isinstance(suggestion, six.string_types): + # `suggestion` is a tuple corresponding to a function + return None + + parts = suggestion.split(".") + if parts[0] == "rec": + result = rec + elif parts[0] == "user": + result = user + if parts in (["user"], ["user", "LinkKey"]): + # `user` and `user.LinkKey` have no useful string representation. + return None + else: + # Other variables are not supported since we can't know their values. + return None + + parts = parts[1:] # attribute names, if any + for part in parts: + try: + result = getattr(result, part) + except Exception: + return None + + # Convert the value to a string and truncate the length if needed. + return repr_example(result)[:arepr.maxother] + + +class AutocompleteExampleRepr(reprlib.Repr): + """ + The default repr for dates and datetimes is long and ugly. + This class is used so that repr_example is mostly the same as repr, + but dates look the way they're formatted in Grist. + """ + @staticmethod + def repr_date(obj, _level): + # e.g. "2019-12-31" + return obj.strftime("%Y-%m-%d") + + @staticmethod + def repr_datetime(obj, _level): + # e.g. "2019-12-31 1:23pm" + return obj.strftime("%Y-%m-%d %-I:%M%p").lower() + + +arepr = AutocompleteExampleRepr() +# Set the same high value for all limits, because we just want to avoid +# sending huge strings to the client, but the truncation shouldn't be visible in the UI. +arepr.maxother = 200 +arepr.maxtuple = arepr.maxother +arepr.maxlist = arepr.maxother +arepr.maxarray = arepr.maxother +arepr.maxdict = arepr.maxother +arepr.maxset = arepr.maxother +arepr.maxfrozenset = arepr.maxother +arepr.maxdeque = arepr.maxother +arepr.maxstring = arepr.maxother +arepr.maxlong = arepr.maxother + +def repr_example(x): + try: + return arepr.repr(x) + except Exception: + # Copied from Repr.repr_instance in Python 3. + return '<%s instance at %#x>' % (x.__class__.__name__, id(x)) diff --git a/sandbox/grist/column.py b/sandbox/grist/column.py index 92d44c7d..d4432c82 100644 --- a/sandbox/grist/column.py +++ b/sandbox/grist/column.py @@ -27,18 +27,12 @@ MANUAL_SORT_DEFAULT = 2147483647.0 SPECIAL_COL_IDS = {'id', MANUAL_SORT} -def is_user_column(col_id): - """ - Returns whether the col_id is of a user column (as opposed to special columns that can't be used - for user data). - """ - return col_id not in SPECIAL_COL_IDS and not col_id.startswith('#') def is_visible_column(col_id): """ Returns whether this is an id of a column that's intended to be shown to the user. """ - return is_user_column(col_id) and not col_id.startswith('gristHelper_') + return col_id not in SPECIAL_COL_IDS and not col_id.startswith(('#', 'gristHelper_')) def is_virtual_column(col_id): """ diff --git a/sandbox/grist/engine.py b/sandbox/grist/engine.py index 41d687a6..7f4926bc 100644 --- a/sandbox/grist/engine.py +++ b/sandbox/grist/engine.py @@ -19,7 +19,7 @@ from sortedcontainers import SortedSet import acl import actions import action_obj -from autocomplete_context import AutocompleteContext, lookup_autocomplete_options +from autocomplete_context import AutocompleteContext, lookup_autocomplete_options, eval_suggestion from codebuilder import DOLLAR_REGEX import depend import docactions @@ -1408,7 +1408,7 @@ class Engine(object): if not self._in_update_loop: self._bring_mlookups_up_to_date(doc_action) - def autocomplete(self, txt, table_id, column_id, user): + def autocomplete(self, txt, table_id, column_id, row_id, user): """ Return a list of suggested completions of the python fragment supplied. """ @@ -1425,13 +1425,15 @@ class Engine(object): result = [ txt + col_id + "=" for col_id in lookup_table.all_columns - if column.is_user_column(col_id) or col_id == 'id' + if column.is_visible_column(col_id) or col_id == 'id' ] # Add specific complete lookups involving reference columns. result += [ txt + option for option in lookup_autocomplete_options(lookup_table, table, reverse_only=False) ] + # Add a dummy empty example value for each result to produce the correct shape. + result = [(r, None) for r in result] return sorted(result) # replace $ with rec. and add a dummy rec object @@ -1479,11 +1481,24 @@ class Engine(object): for option in lookup_autocomplete_options(lookup_table, table, reverse_only=True) ] + ### Add example values to all results where possible. + if row_id == "new": + row_id = table.row_ids.max() + rec = table.Record(row_id) + # Don't use the same user object as above because we don't want is_sample=True, + # which is only needed for the sake of suggesting completions. + # Here we want to show actual values. + user_obj = User(user, self.tables) + results = [ + (result, eval_suggestion(result, rec, user_obj)) + for result in results + ] + # If we changed the prefix (expanding the $ symbol) we now need to change it back. if tweaked_txt != txt: - results = [txt + result[len(tweaked_txt):] for result in results] + results = [(txt + result[len(tweaked_txt):], value) for result, value in results] # pylint:disable=unidiomatic-typecheck - results.sort(key=lambda r: r[0] if type(r) == tuple else r) + results.sort(key=lambda r: r[0][0] if type(r[0]) == tuple else r[0]) return results def _get_undo_checkpoint(self): diff --git a/sandbox/grist/main.py b/sandbox/grist/main.py index f37b8997..89b2f499 100644 --- a/sandbox/grist/main.py +++ b/sandbox/grist/main.py @@ -86,8 +86,8 @@ def run(sandbox): return eng.fetch_table_schema() @export - def autocomplete(txt, table_id, column_id, user): - return eng.autocomplete(txt, table_id, column_id, user) + def autocomplete(txt, table_id, column_id, row_id, user): + return eng.autocomplete(txt, table_id, column_id, row_id, user) @export def find_col_from_values(values, n, opt_table_id): diff --git a/sandbox/grist/table.py b/sandbox/grist/table.py index 0a9c9d53..e3ea5b09 100644 --- a/sandbox/grist/table.py +++ b/sandbox/grist/table.py @@ -255,7 +255,7 @@ class Table(object): # reference values (using .sample_record for other tables) are not yet available. props = {} for col in self.all_columns.values(): - if not (column.is_user_column(col.col_id) or col.col_id == 'id'): + if not (column.is_visible_column(col.col_id) or col.col_id == 'id'): continue # Note c=col to bind at lambda-creation time; see # https://stackoverflow.com/questions/10452770/python-lambdas-binding-to-local-values diff --git a/sandbox/grist/test_completion.py b/sandbox/grist/test_completion.py index cf22235b..682978d8 100644 --- a/sandbox/grist/test_completion.py +++ b/sandbox/grist/test_completion.py @@ -1,7 +1,11 @@ -import testsamples +import datetime + import test_engine +import testsamples +from autocomplete_context import repr_example, eval_suggestion from schema import RecalcWhen + class TestCompletion(test_engine.EngineTestCase): user = { 'Name': 'Foo', @@ -34,85 +38,89 @@ class TestCompletion(test_engine.EngineTestCase): type="Text", isFormula=False, formula="foo@getgrist.com", recalcWhen=RecalcWhen.MANUAL_UPDATES ) + self.update_record('Schools', 3, budget='123.45', yearFounded='2010', lastModified='2018-01-01') + self.update_record('Students', 1, homeAddress=11, school=1) + # Create a summary table of Students grouped by school + self.apply_user_action(["CreateViewSection", 1, 0, "record", [22], None]) def test_keyword(self): - self.assertEqual(self.engine.autocomplete("for", "Address", "city", self.user), + self.assertEqual(self.autocomplete("for", "Address", "city"), ["for", "format("]) def test_grist(self): - self.assertEqual(self.engine.autocomplete("gri", "Address", "city", self.user), + self.assertEqual(self.autocomplete("gri", "Address", "city"), ["grist"]) def test_value(self): # Should only appear if column exists and is a trigger formula. self.assertEqual( - self.engine.autocomplete("val", "Schools", "lastModified", self.user), + self.autocomplete("val", "Schools", "lastModified"), ["value"] ) self.assertEqual( - self.engine.autocomplete("val", "Students", "schoolCities", self.user), + self.autocomplete("val", "Students", "schoolCities"), [] ) self.assertEqual( - self.engine.autocomplete("val", "Students", "nonexistentColumn", self.user), + self.autocomplete("val", "Students", "nonexistentColumn"), [] ) - self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier", self.user), + self.assertEqual(self.autocomplete("valu", "Schools", "lastModifier"), ["value"]) # Should have same type as column. self.assertGreaterEqual( - set(self.engine.autocomplete("value.", "Schools", "lastModifier", self.user)), + set(self.autocomplete("value.", "Schools", "lastModifier")), {'value.startswith(', 'value.replace(', 'value.title('} ) self.assertGreaterEqual( - set(self.engine.autocomplete("value.", "Schools", "lastModified", self.user)), + set(self.autocomplete("value.", "Schools", "lastModified")), {'value.month', 'value.strftime(', 'value.replace('} ) self.assertGreaterEqual( - set(self.engine.autocomplete("value.m", "Schools", "lastModified", self.user)), + set(self.autocomplete("value.m", "Schools", "lastModified")), {'value.month', 'value.minute'} ) def test_user(self): # Should only appear if column exists and is a trigger formula. - self.assertEqual(self.engine.autocomplete("use", "Schools", "lastModified", self.user), + self.assertEqual(self.autocomplete("use", "Schools", "lastModified"), ["user"]) - self.assertEqual(self.engine.autocomplete("use", "Students", "schoolCities", self.user), + self.assertEqual(self.autocomplete("use", "Students", "schoolCities"), []) - self.assertEqual(self.engine.autocomplete("use", "Students", "nonexistentColumn", self.user), + self.assertEqual(self.autocomplete("use", "Students", "nonexistentColumn"), []) - self.assertEqual(self.engine.autocomplete("user", "Schools", "lastModifier", self.user), + self.assertEqual(self.autocomplete("user", "Schools", "lastModifier"), ["user"]) self.assertEqual( - self.engine.autocomplete("user.", "Schools", "lastModified", self.user), + self.autocomplete("user.", "Schools", "lastModified", row_id=2), [ - 'user.Access', - 'user.Email', - 'user.IsLoggedIn', - 'user.LinkKey', - 'user.Name', - 'user.Origin', - 'user.SessionID', - 'user.StudentInfo', - 'user.UserID' + ('user.Access', "'owners'"), + ('user.Email', "'foo@example.com'"), + ('user.IsLoggedIn', 'True'), + ('user.LinkKey', None), + ('user.Name', "'Foo'"), + ('user.Origin', 'None'), + ('user.SessionID', "'u1'"), + ('user.StudentInfo', 'Students[1]'), + ('user.UserID', '1'), ] ) # Should follow user attribute references and autocomplete those types. self.assertEqual( - self.engine.autocomplete("user.StudentInfo.", "Schools", "lastModified", self.user), + self.autocomplete("user.StudentInfo.", "Schools", "lastModified", row_id=2), [ - 'user.StudentInfo.birthDate', - 'user.StudentInfo.firstName', - 'user.StudentInfo.homeAddress', - 'user.StudentInfo.homeAddress.city', - 'user.StudentInfo.id', - 'user.StudentInfo.lastName', - 'user.StudentInfo.lastVisit', - 'user.StudentInfo.school', - 'user.StudentInfo.school.name', - 'user.StudentInfo.schoolCities', - 'user.StudentInfo.schoolIds', - 'user.StudentInfo.schoolName' + ('user.StudentInfo.birthDate', 'None'), + ('user.StudentInfo.firstName', "'Barack'"), + ('user.StudentInfo.homeAddress', 'Address[11]'), + ('user.StudentInfo.homeAddress.city', "'New York'"), + ('user.StudentInfo.id', '1'), + ('user.StudentInfo.lastName', "'Obama'"), + ('user.StudentInfo.lastVisit', 'None'), + ('user.StudentInfo.school', 'Schools[1]'), + ('user.StudentInfo.school.name', "'Columbia'"), + ('user.StudentInfo.schoolCities', repr(u'New York:Colombia')), + ('user.StudentInfo.schoolIds', repr(u'1:2')), + ('user.StudentInfo.schoolName', "'Columbia'"), ] ) # Should not show user attribute completions if user doesn't have attribute. @@ -127,27 +135,27 @@ class TestCompletion(test_engine.EngineTestCase): 'IsLoggedIn': True } self.assertEqual( - self.engine.autocomplete("user.", "Schools", "lastModified", user2), + self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2), [ - 'user.Access', - 'user.Email', - 'user.IsLoggedIn', - 'user.LinkKey', - 'user.Name', - 'user.Origin', - 'user.SessionID', - 'user.UserID' + ('user.Access', "'owners'"), + ('user.Email', "'baro@example.com'"), + ('user.IsLoggedIn', 'True'), + ('user.LinkKey', None), + ('user.Name', "'Bar'"), + ('user.Origin', 'None'), + ('user.SessionID', "'u2'"), + ('user.UserID', '2'), ] ) self.assertEqual( - self.engine.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2), + self.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2), [] ) def test_function(self): - self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city", self.user), + self.assertEqual(self.autocomplete("MEDI", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) - self.assertEqual(self.engine.autocomplete("ma", "Address", "city", self.user), [ + self.assertEqual(self.autocomplete("ma", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), 'map(', @@ -156,31 +164,34 @@ class TestCompletion(test_engine.EngineTestCase): ]) def test_member(self): - self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city", self.user), + self.assertEqual(self.autocomplete("datetime.tz", "Address", "city"), ["datetime.tzinfo("]) def test_case_insensitive(self): - self.assertEqual(self.engine.autocomplete("medi", "Address", "city", self.user), + self.assertEqual(self.autocomplete("medi", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) - self.assertEqual(self.engine.autocomplete("std", "Address", "city", self.user), [ + self.assertEqual(self.autocomplete("std", "Address", "city"), [ ('STDEV', '(value, *more_values)', True), ('STDEVA', '(value, *more_values)', True), ('STDEVP', '(value, *more_values)', True), ('STDEVPA', '(value, *more_values)', True) ]) self.assertEqual( - self.engine.autocomplete("stu", "Address", "city", self.user), + self.autocomplete("stu", "Address", "city"), [ 'Students', ('Students.lookupOne', '(colName=, ...)', True), ('Students.lookupRecords', '(colName=, ...)', True), 'Students.lookupRecords(homeAddress=$id)', + 'Students_summary_school', + ('Students_summary_school.lookupOne', '(colName=, ...)', True), + ('Students_summary_school.lookupRecords', '(colName=, ...)', True) ], ) # Add a table name whose lowercase version conflicts with a builtin. self.apply_user_action(['AddTable', 'Max', []]) - self.assertEqual(self.engine.autocomplete("max", "Address", "city", self.user), [ + self.assertEqual(self.autocomplete("max", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), 'Max', @@ -188,7 +199,7 @@ class TestCompletion(test_engine.EngineTestCase): ('Max.lookupRecords', '(colName=, ...)', True), 'max(', ]) - self.assertEqual(self.engine.autocomplete("MAX", "Address", "city", self.user), [ + self.assertEqual(self.autocomplete("MAX", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), ]) @@ -196,23 +207,23 @@ class TestCompletion(test_engine.EngineTestCase): def test_suggest_globals_and_tables(self): # Should suggest globals and table names. - self.assertEqual(self.engine.autocomplete("ME", "Address", "city", self.user), + self.assertEqual(self.autocomplete("ME", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) self.assertEqual( - self.engine.autocomplete("Ad", "Address", "city", self.user), + self.autocomplete("Ad", "Address", "city"), [ 'Address', ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ], ) - self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city", self.user)), { + self.assertGreaterEqual(set(self.autocomplete("S", "Address", "city")), { 'Schools', 'Students', ('SUM', '(value1, *more_values)', True), ('STDEV', '(value, *more_values)', True), }) - self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city", self.user)), { + self.assertGreaterEqual(set(self.autocomplete("s", "Address", "city")), { 'Schools', 'Students', 'sum(', @@ -220,7 +231,7 @@ class TestCompletion(test_engine.EngineTestCase): ('STDEV', '(value, *more_values)', True), }) self.assertEqual( - self.engine.autocomplete("Addr", "Schools", "budget", self.user), + self.autocomplete("Addr", "Schools", "budget"), [ 'Address', ('Address.lookupOne', '(colName=, ...)', True), @@ -229,26 +240,26 @@ class TestCompletion(test_engine.EngineTestCase): ) def test_suggest_columns(self): - self.assertEqual(self.engine.autocomplete("$ci", "Address", "city", self.user), + self.assertEqual(self.autocomplete("$ci", "Address", "city"), ["$city"]) - self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city", self.user), + self.assertEqual(self.autocomplete("rec.i", "Address", "city"), ["rec.id"]) - self.assertEqual(len(self.engine.autocomplete("$", "Address", "city", self.user)), + self.assertEqual(len(self.autocomplete("$", "Address", "city")), 2) # A few more detailed examples. - self.assertEqual(self.engine.autocomplete("$", "Students", "school", self.user), + self.assertEqual(self.autocomplete("$", "Students", "school"), ['$birthDate', '$firstName', '$homeAddress', '$homeAddress.city', '$id', '$lastName', '$lastVisit', '$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName']) - self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate", self.user), + self.assertEqual(self.autocomplete("$fi", "Students", "birthDate"), ['$firstName']) - self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit", self.user), + self.assertEqual(self.autocomplete("$school", "Students", "lastVisit"), ['$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName']) def test_suggest_lookup_methods(self): # Should suggest lookup formulas for tables. - address_dot_completion = self.engine.autocomplete("Address.", "Students", "firstName", self.user) + address_dot_completion = self.autocomplete("Address.", "Students", "firstName") # In python 3.9.7, rlcompleter stops adding parens for property attributes, # see https://bugs.python.org/issue44752 - seems like a minor issue, so leave test # tolerant. @@ -262,7 +273,7 @@ class TestCompletion(test_engine.EngineTestCase): ]) self.assertEqual( - self.engine.autocomplete("Address.lookup", "Students", "lastName", self.user), + self.autocomplete("Address.lookup", "Students", "lastName"), [ ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), @@ -270,7 +281,7 @@ class TestCompletion(test_engine.EngineTestCase): ) self.assertEqual( - self.engine.autocomplete("address.look", "Students", "schoolName", self.user), + self.autocomplete("address.look", "Students", "schoolName"), [ ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), @@ -280,25 +291,25 @@ class TestCompletion(test_engine.EngineTestCase): def test_suggest_column_type_methods(self): # Should treat columns as correct types. self.assertGreaterEqual( - set(self.engine.autocomplete("$firstName.", "Students", "firstName", self.user)), + set(self.autocomplete("$firstName.", "Students", "firstName")), {'$firstName.startswith(', '$firstName.replace(', '$firstName.title('} ) self.assertGreaterEqual( - set(self.engine.autocomplete("$birthDate.", "Students", "lastName", self.user)), + set(self.autocomplete("$birthDate.", "Students", "lastName")), {'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('} ) self.assertGreaterEqual( - set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName", self.user)), + set(self.autocomplete("$lastVisit.m", "Students", "firstName")), {'$lastVisit.month', '$lastVisit.minute'} ) self.assertGreaterEqual( - set(self.engine.autocomplete("$school.", "Students", "firstName", self.user)), + set(self.autocomplete("$school.", "Students", "firstName")), {'$school.address', '$school.name', '$school.yearFounded', '$school.budget'} ) - self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName", self.user), + self.assertEqual(self.autocomplete("$school.year", "Students", "lastName"), ['$school.yearFounded']) self.assertGreaterEqual( - set(self.engine.autocomplete("$yearFounded.", "Schools", "budget", self.user)), + set(self.autocomplete("$yearFounded.", "Schools", "budget")), { '$yearFounded.denominator', # Only integers have this '$yearFounded.bit_length(', # and this @@ -306,18 +317,18 @@ class TestCompletion(test_engine.EngineTestCase): } ) self.assertGreaterEqual( - set(self.engine.autocomplete("$budget.", "Schools", "budget", self.user)), + set(self.autocomplete("$budget.", "Schools", "budget")), {'$budget.is_integer(', '$budget.real'} # Only floats have this ) def test_suggest_follows_references(self): # Should follow references and autocomplete those types. self.assertEqual( - self.engine.autocomplete("$school.name.st", "Students", "firstName", self.user), + self.autocomplete("$school.name.st", "Students", "firstName"), ['$school.name.startswith(', '$school.name.strip('] ) self.assertGreaterEqual( - set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName", self.user)), + set(self.autocomplete("$school.yearFounded.","Students", "firstName")), { '$school.yearFounded.denominator', '$school.yearFounded.bit_length(', @@ -326,11 +337,11 @@ class TestCompletion(test_engine.EngineTestCase): ) self.assertEqual( - self.engine.autocomplete("$school.address.", "Students", "lastName", self.user), + self.autocomplete("$school.address.", "Students", "lastName"), ['$school.address.city', '$school.address.id'] ) self.assertEqual( - self.engine.autocomplete("$school.address.city.st", "Students", "lastName", self.user), + self.autocomplete("$school.address.city.st", "Students", "lastName"), ['$school.address.city.startswith(', '$school.address.city.strip('] ) @@ -339,17 +350,21 @@ class TestCompletion(test_engine.EngineTestCase): # including a 'reverse reference' lookup, i.e. `=$id`, # but only for `lookupRecords`, not `lookupOne`. self.assertEqual( - self.engine.autocomplete("stu", "Schools", "name", self.user), + self.autocomplete("stu", "Schools", "name"), [ 'Students', ('Students.lookupOne', '(colName=, ...)', True), ('Students.lookupRecords', '(colName=, ...)', True), # i.e. Students.school is a reference to Schools 'Students.lookupRecords(school=$id)', + 'Students_summary_school', + ('Students_summary_school.lookupOne', '(colName=, ...)', True), + ('Students_summary_school.lookupRecords', '(colName=, ...)', True), + 'Students_summary_school.lookupRecords(school=$id)', ], ) self.assertEqual( - self.engine.autocomplete("scho", "Address", "city", self.user), + self.autocomplete("scho", "Address", "city"), [ 'Schools', ('Schools.lookupOne', '(colName=, ...)', True), @@ -362,7 +377,7 @@ class TestCompletion(test_engine.EngineTestCase): # Same as above, but the formula is being entered in 'Students' instead of 'Address', # which means there's no reverse reference to suggest. self.assertEqual( - self.engine.autocomplete("scho", "Students", "firstName", self.user), + self.autocomplete("scho", "Students", "firstName"), [ 'Schools', ('Schools.lookupOne', '(colName=, ...)', True), @@ -370,11 +385,24 @@ class TestCompletion(test_engine.EngineTestCase): ], ) + # Test from within a summary table + self.assertEqual( + self.autocomplete("stu", "Students_summary_school", "count"), + [ + 'Students', + ('Students.lookupOne', '(colName=, ...)', True), + ('Students.lookupRecords', '(colName=, ...)', True), + 'Students_summary_school', + ('Students_summary_school.lookupOne', '(colName=, ...)', True), + ('Students_summary_school.lookupRecords', '(colName=, ...)', True), + ], + ) + def test_suggest_lookup_arguments(self): # Typing in the full `.lookupRecords(` should suggest keyword argument (i.e. column) names, # in addition to reference lookups, including the reverse reference lookups above. self.assertEqual( - self.engine.autocomplete("Schools.lookupRecords(", "Address", "city", self.user), + self.autocomplete("Schools.lookupRecords(", "Address", "city"), [ 'Schools.lookupRecords(address=', 'Schools.lookupRecords(address=$id)', @@ -391,7 +419,7 @@ class TestCompletion(test_engine.EngineTestCase): # columns (one from the looked up table, one from the current table) targeting the same table, # e.g. `address=$homeAddress` in the two cases below. self.assertEqual( - self.engine.autocomplete("Schools.lookupRecords(", "Students", "firstName", self.user), + self.autocomplete("Schools.lookupRecords(", "Students", "firstName"), [ 'Schools.lookupRecords(address=', 'Schools.lookupRecords(address=$homeAddress)', @@ -405,7 +433,7 @@ class TestCompletion(test_engine.EngineTestCase): ) self.assertEqual( - self.engine.autocomplete("Students.lookupRecords(", "Schools", "name", self.user), + self.autocomplete("Students.lookupRecords(", "Schools", "name"), [ 'Students.lookupRecords(birthDate=', 'Students.lookupRecords(firstName=', @@ -430,7 +458,7 @@ class TestCompletion(test_engine.EngineTestCase): # This doesn't affect anything, because there's no way to do the opposite of CONTAINS() self.add_column('Schools', 'otherAddresses', type='RefList:Address') self.assertEqual( - self.engine.autocomplete("Students.lookupRecords(", "Schools", "name", self.user), + self.autocomplete("Students.lookupRecords(", "Schools", "name"), [ 'Students.lookupRecords(birthDate=', 'Students.lookupRecords(firstName=', @@ -453,3 +481,163 @@ class TestCompletion(test_engine.EngineTestCase): 'Students.lookupRecords(schoolName=', ], ) + + def autocomplete(self, formula, table, column, user=None, row_id=None): + """ + Mild convenience over self.engine.autocomplete. + Only returns suggestions without example values, unless row_id is specified. + """ + user = user or self.user + results = self.engine.autocomplete(formula, table, column, row_id or 1, user) + if row_id is None: + return [result for result, value in results] + else: + return results + + def test_example_values(self): + self.assertEqual( + self.autocomplete("$", "Schools", "name", row_id=1), + [ + ('$address', 'Address[11]'), + ('$budget', '0.0'), + ('$id', '1'), + ('$lastModified', 'None'), + ('$lastModifier', repr(u'')), + ('$name', "'Columbia'"), + ('$yearFounded', '0'), + ], + ) + + self.assertEqual( + self.autocomplete("$", "Schools", "name", row_id=3), + [ + ('$address', 'Address[13]'), + ('$budget', '123.45'), + ('$id', '3'), + ('$lastModified', '2018-01-01 12:00am'), + ('$lastModifier', None), + ('$name', "'Yale'"), + ('$yearFounded', '2010'), + ], + ) + + self.assertEqual( + self.autocomplete("$", "Address", "name", row_id=1), + [ + ('$city', repr(u'')), # for Python 2/3 compatibility + ('$id', '0'), # row_id 1 doesn't exist! + ], + ) + self.assertEqual( + self.autocomplete("$", "Address", "name", row_id=11), + [ + ('$city', "'New York'"), + ('$id', '11'), + ], + ) + self.assertEqual( + self.autocomplete("$", "Address", "name", row_id='new'), + [ + ('$city', "'West Haven'"), + ('$id', '14'), # row_id 'new' gets replaced with the maximum row ID in the table + ], + ) + + self.assertEqual( + self.autocomplete("$", "Students", "name", row_id=1), + [ + ('$birthDate', 'None'), + ('$firstName', "'Barack'"), + ('$homeAddress', 'Address[11]'), + ('$homeAddress.city', "'New York'"), + ('$id', '1'), + ('$lastName', "'Obama'"), + ('$lastVisit', 'None'), + ('$school', 'Schools[1]'), + ('$school.name', "'Columbia'"), + ('$schoolCities', repr(u'New York:Colombia')), + ('$schoolIds', repr(u'1:2')), + ('$schoolName', "'Columbia'"), + ], + ) + + self.assertEqual( + self.autocomplete("rec", "Students", "name", row_id=1), + [ + # Mixture of suggestions with and without values + (('RECORD', '(record_or_list, dates_as_iso=False, expand_refs=0)', True), None), + ('rec', 'Students[1]'), + ], + ) + + def test_repr(self): + date = datetime.date(2019, 12, 31) + dtime = datetime.datetime(2019, 12, 31, 13, 23) + self.assertEqual(repr_example(date), "2019-12-31") + self.assertEqual(repr_example(dtime), "2019-12-31 1:23pm") + self.assertEqual(repr_example([1, 'a', dtime, date]), + "[1, 'a', 2019-12-31 1:23pm, 2019-12-31]") + + prefix = "