(core) Adding backend for 2-way references

Summary:
Adding support for 2-way references in data engine.

- Columns have an `reverseCol` field, which says "this is a reverse of the given column, update me when that one changes".
- At the time of setting `reverseCol`, we ensure that it's symmetrical to make a 2-way reference.
- Elsewhere we just implement syncing in one direction:
  - When `reverseCol` is present, user code is generated with a type like `grist.ReferenceList("Tasks", reverse_of="Assignee")`
  - On updating a ref column, we use `prepare_new_values()` method to generate corresponding updates to any column that's a reverse of it.
  - The `prepare_new_values()` approach is extended to support this.
  - We don't add (or remove) any mappings between rows, and rely on existing mappings (in a ref column's `_relation`) to create reverse updates.

NOTE This is polished version of https://phab.getgrist.com/D4307 with tests and 3 bug fixes
- Column transformation didn't work when transforming RefList to Ref, the reverse column became out of sync
- Tables with reverse columns couldn't be removed
- Setting json arrays to RefList didn't work if arrays contained other things besides ints
Those fixes are covered by new tests.

Test Plan: New tests

Reviewers: georgegevoian, paulfitz, dsagal

Reviewed By: georgegevoian, paulfitz

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4322
dependabot/npm_and_yarn/express-4.20.0
Jarosław Sadziński 1 week ago
parent 0ca70e9d43
commit 1d2cf3de49

