mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Show example values in formula autocomplete
Summary: This diff adds a preview of the value of certain autocomplete suggestions, especially of the form `$foo.bar` or `user.email`. The main initial motivation was to show the difference between `$Ref` and `$Ref.DisplayCol`, but the feature is more general. The client now sends the row ID of the row being edited (along with the table and column IDs which were already sent) to the server to fetch autocomplete suggestions. The returned suggestions are now tuples `(suggestion, example_value)` where `example_value` is a string or null. The example value is simply obtained by evaluating (in a controlled way) the suggestion in the context of the given record and the current user. The string representation is similar to the standard `repr` but dates and datetimes are formatted, and the whole thing is truncated for efficiency. The example values are shown in the autocomplete popup separated from the actual suggestion by a number of spaces calculated to: 1. Clearly separate the suggestion from the values 2. Left-align the example values in most cases 3. Avoid having so much space such that connecting suggestions and values becomes visually difficult. The tokenization of the row is then tweaked to show the example in light grey to deemphasise it. Main discussion where the above was decided: https://grist.slack.com/archives/CDHABLZJT/p1661795588100009 The diff also includes various other small improvements and fixes: - The autocomplete popup is much wider to make room for long suggestions, particularly lookups, as pointed out in https://phab.getgrist.com/D3580#inline-41007. The wide popup is the reason a fancy solution was needed to position the example values. I didn't see a way to dynamically resize the popup based on suggestions, and it didn't seem like a good idea to try. - The `grist` and `python` labels previously shown on the right are removed. They were not helpful (https://grist.slack.com/archives/CDHABLZJT/p1659697086155179) and would get in the way of the example values. - Fixed a bug in our custom tokenization that caused function arguments to be weirdly truncated in the middle: https://grist.slack.com/archives/CDHABLZJT/p1661956353699169?thread_ts=1661953258.342739&cid=CDHABLZJT and https://grist.slack.com/archives/C069RUP71/p1659696778991339 - Hide suggestions involving helper columns like `$gristHelper_Display` or `Table.lookupRecords(gristHelper_Display=` (https://grist.slack.com/archives/CDHABLZJT/p1661953258342739). The former has been around for a while and seems to be a mistake. The fix is simply to use `is_visible_column` instead of `is_user_column`. Since the latter is not used anywhere else, and using it in the first place seems like a mistake more than anything else, I've also removed the function to prevent similar mistakes in the future. - Don't suggest private columns as lookup arguments: https://grist.slack.com/archives/CDHABLZJT/p1662133416652499?thread_ts=1661795588.100009&cid=CDHABLZJT - Only fetch fresh suggestions specifically after typing `lookupRecords(` or `lookupOne(` rather than just `(`, as this would needlessly hide function suggestions which could still be useful to see the arguments. However this only makes a difference when there are still multiple matching suggestions, otherwise Ace hides them anyway. Test Plan: Extended and updated several Python and browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D3611
This commit is contained in:
parent
1864b7ba5d
commit
792565976a
@ -41,7 +41,7 @@ export function aclFormulaEditor(options: ACLFormulaOptions) {
|
|||||||
);
|
);
|
||||||
editor.on("change", () => showPlaceholder.set(!editor.getValue().length));
|
editor.on("change", () => showPlaceholder.set(!editor.getValue().length));
|
||||||
|
|
||||||
async function getSuggestions(prefix: string) {
|
async function getSuggestions(prefix: string): Promise<Array<[string, null]>> {
|
||||||
return [
|
return [
|
||||||
// The few Python keywords and constants we support.
|
// The few Python keywords and constants we support.
|
||||||
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
|
'and', 'or', 'not', 'in', 'is', 'True', 'False', 'None',
|
||||||
@ -51,7 +51,7 @@ export function aclFormulaEditor(options: ACLFormulaOptions) {
|
|||||||
'user', 'rec', 'newRec',
|
'user', 'rec', 'newRec',
|
||||||
// Other completions that depend on doc schema or other rules.
|
// Other completions that depend on doc schema or other rules.
|
||||||
...options.getSuggestions(prefix),
|
...options.getSuggestions(prefix),
|
||||||
];
|
].map(suggestion => [suggestion, null]); // null means no example value
|
||||||
}
|
}
|
||||||
setupAceEditorCompletions(editor, {getSuggestions});
|
setupAceEditorCompletions(editor, {getSuggestions});
|
||||||
|
|
||||||
|
@ -8,6 +8,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ace_grist_example {
|
||||||
|
color: #8f8f8f;
|
||||||
|
}
|
||||||
|
|
||||||
.ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link {
|
.ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link {
|
||||||
color: var(--grist-color-dark-green);
|
color: var(--grist-color-dark-green);
|
||||||
}
|
}
|
||||||
@ -16,3 +20,8 @@
|
|||||||
z-index: 7;
|
z-index: 7;
|
||||||
pointer-events: auto;
|
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 */
|
||||||
|
}
|
||||||
|
@ -189,9 +189,11 @@ AceEditor.prototype._setup = function() {
|
|||||||
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
|
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
|
||||||
if (this.gristDoc && this.column) {
|
if (this.gristDoc && this.column) {
|
||||||
const getSuggestions = (prefix) => {
|
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();
|
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});
|
setupAceEditorCompletions(this.editor, {getSuggestions});
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,8 @@
|
|||||||
|
import {ISuggestionWithValue} from 'app/common/ActiveDocAPI';
|
||||||
import * as ace from 'brace';
|
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 {
|
export interface ICompletionOptions {
|
||||||
getSuggestions(prefix: string): Promise<ISuggestion[]>;
|
getSuggestions(prefix: string): Promise<ISuggestionWithValue[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const completionOptions = new WeakMap<ace.Editor, ICompletionOptions>();
|
const completionOptions = new WeakMap<ace.Editor, ICompletionOptions>();
|
||||||
@ -27,18 +21,29 @@ export function setupAceEditorCompletions(editor: ace.Editor, options: ICompleti
|
|||||||
completer.autoSelect = false;
|
completer.autoSelect = false;
|
||||||
(editor as any).completer = completer;
|
(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);
|
const originalUpdate = completer.updateCompletions.bind(completer);
|
||||||
completer.updateCompletions = function(this: any, keepPopupPosition: boolean) {
|
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) {
|
if (keepPopupPosition && this.base && this.completions) {
|
||||||
const pos = this.editor.getCursorPosition();
|
// When we need fresh completions, prevent this same block from running
|
||||||
const prefix = this.editor.session.getTextRange({start: this.base, end: pos});
|
|
||||||
// If the cursor is just after '.' or '(', prevent this same block from running
|
|
||||||
// in the original updateCompletions() function. Otherwise it will just keep any remaining completions that match,
|
// in the original updateCompletions() function. Otherwise it will just keep any remaining completions that match,
|
||||||
// or not show any completions at all.
|
// or not show any completions at all.
|
||||||
// But the last character implies that the set of completions is likely to have changed.
|
if (this._gristShouldRefreshCompletions(this.base)) {
|
||||||
if (prefix.endsWith(".") || prefix.endsWith("(")) {
|
|
||||||
this.completions = null;
|
this.completions = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -50,12 +55,7 @@ export function setupAceEditorCompletions(editor: ace.Editor, options: ICompleti
|
|||||||
completer.insertMatch = function(this: any) {
|
completer.insertMatch = function(this: any) {
|
||||||
const base = this.base; // this.base may become null after the next line, save it now.
|
const base = this.base; // this.base may become null after the next line, save it now.
|
||||||
const result = originalInsertMatch.apply(...arguments);
|
const result = originalInsertMatch.apply(...arguments);
|
||||||
// Like in the above patch, get the current text in the editor to be completed.
|
if (this._gristShouldRefreshCompletions(base)) {
|
||||||
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("(")) {
|
|
||||||
this.showPopup(this.editor);
|
this.showPopup(this.editor);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@ -100,19 +100,73 @@ function initCustomCompleter() {
|
|||||||
const suggestions = await options.getSuggestions(prefix);
|
const suggestions = await options.getSuggestions(prefix);
|
||||||
// ACE autocompletions are very poorly documented. This is somewhat helpful:
|
// ACE autocompletions are very poorly documented. This is somewhat helpful:
|
||||||
// https://prog.world/implementing-code-completion-in-ace-editor/
|
// 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)) {
|
if (Array.isArray(suggestion)) {
|
||||||
const [funcname, argSpec, isGrist] = suggestion;
|
const [funcname, argSpec] = suggestion;
|
||||||
const meta = isGrist ? 'grist' : 'python';
|
return {
|
||||||
return {value: funcname + '(', caption: funcname + argSpec, score: 1, meta, funcname};
|
value: funcname + '(',
|
||||||
|
caption: funcname + argSpec,
|
||||||
|
score: 1,
|
||||||
|
example,
|
||||||
|
funcname,
|
||||||
|
};
|
||||||
} else {
|
} 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
|
* When autocompleting a known function (with funcname received from the server call), turn the
|
||||||
* function name into a link to Grist documentation.
|
* function name into a link to Grist documentation.
|
||||||
@ -159,8 +213,8 @@ interface TokenInfo extends ace.TokenInfo {
|
|||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo[] {
|
function retokenizeAceCompleterRow(rowData: AceSuggestion, tokens: TokenInfo[]): TokenInfo[] {
|
||||||
if (!rowData.funcname) {
|
if (!(rowData.funcname || rowData.example)) {
|
||||||
// Not a special completion, pass through the result of ACE's original tokenizing.
|
// Not a special completion, pass through the result of ACE's original tokenizing.
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
@ -186,9 +240,33 @@ function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo
|
|||||||
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
|
||||||
newTokens.push({value: href, type: 'grist_link_hidden'});
|
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.
|
// Go through tokens, splitting them if needed, and modifying those that form the link part.
|
||||||
let position = 0;
|
let position = 0;
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
|
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.
|
// lStart/lEnd are indices of the link within the token, possibly negative.
|
||||||
const lStart = linkStart - position, lEnd = linkEnd - position;
|
const lStart = linkStart - position, lEnd = linkEnd - position;
|
||||||
if (lStart > 0) {
|
if (lStart > 0) {
|
||||||
@ -199,11 +277,14 @@ function retokenizeAceCompleterRow(rowData: any, tokens: TokenInfo[]): TokenInfo
|
|||||||
const inLink = t.value.slice(Math.max(0, lStart), lEnd);
|
const inLink = t.value.slice(Math.max(0, lStart), lEnd);
|
||||||
const newType = t.type + (t.type ? '.' : '') + 'grist_link';
|
const newType = t.type + (t.type ? '.' : '') + 'grist_link';
|
||||||
newTokens.push({value: inLink, type: newType});
|
newTokens.push({value: inLink, type: newType});
|
||||||
}
|
|
||||||
if (lEnd < t.value.length) {
|
if (lEnd < t.value.length) {
|
||||||
const afterLink = t.value.slice(lEnd);
|
const afterLink = t.value.slice(lEnd);
|
||||||
newTokens.push({value: afterLink, type: t.type});
|
newTokens.push({value: afterLink, type: t.type});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
newTokens.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
position += t.value.length;
|
position += t.value.length;
|
||||||
}
|
}
|
||||||
return newTokens;
|
return newTokens;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {ActionGroup} from 'app/common/ActionGroup';
|
import {ActionGroup} from 'app/common/ActionGroup';
|
||||||
import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
|
import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
|
||||||
import {FormulaProperties} from 'app/common/GranularAccessClause';
|
import {FormulaProperties} from 'app/common/GranularAccessClause';
|
||||||
|
import {UIRowId} from 'app/common/UIRowId';
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
|
import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI';
|
||||||
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
import {ParseOptions} from 'app/plugin/FileParserAPI';
|
||||||
@ -178,6 +179,18 @@ export function summaryGroupByDescription(groupByColumnLabels: string[]): string
|
|||||||
return `[${groupByColumnLabels.length ? 'by ' + groupByColumnLabels.join(", ") : "Totals"}]`;
|
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 {
|
export interface ActiveDocAPI {
|
||||||
/**
|
/**
|
||||||
* Closes a document, and unsubscribes from its userAction events.
|
* 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
|
* Find and return a list of auto-complete suggestions that start with `txt`, when editing a
|
||||||
* formula in table `tableId` and column `columnId`.
|
* formula in table `tableId` and column `columnId`.
|
||||||
*/
|
*/
|
||||||
autocomplete(txt: string, tableId: string, columnId: string): Promise<string[]>;
|
autocomplete(txt: string, tableId: string, columnId: string, rowId: UIRowId): Promise<ISuggestionWithValue[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the current instance from the doc.
|
* Removes the current instance from the doc.
|
||||||
|
@ -21,6 +21,7 @@ import {
|
|||||||
ForkResult,
|
ForkResult,
|
||||||
ImportOptions,
|
ImportOptions,
|
||||||
ImportResult,
|
ImportResult,
|
||||||
|
ISuggestionWithValue,
|
||||||
MergeOptions,
|
MergeOptions,
|
||||||
PermissionDataWithExtraUsers,
|
PermissionDataWithExtraUsers,
|
||||||
QueryResult,
|
QueryResult,
|
||||||
@ -64,6 +65,7 @@ import {Interval} from 'app/common/Interval';
|
|||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
import {schema, SCHEMA_VERSION} from 'app/common/schema';
|
||||||
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
|
import {MetaRowRecord, SingleCell} from 'app/common/TableData';
|
||||||
|
import {UIRowId} from 'app/common/UIRowId';
|
||||||
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, UploadResult} from 'app/common/uploads';
|
||||||
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
import {DocReplacementOptions, DocState, DocStateComparison} from 'app/common/UserAPI';
|
||||||
import {convertFromColumn} from 'app/common/ValueConverter';
|
import {convertFromColumn} from 'app/common/ValueConverter';
|
||||||
@ -1272,12 +1274,14 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
docSession.linkId = 0;
|
docSession.linkId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async autocomplete(docSession: DocSession, txt: string, tableId: string, columnId: string): Promise<string[]> {
|
public async autocomplete(
|
||||||
|
docSession: DocSession, txt: string, tableId: string, columnId: string, rowId: UIRowId
|
||||||
|
): Promise<ISuggestionWithValue[]> {
|
||||||
// Autocompletion can leak names of tables and columns.
|
// Autocompletion can leak names of tables and columns.
|
||||||
if (!await this._granularAccess.canScanData(docSession)) { return []; }
|
if (!await this._granularAccess.canScanData(docSession)) { return []; }
|
||||||
await this.waitForInitialization();
|
await this.waitForInitialization();
|
||||||
const user = await this._granularAccess.getCachedUser(docSession);
|
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<UploadResult> {
|
public fetchURL(docSession: DocSession, url: string, options?: FetchUrlOptions): Promise<UploadResult> {
|
||||||
|
@ -7,7 +7,7 @@ lowercase searches, and adds function usage information to some results.
|
|||||||
import inspect
|
import inspect
|
||||||
import re
|
import re
|
||||||
from collections import namedtuple, defaultdict
|
from collections import namedtuple, defaultdict
|
||||||
from six.moves import builtins
|
from six.moves import builtins, reprlib
|
||||||
import six
|
import six
|
||||||
|
|
||||||
import column
|
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():
|
for col_id, col in formula_table.all_columns.items():
|
||||||
# Note that we can't support reflist columns in the current table,
|
# 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.
|
# 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)
|
ref_cols[col._target_table].append(col_id)
|
||||||
|
|
||||||
# Find referencing columns in the lookup table that target tables in ref_cols.
|
# Find referencing columns in the lookup table that target tables in ref_cols.
|
||||||
results = []
|
results = []
|
||||||
for lookup_col_id, lookup_col in lookup_table.all_columns.items():
|
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):
|
if isinstance(lookup_col, column.ReferenceColumn):
|
||||||
value_template = "${}"
|
value_template = "${}"
|
||||||
elif isinstance(lookup_col, column.ReferenceListColumn):
|
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)
|
value = value_template.format(ref_col_id)
|
||||||
results.append("{}={})".format(lookup_col_id, value))
|
results.append("{}={})".format(lookup_col_id, value))
|
||||||
return results
|
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))
|
||||||
|
@ -27,18 +27,12 @@ MANUAL_SORT_DEFAULT = 2147483647.0
|
|||||||
|
|
||||||
SPECIAL_COL_IDS = {'id', MANUAL_SORT}
|
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):
|
def is_visible_column(col_id):
|
||||||
"""
|
"""
|
||||||
Returns whether this is an id of a column that's intended to be shown to the user.
|
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):
|
def is_virtual_column(col_id):
|
||||||
"""
|
"""
|
||||||
|
@ -19,7 +19,7 @@ from sortedcontainers import SortedSet
|
|||||||
import acl
|
import acl
|
||||||
import actions
|
import actions
|
||||||
import action_obj
|
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
|
from codebuilder import DOLLAR_REGEX
|
||||||
import depend
|
import depend
|
||||||
import docactions
|
import docactions
|
||||||
@ -1408,7 +1408,7 @@ class Engine(object):
|
|||||||
if not self._in_update_loop:
|
if not self._in_update_loop:
|
||||||
self._bring_mlookups_up_to_date(doc_action)
|
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.
|
Return a list of suggested completions of the python fragment supplied.
|
||||||
"""
|
"""
|
||||||
@ -1425,13 +1425,15 @@ class Engine(object):
|
|||||||
result = [
|
result = [
|
||||||
txt + col_id + "="
|
txt + col_id + "="
|
||||||
for col_id in lookup_table.all_columns
|
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.
|
# Add specific complete lookups involving reference columns.
|
||||||
result += [
|
result += [
|
||||||
txt + option
|
txt + option
|
||||||
for option in lookup_autocomplete_options(lookup_table, table, reverse_only=False)
|
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)
|
return sorted(result)
|
||||||
|
|
||||||
# replace $ with rec. and add a dummy rec object
|
# 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)
|
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 we changed the prefix (expanding the $ symbol) we now need to change it back.
|
||||||
if tweaked_txt != txt:
|
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
|
# 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
|
return results
|
||||||
|
|
||||||
def _get_undo_checkpoint(self):
|
def _get_undo_checkpoint(self):
|
||||||
|
@ -86,8 +86,8 @@ def run(sandbox):
|
|||||||
return eng.fetch_table_schema()
|
return eng.fetch_table_schema()
|
||||||
|
|
||||||
@export
|
@export
|
||||||
def 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, user)
|
return eng.autocomplete(txt, table_id, column_id, row_id, user)
|
||||||
|
|
||||||
@export
|
@export
|
||||||
def find_col_from_values(values, n, opt_table_id):
|
def find_col_from_values(values, n, opt_table_id):
|
||||||
|
@ -255,7 +255,7 @@ class Table(object):
|
|||||||
# reference values (using .sample_record for other tables) are not yet available.
|
# reference values (using .sample_record for other tables) are not yet available.
|
||||||
props = {}
|
props = {}
|
||||||
for col in self.all_columns.values():
|
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
|
continue
|
||||||
# Note c=col to bind at lambda-creation time; see
|
# Note c=col to bind at lambda-creation time; see
|
||||||
# https://stackoverflow.com/questions/10452770/python-lambdas-binding-to-local-values
|
# https://stackoverflow.com/questions/10452770/python-lambdas-binding-to-local-values
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import testsamples
|
import datetime
|
||||||
|
|
||||||
import test_engine
|
import test_engine
|
||||||
|
import testsamples
|
||||||
|
from autocomplete_context import repr_example, eval_suggestion
|
||||||
from schema import RecalcWhen
|
from schema import RecalcWhen
|
||||||
|
|
||||||
|
|
||||||
class TestCompletion(test_engine.EngineTestCase):
|
class TestCompletion(test_engine.EngineTestCase):
|
||||||
user = {
|
user = {
|
||||||
'Name': 'Foo',
|
'Name': 'Foo',
|
||||||
@ -34,85 +38,89 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
type="Text", isFormula=False, formula="foo@getgrist.com",
|
type="Text", isFormula=False, formula="foo@getgrist.com",
|
||||||
recalcWhen=RecalcWhen.MANUAL_UPDATES
|
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):
|
def test_keyword(self):
|
||||||
self.assertEqual(self.engine.autocomplete("for", "Address", "city", self.user),
|
self.assertEqual(self.autocomplete("for", "Address", "city"),
|
||||||
["for", "format("])
|
["for", "format("])
|
||||||
|
|
||||||
def test_grist(self):
|
def test_grist(self):
|
||||||
self.assertEqual(self.engine.autocomplete("gri", "Address", "city", self.user),
|
self.assertEqual(self.autocomplete("gri", "Address", "city"),
|
||||||
["grist"])
|
["grist"])
|
||||||
|
|
||||||
def test_value(self):
|
def test_value(self):
|
||||||
# Should only appear if column exists and is a trigger formula.
|
# Should only appear if column exists and is a trigger formula.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("val", "Schools", "lastModified", self.user),
|
self.autocomplete("val", "Schools", "lastModified"),
|
||||||
["value"]
|
["value"]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("val", "Students", "schoolCities", self.user),
|
self.autocomplete("val", "Students", "schoolCities"),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
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"])
|
["value"])
|
||||||
# Should have same type as column.
|
# Should have same type as column.
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("value.", "Schools", "lastModifier", self.user)),
|
set(self.autocomplete("value.", "Schools", "lastModifier")),
|
||||||
{'value.startswith(', 'value.replace(', 'value.title('}
|
{'value.startswith(', 'value.replace(', 'value.title('}
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("value.", "Schools", "lastModified", self.user)),
|
set(self.autocomplete("value.", "Schools", "lastModified")),
|
||||||
{'value.month', 'value.strftime(', 'value.replace('}
|
{'value.month', 'value.strftime(', 'value.replace('}
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("value.m", "Schools", "lastModified", self.user)),
|
set(self.autocomplete("value.m", "Schools", "lastModified")),
|
||||||
{'value.month', 'value.minute'}
|
{'value.month', 'value.minute'}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_user(self):
|
def test_user(self):
|
||||||
# Should only appear if column exists and is a trigger formula.
|
# 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"])
|
["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"])
|
["user"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("user.", "Schools", "lastModified", self.user),
|
self.autocomplete("user.", "Schools", "lastModified", row_id=2),
|
||||||
[
|
[
|
||||||
'user.Access',
|
('user.Access', "'owners'"),
|
||||||
'user.Email',
|
('user.Email', "'foo@example.com'"),
|
||||||
'user.IsLoggedIn',
|
('user.IsLoggedIn', 'True'),
|
||||||
'user.LinkKey',
|
('user.LinkKey', None),
|
||||||
'user.Name',
|
('user.Name', "'Foo'"),
|
||||||
'user.Origin',
|
('user.Origin', 'None'),
|
||||||
'user.SessionID',
|
('user.SessionID', "'u1'"),
|
||||||
'user.StudentInfo',
|
('user.StudentInfo', 'Students[1]'),
|
||||||
'user.UserID'
|
('user.UserID', '1'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# Should follow user attribute references and autocomplete those types.
|
# Should follow user attribute references and autocomplete those types.
|
||||||
self.assertEqual(
|
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.birthDate', 'None'),
|
||||||
'user.StudentInfo.firstName',
|
('user.StudentInfo.firstName', "'Barack'"),
|
||||||
'user.StudentInfo.homeAddress',
|
('user.StudentInfo.homeAddress', 'Address[11]'),
|
||||||
'user.StudentInfo.homeAddress.city',
|
('user.StudentInfo.homeAddress.city', "'New York'"),
|
||||||
'user.StudentInfo.id',
|
('user.StudentInfo.id', '1'),
|
||||||
'user.StudentInfo.lastName',
|
('user.StudentInfo.lastName', "'Obama'"),
|
||||||
'user.StudentInfo.lastVisit',
|
('user.StudentInfo.lastVisit', 'None'),
|
||||||
'user.StudentInfo.school',
|
('user.StudentInfo.school', 'Schools[1]'),
|
||||||
'user.StudentInfo.school.name',
|
('user.StudentInfo.school.name', "'Columbia'"),
|
||||||
'user.StudentInfo.schoolCities',
|
('user.StudentInfo.schoolCities', repr(u'New York:Colombia')),
|
||||||
'user.StudentInfo.schoolIds',
|
('user.StudentInfo.schoolIds', repr(u'1:2')),
|
||||||
'user.StudentInfo.schoolName'
|
('user.StudentInfo.schoolName', "'Columbia'"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
# Should not show user attribute completions if user doesn't have attribute.
|
# Should not show user attribute completions if user doesn't have attribute.
|
||||||
@ -127,27 +135,27 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
'IsLoggedIn': True
|
'IsLoggedIn': True
|
||||||
}
|
}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("user.", "Schools", "lastModified", user2),
|
self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2),
|
||||||
[
|
[
|
||||||
'user.Access',
|
('user.Access', "'owners'"),
|
||||||
'user.Email',
|
('user.Email', "'baro@example.com'"),
|
||||||
'user.IsLoggedIn',
|
('user.IsLoggedIn', 'True'),
|
||||||
'user.LinkKey',
|
('user.LinkKey', None),
|
||||||
'user.Name',
|
('user.Name', "'Bar'"),
|
||||||
'user.Origin',
|
('user.Origin', 'None'),
|
||||||
'user.SessionID',
|
('user.SessionID', "'u2'"),
|
||||||
'user.UserID'
|
('user.UserID', '2'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2),
|
self.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_function(self):
|
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)])
|
[('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),
|
('MAX', '(value, *more_values)', True),
|
||||||
('MAXA', '(value, *more_values)', True),
|
('MAXA', '(value, *more_values)', True),
|
||||||
'map(',
|
'map(',
|
||||||
@ -156,31 +164,34 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
])
|
])
|
||||||
|
|
||||||
def test_member(self):
|
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("])
|
["datetime.tzinfo("])
|
||||||
|
|
||||||
def test_case_insensitive(self):
|
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)])
|
[('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),
|
('STDEV', '(value, *more_values)', True),
|
||||||
('STDEVA', '(value, *more_values)', True),
|
('STDEVA', '(value, *more_values)', True),
|
||||||
('STDEVP', '(value, *more_values)', True),
|
('STDEVP', '(value, *more_values)', True),
|
||||||
('STDEVPA', '(value, *more_values)', True)
|
('STDEVPA', '(value, *more_values)', True)
|
||||||
])
|
])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("stu", "Address", "city", self.user),
|
self.autocomplete("stu", "Address", "city"),
|
||||||
[
|
[
|
||||||
'Students',
|
'Students',
|
||||||
('Students.lookupOne', '(colName=<value>, ...)', True),
|
('Students.lookupOne', '(colName=<value>, ...)', True),
|
||||||
('Students.lookupRecords', '(colName=<value>, ...)', True),
|
('Students.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
'Students.lookupRecords(homeAddress=$id)',
|
'Students.lookupRecords(homeAddress=$id)',
|
||||||
|
'Students_summary_school',
|
||||||
|
('Students_summary_school.lookupOne', '(colName=<value>, ...)', True),
|
||||||
|
('Students_summary_school.lookupRecords', '(colName=<value>, ...)', True)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add a table name whose lowercase version conflicts with a builtin.
|
# Add a table name whose lowercase version conflicts with a builtin.
|
||||||
self.apply_user_action(['AddTable', 'Max', []])
|
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),
|
('MAX', '(value, *more_values)', True),
|
||||||
('MAXA', '(value, *more_values)', True),
|
('MAXA', '(value, *more_values)', True),
|
||||||
'Max',
|
'Max',
|
||||||
@ -188,7 +199,7 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
('Max.lookupRecords', '(colName=<value>, ...)', True),
|
('Max.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
'max(',
|
'max(',
|
||||||
])
|
])
|
||||||
self.assertEqual(self.engine.autocomplete("MAX", "Address", "city", self.user), [
|
self.assertEqual(self.autocomplete("MAX", "Address", "city"), [
|
||||||
('MAX', '(value, *more_values)', True),
|
('MAX', '(value, *more_values)', True),
|
||||||
('MAXA', '(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):
|
def test_suggest_globals_and_tables(self):
|
||||||
# Should suggest globals and table names.
|
# 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)])
|
[('MEDIAN', '(value, *more_values)', True)])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Ad", "Address", "city", self.user),
|
self.autocomplete("Ad", "Address", "city"),
|
||||||
[
|
[
|
||||||
'Address',
|
'Address',
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city", self.user)), {
|
self.assertGreaterEqual(set(self.autocomplete("S", "Address", "city")), {
|
||||||
'Schools',
|
'Schools',
|
||||||
'Students',
|
'Students',
|
||||||
('SUM', '(value1, *more_values)', True),
|
('SUM', '(value1, *more_values)', True),
|
||||||
('STDEV', '(value, *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',
|
'Schools',
|
||||||
'Students',
|
'Students',
|
||||||
'sum(',
|
'sum(',
|
||||||
@ -220,7 +231,7 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
('STDEV', '(value, *more_values)', True),
|
('STDEV', '(value, *more_values)', True),
|
||||||
})
|
})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Addr", "Schools", "budget", self.user),
|
self.autocomplete("Addr", "Schools", "budget"),
|
||||||
[
|
[
|
||||||
'Address',
|
'Address',
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
@ -229,26 +240,26 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_suggest_columns(self):
|
def test_suggest_columns(self):
|
||||||
self.assertEqual(self.engine.autocomplete("$ci", "Address", "city", self.user),
|
self.assertEqual(self.autocomplete("$ci", "Address", "city"),
|
||||||
["$city"])
|
["$city"])
|
||||||
self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city", self.user),
|
self.assertEqual(self.autocomplete("rec.i", "Address", "city"),
|
||||||
["rec.id"])
|
["rec.id"])
|
||||||
self.assertEqual(len(self.engine.autocomplete("$", "Address", "city", self.user)),
|
self.assertEqual(len(self.autocomplete("$", "Address", "city")),
|
||||||
2)
|
2)
|
||||||
|
|
||||||
# A few more detailed examples.
|
# 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',
|
['$birthDate', '$firstName', '$homeAddress', '$homeAddress.city',
|
||||||
'$id', '$lastName', '$lastVisit',
|
'$id', '$lastName', '$lastVisit',
|
||||||
'$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName'])
|
'$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||||
self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate", self.user),
|
self.assertEqual(self.autocomplete("$fi", "Students", "birthDate"),
|
||||||
['$firstName'])
|
['$firstName'])
|
||||||
self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit", self.user),
|
self.assertEqual(self.autocomplete("$school", "Students", "lastVisit"),
|
||||||
['$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName'])
|
['$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName'])
|
||||||
|
|
||||||
def test_suggest_lookup_methods(self):
|
def test_suggest_lookup_methods(self):
|
||||||
# Should suggest lookup formulas for tables.
|
# 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,
|
# 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
|
# see https://bugs.python.org/issue44752 - seems like a minor issue, so leave test
|
||||||
# tolerant.
|
# tolerant.
|
||||||
@ -262,7 +273,7 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
])
|
])
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Address.lookup", "Students", "lastName", self.user),
|
self.autocomplete("Address.lookup", "Students", "lastName"),
|
||||||
[
|
[
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
@ -270,7 +281,7 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("address.look", "Students", "schoolName", self.user),
|
self.autocomplete("address.look", "Students", "schoolName"),
|
||||||
[
|
[
|
||||||
('Address.lookupOne', '(colName=<value>, ...)', True),
|
('Address.lookupOne', '(colName=<value>, ...)', True),
|
||||||
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
('Address.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
@ -280,25 +291,25 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
def test_suggest_column_type_methods(self):
|
def test_suggest_column_type_methods(self):
|
||||||
# Should treat columns as correct types.
|
# Should treat columns as correct types.
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("$firstName.", "Students", "firstName", self.user)),
|
set(self.autocomplete("$firstName.", "Students", "firstName")),
|
||||||
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}
|
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("$birthDate.", "Students", "lastName", self.user)),
|
set(self.autocomplete("$birthDate.", "Students", "lastName")),
|
||||||
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}
|
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName", self.user)),
|
set(self.autocomplete("$lastVisit.m", "Students", "firstName")),
|
||||||
{'$lastVisit.month', '$lastVisit.minute'}
|
{'$lastVisit.month', '$lastVisit.minute'}
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
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'}
|
{'$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'])
|
['$school.yearFounded'])
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("$yearFounded.", "Schools", "budget", self.user)),
|
set(self.autocomplete("$yearFounded.", "Schools", "budget")),
|
||||||
{
|
{
|
||||||
'$yearFounded.denominator', # Only integers have this
|
'$yearFounded.denominator', # Only integers have this
|
||||||
'$yearFounded.bit_length(', # and this
|
'$yearFounded.bit_length(', # and this
|
||||||
@ -306,18 +317,18 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
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
|
{'$budget.is_integer(', '$budget.real'} # Only floats have this
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_suggest_follows_references(self):
|
def test_suggest_follows_references(self):
|
||||||
# Should follow references and autocomplete those types.
|
# Should follow references and autocomplete those types.
|
||||||
self.assertEqual(
|
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(']
|
['$school.name.startswith(', '$school.name.strip(']
|
||||||
)
|
)
|
||||||
self.assertGreaterEqual(
|
self.assertGreaterEqual(
|
||||||
set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName", self.user)),
|
set(self.autocomplete("$school.yearFounded.","Students", "firstName")),
|
||||||
{
|
{
|
||||||
'$school.yearFounded.denominator',
|
'$school.yearFounded.denominator',
|
||||||
'$school.yearFounded.bit_length(',
|
'$school.yearFounded.bit_length(',
|
||||||
@ -326,11 +337,11 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("$school.address.", "Students", "lastName", self.user),
|
self.autocomplete("$school.address.", "Students", "lastName"),
|
||||||
['$school.address.city', '$school.address.id']
|
['$school.address.city', '$school.address.id']
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
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(']
|
['$school.address.city.startswith(', '$school.address.city.strip(']
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -339,17 +350,21 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
# including a 'reverse reference' lookup, i.e. `<refcol to current table>=$id`,
|
# including a 'reverse reference' lookup, i.e. `<refcol to current table>=$id`,
|
||||||
# but only for `lookupRecords`, not `lookupOne`.
|
# but only for `lookupRecords`, not `lookupOne`.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("stu", "Schools", "name", self.user),
|
self.autocomplete("stu", "Schools", "name"),
|
||||||
[
|
[
|
||||||
'Students',
|
'Students',
|
||||||
('Students.lookupOne', '(colName=<value>, ...)', True),
|
('Students.lookupOne', '(colName=<value>, ...)', True),
|
||||||
('Students.lookupRecords', '(colName=<value>, ...)', True),
|
('Students.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
# i.e. Students.school is a reference to Schools
|
# i.e. Students.school is a reference to Schools
|
||||||
'Students.lookupRecords(school=$id)',
|
'Students.lookupRecords(school=$id)',
|
||||||
|
'Students_summary_school',
|
||||||
|
('Students_summary_school.lookupOne', '(colName=<value>, ...)', True),
|
||||||
|
('Students_summary_school.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
|
'Students_summary_school.lookupRecords(school=$id)',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("scho", "Address", "city", self.user),
|
self.autocomplete("scho", "Address", "city"),
|
||||||
[
|
[
|
||||||
'Schools',
|
'Schools',
|
||||||
('Schools.lookupOne', '(colName=<value>, ...)', True),
|
('Schools.lookupOne', '(colName=<value>, ...)', True),
|
||||||
@ -362,7 +377,7 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
# Same as above, but the formula is being entered in 'Students' instead of 'Address',
|
# Same as above, but the formula is being entered in 'Students' instead of 'Address',
|
||||||
# which means there's no reverse reference to suggest.
|
# which means there's no reverse reference to suggest.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("scho", "Students", "firstName", self.user),
|
self.autocomplete("scho", "Students", "firstName"),
|
||||||
[
|
[
|
||||||
'Schools',
|
'Schools',
|
||||||
('Schools.lookupOne', '(colName=<value>, ...)', True),
|
('Schools.lookupOne', '(colName=<value>, ...)', 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=<value>, ...)', True),
|
||||||
|
('Students.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
|
'Students_summary_school',
|
||||||
|
('Students_summary_school.lookupOne', '(colName=<value>, ...)', True),
|
||||||
|
('Students_summary_school.lookupRecords', '(colName=<value>, ...)', True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_suggest_lookup_arguments(self):
|
def test_suggest_lookup_arguments(self):
|
||||||
# Typing in the full `.lookupRecords(` should suggest keyword argument (i.e. column) names,
|
# Typing in the full `.lookupRecords(` should suggest keyword argument (i.e. column) names,
|
||||||
# in addition to reference lookups, including the reverse reference lookups above.
|
# in addition to reference lookups, including the reverse reference lookups above.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Schools.lookupRecords(", "Address", "city", self.user),
|
self.autocomplete("Schools.lookupRecords(", "Address", "city"),
|
||||||
[
|
[
|
||||||
'Schools.lookupRecords(address=',
|
'Schools.lookupRecords(address=',
|
||||||
'Schools.lookupRecords(address=$id)',
|
'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,
|
# 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.
|
# e.g. `address=$homeAddress` in the two cases below.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Schools.lookupRecords(", "Students", "firstName", self.user),
|
self.autocomplete("Schools.lookupRecords(", "Students", "firstName"),
|
||||||
[
|
[
|
||||||
'Schools.lookupRecords(address=',
|
'Schools.lookupRecords(address=',
|
||||||
'Schools.lookupRecords(address=$homeAddress)',
|
'Schools.lookupRecords(address=$homeAddress)',
|
||||||
@ -405,7 +433,7 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Students.lookupRecords(", "Schools", "name", self.user),
|
self.autocomplete("Students.lookupRecords(", "Schools", "name"),
|
||||||
[
|
[
|
||||||
'Students.lookupRecords(birthDate=',
|
'Students.lookupRecords(birthDate=',
|
||||||
'Students.lookupRecords(firstName=',
|
'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()
|
# 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.add_column('Schools', 'otherAddresses', type='RefList:Address')
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.engine.autocomplete("Students.lookupRecords(", "Schools", "name", self.user),
|
self.autocomplete("Students.lookupRecords(", "Schools", "name"),
|
||||||
[
|
[
|
||||||
'Students.lookupRecords(birthDate=',
|
'Students.lookupRecords(birthDate=',
|
||||||
'Students.lookupRecords(firstName=',
|
'Students.lookupRecords(firstName=',
|
||||||
@ -453,3 +481,163 @@ class TestCompletion(test_engine.EngineTestCase):
|
|||||||
'Students.lookupRecords(schoolName=',
|
'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 = "<BadRepr instance at 0x"
|
||||||
|
self.assertEqual(repr_example(BadRepr())[:len(prefix)], prefix)
|
||||||
|
|
||||||
|
big_list = [9] * 100000
|
||||||
|
self.assertEqual(len(big_list), 100000)
|
||||||
|
big_list_repr = repr_example(big_list)
|
||||||
|
self.assertEqual(len(big_list_repr), 605)
|
||||||
|
self.assertEqual(big_list_repr, "[%s...]" % ("9, " * 200))
|
||||||
|
|
||||||
|
def test_eval_suggestion(self):
|
||||||
|
class Record(object):
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "Record(%s)" % self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bad(self):
|
||||||
|
raise Exception("bad")
|
||||||
|
|
||||||
|
rec = Record('rec')
|
||||||
|
rec.subrec = Record('subrec')
|
||||||
|
rec.subrec.meaning = 42
|
||||||
|
rec.bad_repr = BadRepr()
|
||||||
|
rec.big = "a" * 100000
|
||||||
|
user = Record('user')
|
||||||
|
user.email = 'my_email'
|
||||||
|
user.LinkKey = Record('LinkKey')
|
||||||
|
user.LinkKey.id = 123
|
||||||
|
|
||||||
|
self.assertEqual(eval_suggestion('rec', rec, user), 'Record(rec)')
|
||||||
|
self.assertEqual(eval_suggestion('rec.subrec', rec, user), 'Record(subrec)')
|
||||||
|
self.assertEqual(eval_suggestion('rec.subrec.meaning', rec, user), '42')
|
||||||
|
|
||||||
|
self.assertEqual(eval_suggestion('rec.spam', rec, user), None) # doesn't exist
|
||||||
|
self.assertEqual(eval_suggestion('rec.bad', rec, user), None) # property raises an error
|
||||||
|
|
||||||
|
# attribute exists, but repr() raises an error
|
||||||
|
prefix = "<BadRepr instance at 0x"
|
||||||
|
self.assertEqual(eval_suggestion('rec.bad_repr', rec, user)[:len(prefix)], prefix)
|
||||||
|
|
||||||
|
# attribute exists, but repr() is too long and gets truncated
|
||||||
|
big_repr = repr_example(rec.big)
|
||||||
|
self.assertEqual(eval_suggestion('rec.big', rec, user), big_repr)
|
||||||
|
self.assertEqual(len(big_repr), 200)
|
||||||
|
|
||||||
|
# No string representations for these two
|
||||||
|
self.assertEqual(eval_suggestion('user', rec, user), None)
|
||||||
|
self.assertEqual(eval_suggestion('user.LinkKey', rec, user), None)
|
||||||
|
|
||||||
|
self.assertEqual(eval_suggestion('user.email', rec, user), "'my_email'")
|
||||||
|
self.assertEqual(eval_suggestion('user.LinkKey.id', rec, user), '123')
|
||||||
|
|
||||||
|
self.assertEqual(eval_suggestion('user.spam', rec, user), None) # doesn't exist
|
||||||
|
self.assertEqual(eval_suggestion('user.bad', rec, user), None) # property raises an error
|
||||||
|
|
||||||
|
self.assertEqual(eval_suggestion('subrec', rec, user), None) # other variables not supported
|
||||||
|
|
||||||
|
|
||||||
|
class BadRepr(object):
|
||||||
|
def __repr__(self):
|
||||||
|
raise Exception("Bad repr")
|
||||||
|
@ -334,15 +334,19 @@ class TestRenames(test_engine.EngineTestCase):
|
|||||||
# Renaming a table should not leave the old name available for auto-complete.
|
# Renaming a table should not leave the old name available for auto-complete.
|
||||||
self.load_sample(self.sample)
|
self.load_sample(self.sample)
|
||||||
names = {"People", "Persons"}
|
names = {"People", "Persons"}
|
||||||
|
autocomplete = self.engine.autocomplete("Pe", "Address", "city", 1, user)
|
||||||
|
suggestions = {suggestion for suggestion, value in autocomplete}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
names.intersection(suggestions),
|
||||||
{"People"}
|
{"People"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rename the table and ensure that "People" is no longer present among top-level names.
|
# Rename the table and ensure that "People" is no longer present among top-level names.
|
||||||
out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
|
self.apply_user_action(["RenameTable", "People", "Persons"])
|
||||||
|
autocomplete = self.engine.autocomplete("Pe", "Address", "city", 1, user)
|
||||||
|
suggestions = {suggestion for suggestion, value in autocomplete}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
names.intersection(self.engine.autocomplete("Pe", "Address", "city", user)),
|
names.intersection(suggestions),
|
||||||
{"Persons"}
|
{"Persons"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user