import datetime import sys import test_engine import testsamples from autocomplete_context import repr_example, eval_suggestion from schema import RecalcWhen class TestCompletion(test_engine.EngineTestCase): user = { 'Name': 'Foo', 'UserID': 1, 'UserRef': '1', 'StudentInfo': ['Students', 1], 'LinkKey': {}, 'Origin': None, 'Email': 'foo@example.com', 'Access': 'owners', 'SessionID': 'u1', 'IsLoggedIn': True, 'ShareRef': None } def setUp(self): super(TestCompletion, self).setUp() self.load_sample(testsamples.sample_students) # To test different column types, we add some differently-typed columns to the sample. self.add_column('Students', 'school', type='Ref:Schools', visibleCol=10) self.add_column('Students', 'homeAddress', type='Ref:Address', visibleCol=21) self.add_column('Students', 'birthDate', type='Date') self.add_column('Students', 'lastVisit', type='DateTime:America/New_York') self.add_column('Schools', 'yearFounded', type='Int') 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 ) self.update_record('Schools', 3, budget='123.45', yearFounded='2010', lastModified='2018-01-01') self.update_record('Students', 1, homeAddress=11, school=1) # Create a summary table of Students grouped by school self.apply_user_action(["CreateViewSection", 1, 0, "record", [22], None]) def test_keyword(self): self.assertEqual(self.autocomplete("for", "Address", "city"), ["for", "format("]) def test_grist(self): self.assertEqual(self.autocomplete("gri", "Address", "city"), ["grist"]) def test_value(self): # Should only appear if column exists and is a trigger formula. self.assertEqual( self.autocomplete("val", "Schools", "lastModified"), ["value"] ) self.assertEqual( self.autocomplete("val", "Students", "schoolCities"), [] ) self.assertEqual( self.autocomplete("val", "Students", "nonexistentColumn"), [] ) self.assertEqual(self.autocomplete("valu", "Schools", "lastModifier"), ["value"]) # Should have same type as column. self.assert_autocomplete_includes("value.", "Schools", "lastModifier", {'value.startswith(', 'value.replace(', 'value.title('} ) self.assert_autocomplete_includes("value.", "Schools", "lastModified", {'value.month', 'value.strftime(', 'value.replace('} ) self.assert_autocomplete_includes("value.m", "Schools", "lastModified", {'value.month', 'value.minute'} ) def test_user(self): # Should only appear if column exists and is a trigger formula. self.assertEqual(self.autocomplete("use", "Schools", "lastModified"), ["user"]) self.assertEqual(self.autocomplete("use", "Students", "schoolCities"), []) self.assertEqual(self.autocomplete("use", "Students", "nonexistentColumn"), []) self.assertEqual(self.autocomplete("user", "Schools", "lastModifier"), ["user"]) self.assertEqual( self.autocomplete("user.", "Schools", "lastModified", row_id=2), [ ('user.Access', "'owners'"), ('user.Email', "'foo@example.com'"), ('user.IsLoggedIn', 'True'), ('user.LinkKey', None), ('user.Name', "'Foo'"), ('user.Origin', 'None'), ('user.SessionID', "'u1'"), ('user.ShareRef', 'None'), ('user.StudentInfo', 'Students[1]'), ('user.UserID', '1'), ('user.UserRef', "'1'") ] ) # Should follow user attribute references and autocomplete those types. self.assertEqual( self.autocomplete("user.StudentInfo.", "Schools", "lastModified", row_id=2), [ ('user.StudentInfo.birthDate', 'None'), ('user.StudentInfo.firstName', "'Barack'"), ('user.StudentInfo.homeAddress', 'Address[11]'), ('user.StudentInfo.homeAddress.city', "'New York'"), ('user.StudentInfo.id', '1'), ('user.StudentInfo.lastName', "'Obama'"), ('user.StudentInfo.lastVisit', 'None'), ('user.StudentInfo.school', 'Schools[1]'), ('user.StudentInfo.school.name', "'Columbia'"), ('user.StudentInfo.schoolCities', repr(u'New York:Colombia')), ('user.StudentInfo.schoolIds', repr(u'1:2')), ('user.StudentInfo.schoolName', "'Columbia'"), ] ) # Should not show user attribute completions if user doesn't have attribute. user2 = { 'Name': 'Bar', 'Origin': None, 'Email': 'baro@example.com', 'LinkKey': {}, 'UserID': 2, 'UserRef': '2', 'Access': 'owners', 'SessionID': 'u2', 'IsLoggedIn': True, 'ShareRef': None } self.assertEqual( self.autocomplete("user.", "Schools", "lastModified", user2, row_id=2), [ ('user.Access', "'owners'"), ('user.Email', "'baro@example.com'"), ('user.IsLoggedIn', 'True'), ('user.LinkKey', None), ('user.Name', "'Bar'"), ('user.Origin', 'None'), ('user.SessionID', "'u2'"), ('user.ShareRef', 'None'), ('user.UserID', '2'), ('user.UserRef', "'2'"), ] ) self.assertEqual( self.autocomplete("user.StudentInfo.", "Schools", "schoolCities", user2), [] ) def test_function(self): self.assertEqual(self.autocomplete("MEDI", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) self.assert_autocomplete_includes("ma", "Address", "city", { ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), 'map(', 'math', 'max(', }) def test_member(self): self.assertEqual(self.autocomplete("datetime.tz", "Address", "city"), ["datetime.tzinfo("]) def test_case_insensitive(self): self.assertEqual(self.autocomplete("medi", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) self.assertEqual(self.autocomplete("std", "Address", "city"), [ ('STDEV', '(value, *more_values)', True), ('STDEVA', '(value, *more_values)', True), ('STDEVP', '(value, *more_values)', True), ('STDEVPA', '(value, *more_values)', True) ]) self.assertEqual( self.autocomplete("stu", "Address", "city"), [ 'Students', ('Students.lookupOne', '(colName=, ...)', True), ('Students.lookupRecords', '(colName=, ...)', True), 'Students.lookupRecords(homeAddress=$id)', 'Students_summary_school', ('Students_summary_school.lookupOne', '(colName=, ...)', True), ('Students_summary_school.lookupRecords', '(colName=, ...)', True) ], ) # Add a table name whose lowercase version conflicts with a builtin. self.apply_user_action(['AddTable', 'Max', []]) self.assertEqual(self.autocomplete("max", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), 'Max', ('Max.lookupOne', '(colName=, ...)', True), ('Max.lookupRecords', '(colName=, ...)', True), 'max(', ]) self.assertEqual(self.autocomplete("MAX", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), ]) def test_suggest_globals_and_tables(self): # Should suggest globals and table names. self.assertEqual(self.autocomplete("ME", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) self.assertEqual( self.autocomplete("Ad", "Address", "city"), [ 'Address', ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ], ) self.assertGreaterEqual(set(self.autocomplete("S", "Address", "city")), { 'Schools', 'Students', ('SUM', '(value1, *more_values)', True), ('STDEV', '(value, *more_values)', True), }) self.assertGreaterEqual(set(self.autocomplete("s", "Address", "city")), { 'Schools', 'Students', 'sum(', ('SUM', '(value1, *more_values)', True), ('STDEV', '(value, *more_values)', True), }) self.assertEqual( self.autocomplete("Addr", "Schools", "budget"), [ 'Address', ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ], ) def test_suggest_columns(self): self.assertEqual(self.autocomplete("$ci", "Address", "city"), ["$city"]) self.assertEqual(self.autocomplete("rec.i", "Address", "city"), ["rec.id"]) self.assertEqual(len(self.autocomplete("$", "Address", "city")), 2) # A few more detailed examples. self.assertEqual(self.autocomplete("$", "Students", "school"), ['$birthDate', '$firstName', '$homeAddress', '$homeAddress.city', '$id', '$lastName', '$lastVisit', '$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName']) self.assertEqual(self.autocomplete("$fi", "Students", "birthDate"), ['$firstName']) self.assertEqual(self.autocomplete("$school", "Students", "lastVisit"), ['$school', '$school.name', '$schoolCities', '$schoolIds', '$schoolName']) def test_suggest_lookup_methods(self): # Should suggest lookup formulas for tables. address_dot_completion = self.autocomplete("Address.", "Students", "firstName") # In python 3.9.7, rlcompleter stops adding parens for property attributes, # see https://bugs.python.org/issue44752 - seems like a minor issue, so leave test # tolerant. property_aware_completer = address_dot_completion[0] == 'Address.Record' self.assertEqual(address_dot_completion, [ 'Address.Record' if property_aware_completer else ('Address.Record', '', True), 'Address.RecordSet' if property_aware_completer else ('Address.RecordSet', '', True), 'Address.all', ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ]) self.assertEqual( self.autocomplete("Address.lookup", "Students", "lastName"), [ ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ] ) self.assertEqual( self.autocomplete("address.look", "Students", "schoolName"), [ ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ] ) def test_suggest_column_type_methods(self): # Should treat columns as correct types. self.assert_autocomplete_includes("$firstName.", "Students", "firstName", {'$firstName.startswith(', '$firstName.replace(', '$firstName.title('} ) self.assert_autocomplete_includes("$birthDate.", "Students", "lastName", {'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('} ) self.assert_autocomplete_includes("$lastVisit.m", "Students", "firstName", {'$lastVisit.month', '$lastVisit.minute'} ) self.assert_autocomplete_includes("$school.", "Students", "firstName", {'$school.address', '$school.name', '$school.yearFounded', '$school.budget'} ) self.assertEqual(self.autocomplete("$school.year", "Students", "lastName"), ['$school.yearFounded']) self.assert_autocomplete_includes("$yearFounded.", "Schools", "budget", { '$yearFounded.denominator', # Only integers have this '$yearFounded.bit_length(', # and this '$yearFounded.real' } ) self.assert_autocomplete_includes("$budget.", "Schools", "budget", {'$budget.is_integer(', '$budget.real'} # Only floats have this ) def test_suggest_follows_references(self): # Should follow references and autocomplete those types. self.assertEqual( self.autocomplete("$school.name.st", "Students", "firstName"), ['$school.name.startswith(', '$school.name.strip('] ) self.assert_autocomplete_includes("$school.yearFounded.","Students", "firstName", { '$school.yearFounded.denominator', '$school.yearFounded.bit_length(', '$school.yearFounded.real' } ) self.assertEqual( self.autocomplete("$school.address.", "Students", "lastName"), ['$school.address.city', '$school.address.id'] ) self.assertEqual( self.autocomplete("$school.address.city.st", "Students", "lastName"), ['$school.address.city.startswith(', '$school.address.city.strip('] ) def test_suggest_lookup_early(self): # For part of a table name, suggest lookup methods early, # including a 'reverse reference' lookup, i.e. `=$id`, # but only for `lookupRecords`, not `lookupOne`. self.assertEqual( self.autocomplete("stu", "Schools", "name"), [ 'Students', ('Students.lookupOne', '(colName=, ...)', True), ('Students.lookupRecords', '(colName=, ...)', True), # i.e. Students.school is a reference to Schools 'Students.lookupRecords(school=$id)', 'Students_summary_school', ('Students_summary_school.lookupOne', '(colName=, ...)', True), ('Students_summary_school.lookupRecords', '(colName=, ...)', True), 'Students_summary_school.lookupRecords(school=$id)', ], ) self.assertEqual( self.autocomplete("scho", "Address", "city"), [ 'Schools', ('Schools.lookupOne', '(colName=, ...)', True), ('Schools.lookupRecords', '(colName=, ...)', True), # i.e. Schools.address is a reference to Address 'Schools.lookupRecords(address=$id)', ], ) # Same as above, but the formula is being entered in 'Students' instead of 'Address', # which means there's no reverse reference to suggest. self.assertEqual( self.autocomplete("scho", "Students", "firstName"), [ 'Schools', ('Schools.lookupOne', '(colName=, ...)', True), ('Schools.lookupRecords', '(colName=, ...)', True), ], ) # Test from within a summary table self.assertEqual( self.autocomplete("stu", "Students_summary_school", "count"), [ 'Students', ('Students.lookupOne', '(colName=, ...)', True), ('Students.lookupRecords', '(colName=, ...)', True), 'Students_summary_school', ('Students_summary_school.lookupOne', '(colName=, ...)', True), ('Students_summary_school.lookupRecords', '(colName=, ...)', True), ], ) def test_suggest_lookup_arguments(self): # Typing in the full `.lookupRecords(` should suggest keyword argument (i.e. column) names, # in addition to reference lookups, including the reverse reference lookups above. self.assertEqual( self.autocomplete("Schools.lookupRecords(", "Address", "city"), [ 'Schools.lookupRecords(address=', 'Schools.lookupRecords(address=$id)', 'Schools.lookupRecords(budget=', 'Schools.lookupRecords(id=', 'Schools.lookupRecords(lastModified=', 'Schools.lookupRecords(lastModifier=', 'Schools.lookupRecords(name=', 'Schools.lookupRecords(yearFounded=', ], ) # In addition to reverse reference lookups, suggest other lookups involving two reference # columns (one from the looked up table, one from the current table) targeting the same table, # e.g. `address=$homeAddress` in the two cases below. self.assertEqual( self.autocomplete("Schools.lookupRecords(", "Students", "firstName"), [ 'Schools.lookupRecords(address=', 'Schools.lookupRecords(address=$homeAddress)', 'Schools.lookupRecords(budget=', 'Schools.lookupRecords(id=', 'Schools.lookupRecords(lastModified=', 'Schools.lookupRecords(lastModifier=', 'Schools.lookupRecords(name=', 'Schools.lookupRecords(yearFounded=', ], ) self.assertEqual( self.autocomplete("Students.lookupRecords(", "Schools", "name"), [ 'Students.lookupRecords(birthDate=', 'Students.lookupRecords(firstName=', 'Students.lookupRecords(homeAddress=', 'Students.lookupRecords(homeAddress=$address)', 'Students.lookupRecords(id=', 'Students.lookupRecords(lastName=', 'Students.lookupRecords(lastVisit=', 'Students.lookupRecords(school=', 'Students.lookupRecords(school=$id)', 'Students.lookupRecords(schoolCities=', 'Students.lookupRecords(schoolIds=', 'Students.lookupRecords(schoolName=', ], ) # Add some more reference columns to test that all combinations are offered self.add_column('Students', 'homeAddress2', type='Ref:Address') self.add_column('Schools', 'address2', type='Ref:Address') # This leads to `Students.lookupRecords(moreAddresses=CONTAINS($address[2]))` self.add_column('Students', 'moreAddresses', type='RefList:Address') # This doesn't affect anything, because there's no way to do the opposite of CONTAINS() self.add_column('Schools', 'otherAddresses', type='RefList:Address') self.assertEqual( self.autocomplete("Students.lookupRecords(", "Schools", "name"), [ 'Students.lookupRecords(birthDate=', 'Students.lookupRecords(firstName=', 'Students.lookupRecords(homeAddress2=', 'Students.lookupRecords(homeAddress2=$address)', 'Students.lookupRecords(homeAddress2=$address2)', 'Students.lookupRecords(homeAddress=', 'Students.lookupRecords(homeAddress=$address)', 'Students.lookupRecords(homeAddress=$address2)', 'Students.lookupRecords(id=', 'Students.lookupRecords(lastName=', 'Students.lookupRecords(lastVisit=', 'Students.lookupRecords(moreAddresses=', 'Students.lookupRecords(moreAddresses=CONTAINS($address))', 'Students.lookupRecords(moreAddresses=CONTAINS($address2))', 'Students.lookupRecords(school=', 'Students.lookupRecords(school=$id)', 'Students.lookupRecords(schoolCities=', 'Students.lookupRecords(schoolIds=', '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 assert_autocomplete_includes(self, formula, table, column, expected, user=None, row_id=None): completions = self.autocomplete(formula, table, column, user=user, row_id=row_id) def replace_completion(completion): if isinstance(completion, str) and completion.endswith('()'): # Python 3.10+ autocompletes the closing paren for methods with no arguments. # This allows the test to check for `somestring.title(` and work across Python versions. assert sys.version_info >= (3, 10) return completion[:-1] return completion completions = set(replace_completion(completion) for completion in completions) self.assertGreaterEqual(completions, expected) 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 = "