@ -12,8 +12,9 @@ import {icon} from 'app/client/ui2018/icons';
import {loadingDots} from 'app/client/ui2018/loaders'; import {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuDivider, menuIcon, menuItem, menuItemAsync, menuText} from 'app/client/ui2018/menus'; import {menu, menuDivider, menuIcon, menuItem, menuItemAsync, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, observable, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, fromKo, observable, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {makeTestId} from 'app/client/lib/domUtils';
import * as weasel from 'popweasel'; import * as weasel from 'popweasel';
const testId = makeTestId('test-raw-data-'); const testId = makeTestId('test-raw-data-');
@ -109,6 +110,7 @@ export class DataTables extends Disposable {
), ),
cssDotsButton( cssDotsButton(
testId('table-menu'), testId('table-menu'),
testId(use => `table-menu-${use(tableRec.tableId)}`),
icon('Dots'), icon('Dots'),
menu(() => this._menuItems(tableRec, isEditingName), {placement: 'bottom-start'}), menu(() => this._menuItems(tableRec, isEditingName), {placement: 'bottom-start'}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 42; export const SCHEMA_VERSION = 43;
export const schema = { export const schema = {
@ -41,6 +41,7 @@ export const schema = {
displayCol : "Ref:_grist_Tables_column", displayCol : "Ref:_grist_Tables_column",
visibleCol : "Ref:_grist_Tables_column", visibleCol : "Ref:_grist_Tables_column",
rules : "RefList:_grist_Tables_column", rules : "RefList:_grist_Tables_column",
reverseCol : "Ref:_grist_Tables_column",
recalcWhen : "Int", recalcWhen : "Int",
recalcDeps : "RefList:_grist_Tables_column", recalcDeps : "RefList:_grist_Tables_column",
}, },
@ -264,6 +265,7 @@ export interface SchemaTypes {
displayCol: number; displayCol: number;
visibleCol: number; visibleCol: number;
rules: [GristObjCode.List, ...number[]]|null; rules: [GristObjCode.List, ...number[]]|null;
reverseCol: number;
recalcWhen: number; recalcWhen: number;
recalcDeps: [GristObjCode.List, ...number[]]|null; recalcDeps: [GristObjCode.List, ...number[]]|null;
}; };

@ -160,6 +160,7 @@ const OTHER_RECOGNIZED_ACTIONS = new Set([
'RemoveColumn', 'RemoveColumn',
'RenameColumn', 'RenameColumn',
'ModifyColumn', 'ModifyColumn',
'AddReverseColumn',
// Table-level schema changes. // Table-level schema changes.
'AddEmptyTable', 'AddEmptyTable',
@ -913,14 +914,20 @@ export class GranularAccess implements GranularAccessForBundle {
*/ */
public needEarlySchemaPermission(a: UserAction|DocAction): boolean { public needEarlySchemaPermission(a: UserAction|DocAction): boolean {
const name = a[0] as string; const name = a[0] as string;
if (name === 'ModifyColumn' || name === 'SetDisplayFormula' ||
// ConvertFromColumn and CopyFromColumn are hard to reason // ConvertFromColumn and CopyFromColumn are hard to reason
// about, especially since they appear in bundles with other // about, especially since they appear in bundles with other
// actions. We throw up our hands a bit here, and just make // actions. We throw up our hands a bit here, and just make
// sure the user has schema permissions. Today, in Grist, that // sure the user has schema permissions. Today, in Grist, that
// gives a lot of power. If this gets narrowed down in future, // gives a lot of power. If this gets narrowed down in future,
// we'll have to rethink this. // we'll have to rethink this.
name === 'ConvertFromColumn' || name === 'CopyFromColumn') { const actionNames = [
'ModifyColumn',
'SetDisplayFormula',
'ConvertFromColumn',
'CopyFromColumn',
'AddReverseColumn',
];
if (actionNames.includes(name)) {
return true; return true;
} else if (isDataAction(a)) { } else if (isDataAction(a)) {
const tableId = getTableId(a); const tableId = getTableId(a);

@ -6,9 +6,9 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',43,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "reverseCol" INTEGER DEFAULT 0, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "databaseRef" INTEGER DEFAULT 0, "tableName" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "databaseRef" INTEGER DEFAULT 0, "tableName" TEXT DEFAULT '');
@ -44,14 +44,14 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',42,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',43,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "reverseCol" INTEGER DEFAULT 0, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort','',0,0,0,0,NULL,0,NULL); INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort','',0,0,0,0,NULL,0,0,NULL);
INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A','',0,0,0,0,NULL,0,NULL); INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A','',0,0,0,0,NULL,0,0,NULL);
INSERT INTO _grist_Tables_column VALUES(3,1,3,'B','Any','',1,'','B','',0,0,0,0,NULL,0,NULL); INSERT INTO _grist_Tables_column VALUES(3,1,3,'B','Any','',1,'','B','',0,0,0,0,NULL,0,0,NULL);
INSERT INTO _grist_Tables_column VALUES(4,1,4,'C','Any','',1,'','C','',0,0,0,0,NULL,0,NULL); INSERT INTO _grist_Tables_column VALUES(4,1,4,'C','Any','',1,'','C','',0,0,0,0,NULL,0,0,NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "databaseRef" INTEGER DEFAULT 0, "tableName" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_External_table" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "databaseRef" INTEGER DEFAULT 0, "tableName" TEXT DEFAULT '');

@ -6,11 +6,13 @@ from numbers import Number
import six import six
import actions
import depend import depend
import objtypes import objtypes
import usertypes import usertypes
import relabeling import relabeling
import relation import relation
import reverse_references
import moment import moment
from sortedcontainers import SortedListWithKey from sortedcontainers import SortedListWithKey
@ -221,17 +223,23 @@ class BaseColumn(object):
""" """
return self.type_obj.convert(value_to_convert) return self.type_obj.convert(value_to_convert)
def prepare_new_values(self, values, ignore_data=False, action_summary=None): def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):
""" """
This allows us to modify values and also produce adjustments to existing records. This This allows us to modify values and also produce adjustments to existing records.
currently is only used by PositionColumn. Returns two lists: new_values, and
[(row_id, new_value)] list of adjustments to existing records. Returns the pair (new_values, adjustments), where new_values is a list to replace `values`
(one for each row_id), and adjustments is a list of additional docactions to apply, e.g. to
adjust other rows.
If ignore_data is True, makes adjustments without regard to the existing data; this is used If ignore_data is True, makes adjustments without regard to the existing data; this is used
for processing ReplaceTableData actions. for processing ReplaceTableData actions.
""" """
# pylint: disable=no-self-use, unused-argument # pylint: disable=no-self-use, unused-argument
return values, [] return values, []
def recalc_from_reverse_values(self):
pass # Only two-way references implement this
class DataColumn(BaseColumn): class DataColumn(BaseColumn):
""" """
@ -253,6 +261,7 @@ class ChoiceColumn(DataColumn):
return row_ids, values return row_ids, values
def _rename_cell_choice(self, renames, value): def _rename_cell_choice(self, renames, value):
# pylint: disable=no-self-use
return renames.get(value) return renames.get(value)
@ -373,7 +382,7 @@ class PositionColumn(NumericColumn):
self._sorted_rows = SortedListWithKey(other_column._sorted_rows[:], self._sorted_rows = SortedListWithKey(other_column._sorted_rows[:],
key=lambda x: SafeSortKey(self.raw_get(x))) key=lambda x: SafeSortKey(self.raw_get(x)))
def prepare_new_values(self, values, ignore_data=False, action_summary=None): def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):
# This does the work of adjusting positions and relabeling existing rows with new position # This does the work of adjusting positions and relabeling existing rows with new position
# (without changing sort order) to make space for the new positions. Note that this is also # (without changing sort order) to make space for the new positions. Note that this is also
# used for updating a position for an existing row: we'll find a new value for it; later when # used for updating a position for an existing row: we'll find a new value for it; later when
@ -385,7 +394,9 @@ class PositionColumn(NumericColumn):
# prepare_inserts expects floats as keys, not MixedTypesKeys # prepare_inserts expects floats as keys, not MixedTypesKeys
rows = SortedListWithKey(rows, key=self.raw_get) rows = SortedListWithKey(rows, key=self.raw_get)
adjustments, new_values = relabeling.prepare_inserts(rows, values) adjustments, new_values = relabeling.prepare_inserts(rows, values)
return new_values, [(self._sorted_rows[i], pos) for (i, pos) in adjustments] adj_action = _adjustments_to_action(self.node,
[(self._sorted_rows[i], pos) for (i, pos) in adjustments])
return new_values, ([adj_action] if adj_action else [])
class ChoiceListColumn(ChoiceColumn): class ChoiceListColumn(ChoiceColumn):
@ -410,6 +421,7 @@ class ChoiceListColumn(ChoiceColumn):
def _rename_cell_choice(self, renames, value): def _rename_cell_choice(self, renames, value):
if any((v in renames) for v in value): if any((v in renames) for v in value):
return tuple(renames.get(choice, choice) for choice in value) return tuple(renames.get(choice, choice) for choice in value)
return None
class BaseReferenceColumn(BaseColumn): class BaseReferenceColumn(BaseColumn):
@ -420,24 +432,45 @@ class BaseReferenceColumn(BaseColumn):
super(BaseReferenceColumn, self).__init__(table, col_id, col_info) super(BaseReferenceColumn, self).__init__(table, col_id, col_info)
# We can assume that all tables have been instantiated, but not all initialized. # We can assume that all tables have been instantiated, but not all initialized.
target_table_id = self.type_obj.table_id target_table_id = self.type_obj.table_id
self._table = table
self._target_table = table._engine.tables.get(target_table_id, None) self._target_table = table._engine.tables.get(target_table_id, None)
self._relation = relation.ReferenceRelation(table.table_id, target_table_id, col_id) self._relation = relation.ReferenceRelation(table.table_id, target_table_id, col_id)
# Note that we need to remove these back-references when the column is removed. # Note that we need to remove these back-references when the column is removed.
if self._target_table: if self._target_table:
self._target_table._back_references.add(self) self._target_table._back_references.add(self)
self._reverse_source_node = self.type_obj.reverse_source_node()
if self._reverse_source_node:
_multimap_add(self._table._reverse_cols_by_source_node, self._reverse_source_node, self)
def destroy(self): def destroy(self):
# Destroy the column and remove the back-reference we created in the constructor. # Destroy the column and remove the back-reference we created in the constructor.
super(BaseReferenceColumn, self).destroy() super(BaseReferenceColumn, self).destroy()
if self._reverse_source_node:
_multimap_remove(self._table._reverse_cols_by_source_node, self._reverse_source_node, self)
if self._target_table: if self._target_table:
self._target_table._back_references.remove(self) self._target_table._back_references.remove(self)
def _update_references(self, row_id, old_value, new_value): def _update_references(self, row_id, old_value, new_value):
for r in self._value_iterable(old_value):
self._relation.remove_reference(row_id, r)
for r in self._value_iterable(new_value):
self._relation.add_reference(row_id, r)
def _clean_up_value(self, value):
raise NotImplementedError()
def _value_iterable(self, value):
raise NotImplementedError()
def _list_to_value(self, value_as_list):
raise NotImplementedError() raise NotImplementedError()
def set(self, row_id, value): def set(self, row_id, value):
old = self.safe_get(row_id) old = self.safe_get(row_id)
super(BaseReferenceColumn, self).set(row_id, value) super(BaseReferenceColumn, self).set(row_id, self._clean_up_value(value))
new = self.safe_get(row_id) new = self.safe_get(row_id)
self._update_references(row_id, old, new) self._update_references(row_id, old, new)
@ -462,6 +495,39 @@ class BaseReferenceColumn(BaseColumn):
affected_rows = sorted(self._relation.get_affected_rows(target_row_ids)) affected_rows = sorted(self._relation.get_affected_rows(target_row_ids))
return [(row_id, self._raw_get_without(row_id, target_row_ids)) for row_id in affected_rows] return [(row_id, self._raw_get_without(row_id, target_row_ids)) for row_id in affected_rows]
def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):
values = [self._clean_up_value(v) for v in values]
reverse_cols = self._target_table._reverse_cols_by_source_node.get(self.node, [])
adjustments = []
if reverse_cols:
old_values = [self.raw_get(r) for r in row_ids]
reverse_adjustments = reverse_references.get_reverse_adjustments(
row_ids, old_values, values, self._value_iterable, self._relation)
if reverse_adjustments:
for reverse_col in reverse_cols:
adjustments.append(_adjustments_to_action(
reverse_col.node,
[(row_id, reverse_col._list_to_value(value)) for (row_id, value) in reverse_adjustments]
))
return values, adjustments
def recalc_from_reverse_values(self):
"""
Generates actions to update reverse column based on this column.
"""
if not self._reverse_source_node:
return None
rev_table_id, rev_col_id = self._reverse_source_node
reverse_col = self._target_table.get_column(rev_col_id)
reverse_adjustments = []
for target_row_id in self._target_table.row_ids:
reverse_value = self._relation.get_affected_rows((target_row_id,))
reverse_adjustments.append((target_row_id, sorted(reverse_value)))
return _adjustments_to_action(reverse_col.node,
[(row_id, reverse_col._list_to_value(value)) for (row_id, value) in reverse_adjustments])
def _raw_get_without(self, _row_id, _target_row_ids): def _raw_get_without(self, _row_id, _target_row_ids):
""" """
Returns a Ref or RefList cell value with the specified target_row_ids removed, assuming one of Returns a Ref or RefList cell value with the specified target_row_ids removed, assuming one of
@ -493,28 +559,33 @@ class ReferenceColumn(BaseReferenceColumn):
# the 0 index will contain the all-defaults record. # the 0 index will contain the all-defaults record.
return self._target_table.Record(typed_value, self._relation) return self._target_table.Record(typed_value, self._relation)
def _update_references(self, row_id, old_value, new_value): def _value_iterable(self, value):
if old_value: return (value,) if value and self.type_obj.is_right_type(value) else ()
self._relation.remove_reference(row_id, old_value)
if new_value:
self._relation.add_reference(row_id, new_value)
def set(self, row_id, value): def _list_to_value(self, value_as_list):
if len(value_as_list) > 1:
raise ValueError("UNIQUE reference constraint failed for action")
return value_as_list[0] if value_as_list else 0
def _clean_up_value(self, value):
# Allow float values that are small integers. In practice, this only turns out to be relevant # Allow float values that are small integers. In practice, this only turns out to be relevant
# in rare cases (such as undo of Ref->Numeric conversion). # in rare cases (such as undo of Ref->Numeric conversion).
if type(value) == float and value.is_integer(): # pylint:disable=unidiomatic-typecheck if type(value) == float and value.is_integer(): # pylint:disable=unidiomatic-typecheck
if value > 0 and objtypes.is_int_short(int(value)): if value > 0 and objtypes.is_int_short(int(value)):
value = int(value) return int(value)
super(ReferenceColumn, self).set(row_id, value) return value
def prepare_new_values(self, values, ignore_data=False, action_summary=None): def prepare_new_values(self, row_ids, values, ignore_data=False, action_summary=None):
if action_summary and values: if action_summary and values:
values = action_summary.translate_new_row_ids(self._target_table.table_id, values) values = action_summary.translate_new_row_ids(self._target_table.table_id, values)
return values, [] return super(ReferenceColumn, self).prepare_new_values(row_ids, values,
ignore_data=ignore_data, action_summary=action_summary)
def convert(self, val): def convert(self, val):
if isinstance(val, objtypes.ReferenceLookup): if isinstance(val, objtypes.ReferenceLookup):
val = self._lookup(val, val.value) or self._alt_text(val.alt_text) val = self._lookup(val, val.value) or self._alt_text(val.alt_text)
elif isinstance(val, list):
val = val[0] if val else 0
return super(ReferenceColumn, self).convert(val) return super(ReferenceColumn, self).convert(val)
@ -523,7 +594,7 @@ class ReferenceListColumn(BaseReferenceColumn):
ReferenceListColumn maintains for each row a list of references (row IDs) into another table. ReferenceListColumn maintains for each row a list of references (row IDs) into another table.
Accessing them yields RecordSets. Accessing them yields RecordSets.
""" """
def set(self, row_id, value): def _clean_up_value(self, value):
if isinstance(value, six.string_types): if isinstance(value, six.string_types):
# This is second part of a "hack" we have to do when we rename tables. During # This is second part of a "hack" we have to do when we rename tables. During
# the rename, we briefly change all Ref columns to Int columns (to lose the table # the rename, we briefly change all Ref columns to Int columns (to lose the table
@ -535,20 +606,27 @@ class ReferenceListColumn(BaseReferenceColumn):
try: try:
# If it's a string that looks like JSON, try to parse it as such. # If it's a string that looks like JSON, try to parse it as such.
if value.startswith('['): if value.startswith('['):
value = json.loads(value) parsed = json.loads(value)
# It must be list of integers.
if not isinstance(parsed, list):
return value
# All of them must be positive integers
if all(isinstance(v, int) and v > 0 for v in parsed):
return parsed
else: else:
# Else try to parse it as a RecordList # Else try to parse it as a RecordList
value = objtypes.RecordList.from_repr(value) return objtypes.RecordList.from_repr(value)
except Exception: except Exception:
pass pass
return value
super(ReferenceListColumn, self).set(row_id, value) def _value_iterable(self, value):
return value if value and self.type_obj.is_right_type(value) else ()
def _update_references(self, row_id, old_list, new_list): def _list_to_value(self, value_as_list):
for old_value in old_list or (): return value_as_list or None
self._relation.remove_reference(row_id, old_value)
for new_value in new_list or ():
self._relation.add_reference(row_id, new_value)
def _make_rich_value(self, typed_value): def _make_rich_value(self, typed_value):
if typed_value is None: if typed_value is None:
@ -579,8 +657,27 @@ class ReferenceListColumn(BaseReferenceColumn):
return self._alt_text(val.alt_text) return self._alt_text(val.alt_text)
result.append(lookup_value) result.append(lookup_value)
val = result val = result
if isinstance(val, int) and val:
val = [val]
return super(ReferenceListColumn, self).convert(val) return super(ReferenceListColumn, self).convert(val)
def _multimap_add(mapping, key, value):
mapping.setdefault(key, []).append(value)
def _multimap_remove(mapping, key, value):
if key in mapping and value in mapping[key]:
mapping[key].remove(value)
if not mapping[key]:
del mapping[key]
def _adjustments_to_action(node, row_value_pairs):
# Takes a depend.Node and a list of (row_id, value) pairs, and returns a BulkUpdateRecord action.
if not row_value_pairs:
return None
row_ids, values = zip(*row_value_pairs)
return actions.BulkUpdateRecord(node.table_id, row_ids, {node.col_id: values})
# Set up the relationship between usertypes objects and column objects. # Set up the relationship between usertypes objects and column objects.
usertypes.BaseColumnType.ColType = DataColumn usertypes.BaseColumnType.ColType = DataColumn

@ -166,8 +166,7 @@ class DocActions(object):
# Replace the renamed column in the schema object. # Replace the renamed column in the schema object.
schema_table_info = self._engine.schema[table_id] schema_table_info = self._engine.schema[table_id]
colinfo = schema_table_info.columns.pop(old_col_id) colinfo = schema_table_info.columns.pop(old_col_id)
schema_table_info.columns[new_col_id] = schema.SchemaColumn( schema_table_info.columns[new_col_id] = colinfo._replace(colId=new_col_id)
new_col_id, colinfo.type, colinfo.isFormula, colinfo.formula)
self._engine.rebuild_usercode() self._engine.rebuild_usercode()
self._engine.new_column_name(table) self._engine.new_column_name(table)
@ -192,12 +191,14 @@ class DocActions(object):
new = schema.SchemaColumn(col_id, new = schema.SchemaColumn(col_id,
col_info.get('type', old.type), col_info.get('type', old.type),
bool(col_info.get('isFormula', old.isFormula)), bool(col_info.get('isFormula', old.isFormula)),
col_info.get('formula', old.formula)) col_info.get('formula', old.formula),
col_info.get('reverseColId', old.reverseColId))
if new == old: if new == old:
log.info("ModifyColumn called which was a noop") log.info("ModifyColumn called which was a noop")
return return
undo_col_info = {k: v for k, v in six.iteritems(schema.col_to_dict(old, include_id=False)) undo_col_info = {k: v for k, v in six.iteritems(
schema.col_to_dict(old, include_id=False, include_default=True))
if k in col_info} if k in col_info}
# Remove the column from the schema, then re-add it, to force creation of a new column object. # Remove the column from the schema, then re-add it, to force creation of a new column object.

@ -507,9 +507,11 @@ class Engine(object):
if col_parent_ids > valid_table_refs: if col_parent_ids > valid_table_refs:
collist = sorted(actions.transpose_bulk_action(meta_columns), collist = sorted(actions.transpose_bulk_action(meta_columns),
key=lambda c: (c.parentId, c.parentPos)) key=lambda c: (c.parentId, c.parentPos))
reverse_col_id = schema.get_reverse_col_id_lookup_func(collist)
raise AssertionError("Internal schema inconsistent; extra columns in metadata:\n" raise AssertionError("Internal schema inconsistent; extra columns in metadata:\n"
+ "\n".join(' #%s %s' % + "\n".join(' #%s %s' %
(c.id, schema.SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula)) (c.id, schema.SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula,
reverse_col_id(c)))
for c in collist if c.parentId not in valid_table_refs)) for c in collist if c.parentId not in valid_table_refs))
def dump_state(self): def dump_state(self):
@ -1035,11 +1037,9 @@ class Engine(object):
# If there are values for any PositionNumber columns, ensure PositionNumbers are ordered as # If there are values for any PositionNumber columns, ensure PositionNumbers are ordered as
# intended but are all unique, which may require updating other positions. # intended but are all unique, which may require updating other positions.
nvalues, adjustments = col_obj.prepare_new_values(values, nvalues, adjustments = col_obj.prepare_new_values(row_ids, values,
action_summary=self.out_actions.summary) action_summary=self.out_actions.summary)
if adjustments: extra_actions.extend(adjustments)
extra_actions.append(actions.BulkUpdateRecord(
action.table_id, [r for r,v in adjustments], {col_id: [v for r,v in adjustments]}))
new_values[col_id] = nvalues new_values[col_id] = nvalues
@ -1054,11 +1054,10 @@ class Engine(object):
defaults = [col_obj.getdefault() for r in row_ids] defaults = [col_obj.getdefault() for r in row_ids]
# We use defaults to get new values or adjustments. If we are replacing data, we'll make # We use defaults to get new values or adjustments. If we are replacing data, we'll make
# the adjustments without regard to the existing data. # the adjustments without regard to the existing data.
nvalues, adjustments = col_obj.prepare_new_values(defaults, ignore_data=ignore_data, nvalues, adjustments = col_obj.prepare_new_values(row_ids, defaults,
ignore_data=ignore_data,
action_summary=self.out_actions.summary) action_summary=self.out_actions.summary)
if adjustments: extra_actions.extend(adjustments)
extra_actions.append(actions.BulkUpdateRecord(
action.table_id, [r for r,v in adjustments], {col_id: [v for r,v in adjustments]}))
if nvalues != defaults: if nvalues != defaults:
new_values[col_id] = nvalues new_values[col_id] = nvalues

@ -42,7 +42,7 @@ def indent(body, levels=1):
#---------------------------------------------------------------------- #----------------------------------------------------------------------
def get_grist_type(col_type): def get_grist_type(col_type, reverse_col_id=None):
"""Returns code for a grist usertype object given a column type string.""" """Returns code for a grist usertype object given a column type string."""
col_type_split = col_type.split(':', 1) col_type_split = col_type.split(':', 1)
typename = col_type_split[0] typename = col_type_split[0]
@ -54,7 +54,12 @@ def get_grist_type(col_type):
arg = col_type_split[1] if len(col_type_split) > 1 else '' arg = col_type_split[1] if len(col_type_split) > 1 else ''
arg = arg.strip().replace("'", "\\'") arg = arg.strip().replace("'", "\\'")
return "grist.%s(%s)" % (typename, ("'%s'" % arg) if arg else '') args = []
if arg:
args.append("'%s'" % arg)
if reverse_col_id and typename in ('Reference', 'ReferenceList'):
args.append('reverse_of=' + repr(reverse_col_id))
return "grist.%s(%s)" % (typename, ", ".join(args))
class GenCode(object): class GenCode(object):
@ -99,7 +104,7 @@ class GenCode(object):
decorator = '' decorator = ''
if include_type and col_info.type != 'Any': if include_type and col_info.type != 'Any':
decorator = '@grist.formulaType(%s)\n' % get_grist_type(col_info.type) decorator = '@grist.formulaType(%s)\n' % get_grist_type(col_info.type, col_info.reverseColId)
return textbuilder.Combiner(['\n' + decorator + decl, indent(body), '\n']) return textbuilder.Combiner(['\n' + decorator + decl, indent(body), '\n'])
@ -111,7 +116,8 @@ class GenCode(object):
name=table.get_default_func_name(col_info.colId), name=table.get_default_func_name(col_info.colId),
include_type=False, include_type=False,
additional_params=['value', 'user'])) additional_params=['value', 'user']))
parts.append("%s = %s\n" % (col_info.colId, get_grist_type(col_info.type))) parts.append("%s = %s\n" % (col_info.colId,
get_grist_type(col_info.type, col_info.reverseColId)))
return textbuilder.Combiner(parts) return textbuilder.Combiner(parts)

