gristlabs_grist-core/sandbox/grist/relation.py
Paul Fitzpatrick b82eec714a (core) move data engine code to core
Summary:
this moves sandbox/grist to core, and adds a requirements.txt
file for reconstructing the content of sandbox/thirdparty.

Test Plan:
existing tests pass.
Tested core functionality manually.  Tested docker build manually.

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2563
2020-07-29 08:57:25 -04:00

123 lines
4.6 KiB
Python

"""
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.]
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.
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):
self.inverse_map[target_row_id].remove(referring_row_id)
def clear(self):
self.inverse_map.clear()