(core) Add 'value' to trigger formula autocomplete

Summary:
API signature for autocomplete updated to add column ID, which is
necessary for exposing correct types for 'value'.

Test Plan: Unit tests.

Reviewers: alexmojaki

Reviewed By: alexmojaki

Subscribers: jarek, alexmojaki

Differential Revision: https://phab.getgrist.com/D2896
This commit is contained in:
George Gevoian 2021-07-07 09:03:01 -07:00
parent 8524b4f791
commit 9592e3610b
9 changed files with 105 additions and 54 deletions

View File

@ -20,12 +20,14 @@ var modelUtil = require('../models/modelUtil');
* element. Both desiredSize and the return value are objects with 'width' and 'height' members. * element. Both desiredSize and the return value are objects with 'width' and 'height' members.
*/ */
function AceEditor(options) { function AceEditor(options) {
options = options || {};
// Observable subscription is not created until the dom is built // Observable subscription is not created until the dom is built
this.observable = (options && options.observable) || null; this.observable = options.observable || null;
this.saveValueOnBlurEvent = !(options && (options.saveValueOnBlurEvent === false)); this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false);
this.calcSize = (options && options.calcSize) || ((elem, size) => size); this.calcSize = options.calcSize || ((_elem, size) => size);
this.gristDoc = (options && options.gristDoc) || null; this.gristDoc = options.gristDoc || null;
this.editorState = (options && options.editorState) || null; this.field = options.field || null;
this.editorState = options.editorState || null;
this._readonly = options.readonly || false; this._readonly = options.readonly || false;
this.editor = null; this.editor = null;
@ -183,10 +185,11 @@ AceEditor.prototype.setFontSize = function(pxVal) {
AceEditor.prototype._setup = function() { AceEditor.prototype._setup = function() {
// Standard editor setup // Standard editor setup
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom)); this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
if (this.gristDoc) { if (this.gristDoc && this.field) {
const getSuggestions = (prefix) => { const getSuggestions = (prefix) => {
const tableId = this.gristDoc.viewModel.activeSection().table().tableId(); const tableId = this.gristDoc.viewModel.activeSection().table().tableId();
return this.gristDoc.docComm.autocomplete(prefix, tableId); const columnId = this.field.column().colId();
return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId);
}; };
setupAceEditorCompletions(this.editor, {getSuggestions}); setupAceEditorCompletions(this.editor, {getSuggestions});
} }

View File

@ -45,6 +45,7 @@ export class FormulaEditor extends NewBaseEditor {
this._formulaEditor = AceEditor.create({ this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called // A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created. // and _editorPlacement created.
field: options.field,
calcSize: this._calcSize.bind(this), calcSize: this._calcSize.bind(this),
gristDoc: options.gristDoc, gristDoc: options.gristDoc,
saveValueOnBlurEvent: !options.readonly, saveValueOnBlurEvent: !options.readonly,

View File

@ -174,9 +174,9 @@ 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`. * formula in table `tableId` and column `columnId`.
*/ */
autocomplete(txt: string, tableId: string): Promise<string[]>; autocomplete(txt: string, tableId: string, columnId: string): Promise<string[]>;
/** /**
* Removes the current instance from the doc. * Removes the current instance from the doc.

View File

@ -836,11 +836,11 @@ export class ActiveDoc extends EventEmitter {
docSession.linkId = 0; docSession.linkId = 0;
} }
public async autocomplete(docSession: DocSession, txt: string, tableId: string): Promise<string[]> { public async autocomplete(docSession: DocSession, txt: string, tableId: string, columnId: string): Promise<string[]> {
// 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();
return this._pyCall('autocomplete', txt, tableId); return this._pyCall('autocomplete', txt, tableId, columnId);
} }
public fetchURL(docSession: DocSession, url: string): Promise<UploadResult> { public fetchURL(docSession: DocSession, url: string): Promise<UploadResult> {

View File

@ -60,6 +60,9 @@ class AutocompleteContext(object):
lower += '*' lower += '*'
self._lowercase[lower] = key self._lowercase[lower] = key
# Lowercase 'value' is used in trigger formulas, and is not the same as 'VALUE'.
self._lowercase.pop('value', None)
# Add the lowercase names to the context, and to the detailed completions in _functions. # Add the lowercase names to the context, and to the detailed completions in _functions.
for lower, key in six.iteritems(self._lowercase): for lower, key in six.iteritems(self._lowercase):
self._context[lower] = self._context[key] self._context[lower] = self._context[key]

View File

@ -1281,7 +1281,7 @@ class Engine(object):
if not self._compute_stack: if not self._compute_stack:
self._bring_lookups_up_to_date(doc_action) self._bring_lookups_up_to_date(doc_action)
def autocomplete(self, txt, table_id): def autocomplete(self, txt, table_id, column_id):
""" """
Return a list of suggested completions of the python fragment supplied. Return a list of suggested completions of the python fragment supplied.
""" """
@ -1295,6 +1295,13 @@ class Engine(object):
context = self._autocomplete_context.get_context() context = self._autocomplete_context.get_context()
context['rec'] = table.sample_record context['rec'] = table.sample_record
# Remove values from the context that need to be recomputed.
context.pop('value', None)
column = table.get_column(column_id) if table.has_column(column_id) else None
if column and not column.is_formula():
context['value'] = column.sample_value()
completer = rlcompleter.Completer(context) completer = rlcompleter.Completer(context)
results = [] results = []
at = 0 at = 0

View File

@ -73,8 +73,8 @@ def run(sandbox):
return eng.acl_split(action_group).to_json_obj() return eng.acl_split(action_group).to_json_obj()
@export @export
def autocomplete(txt, table_id): def autocomplete(txt, table_id, column_id):
return eng.autocomplete(txt, table_id) return eng.autocomplete(txt, table_id, column_id)
@export @export
def find_col_from_values(values, n, opt_table_id): def find_col_from_values(values, n, opt_table_id):

View File

@ -1,5 +1,6 @@
import testsamples import testsamples
import test_engine import test_engine
from schema import RecalcWhen
class TestCompletion(test_engine.EngineTestCase): class TestCompletion(test_engine.EngineTestCase):
def setUp(self): def setUp(self):
@ -12,19 +13,45 @@ class TestCompletion(test_engine.EngineTestCase):
self.add_column('Students', 'lastVisit', type='DateTime:America/New_York') self.add_column('Students', 'lastVisit', type='DateTime:America/New_York')
self.add_column('Schools', 'yearFounded', type='Int') self.add_column('Schools', 'yearFounded', type='Int')
self.add_column('Schools', 'budget', type='Numeric') self.add_column('Schools', 'budget', type='Numeric')
self.add_column('Schools', 'lastModified',
type="DateTime:America/Los_Angeles", isFormula=False, formula="NOW()",
recalcWhen=RecalcWhen.MANUAL_UPDATES
)
self.add_column('Schools', 'lastModifier',
type="Text", isFormula=False, formula="foo@getgrist.com",
recalcWhen=RecalcWhen.MANUAL_UPDATES
)
def test_keyword(self): def test_keyword(self):
self.assertEqual(self.engine.autocomplete("for", "Address"), self.assertEqual(self.engine.autocomplete("for", "Address", "city"),
["for", "format("]) ["for", "format("])
def test_grist(self): def test_grist(self):
self.assertEqual(self.engine.autocomplete("gri", "Address"), self.assertEqual(self.engine.autocomplete("gri", "Address", "city"),
["grist"]) ["grist"])
def test_value(self):
# Should only appear if column exists and is a trigger formula.
self.assertEqual(self.engine.autocomplete("val", "Schools", "lastModified"),
["value"])
self.assertEqual(self.engine.autocomplete("val", "Students", "schoolCities"),
[])
self.assertEqual(self.engine.autocomplete("val", "Students", "nonexistentColumn"),
[])
self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier"),
["value"])
# Should have same type as column.
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModifier")),
{'value.startswith(', 'value.replace(', 'value.title('})
self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModified")),
{'value.month', 'value.strftime(', 'value.replace('})
self.assertGreaterEqual(set(self.engine.autocomplete("value.m", "Schools", "lastModified")),
{'value.month', 'value.minute'})
def test_function(self): def test_function(self):
self.assertEqual(self.engine.autocomplete("MEDI", "Address"), self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city"),
[('MEDIAN', '(value, *more_values)', True)]) [('MEDIAN', '(value, *more_values)', True)])
self.assertEqual(self.engine.autocomplete("ma", "Address"), [ self.assertEqual(self.engine.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(',
@ -33,30 +60,30 @@ class TestCompletion(test_engine.EngineTestCase):
]) ])
def test_member(self): def test_member(self):
self.assertEqual(self.engine.autocomplete("datetime.tz", "Address"), self.assertEqual(self.engine.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"), self.assertEqual(self.engine.autocomplete("medi", "Address", "city"),
[('MEDIAN', '(value, *more_values)', True)]) [('MEDIAN', '(value, *more_values)', True)])
self.assertEqual(self.engine.autocomplete("std", "Address"), [ self.assertEqual(self.engine.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.engine.autocomplete("stu", "Address"), self.assertEqual(self.engine.autocomplete("stu", "Address", "city"),
["Students"]) ["Students"])
# 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"), [ self.assertEqual(self.engine.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',
'max(', 'max(',
]) ])
self.assertEqual(self.engine.autocomplete("MAX", "Address"), [ self.assertEqual(self.engine.autocomplete("MAX", "Address", "city"), [
('MAX', '(value, *more_values)', True), ('MAX', '(value, *more_values)', True),
('MAXA', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True),
]) ])
@ -64,89 +91,93 @@ 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"), self.assertEqual(self.engine.autocomplete("ME", "Address", "city"),
[('MEDIAN', '(value, *more_values)', True)]) [('MEDIAN', '(value, *more_values)', True)])
self.assertEqual(self.engine.autocomplete("Ad", "Address"), ['Address']) self.assertEqual(self.engine.autocomplete("Ad", "Address", "city"), ['Address'])
self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address")), { self.assertGreaterEqual(set(self.engine.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")), { self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city")), {
'Schools', 'Schools',
'Students', 'Students',
'sum(', 'sum(',
('SUM', '(value1, *more_values)', True), ('SUM', '(value1, *more_values)', True),
('STDEV', '(value, *more_values)', True), ('STDEV', '(value, *more_values)', True),
}) })
self.assertEqual(self.engine.autocomplete("Addr", "Schools"), ['Address']) self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget"), ['Address'])
def test_suggest_columns(self): def test_suggest_columns(self):
self.assertEqual(self.engine.autocomplete("$ci", "Address"), self.assertEqual(self.engine.autocomplete("$ci", "Address", "city"),
["$city"]) ["$city"])
self.assertEqual(self.engine.autocomplete("rec.i", "Address"), self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city"),
["rec.id"]) ["rec.id"])
self.assertEqual(len(self.engine.autocomplete("$", "Address")), self.assertEqual(len(self.engine.autocomplete("$", "Address", "city")),
2) 2)
# A few more detailed examples. # A few more detailed examples.
self.assertEqual(self.engine.autocomplete("$", "Students"), self.assertEqual(self.engine.autocomplete("$", "Students", "school"),
['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit', ['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit',
'$school', '$schoolCities', '$schoolIds', '$schoolName']) '$school', '$schoolCities', '$schoolIds', '$schoolName'])
self.assertEqual(self.engine.autocomplete("$fi", "Students"), ['$firstName']) self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate"), ['$firstName'])
self.assertEqual(self.engine.autocomplete("$school", "Students"), self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit"),
['$school', '$schoolCities', '$schoolIds', '$schoolName']) ['$school', '$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.
self.assertEqual(self.engine.autocomplete("Address.", "Students"), [ self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName"), [
'Address.all', 'Address.all',
('Address.lookupOne', '(colName=<value>, ...)', True), ('Address.lookupOne', '(colName=<value>, ...)', True),
('Address.lookupRecords', '(colName=<value>, ...)', True), ('Address.lookupRecords', '(colName=<value>, ...)', True),
]) ])
self.assertEqual(self.engine.autocomplete("Address.lookup", "Students"), [ self.assertEqual(self.engine.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),
]) ])
self.assertEqual(self.engine.autocomplete("address.look", "Students"), [ self.assertEqual(self.engine.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),
]) ])
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(set(self.engine.autocomplete("$firstName.", "Students")), self.assertGreaterEqual(set(self.engine.autocomplete("$firstName.", "Students", "firstName")),
{'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}) {'$firstName.startswith(', '$firstName.replace(', '$firstName.title('})
self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students")), self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students", "lastName")),
{'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}) {'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('})
self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students")), self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName")),
{'$lastVisit.month', '$lastVisit.minute'}) {'$lastVisit.month', '$lastVisit.minute'})
self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students")), self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students", "firstName")),
{'$school.address', '$school.name', {'$school.address', '$school.name',
'$school.yearFounded', '$school.budget'}) '$school.yearFounded', '$school.budget'})
self.assertEqual(self.engine.autocomplete("$school.year", "Students"), self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName"),
['$school.yearFounded']) ['$school.yearFounded'])
self.assertGreaterEqual(set(self.engine.autocomplete("$yearFounded.", "Schools")), self.assertGreaterEqual(set(self.engine.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
'$yearFounded.real'}) '$yearFounded.real'})
self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools")), self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools", "budget")),
{'$budget.is_integer(', # Only floats have this {'$budget.is_integer(', # Only floats have this
'$budget.real'}) '$budget.real'})
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.engine.autocomplete("$school.name.st", "Students"), self.assertEqual(self.engine.autocomplete("$school.name.st", "Students", "firstName"),
['$school.name.startswith(', '$school.name.strip(']) ['$school.name.startswith(', '$school.name.strip('])
self.assertGreaterEqual(set(self.engine.autocomplete("$school.yearFounded.", "Students")), self.assertGreaterEqual(
{'$school.yearFounded.denominator', set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName")),
'$school.yearFounded.bit_length(', {
'$school.yearFounded.real'}) '$school.yearFounded.denominator',
'$school.yearFounded.bit_length(',
'$school.yearFounded.real'
}
)
self.assertEqual(self.engine.autocomplete("$school.address.", "Students"), self.assertEqual(self.engine.autocomplete("$school.address.", "Students", "lastName"),
['$school.address.city', '$school.address.id']) ['$school.address.city', '$school.address.id'])
self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students"), self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students", "lastName"),
['$school.address.city.startswith(', '$school.address.city.strip(']) ['$school.address.city.startswith(', '$school.address.city.strip('])

View File

@ -312,11 +312,17 @@ 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"}
self.assertEqual(names.intersection(self.engine.autocomplete("Pe", "Address")), {"People"}) self.assertEqual(
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
{"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"]) out_actions = self.apply_user_action(["RenameTable", "People", "Persons"])
self.assertEqual(names.intersection(self.engine.autocomplete("Pe", "Address")), {"Persons"}) self.assertEqual(
names.intersection(self.engine.autocomplete("Pe", "Address", "city")),
{"Persons"}
)
def test_rename_to_id(self): def test_rename_to_id(self):
# Check that we renaming a column to "Id" disambiguates it with a suffix. # Check that we renaming a column to "Id" disambiguates it with a suffix.