@ -1317,3 +1317,11 @@ def migration42(tdset):
add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'), add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'),
add_column('_grist_Triggers', 'options', 'Text'), add_column('_grist_Triggers', 'options', 'Text'),
]) ])
@migration(schema_version=43)
def migration43(tdset):
"""
Adds reverseCol for two-way references.
"""
return tdset.apply_doc_actions([
add_column('_grist_Tables_column', 'reverseCol', 'Ref:_grist_Tables_column')])

@ -111,6 +111,11 @@ class Record(object):
def __repr__(self): def __repr__(self):
return "%s[%s]" % (self._table.table_id, self._row_id) return "%s[%s]" % (self._table.table_id, self._row_id)
def _exists(self):
# Whether the record exists: helpful for the rare cases when examining a record with a
# non-zero rowId which has just been deleted.
return self._row_id in self._table.row_ids
def _clone_with_relation(self, src_relation): def _clone_with_relation(self, src_relation):
return self._table.Record(self._row_id, return self._table.Record(self._row_id,
relation=src_relation.compose(self._source_relation)) relation=src_relation.compose(self._source_relation))

@ -0,0 +1,73 @@
from collections import defaultdict
import six
from usertypes import get_referenced_table_id
class _RefUpdates(object):
def __init__(self):
self.removals = set()
self.additions = set()
def get_reverse_adjustments(row_ids, old_values, new_values, value_iterator, relation):
"""
Generates data for updating reverse columns, based on changes to this column
"""
# Stores removals and addons for each target row
affected_target_rows = defaultdict(_RefUpdates)
# Iterate over changes to source column (my column)
for (source_row_id, old_value, new_value) in zip(row_ids, old_values, new_values):
if new_value != old_value:
# Treat old_values as removals, and new_values as additions
for target_row_id in value_iterator(old_value):
affected_target_rows[target_row_id].removals.add(source_row_id)
for target_row_id in value_iterator(new_value):
affected_target_rows[target_row_id].additions.add(source_row_id)
# Now in affected_target_rows, we have the changes (deltas), now we are going to convert them
# to updates (full list of values) in target columns.
adjustments = []
# For each target row (that needs to be updated, and was change in our column)
for target_row_id, updates in six.iteritems(affected_target_rows):
# Get the value stored in that column by using our own relation object (which should store
# correct values - the same that are stored in that reverse column). `reverse_value` is the
# value in that reverse cell
reverse_value = relation.get_affected_rows((target_row_id,))
# Now make the adjustments using calculated deltas
for source_row_id in updates.removals:
reverse_value.discard(source_row_id)
for source_row_id in updates.additions:
reverse_value.add(source_row_id)
adjustments.append((target_row_id, sorted(reverse_value)))
return adjustments
def check_desired_reverse_col(col_type, desired_reverse_col):
if not desired_reverse_col:
raise ValueError("invalid column specified in reverseCol")
if desired_reverse_col.reverseCol:
raise ValueError("reverseCol specifies an existing two-way reference column")
ref_table_id = get_referenced_table_id(col_type)
if not ref_table_id:
raise ValueError("reverseCol may only be set on a column with a reference type")
if desired_reverse_col.tableId != ref_table_id:
raise ValueError("reverseCol must be a column in the target table")
def pick_reverse_col_label(docmodel, col_rec):
ref_table_id = get_referenced_table_id(col_rec.type)
ref_table_rec = docmodel.get_table_rec(ref_table_id)
# First try the source table title.
source_table_rec = col_rec.parentId
reverse_label = source_table_rec.rawViewSectionRef.title or source_table_rec.tableId
# If that name already exists (as a label), add the source column's name as a suffix.
avoid_set = set(c.label for c in ref_table_rec.columns)
if reverse_label in avoid_set:
return reverse_label + "-" + (col_rec.label or col_rec.colId)
return reverse_label

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 42 SCHEMA_VERSION = 43
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -88,6 +88,10 @@ def schema_create_actions():
# Points to formula columns that hold conditional formatting rules. # Points to formula columns that hold conditional formatting rules.
make_column("rules", "RefList:_grist_Tables_column"), make_column("rules", "RefList:_grist_Tables_column"),
# For Ref/RefList columns only, points to the corresponding reverse reference column.
# This column will get automatically set to reverse of the references in reverseCol.
make_column("reverseCol", "Ref:_grist_Tables_column"),
# Instructions when to recalculate the formula on a column with isFormula=False (previously # Instructions when to recalculate the formula on a column with isFormula=False (previously
# known as a "default formula"). Values are RecalcWhen constants defined below. # known as a "default formula"). Values are RecalcWhen constants defined below.
make_column("recalcWhen", "Int"), make_column("recalcWhen", "Int"),
@ -379,17 +383,25 @@ class RecalcWhen(object):
# These are little structs to represent the document schema that's used in code generation. # These are little structs to represent the document schema that's used in code generation.
# Schema itself (as stored by Engine) is an OrderedDict(tableId -> SchemaTable), with # Schema itself (as stored by Engine) is an OrderedDict(tableId -> SchemaTable), with
# SchemaTable.columns being an OrderedDict(colId -> SchemaColumn). # SchemaTable.columns being an OrderedDict(colId -> SchemaColumn).
# Note: reverseColId produces types like grist.ReferenceList("Table", reverse_of="ColId")
# used for two-way references.
SchemaTable = namedtuple('SchemaTable', ('tableId', 'columns')) SchemaTable = namedtuple('SchemaTable', ('tableId', 'columns'))
SchemaColumn = namedtuple('SchemaColumn', ('colId', 'type', 'isFormula', 'formula')) SchemaColumn = namedtuple('SchemaColumn', ('colId', 'type', 'isFormula', 'formula', 'reverseColId'))
# Helpers to convert between schema structures and dicts used in schema actions. # Helpers to convert between schema structures and dicts used in schema actions.
def dict_to_col(col, col_id=None): def dict_to_col(col, col_id=None):
"""Convert dict as used in AddColumn/AddTable actions to a SchemaColumn object.""" """Convert dict as used in AddColumn/AddTable actions to a SchemaColumn object."""
return SchemaColumn(col_id or col["id"], col["type"], bool(col["isFormula"]), col["formula"]) return SchemaColumn(col_id or col["id"], col["type"], bool(col["isFormula"]), col["formula"],
col.get("reverseColId"))
def col_to_dict(col, include_id=True): def col_to_dict(col, include_id=True, include_default=False):
"""Convert SchemaColumn to dict to use in AddColumn/AddTable actions.""" """
Convert SchemaColumn to dict to use in AddColumn/AddTable actions.
Set include_default=True to include default values explicitly, e.g. override previous values.
"""
ret = {"type": col.type, "isFormula": col.isFormula, "formula": col.formula} ret = {"type": col.type, "isFormula": col.isFormula, "formula": col.formula}
if col.reverseColId or include_default:
ret["reverseColId"] = col.reverseColId
if include_id: if include_id:
ret["id"] = col.colId ret["id"] = col.colId
return ret return ret
@ -406,6 +418,14 @@ def clone_schema(schema):
return OrderedDict((t, SchemaTable(s.tableId, s.columns.copy())) return OrderedDict((t, SchemaTable(s.tableId, s.columns.copy()))
for (t, s) in six.iteritems(schema)) for (t, s) in six.iteritems(schema))
def get_reverse_col_id_lookup_func(collist):
"""
Given a list of _grist_Tables_column records, return a function that takes a record and
returns its reverseColId.
"""
col_ref_to_col_id = {c.id: c.colId for c in collist}
return lambda c: col_ref_to_col_id.get(getattr(c, 'reverseCol', 0))
def build_schema(meta_tables, meta_columns, include_builtin=True): def build_schema(meta_tables, meta_columns, include_builtin=True):
""" """
Arguments are TableData objects for the _grist_Tables and _grist_Tables_column tables. Arguments are TableData objects for the _grist_Tables and _grist_Tables_column tables.
@ -425,8 +445,12 @@ def build_schema(meta_tables, meta_columns, include_builtin=True):
key=lambda c: (c.parentId, c.parentPos)) key=lambda c: (c.parentId, c.parentPos))
coldict = {t: list(cols) for t, cols in itertools.groupby(collist, lambda r: r.parentId)} coldict = {t: list(cols) for t, cols in itertools.groupby(collist, lambda r: r.parentId)}
# Translate reverseCol in metadata to reverseColId in schema structure.
reverse_col_id = get_reverse_col_id_lookup_func(collist)
for t in actions.transpose_bulk_action(meta_tables): for t in actions.transpose_bulk_action(meta_tables):
columns = OrderedDict((c.colId, SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula)) columns = OrderedDict(
(c.colId, SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula, reverse_col_id(c)))
for c in coldict[t.id]) for c in coldict[t.id])
schema[t.tableId] = SchemaTable(t.tableId, columns) schema[t.tableId] = SchemaTable(t.tableId, columns)
return schema return schema

