2020-07-27 18:57:36 +00:00
|
|
|
"""
|
|
|
|
A Relation represent mapping between rows, and used in determining which rows need to be
|
|
|
|
recomputed when something changes.
|
|
|
|
|
|
|
|
Relations can be determined by a foreign key or another form of lookup, and they may be composed.
|
|
|
|
|
|
|
|
For example, if Person.zip is the formula 'rec.school.address.zip', it involves three Relations:
|
|
|
|
ReferenceRelation between Person and School tables, another ReferenceRelation between School and
|
|
|
|
Address tables. Together, they form ComposedRelation relation between Person and Address tables.
|
|
|
|
|
|
|
|
"""
|
|
|
|
import depend
|
|
|
|
|
|
|
|
class Relation(object):
|
|
|
|
"""
|
|
|
|
Represents a row mapping between two tables. The arguments are table IDs (not actual tables).
|
|
|
|
"""
|
|
|
|
def __init__(self, referring_table, target_table):
|
|
|
|
self.referring_table = referring_table
|
|
|
|
self.target_table = target_table
|
|
|
|
|
|
|
|
# Maps the relation objects that we wrap to the resulting composed relations.
|
|
|
|
self._target_relations = {}
|
|
|
|
|
|
|
|
def get_affected_rows(self, input_rows):
|
|
|
|
"""
|
|
|
|
Given an iterable over input (dependency) rows, returns a `set` of output (dependent) rows.
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
|
|
|
|
def reset_all(self):
|
|
|
|
"""
|
|
|
|
Called when the dependency using this relation is reset, and this relation is no longer used.
|
|
|
|
"""
|
|
|
|
self.reset_rows(depend.ALL_ROWS)
|
|
|
|
|
|
|
|
def reset_rows(self, referring_rows):
|
|
|
|
"""
|
|
|
|
Call when starting to compute a formula to tell a Relation that it can start with a clean
|
|
|
|
slate for all row_ids in the passed-in iterable.
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
def compose(self, other_relation):
|
|
|
|
r = self._target_relations.get(other_relation)
|
|
|
|
if r is None:
|
|
|
|
r = self._target_relations[other_relation] = ComposedRelation(self, other_relation)
|
|
|
|
return r
|
|
|
|
|
|
|
|
class IdentityRelation(Relation):
|
|
|
|
"""
|
|
|
|
The trivial mapping, used to represent the relation between fields of the same record.
|
|
|
|
"""
|
|
|
|
def __init__(self, table_id):
|
|
|
|
super(IdentityRelation, self).__init__(table_id, table_id)
|
|
|
|
|
|
|
|
def get_affected_rows(self, input_rows):
|
|
|
|
return input_rows
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return "Identity(%s)" % self.referring_table
|
|
|
|
|
|
|
|
# Important: we intentionally do not optimize compose() for an IdentityRelation, since
|
|
|
|
# (Identity + Rel) is not the same Rel when it comes to reset_rows() calls. [See test_lookups.py
|
|
|
|
# test_dependencies_relations_bug for a detailed description of a bug this can cause.]
|
|
|
|
|
|
|
|
|
(core) Implement trigger formulas (generalizing default formulas)
Summary:
Trigger formulas can be calculated for new records, or for new records and
updates to certain fields, or all fields. They do not recalculate on open,
and they MAY be set directly by the user, including for data-cleaning.
- Column metadata now includes recalcWhen and recalcDeps fields.
- Trigger formulas are NOT recalculated on open or on schema changes.
- When recalcWhen is "never", formula isn't calculated even for new records.
- When recalcWhen is "allupdates", formula is calculated for new records and
any manual (non-formula) updates to the record.
- When recalcWhen is "", formula is calculated for new records, and changes to
recalcDeps fields (which may be formula fields or column itself).
- A column whose recalcDeps includes itself is a "data-cleaning" column; a
value set by the user will still trigger the formula.
- All trigger-formulas receive a "value" argument (to support the case above).
Small changes
- Update RefLists (used for recalcDeps) when target rows are deleted.
- Add RecordList.__contains__ (for `rec in refList` or `id in refList` checks)
- Clarify that Calculate action has replaced load_done() in practice,
and use it in tests too, to better match reality.
Left for later:
- UI for setting recalcWhen / recalcDeps.
- Implementation of actions such as "Recalculate for all cells".
- Allowing trigger-formulas access to the current user's info.
Test Plan: Added a comprehensive python-side test for various trigger combinations
Reviewers: paulfitz, alexmojaki
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2872
2021-06-25 20:34:20 +00:00
|
|
|
class SingleRowsIdentityRelation(IdentityRelation):
|
|
|
|
"""
|
|
|
|
Represents an identity relation, but one which refuses to pass along ALL_ROWS. In other words,
|
|
|
|
if a full column changed (i.e. ALL_ROWS), none of the dependent cells will be considered
|
|
|
|
changed. But when specific rows are changed, those changes propagate.
|
|
|
|
|
|
|
|
This is used for trigger formulas, to ensure they don't recalculate in full when a dependency
|
|
|
|
column is renamed or modified (as opposed to particular records).
|
|
|
|
"""
|
|
|
|
def get_affected_rows(self, input_rows):
|
|
|
|
return [] if input_rows == depend.ALL_ROWS else input_rows
|
|
|
|
|
|
|
|
|
2020-07-27 18:57:36 +00:00
|
|
|
class ComposedRelation(Relation):
|
|
|
|
"""
|
|
|
|
Represents a composition of two Relations. E.g. if referring side maps Students to Schools, and
|
|
|
|
target_side maps Schools to Addresses, then the composition maps Students to Addresses (so a
|
|
|
|
Student records depend on Address records, and changes to Address records affect Students).
|
|
|
|
"""
|
|
|
|
def __init__(self, referring_side, target_side):
|
|
|
|
assert referring_side.target_table == target_side.referring_table
|
|
|
|
super(ComposedRelation, self).__init__(referring_side.referring_table,
|
|
|
|
target_side.target_table)
|
|
|
|
self.source_relation = referring_side
|
|
|
|
self.target_relation = target_side
|
|
|
|
|
|
|
|
def get_affected_rows(self, input_rows):
|
|
|
|
return self.source_relation.get_affected_rows(
|
|
|
|
self.target_relation.get_affected_rows(input_rows))
|
|
|
|
|
|
|
|
def reset_rows(self, referring_rows):
|
|
|
|
# In the example from the doc-string, this says that certain Students are being recomputed, so
|
|
|
|
# no longer refer to any Schools. It doesn't say anything about Schools' dependence on
|
|
|
|
# Addresses, so there is nothing to reset in self.target_relation.
|
|
|
|
self.source_relation.reset_rows(referring_rows)
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return "%s + %s" % (self.source_relation, self.target_relation)
|
|
|
|
|
|
|
|
|
|
|
|
class ReferenceRelation(Relation):
|
|
|
|
"""
|
|
|
|
Base class for Relations between records in two tables.
|
|
|
|
"""
|
|
|
|
def __init__(self, referring_table, target_table, ref_col_id):
|
|
|
|
super(ReferenceRelation, self).__init__(referring_table, target_table)
|
|
|
|
self.inverse_map = {} # maps target rows to sets of referring rows
|
|
|
|
self._ref_col_id = ref_col_id
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return "ReferenceRelation(%s.%s)" % (self.referring_table, self._ref_col_id)
|
|
|
|
|
|
|
|
def get_affected_rows(self, input_rows):
|
|
|
|
# Each input row (target of the reference link) may be pointed to by multiple references,
|
|
|
|
# so we need to take the union of all of those sets.
|
(core) Implement trigger formulas (generalizing default formulas)
Summary:
Trigger formulas can be calculated for new records, or for new records and
updates to certain fields, or all fields. They do not recalculate on open,
and they MAY be set directly by the user, including for data-cleaning.
- Column metadata now includes recalcWhen and recalcDeps fields.
- Trigger formulas are NOT recalculated on open or on schema changes.
- When recalcWhen is "never", formula isn't calculated even for new records.
- When recalcWhen is "allupdates", formula is calculated for new records and
any manual (non-formula) updates to the record.
- When recalcWhen is "", formula is calculated for new records, and changes to
recalcDeps fields (which may be formula fields or column itself).
- A column whose recalcDeps includes itself is a "data-cleaning" column; a
value set by the user will still trigger the formula.
- All trigger-formulas receive a "value" argument (to support the case above).
Small changes
- Update RefLists (used for recalcDeps) when target rows are deleted.
- Add RecordList.__contains__ (for `rec in refList` or `id in refList` checks)
- Clarify that Calculate action has replaced load_done() in practice,
and use it in tests too, to better match reality.
Left for later:
- UI for setting recalcWhen / recalcDeps.
- Implementation of actions such as "Recalculate for all cells".
- Allowing trigger-formulas access to the current user's info.
Test Plan: Added a comprehensive python-side test for various trigger combinations
Reviewers: paulfitz, alexmojaki
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2872
2021-06-25 20:34:20 +00:00
|
|
|
if input_rows == depend.ALL_ROWS:
|
|
|
|
return depend.ALL_ROWS
|
2020-07-27 18:57:36 +00:00
|
|
|
affected_rows = set()
|
|
|
|
for target_row_id in input_rows:
|
|
|
|
affected_rows.update(self.inverse_map.get(target_row_id, ()))
|
|
|
|
return affected_rows
|
|
|
|
|
|
|
|
def add_reference(self, referring_row_id, target_row_id):
|
|
|
|
self.inverse_map.setdefault(target_row_id, set()).add(referring_row_id)
|
|
|
|
|
|
|
|
def remove_reference(self, referring_row_id, target_row_id):
|
2021-08-12 18:06:40 +00:00
|
|
|
self.inverse_map[target_row_id].discard(referring_row_id)
|
2020-07-27 18:57:36 +00:00
|
|
|
|
|
|
|
def clear(self):
|
|
|
|
self.inverse_map.clear()
|