@ -229,6 +229,10 @@ class Table(object):
# Set of ReferenceColumn objects that refer to this table # Set of ReferenceColumn objects that refer to this table
self._back_references = set() self._back_references = set()
# Maps the depend.Node of the source column (possibly in a different table) to column(s) in
# this table that have that source column as reverseColRef: {Node: [Column, ...]}.
self._reverse_cols_by_source_node = {}
# Store the constant Node for "new columns". Accessing invalid columns creates a dependency # Store the constant Node for "new columns". Accessing invalid columns creates a dependency
# on this node, and triggers recomputation when columns are added or renamed. # on this node, and triggers recomputation when columns are added or renamed.
self._new_columns_node = depend.Node(self.table_id, None) self._new_columns_node = depend.Node(self.table_id, None)

@ -90,6 +90,7 @@ class TestGenCode(unittest.TestCase):
gcode = gencode.GenCode() gcode = gencode.GenCode()
gcode.make_module(self.schema) gcode.make_module(self.schema)
module = gcode.usercode module = gcode.usercode
# pylint: disable=E1101
self.assertTrue(isinstance(module.Students, table.UserTable)) self.assertTrue(isinstance(module.Students, table.UserTable))
def test_ident_combining_chars(self): def test_ident_combining_chars(self):
@ -242,7 +243,7 @@ class TestGenCode(unittest.TestCase):
# Test the case of a bare-word function with a keyword argument appearing in a formula. This # Test the case of a bare-word function with a keyword argument appearing in a formula. This
# case had a bug with code parsing. # case had a bug with code parsing.
self.schema['Address'].columns['testcol'] = schema.SchemaColumn( self.schema['Address'].columns['testcol'] = schema.SchemaColumn(
'testcol', 'Any', True, 'foo(bar=$region) or max(Students.all, key=lambda n: -n)') 'testcol', 'Any', True, 'foo(bar=$region) or max(Students.all, key=lambda n: -n)', None)
gcode.make_module(self.schema) gcode.make_module(self.schema)
self.assertEqual(gcode.grist_names(), expected_names + [ self.assertEqual(gcode.grist_names(), expected_names + [
(('Address', 'testcol'), 9, 'Address', 'region'), (('Address', 'testcol'), 9, 'Address', 'region'),

@ -1,8 +1,6 @@
""" import json
This test replicates a bug involving a column conversion after a table rename in the presence of
a RefList. A RefList column type today only appears as a result of detaching a summary table.
"""
import logging import logging
import unittest
import test_engine import test_engine
from test_engine import Table, Column from test_engine import Table, Column
@ -10,6 +8,10 @@ log = logging.getLogger(__name__)
class TestRefListRelation(test_engine.EngineTestCase): class TestRefListRelation(test_engine.EngineTestCase):
def test_ref_list_relation(self): def test_ref_list_relation(self):
"""
This test replicates a bug involving a column conversion after a table rename in the presence of
a RefList. A RefList column type today only appears as a result of detaching a summary table.
"""
# Create two tables, the second referring to the first using a RefList and a Ref column. # Create two tables, the second referring to the first using a RefList and a Ref column.
self.apply_user_action(["AddTable", "TableA", [ self.apply_user_action(["AddTable", "TableA", [
{"id": "ColA", "type": "Text"} {"id": "ColA", "type": "Text"}
@ -100,3 +102,69 @@ class TestRefListRelation(test_engine.EngineTestCase):
[ 2, 2, "b", [], 0 ], [ 2, 2, "b", [], 0 ],
[ 3, 1, "a", [], 0 ], [ 3, 1, "a", [], 0 ],
]) ])
def test_ref_list_conversion_from_string(self):
"""
RefLists can accept JSON arrays as strings, but only if they look valid.
This feature is used by 2 way references, and column renames where type of the column
is changed briefly to Int (or other) and the value is converted to string (to represent
an error), then when column recovers its type, it should be able to read this string
and restore its value
"""
self.apply_user_action(["AddTable", "Tree", [
{"id": "Name", "type": "Text"},
{"id": "Children", "type": "RefList:Tree"},
]])
# Add two records.
self.apply_user_action(["BulkAddRecord", "Tree", [None]*2,
{"Name": ["John", "Bobby"]}])
test_literal = lambda x: self.assertTableData('Tree', cols="subset", data=[
[ "id", "Name", "Children" ],
[ 1, "John", x],
[ 2, "Bobby", None ],
])
invalid_json_arrays = (
'["Bobby"]',
'["2"]',
'["2", "3"]',
'[-1]',
'["1", "-1"]',
'[0]',
)
for value in invalid_json_arrays:
self.apply_user_action(
['UpdateRecord', 'Tree', 1, {'Children': value}]
)
test_literal(value)
valid_json_arrays = (
'[2]',
'[1, 2]',
'[100]',
)
for value in valid_json_arrays:
# Clear value
self.apply_user_action(
['UpdateRecord', 'Tree', 1, {'Children': None}]
)
self.apply_user_action(
['UpdateRecord', 'Tree', 1, {'Children': value}]
)
self.assertTableData('Tree', cols="subset", data=[
[ "id", "Name", "Children" ],
[ 1, "John", json.loads(value) ],
[ 2, "Bobby", None ],
])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

@ -10,9 +10,9 @@ import six
from six.moves import xrange from six.moves import xrange
import acl import acl
from acl import parse_acl_formulas
import depend import depend
import gencode import gencode
from acl import parse_acl_formulas
from dropdown_condition import parse_dropdown_conditions from dropdown_condition import parse_dropdown_conditions
import dropdown_condition import dropdown_condition
import actions import actions
@ -20,12 +20,14 @@ import column
import sort_specs import sort_specs
import identifiers import identifiers
from objtypes import strict_equal, encode_object from objtypes import strict_equal, encode_object
from reverse_references import check_desired_reverse_col, pick_reverse_col_label
import schema import schema
from schema import RecalcWhen from schema import RecalcWhen
import summary import summary
import import_actions import import_actions
import textbuilder import textbuilder
import usertypes import usertypes
from usertypes import get_referenced_table_id, get_pure_type, is_compatible_ref_type
import treeview import treeview
from table import get_validation_func_name from table import get_validation_func_name
@ -51,7 +53,7 @@ _inherited_groupby_col_fields = {'colId', 'widgetOptions', 'label', 'untieColIdF
_inherited_summary_col_fields = {'colId', 'label'} _inherited_summary_col_fields = {'colId', 'label'}
# Schema properties that can be modified using ModifyColumn docaction. # Schema properties that can be modified using ModifyColumn docaction.
_modify_col_schema_props = {'type', 'formula', 'isFormula'} _modify_col_schema_props = {'type', 'formula', 'isFormula', 'reverseColId'}
# A few generic helpers. # A few generic helpers.
@ -548,8 +550,9 @@ class UserActions(object):
@useraction @useraction
def BulkUpdateRecord(self, table_id, row_ids, columns): def BulkUpdateRecord(self, table_id, row_ids, columns):
columns = actions.decode_bulk_values(columns) return self._BulkUpdateRecord_decoded(table_id, row_ids, actions.decode_bulk_values(columns))
def _BulkUpdateRecord_decoded(self, table_id, row_ids, columns):
# Handle special tables, updates to which imply metadata actions. # Handle special tables, updates to which imply metadata actions.
# Check that the update is valid. # Check that the update is valid.
@ -664,6 +667,8 @@ class UserActions(object):
@override_action('BulkUpdateRecord', '_grist_Tables_column') @override_action('BulkUpdateRecord', '_grist_Tables_column')
def _updateColumnRecords(self, table_id, row_ids, col_values): def _updateColumnRecords(self, table_id, row_ids, col_values):
# pylint: disable=too-many-statements
# Does various automatic adjustments required for column updates. # Does various automatic adjustments required for column updates.
# col_values is a dict of arrays, each array containing values for all col_recs. We process # col_values is a dict of arrays, each array containing values for all col_recs. We process
# each column individually (to keep code simpler), in _adjust_one_column_update. # each column individually (to keep code simpler), in _adjust_one_column_update.
@ -708,6 +713,14 @@ class UserActions(object):
for col_rec, new_formula in sorted(six.iteritems(formula_updates)): for col_rec, new_formula in sorted(six.iteritems(formula_updates)):
col_updates.setdefault(col_rec, {}).setdefault('formula', new_formula) col_updates.setdefault(col_rec, {}).setdefault('formula', new_formula)
# For any renames of columns that have a reverse, tag their reverse column as
# changing, so that we can update its schema.
for c, values in list(col_updates.items()):
reverse_col_ref = values.get('reverseCol', c.reverseCol.id)
if has_diff_value(values, 'colId', c.colId) and reverse_col_ref:
rcol_rec = self._docmodel.columns.table.get_record(reverse_col_ref)
col_updates.setdefault(rcol_rec, {}).setdefault('reverseCol', c.id)
update_pairs = col_updates.items() update_pairs = col_updates.items()
# Disallow most changes to summary group-by columns, except to match the underlying column. # Disallow most changes to summary group-by columns, except to match the underlying column.
@ -717,6 +730,10 @@ class UserActions(object):
if col.summarySourceCol: if col.summarySourceCol:
underlying_updates = col_updates.get(col.summarySourceCol, {}) underlying_updates = col_updates.get(col.summarySourceCol, {})
for key, value in six.iteritems(values): for key, value in six.iteritems(values):
if key == 'summarySourceCol' and not value and not col.summarySourceCol._exists():
# We are unsetting summarySourceCol because it no longer exists. That's fine; the
# record we are updating is actually also about to be deleted.
continue
if key in ('displayCol', 'visibleCol'): if key in ('displayCol', 'visibleCol'):
# These can't always match the underlying column, and can now be changed in the # These can't always match the underlying column, and can now be changed in the
# group-by column. (Perhaps the same should be permitted for all widget options.) # group-by column. (Perhaps the same should be permitted for all widget options.)
@ -735,6 +752,18 @@ class UserActions(object):
for c, values in update_pairs: for c, values in update_pairs:
# Trigger ModifyColumn and RenameColumn as necessary # Trigger ModifyColumn and RenameColumn as necessary
schema_colinfo = select_keys(values, _modify_col_schema_props) schema_colinfo = select_keys(values, _modify_col_schema_props)
# If we set reverseCol in metadata, that turns into reverseColId in schema (which is used to
# generate Python code). Note that _adjust_one_column_update has already done sanity checks.
if 'reverseCol' in values:
reverse_col_ref = values['reverseCol']
if reverse_col_ref:
reverse_col = self._docmodel.columns.table.get_record(reverse_col_ref)
reverse_updates = col_updates.get(reverse_col, {})
schema_colinfo['reverseColId'] = reverse_updates.get('colId', reverse_col.colId)
else:
schema_colinfo['reverseColId'] = None
if schema_colinfo: if schema_colinfo:
self.doModifyColumn(c.parentId.tableId, c.colId, schema_colinfo) self.doModifyColumn(c.parentId.tableId, c.colId, schema_colinfo)
if has_diff_value(values, 'colId', c.colId): if has_diff_value(values, 'colId', c.colId):
@ -743,9 +772,10 @@ class UserActions(object):
rename_summary_tables.add(c.parentId) rename_summary_tables.add(c.parentId)
# If we change a column's type, we should ALSO unset each affected field's displayCol. # If we change a column's type, we should ALSO unset each affected field's displayCol.
type_changed = [c for c, values in update_pairs if has_diff_value(values, 'type', c.type)] type_changed = [c for c, values in update_pairs if has_diff_value(values, 'type', c.type)
and not is_compatible_ref_type(values.get('type', c.type), c.type)]
self._docmodel.update([f for c in type_changed for f in c.viewFields], self._docmodel.update([f for c in type_changed for f in c.viewFields],
displayCol=0) displayCol=0, visibleCol=0)
self.doBulkUpdateFromPairs(table_id, update_pairs) self.doBulkUpdateFromPairs(table_id, update_pairs)
@ -868,14 +898,38 @@ class UserActions(object):
col_values['type'] = guess_type(self._get_column_values(col), convert=False) col_values['type'] = guess_type(self._get_column_values(col), convert=False)
# If changing the type of a column, unset its displayCol by default. # If changing the type of a column, unset its displayCol by default.
if 'type' in col_values: new_type = col_values.get('type', col.type)
if 'type' in col_values and not is_compatible_ref_type(new_type, col.type):
col_values.setdefault('displayCol', 0) col_values.setdefault('displayCol', 0)
col_values.setdefault('visibleCol', 0)
# Collect all updates for dependent summary columns. # Collect all updates for dependent summary columns.
results = [] results = []
def add(cols, value_dict): def add(cols, value_dict):
results.extend((c, summary.skip_rules_update(c, value_dict)) for c in cols) results.extend((c, summary.skip_rules_update(c, value_dict)) for c in cols)
# If changing reverseCol, do some sanity checks and update its counterpart.
if has_diff_value(col_values, 'reverseCol', col.reverseCol):
reverse_col_ref = col_values['reverseCol']
if col.reverseCol and (col.reverseCol.id in self._docmodel.columns.table.row_ids):
# If unsetting (or changing) reverseCol, unset the counterpart that was pointing back.
# The existence check above is to handle case when reverseCol is what just got deleted.
results.append((col.reverseCol, {'reverseCol': 0}))
if reverse_col_ref:
# If setting new reverseCol, set its counterpart pointing back to us.
rcol_rec = self._docmodel.columns.table.get_record(reverse_col_ref)
check_desired_reverse_col(new_type, rcol_rec)
results.append((rcol_rec, {'reverseCol': col.id}))
# If a column has a reverseCol, we restrict some changes to it while reverseCol is set.
if col_values.get('reverseCol', col.reverseCol):
if not is_compatible_ref_type(new_type, col.type):
raise ValueError("invalid change to type of a two-way reference column")
if col_values.get('formula'):
raise ValueError("cannot set formula on a two-way reference column")
source_table = col.parentId.summarySourceTable source_table = col.parentId.summarySourceTable
if source_table: # This is a summary-table column. if source_table: # This is a summary-table column.
# Disallow isFormula changes. # Disallow isFormula changes.
@ -1096,10 +1150,13 @@ class UserActions(object):
continue continue
updates = ref_col.get_updates_for_removed_target_rows(row_id_set) updates = ref_col.get_updates_for_removed_target_rows(row_id_set)
if updates: if updates:
self._do_doc_action(actions.BulkUpdateRecord(ref_col.table_id, # Previously we sent this as a docaction. Now we do a proper useraction with all the
# processing that involves, e.g. triggering two-way-reference updates, and also all the
# metadata checks and updates.
self._BulkUpdateRecord_decoded(ref_col.table_id,
[row_id for (row_id, value) in updates], [row_id for (row_id, value) in updates],
{ ref_col.col_id: [value for (row_id, value) in updates] } { ref_col.col_id: [value for (row_id, value) in updates] }
)) )
@useraction @useraction
def RemoveRecord(self, table_id, row_id): def RemoveRecord(self, table_id, row_id):
@ -1134,6 +1191,11 @@ class UserActions(object):
def _removeTableRecords(self, table_id, row_ids): def _removeTableRecords(self, table_id, row_ids):
remove_table_recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)] remove_table_recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]
# If there are any two-way reference columns in this table, break the connection.
cols = (c for t in remove_table_recs for c in t.columns if c.reverseCol)
if cols:
self._docmodel.update(cols, reverseCol=0)
# If there are summary tables based on this table, remove those too. # If there are summary tables based on this table, remove those too.
remove_table_recs.extend(st for t in remove_table_recs for st in t.summaryTables) remove_table_recs.extend(st for t in remove_table_recs for st in t.summaryTables)
@ -1190,7 +1252,7 @@ class UserActions(object):
if not (visible_col and display_col): if not (visible_col and display_col):
# If there's no visible/display column, we just keep row IDs. # If there's no visible/display column, we just keep row IDs.
if col.type.startswith("Ref:"): if col.type.startswith("Ref:"):
self.ModifyColumn(table_id, col_id, dict(type="Int")) self.ModifyColumn(table_id, col_id, {"type": "Int", "reverseCol": 0})
else: else:
# Data columns can't be of type Any, and there's no type that can # Data columns can't be of type Any, and there's no type that can
# hold a list of numbers. So we convert the lists of row IDs # hold a list of numbers. So we convert the lists of row IDs
@ -1198,7 +1260,7 @@ class UserActions(object):
# We need to get the values before changing the column type. # We need to get the values before changing the column type.
table = self._engine.tables[table_id] table = self._engine.tables[table_id]
new_values = [",".join(map(str, row or [])) for row in self._get_column_values(col)] new_values = [",".join(map(str, row or [])) for row in self._get_column_values(col)]
self.ModifyColumn(table_id, col_id, dict(type="Text")) self.ModifyColumn(table_id, col_id, {"type": "Text", "reverseCol": 0})
self.BulkUpdateRecord(table_id, list(table.row_ids), {col_id: new_values}) self.BulkUpdateRecord(table_id, list(table.row_ids), {col_id: new_values})
return return
@ -1600,7 +1662,7 @@ class UserActions(object):
to_formula = bool(col_info.get('isFormula', from_formula)) to_formula = bool(col_info.get('isFormula', from_formula))
old_col_info = schema.col_to_dict(self._engine.schema[table_id].columns[col_id], old_col_info = schema.col_to_dict(self._engine.schema[table_id].columns[col_id],
include_id=False) include_id=False, include_default=True)
col_info = {k: v for k, v in six.iteritems(col_info) if old_col_info.get(k, v) != v} col_info = {k: v for k, v in six.iteritems(col_info) if old_col_info.get(k, v) != v}
if not col_info: if not col_info:
@ -1656,6 +1718,11 @@ class UserActions(object):
finally: finally:
self._engine.out_actions.undo.append(mod_action) self._engine.out_actions.undo.append(mod_action)
# Give two-way reference columns a chance to get rebuilt after a Ref<>RefList switch.
if 'type' in col_info:
update_action = new_column.recalc_from_reverse_values()
self._do_doc_action(update_action)
@useraction @useraction
def ConvertFromColumn(self, table_id, src_col_id, dst_col_id, typ, widgetOptions, visibleColRef): def ConvertFromColumn(self, table_id, src_col_id, dst_col_id, typ, widgetOptions, visibleColRef):
from sandbox import call_external from sandbox import call_external
@ -1749,8 +1816,7 @@ class UserActions(object):
changed_values.append(src_value) changed_values.append(src_value)
# Produce the BulkUpdateRecord update. # Produce the BulkUpdateRecord update.
self._do_doc_action(actions.BulkUpdateRecord(table_id, changed_rows, self._BulkUpdateRecord_decoded(table_id, changed_rows, {dst_col_id: changed_values})
{dst_col_id: changed_values}))
@useraction @useraction
def MaybeCopyDisplayFormula(self, src_col_ref, dst_col_ref): def MaybeCopyDisplayFormula(self, src_col_ref, dst_col_ref):
@ -1843,6 +1909,66 @@ class UserActions(object):
updated_rules = existing_rules + [new_rule] updated_rules = existing_rules + [new_rule]
self._docmodel.update([rule_owner], rules=[encode_object(updated_rules)]) self._docmodel.update([rule_owner], rules=[encode_object(updated_rules)])
@useraction
def AddReverseColumn(self, table_id, col_id):
"""
Adds a reverse reference column corresponding to `col_id`. This creates a two-way binding
between two Ref/RefList columns. Updating one of them will result in updating the other. To
break the binding, one of the columns should be removed (using a regular DocAction).
If a Foo column (Ref:Table1) has a reverse Bar column (RefList:Table2), then updating the Foo
column with a doc action like:
['UpdateRecord', 'Table2', 1, {'Foo': 2}]
will result in updating the Bar column with a "back reference" like:
['UpdateRecord', 'Table1', 2, {'Bar': ['L', 1]}]
By default, the type of the reverse column added is RefList, as the column `col_id` might have
multiple references (duplicated data). To properly represent it, the reverse column must be of
RefList type. The user can change the type of both columns (or either one) to Ref type, but the
engine will prevent it if one of the columns has duplicated values (more than one row in Table1
points to the same row in Table2).
The binding is symmetric. There is no "primary" or "secondary" column. Both columns are equal,
and the user can remove either of them and recreate it later from the other one.
"""
col_rec = self._docmodel.get_column_rec(table_id, col_id)
if col_rec.reverseCol:
raise ValueError('reverse reference column already exists')
target_table_id = get_referenced_table_id(col_rec.type)
if not target_table_id:
raise ValueError('reverse column can only be added to a reference column')
reverse_label = pick_reverse_col_label(self._docmodel, col_rec)
ret = self.AddVisibleColumn(target_table_id, reverse_label, {
"isFormula": False,
"type": "RefList:" + table_id,
})
added_col = self._docmodel.columns.table.get_record(ret['colRef'])
self._docmodel.update([col_rec], reverseCol=added_col.id)
self._pick_and_set_display_col(added_col)
# Fill in the new column.
col_obj = self._docmodel.get_table(table_id).table.get_column(col_id)
update_action = col_obj.recalc_from_reverse_values()
self._do_doc_action(update_action)
return ret
def _pick_and_set_display_col(self, col_rec):
target_table_id = get_referenced_table_id(col_rec.type)
target_table_rec = self._docmodel.get_table_rec(target_table_id)
# Types that could conceivably be identifiers for a record (this is very loose, but at
# least excludes types like references and attachments).
maybe_ident_types = ['Text', 'Any', 'Numeric', 'Int', 'Date', 'DateTime', 'Choice']
# Use the first column from target table, if it's a reasonable type.
for vcol in target_table_rec.columns:
if column.is_visible_column(vcol.colId) and get_pure_type(vcol.type) in maybe_ident_types:
self._docmodel.update([col_rec], visibleCol=vcol.id)
self.SetDisplayFormula(col_rec.tableId, None, col_rec.id,
'$%s.%s' % (col_rec.colId, vcol.colId))
break
#---------------------------------------- #----------------------------------------
# User actions on tables. # User actions on tables.

@ -20,6 +20,7 @@ import math
import six import six
from six import integer_types from six import integer_types
import depend
import objtypes import objtypes
from objtypes import AltText, is_int_short from objtypes import AltText, is_int_short
import moment import moment
@ -49,9 +50,14 @@ _type_defaults = {
'Text': u'', 'Text': u'',
} }
def get_pure_type(col_type):
"""
Returns type to the first colon, i.e. strips suffix for Ref:, DateTime:, etc.
"""
return col_type.split(':', 1)[0]
def get_type_default(col_type): def get_type_default(col_type):
col_type = col_type.split(':', 1)[0] # Strip suffix for Ref:, DateTime:, etc. return _type_defaults.get(get_pure_type(col_type), None)
return _type_defaults.get(col_type, None)
def formulaType(grist_type): def formulaType(grist_type):
""" """
@ -70,6 +76,13 @@ def get_referenced_table_id(col_type):
return col_type[8:] return col_type[8:]
return None return None
def is_compatible_ref_type(type1, type2):
"""
Returns whether type1 and type2 are Ref or RefList types with the same target table.
"""
ref_table1 = get_referenced_table_id(type1)
ref_table2 = get_referenced_table_id(type2)
return bool(ref_table1 and ref_table1 == ref_table2)
def ifError(value, value_if_error): def ifError(value, value_if_error):
""" """
@ -415,27 +428,36 @@ class Reference(Id):
record is available as `rec.foo._row_id`. It is equivalent to `rec.foo.id`, except that record is available as `rec.foo._row_id`. It is equivalent to `rec.foo.id`, except that
accessing `id`, as other public properties, involves a lookup in `Foo` table. accessing `id`, as other public properties, involves a lookup in `Foo` table.
""" """
def __init__(self, table_id): def __init__(self, table_id, reverse_of=None):
super(Reference, self).__init__() super(Reference, self).__init__()
self.table_id = table_id self.table_id = table_id
self._reverse_col_id = reverse_of
@classmethod @classmethod
def typename(cls): def typename(cls):
return "Ref" return "Ref"
def reverse_source_node(self):
""" Returns the reverse column as depend.Node, if it exists. """
return depend.Node(self.table_id, self._reverse_col_id) if self._reverse_col_id else None
class ReferenceList(BaseColumnType): class ReferenceList(BaseColumnType):
""" """
ReferenceList stores a list of references into another table. ReferenceList stores a list of references into another table.
""" """
def __init__(self, table_id): def __init__(self, table_id, reverse_of=None):
super(ReferenceList, self).__init__() super(ReferenceList, self).__init__()
self.table_id = table_id self.table_id = table_id
self._reverse_col_id = reverse_of
@classmethod @classmethod
def typename(cls): def typename(cls):
return "RefList" return "RefList"
def reverse_source_node(self):
""" Returns the reverse column as depend.Node, if it exists. """
return depend.Node(self.table_id, self._reverse_col_id) if self._reverse_col_id else None
def do_convert(self, value): def do_convert(self, value):
if isinstance(value, six.string_types): if isinstance(value, six.string_types):
# This is second part of a "hack" we have to do when we rename tables. During # This is second part of a "hack" we have to do when we rename tables. During
@ -448,7 +470,11 @@ class ReferenceList(BaseColumnType):
try: try:
# If it's a string that looks like JSON, try to parse it as such. # If it's a string that looks like JSON, try to parse it as such.
if value.startswith('['): if value.startswith('['):
value = json.loads(value) parsed = json.loads(value)
# It must be list of integers, and all of them must be positive integers.
if (isinstance(parsed, list) and
all(isinstance(v, int) and v > 0 for v in parsed)):
value = parsed
else: else:
# Else try to parse it as a RecordList # Else try to parse it as a RecordList
value = objtypes.RecordList.from_repr(value) value = objtypes.RecordList.from_repr(value)

Binary file not shown.

@ -66,7 +66,7 @@ describe('DropdownConditionEditor', function () {
'user.A\nc\ncess\n ', 'user.A\nc\ncess\n ',
]); ]);
}); });
await gu.sendKeysSlowly(['hoice not in ']); await gu.sendKeysSlowly('hoice not in ');
// Attempts to reduce test flakiness by delaying input of $. Not guaranteed to do anything. // Attempts to reduce test flakiness by delaying input of $. Not guaranteed to do anything.
await driver.sleep(100); await driver.sleep(100);
await gu.sendKeys('$'); await gu.sendKeys('$');
@ -192,7 +192,7 @@ describe('DropdownConditionEditor', function () {
assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent()); assert.isFalse(await driver.find('.test-field-dropdown-condition').isPresent());
await driver.find('.test-field-set-dropdown-condition').click(); await driver.find('.test-field-set-dropdown-condition').click();
await gu.waitAppFocus(false); await gu.waitAppFocus(false);
await gu.sendKeysSlowly(['choice']); await gu.sendKeysSlowly('choice');
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText());
assert.deepEqual(completions, [ assert.deepEqual(completions, [
@ -340,7 +340,7 @@ describe('DropdownConditionEditor', function () {
await gu.getCell(1, 1).click(); await gu.getCell(1, 1).click();
await driver.find('.test-field-set-dropdown-condition').click(); await driver.find('.test-field-set-dropdown-condition').click();
await gu.waitAppFocus(false); await gu.waitAppFocus(false);
await gu.sendKeysSlowly(['user.']); await gu.sendKeysSlowly('user.');
await gu.waitToPass(async () => { await gu.waitToPass(async () => {
const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText()); const completions = await driver.findAll('.ace_autocomplete .ace_line', el => el.getText());
assert.deepEqual(completions, [ assert.deepEqual(completions, [

@ -231,13 +231,13 @@ return [
await driver.sendKeys('='); await driver.sendKeys('=');
await gu.waitAppFocus(false); await gu.waitAppFocus(false);
// A single long string often works, but sometimes fails, so break up into multiple. // A single long string often works, but sometimes fails, so break up into multiple.
await gu.sendKeys(` if $Budget > 50:${Key.chord(Key.SHIFT, Key.ENTER)}`); await gu.sendKeysSlowly(` if $Budget > 50:${Key.chord(Key.SHIFT, Key.ENTER)}`);
await driver.sleep(50); await driver.sleep(50);
// The next line should get auto-indented. // The next line should get auto-indented.
await gu.sendKeys(`return 'Big'${Key.chord(Key.SHIFT, Key.ENTER)}`); await gu.sendKeysSlowly(`return 'Big'${Key.chord(Key.SHIFT, Key.ENTER)}`);
await driver.sleep(50); await driver.sleep(50);
// In the next line, we want to remove one level of indent. // In the next line, we want to remove one level of indent.
await gu.sendKeys(`${Key.BACK_SPACE}return 'Small'`); await gu.sendKeysSlowly(`${Key.BACK_SPACE}return 'Small'`);
await gu.sendKeys(Key.ENTER); await gu.sendKeys(Key.ENTER);
await gu.waitForServer(); await gu.waitForServer();

@ -0,0 +1,382 @@
import {assert, driver, Key} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {Session} from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('TwoWayReference', function() {
this.timeout('3m');
let session: Session;
let docId: string;
const cleanup = setupTestSuite();
afterEach(() => gu.checkForErrors());
before(async function() {
session = await gu.session().login();
docId = await session.tempNewDoc(cleanup);
await gu.toggleSidePanel('left', 'close');
await petsSetup();
});
async function petsSetup() {
await gu.sendActions([
['RenameColumn', 'Table1', 'A', 'Name'],
['ModifyColumn', 'Table1', 'Name', {label: 'Name'}],
['RemoveColumn', 'Table1', 'B'],
['RemoveColumn', 'Table1', 'C'],
['RenameTable', 'Table1', 'Owners'],
['AddTable', 'Pets', [
{id: 'Name', type: 'Text'},
{id: 'Owner', type: 'Ref:Owners'},
]],
['AddRecord', 'Owners', -1, {Name: 'Alice'}],
['AddRecord', 'Owners', -2, {Name: 'Bob'}],
['AddRecord', 'Pets', null, {Name: 'Rex', Owner: -2}],
]);
await gu.addNewSection('Table', 'Pets');
await gu.openColumnPanel('Owner');
await gu.setRefShowColumn('Name');
await addReverseColumn('Pets', 'Owner');
}
it('deletes tables with 2 way references', async function() {
const revert = await gu.begin();
await gu.toggleSidePanel('left', 'open');
await driver.find('.test-tools-raw').click();
const removeTable = async (tableId: string) => {
await driver.findWait(`.test-raw-data-table-menu-${tableId}`, 1000).click();
await driver.find('.test-raw-data-menu-remove-table').click();
await driver.find('.test-modal-confirm').click();
await gu.waitForServer();
};
await removeTable('Pets');
await revert();
await removeTable('Owners');
await gu.checkForErrors();
await revert();
await gu.openPage('Table1');
});
it('detects new columns after modify', async function() {
const revert = await gu.begin();
await gu.selectSectionByTitle('Owners');
await gu.selectColumn('Pets');
await gu.setType('Reference', {apply: true});
await gu.setType('Reference List', {apply: true});
await gu.selectSectionByTitle('Pets');
await gu.getCell('Owner', 1).click();
await gu.sendKeys(Key.DELETE);
await gu.waitForServer();
await gu.selectSectionByTitle('Owners');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['', '']);
await revert();
});
it('can delete reverse column without an error', async function() {
const revert = await gu.begin();
// This can't be tested easily in python as it requries node server for type transformation.
await gu.toggleSidePanel('left', 'close');
await gu.toggleSidePanel('right', 'close');
// Remove the reverse column.
await gu.selectSectionByTitle('OWNERS');
await gu.deleteColumn('Pets');
await gu.checkForErrors();
// Check data.
assert.deepEqual(await columns(), [
['Name'],
['Name', 'Owner']
]);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Bob']);
await gu.undo();
// Check data.
assert.deepEqual(await columns(), [
['Name', 'Pets'],
['Name', 'Owner']
]);
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2], 'OWNERS'), ['', 'Rex']);
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Bob']);
// Check that connection works.
// Make sure we can change data.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.enterCell('Alice', Key.ENTER);
await gu.waitForServer();
await gu.checkForErrors();
// Check data.
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Alice']);
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2], 'OWNERS'), ['Rex', '']);
// Now delete Owner column, and redo it
await gu.selectSectionByTitle('Pets');
await gu.deleteColumn('Owner');
await gu.checkForErrors();
await gu.undo();
await gu.checkForErrors();
// Check data.
assert.deepEqual(await gu.getVisibleGridCells('Owner', [1], 'PETS'), ['Alice']);
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2], 'OWNERS'), ['Rex', '']);
await revert();
});
it('breaks connection after removing reverseCol', async function() {
const revert = await gu.begin();
// Make sure Rex is owned by Bob, in both tables.
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", ""],
[2, "Bob", "Rex"],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Bob"],
]);
// Now move Rex to Alice.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.enterCell("Alice", Key.ENTER);
await gu.waitForServer();
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", "Rex"],
[2, "Bob", ""],
]);
// Now remove connection using Owner column.
await gu.sendActions([['ModifyColumn', 'Pets', 'Owner', {reverseCol: 0}]]);
await gu.checkForErrors();
// And check that after moving Rex to Bob, it's not shown in the Owners table.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.enterCell("Bob", Key.ENTER);
await gu.waitForServer();
await gu.checkForErrors();
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", "Rex"],
[2, "Bob", ""],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Bob"],
]);
// Check undo, it should restore the link.
await gu.undo(2);
// Rex is now in Alice again in both tables.
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", "Rex"],
[2, "Bob", ""],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Alice"],
]);
// Move Rex to Bob again.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.enterCell("Bob", Key.ENTER);
await gu.waitForServer();
await gu.checkForErrors();
// And check that connection works.
await gu.assertGridData('OWNERS', [
[0, "Name", "Pets"],
[1, "Alice", ""],
[2, "Bob", "Rex"],
]);
await gu.assertGridData("PETS", [
[0, "Name", "Owner"],
[1, "Rex", "Bob"],
]);
await revert();
});
it('works after reload', async function() {
const revert = await gu.begin();
await gu.selectSectionByTitle('OWNERS');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['', 'Rex']);
await session.createHomeApi().getDocAPI(docId).forceReload();
await driver.navigate().refresh();
await gu.waitForDocToLoad();
// Change Rex owner to Alice.
await gu.selectSectionByTitle('PETS');
await gu.getCell('Owner', 1).click();
await gu.sendKeys('Alice', Key.ENTER);
await gu.waitForServer();
await gu.selectSectionByTitle('OWNERS');
assert.deepEqual(await gu.getVisibleGridCells('Pets', [1, 2]), ['Rex', '']);
await revert();
});
async function projectSetup() {
await gu.sendActions([
['AddTable', 'Projects', []],
['AddTable', 'People', []],
['AddVisibleColumn', 'Projects', 'Name', {type: 'Text'}],
['AddVisibleColumn', 'Projects', 'Owner', {type: 'Ref:People'}],
['AddVisibleColumn', 'People', 'Name', {type: 'Text'}],
]);
await gu.addNewPage('Table', 'Projects');
await gu.addNewSection('Table', 'People');
await gu.selectSectionByTitle('Projects');
await gu.openColumnPanel();
await gu.toggleSidePanel('left', 'close');
}
it('undo works for adding reverse column', async function() {
await projectSetup();
const revert = await gu.begin();
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
await addReverseColumn('Projects', 'Owner');
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name', 'Projects']
]);
await gu.undo(1);
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name']
]);
await gu.redo(1);
assert.deepEqual(await columns(), [
['Name', 'Owner'],
['Name', 'Projects']
]);
await revert();
});
it('creates proper names when added multiple times', async function() {
const revert = await gu.begin();
await addReverseColumn('Projects', 'Owner');
// Add another reference to Projects from People.
await gu.selectSectionByTitle('Projects');
await gu.addColumn('Tester', 'Reference');
await gu.setRefTable('People');
await gu.setRefShowColumn('Name');
// And now show it on People.
await addReverseColumn('Projects', 'Tester');
// We should now see 3 columns on People.
await gu.selectSectionByTitle('People');
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Projects', 'Projects_Tester']);
// Add yet another one.
await gu.selectSectionByTitle('Projects');
await gu.addColumn('PM', 'Reference');
await gu.setRefTable('People');
await gu.setRefShowColumn('Name');
await addReverseColumn('Projects', 'PM');
// We should now see 4 columns on People.
await gu.selectSectionByTitle('People');
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Projects', 'Projects_Tester', 'Projects_PM']);
await revert();
});
it('works well for self reference', async function() {
const revert = await gu.begin();
// Create a new table with task hierarchy and check if looks sane.
await gu.addNewPage('Table', 'New Table', {
tableName: 'Tasks',
});
await gu.renameColumn('A', 'Name');
await gu.renameColumn('B', 'Parent');
await gu.sendActions([
['RemoveColumn', 'Tasks', 'C']
]);
await gu.setType('Reference');
await gu.setRefTable('Tasks');
await gu.setRefShowColumn('Name');
await gu.sendActions([
['AddRecord', 'Tasks', -1, {Name: 'Parent'}],
['AddRecord', 'Tasks', null, {Name: 'Child', Parent: -1}],
]);
await gu.openColumnPanel('Parent');
await addReverseColumn('Tasks', 'Parent');
// We should now see 3 columns on Tasks.
assert.deepEqual(await gu.getColumnNames(), ['Name', 'Parent', 'Tasks']);
await gu.openColumnPanel('Tasks');
await gu.setRefShowColumn('Name');
// Check that data looks ok.
assert.deepEqual(await gu.getVisibleGridCells('Name', [1, 2]), ['Parent', 'Child']);
assert.deepEqual(await gu.getVisibleGridCells('Parent', [1, 2]), ['', 'Parent']);
assert.deepEqual(await gu.getVisibleGridCells('Tasks', [1, 2]), ['Child', '']);
await revert();
});
it('converts from RefList to Ref without problems', async function() {
await session.tempNewDoc(cleanup);
const revert = await gu.begin();
await gu.sendActions([
['AddTable', 'People', [
{id: 'Name', type: 'Text'},
{id: 'Supervisor', type: 'Ref:People'},
]],
['AddRecord', 'People', 1, {Name: 'Alice'}],
['AddRecord', 'People', 4, {Name: 'Bob'}],
['UpdateRecord', 'People', 1, {Supervisor: 4}],
['UpdateRecord', 'People', 3, {Supervisor: 0}],
]);
await gu.toggleSidePanel('left', 'open');
await gu.openPage('People');
await gu.openColumnPanel('Supervisor');
await gu.setRefShowColumn('Name');
// Using the convert dialog caused an error, which wasn't raised when doing it manually.
await gu.setType('Reference List', {apply: true});
await gu.setType('Reference', {apply: true});
await gu.checkForErrors();
await revert();
});
});
async function addReverseColumn(tableId: string, colId: string) {
await gu.sendActions([
['AddReverseColumn', tableId, colId],
]);
}
/**
* Returns an array of column headers for each table in the document.
*/
async function columns() {
const headers: string[][] = [];
for (const table of await driver.findAll('.gridview_stick-top')) {
const cols = await table.findAll('.g-column-label', e => e.getText());
headers.push(cols);
}
return headers;
}

@ -18,7 +18,7 @@ import { AccessLevel } from 'app/common/CustomWidget';
import { decodeUrl } from 'app/common/gristUrls'; import { decodeUrl } from 'app/common/gristUrls';
import { FullUser, UserProfile } from 'app/common/LoginSessionAPI'; import { FullUser, UserProfile } from 'app/common/LoginSessionAPI';
import { resetOrg } from 'app/common/resetOrg'; import { resetOrg } from 'app/common/resetOrg';
import { UserAction } from 'app/common/DocActions'; import { DocAction, UserAction } from 'app/common/DocActions';
import { TestState } from 'app/common/TestState'; import { TestState } from 'app/common/TestState';
import { Organization as APIOrganization, DocStateComparison, import { Organization as APIOrganization, DocStateComparison,
UserAPI, UserAPIImpl, Workspace } from 'app/common/UserAPI'; UserAPI, UserAPIImpl, Workspace } from 'app/common/UserAPI';
@ -342,6 +342,31 @@ export async function getVisibleGridCells<T>(
return rowNums.map((n) => fields[visibleRowNums.indexOf(n)]); return rowNums.map((n) => fields[visibleRowNums.indexOf(n)]);
} }
/**
* Checks if visible section with a grid contains the given data.
* Data is in form of:
* [0, "ColA", "ColB"]
* [1, "Val", "1"] // cells are strings
* [2, "Val2", "2"]
*/
export async function assertGridData(section: string, data: any[][]) {
// Data is in form of
// [0, "ColA", "ColB"]
// [1, "Val", 1]
const rowIndices = data.slice(1).map((row: number[]) => row[0]);
const columnNames = data[0].slice(1);
for(const col of columnNames) {
const colIndex = columnNames.indexOf(col) + 1;
const colValues = data.slice(1).map((row: string[]) => row[colIndex]);
assert.deepEqual(
await getVisibleGridCells(col, rowIndices, section),
colValues
);
}
}
/** /**
* Experimental fast version of getVisibleGridCells that reads data directly from browser by * Experimental fast version of getVisibleGridCells that reads data directly from browser by
* invoking javascript code. * invoking javascript code.
@ -539,7 +564,8 @@ export function getColumnHeader(colOrColOptions: string|IColHeader): WebElementP
} }
export async function getColumnNames() { export async function getColumnNames() {
return (await driver.findAll('.column_name', el => el.getText())) const section = await driver.findWait('.active_section', 4000);
return (await section.findAll('.column_name', el => el.getText()))
.filter(name => name !== '+'); .filter(name => name !== '+');
} }
@ -1028,7 +1054,7 @@ export async function waitForLabelInput(): Promise<void> {
/** /**
* Sends UserActions using client api from the browser. * Sends UserActions using client api from the browser.
*/ */
export async function sendActions(actions: UserAction[]) { export async function sendActions(actions: (DocAction|UserAction)[]) {
await driver.manage().setTimeouts({ await driver.manage().setTimeouts({
script: 1000 * 2, /* 2 seconds, default is 0.5s */ script: 1000 * 2, /* 2 seconds, default is 0.5s */
}); });
@ -1438,6 +1464,13 @@ export async function redo(optCount: number = 1, optTimeout?: number) {
await waitForServer(optTimeout); await waitForServer(optTimeout);
} }
export async function redoAll() {
const isActive = () => driver.find('.test-redo').matches('[class*="disabled"]').then((v) => !v);
while (await isActive()) {
await redo();
}
}
/** /**
* Asserts the absence of javascript errors. * Asserts the absence of javascript errors.
*/ */
@ -1458,7 +1491,10 @@ export async function openWidgetPanel(tab: 'widget'|'sortAndFilter'|'data' = 'wi
/** /**
* Opens a Creator Panel on Widget/Table settings tab. * Opens a Creator Panel on Widget/Table settings tab.
*/ */
export async function openColumnPanel() { export async function openColumnPanel(col?: string|number) {
if (col !== undefined) {
await getColumnHeader({col}).click();
}
await toggleSidePanel('right', 'open'); await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click(); await driver.find('.test-right-tab-field').click();
} }
@ -1746,7 +1782,17 @@ export async function selectAllKey() {
* Send keys, with support for Key.chord(), similar to driver.sendKeys(). Note that while * Send keys, with support for Key.chord(), similar to driver.sendKeys(). Note that while
* elem.sendKeys() supports Key.chord(...), driver.sendKeys() does not. This is a replacement. * elem.sendKeys() supports Key.chord(...), driver.sendKeys() does not. This is a replacement.
*/ */
export async function sendKeys(...keys: string[]) { export async function sendKeys(...keys: string[]): Promise<void>
/**
* Send keys with a pause between each key.
*/
export async function sendKeys(interval: number, ...keys: string[]): Promise<void>
export async function sendKeys(...args: (string|number)[]) {
let interval = 0;
if (typeof args[0] === 'number') {
interval = args.shift() as number;
}
const keys = args as string[];
// tslint:disable-next-line:max-line-length // tslint:disable-next-line:max-line-length
// Implementation follows the description of WebElement.sendKeys functionality at https://github.com/SeleniumHQ/selenium/blob/2f7727c314f943582f9f1b2a7e4d77ebdd64bdd3/javascript/node/selenium-webdriver/lib/webdriver.js#L2146 // Implementation follows the description of WebElement.sendKeys functionality at https://github.com/SeleniumHQ/selenium/blob/2f7727c314f943582f9f1b2a7e4d77ebdd64bdd3/javascript/node/selenium-webdriver/lib/webdriver.js#L2146
await driver.withActions((a) => { await driver.withActions((a) => {
@ -1761,19 +1807,19 @@ export async function sendKeys(...keys: string[]) {
} else { } else {
a.sendKeys(key); a.sendKeys(key);
} }
if (interval) {
a.pause(interval);
}
} }
} }
}); });
} }
/** /**
* Send keys with a pause between each key. * An default ovveride for sendKeys that sends keys slowly, suitable for formula editor.
*/ */
export async function sendKeysSlowly(keys: string[], delayMs = 40) { export async function sendKeysSlowly(...keys: string[]) {
for (const [i, key] of keys.entries()) { return await sendKeys(10, ...keys);
await sendKeys(key);
if (i < keys.length - 1) { await driver.sleep(delayMs); }
}
} }
/** /**

@ -831,7 +831,8 @@ function testDocApi() {
visibleCol: 0, visibleCol: 0,
rules: null, rules: null,
recalcWhen: 0, recalcWhen: 0,
recalcDeps: null recalcDeps: null,
reverseCol: 0,
} }
}, },
{ {
@ -852,7 +853,8 @@ function testDocApi() {
visibleCol: 0, visibleCol: 0,
rules: null, rules: null,
recalcWhen: 0, recalcWhen: 0,
recalcDeps: null recalcDeps: null,
reverseCol: 0,
} }
}, },
{ {
@ -873,7 +875,8 @@ function testDocApi() {
visibleCol: 0, visibleCol: 0,
rules: null, rules: null,
recalcWhen: 0, recalcWhen: 0,
recalcDeps: null recalcDeps: null,
reverseCol: 0,
} }
}, },
{ {
@ -894,7 +897,8 @@ function testDocApi() {
visibleCol: 0, visibleCol: 0,
rules: null, rules: null,
recalcWhen: 0, recalcWhen: 0,
recalcDeps: null recalcDeps: null,
reverseCol: 0,
} }
}, },
{ {
@ -915,7 +919,8 @@ function testDocApi() {
visibleCol: 0, visibleCol: 0,
rules: null, rules: null,
recalcWhen: 0, recalcWhen: 0,
recalcDeps: null recalcDeps: null,
reverseCol: 0,
} }
} }
] ]
@ -1715,7 +1720,8 @@ function testDocApi() {
"visibleCol": 0, "visibleCol": 0,
"rules": null, "rules": null,
"recalcWhen": 0, "recalcWhen": 0,
"recalcDeps": null "recalcDeps": null,
reverseCol: 0,
} }
} }
] ]

Loading…
Cancel
Save