diff --git a/datajoint/__init__.py b/datajoint/__init__.py index 7cac45a0e..45df9c9c8 100644 --- a/datajoint/__init__.py +++ b/datajoint/__init__.py @@ -16,7 +16,8 @@ __all__ = ['__author__', '__version__', 'config', 'conn', 'kill', 'Connection', 'Heading', 'BaseRelation', 'FreeRelation', 'Not', 'schema', - 'Manual', 'Lookup', 'Imported', 'Computed', 'Part'] + 'Manual', 'Lookup', 'Imported', 'Computed', 'Part', + 'AndList', 'OrList'] class key: @@ -57,7 +58,7 @@ class DataJointError(Exception): * modify the local copy of %s that datajoint just saved for you * put a file named %s with the same configuration format in your home * specify the environment variables DJ_USER, DJ_HOST, DJ_PASS - """) + """ % (LOCALCONFIG, GLOBALCONFIG)) local_config_file = os.path.expanduser(LOCALCONFIG) logger.log(logging.INFO, "No config found. Generating {0:s}".format(local_config_file)) config.save(local_config_file) @@ -69,7 +70,7 @@ class DataJointError(Exception): from .connection import conn, Connection from .base_relation import BaseRelation from .user_relations import Manual, Lookup, Imported, Computed, Part -from .relational_operand import Not +from .relational_operand import Not, AndList, OrList from .heading import Heading from .schema import Schema as schema from .kill import kill diff --git a/datajoint/autopopulate.py b/datajoint/autopopulate.py index c910925e6..8b4c652ec 100644 --- a/datajoint/autopopulate.py +++ b/datajoint/autopopulate.py @@ -3,7 +3,7 @@ import logging import datetime import random -from .relational_operand import RelationalOperand +from .relational_operand import RelationalOperand, AndList from . import DataJointError from .base_relation import FreeRelation @@ -56,13 +56,12 @@ def target(self): """ return self - def populate(self, restriction=None, suppress_errors=False, - reserve_jobs=False, order="original"): + def populate(self, *restrictions, suppress_errors=False, reserve_jobs=False, order="original"): """ rel.populate() calls rel._make_tuples(key) for every primary key in self.populated_from for which there is not already a tuple in rel. - :param restriction: restriction on rel.populated_from - target + :param restrictions: a list of restrictions each restrict (rel.populated_from - target.proj()) :param suppress_errors: suppresses error if true :param reserve_jobs: if true, reserves job to populate in asynchronous fashion :param order: "original"|"reverse"|"random" - the order of execution @@ -77,13 +76,13 @@ def populate(self, restriction=None, suppress_errors=False, todo = self.populated_from if not isinstance(todo, RelationalOperand): raise DataJointError('Invalid populated_from value') - todo.restrict(restriction) + todo.restrict(AndList(restrictions)) error_list = [] if suppress_errors else None - jobs = self.connection.jobs[self.target.database] - table_name = self.target.table_name - keys = (todo - self.target.project()).fetch.keys() + jobs = self.connection.jobs[self.target.database] if reserve_jobs else None + todo -= self.target.proj() + keys = todo.fetch.keys() if order == "reverse": keys = list(keys) keys.reverse() @@ -94,12 +93,12 @@ def populate(self, restriction=None, suppress_errors=False, raise DataJointError('Invalid order specification') for key in keys: - if not reserve_jobs or jobs.reserve(table_name, key): + if not reserve_jobs or jobs.reserve(self.target.table_name, key): self.connection.start_transaction() if key in self.target: # already populated self.connection.cancel_transaction() if reserve_jobs: - jobs.complete(table_name, key) + jobs.complete(self.target.table_name, key) else: logger.info('Populating: ' + str(key)) try: @@ -107,7 +106,7 @@ def populate(self, restriction=None, suppress_errors=False, except Exception as error: self.connection.cancel_transaction() if reserve_jobs: - jobs.error(table_name, key, error_message=str(error)) + jobs.error(self.target.table_name, key, error_message=str(error)) if not suppress_errors: raise else: @@ -116,7 +115,7 @@ def populate(self, restriction=None, suppress_errors=False, else: self.connection.commit_transaction() if reserve_jobs: - jobs.complete(table_name, key) + jobs.complete(self.target.table_name, key) return error_list def progress(self, restriction=None, display=True): diff --git a/datajoint/fetch.py b/datajoint/fetch.py index ffbea5c75..96c7b9754 100644 --- a/datajoint/fetch.py +++ b/datajoint/fetch.py @@ -190,7 +190,7 @@ def keys(self, **kwargs): """ Iterator that returns primary keys. """ - yield from self._relation.project().fetch.set_behavior(**dict(self.behavior, as_dict=True, **kwargs)) + yield from self._relation.proj().fetch.set_behavior(**dict(self.behavior, as_dict=True, **kwargs)) def __getitem__(self, item): """ diff --git a/datajoint/heading.py b/datajoint/heading.py index a08e3f3af..0e81e0ce4 100644 --- a/datajoint/heading.py +++ b/datajoint/heading.py @@ -184,7 +184,7 @@ def init_from_database(self, conn, database, table_name): attr['dtype'] = numeric_types[(t, is_unsigned)] self.attributes = OrderedDict([(q['name'], Attribute(**q)) for q in attributes]) - def project(self, *attribute_list, **renamed_attributes): + def proj(self, *attribute_list, **renamed_attributes): """ derive a new heading by selecting, renaming, or computing attributes. In relational algebra these operators are known as project, rename, and expand. diff --git a/datajoint/relational_operand.py b/datajoint/relational_operand.py index 6188c3989..362a8fb21 100644 --- a/datajoint/relational_operand.py +++ b/datajoint/relational_operand.py @@ -11,45 +11,77 @@ logger = logging.getLogger(__name__) -class AndList(Sequence): +class AndList(list): """ A list of restrictions to by applied to a relation. The restrictions are ANDed. Each restriction can be a list or set or a relation whose elements are ORed. - But the elements that are lists can contain + But the elements that are lists can contain other AndLists. + + Example: + rel2 = rel & dj.AndList((cond1, cond2, cond3)) + is equivalent to + rel2 = rel & cond1 & cond2 & cond3 """ + pass - def __init__(self, heading): - self.heading = heading - self._list = [] - def __len__(self): - return len(self._list) - - def __getitem__(self, i): - return self._list[i] - - def add(self, *args): - # remove Nones and duplicates - args = [r for r in args if r is not None and r not in self] - if args: - if any(is_empty_set(r) for r in args): - # if any condition is an empty list, return FALSE - self._list = ['FALSE'] - else: - self._list.extend(args) +class OrList(list): + """ + A list of restrictions to by applied to a relation. The restrictions are ORed. + If any restriction is . + But the elements that are lists can contain other AndLists. + + Example: + rel2 = rel & dj.ORList((cond1, cond2, cond3)) + is equivalent to + rel2 = rel & [cond1, cond2, cond3] + + Since ORList is just an alias for list, it is not necessary and is only provided + for consistency with AndList. + """ + pass + +class RelationalOperand(metaclass=abc.ABCMeta): + """ + RelationalOperand implements relational algebra and fetch methods. + RelationalOperand objects reference other relation objects linked by operators. + The leaves of this tree of objects are base relations. + When fetching data from the database, this tree of objects is compiled into an SQL expression. + It is a mixin class that provides relational operators, iteration, and fetch capability. + RelationalOperand operators are: restrict, pro, and join. + """ + + _restrictions = None + + @property + def restrictions(self): + if self._restrictions is None: + self._restrictions = AndList() + return self._restrictions + + def clear_restrictions(self): + self._restrictions = None + + @property + def primary_key(self): + return self.heading.primary_key + + @property def where_clause(self): """ convert to a WHERE clause string """ - def make_condition(arg, _negate=False): if isinstance(arg, str): return arg, _negate elif isinstance(arg, AndList): - return '(' + ' AND '.join([make_condition(element)[0] for element in arg]) + ')', _negate + if arg: + return '(' + ' AND '.join([make_condition(element)[0] for element in arg]) + ')', _negate + else: + return 'FALSE' if _negate else 'TRUE', False - # semijoin or antijoin + # semijoin or antijoin elif isinstance(arg, RelationalOperand): common_attributes = [q for q in self.heading.names if q in arg.heading.names] if not common_attributes: @@ -71,20 +103,26 @@ def make_condition(arg, _negate=False): # element of a record array condition = ['`%s`=%s' % (k, arg[k]) for k in arg.dtype.fields if k in self.heading] else: - raise DataJointError('invalid restriction type') + raise DataJointError('Invalid restriction type') return ' AND '.join(condition) if condition else 'TRUE', _negate - if not self: + if len(self.restrictions) == 0: # an empty list -> no WHERE clause return '' + # An empty or-list in the restrictions immediately causes an empty result + assert isinstance(self.restrictions, AndList) + if any(is_empty_or_list(r) for r in self.restrictions): + return ' WHERE FALSE' + conditions = [] - for item in self: + for item in self.restrictions: negate = isinstance(item, Not) if negate: - item = item.restriction + item = item.restriction # NOT is added below if isinstance(item, (list, tuple, set, np.ndarray)): - # sets of conditions are ORed - item = '(' + ') OR ('.join([make_condition(q)[0] for q in item]) + ')' + # process an OR list + temp = [make_condition(q)[0] for q in item if q is not is_empty_or_list(q)] + item = '(' + ') OR ('.join(temp) + ')' if temp else 'FALSE' else: item, negate = make_condition(item, negate) if not item: @@ -92,39 +130,6 @@ def make_condition(arg, _negate=False): conditions.append(('NOT (%s)' if negate else '(%s)') % item) return ' WHERE ' + ' AND '.join(conditions) - def __repr__(self): - return 'AND List: ' + repr(self._list) - - -class RelationalOperand(metaclass=abc.ABCMeta): - """ - RelationalOperand implements relational algebra and fetch methods. - RelationalOperand objects reference other relation objects linked by operators. - The leaves of this tree of objects are base relations. - When fetching data from the database, this tree of objects is compiled into an SQL expression. - It is a mixin class that provides relational operators, iteration, and fetch capability. - RelationalOperand operators are: restrict, pro, and join. - """ - - _restrictions = None - - @property - def restrictions(self): - if self._restrictions is None: - self._restrictions = AndList(self.heading) - return self._restrictions - - def clear_restrictions(self): - self._restrictions = None - - @property - def primary_key(self): - return self.heading.primary_key - - @property - def where_clause(self): - return self.restrictions.where_clause() - # --------- abstract properties ----------- @property @@ -171,15 +176,21 @@ def __mod__(self, attributes=None): """ relational projection operator. See RelationalOperand.project """ - return self.project(*attributes) + return self.proj(*attributes) - def project(self, *attributes, **renamed_attributes): + def project(self, *args, **kwargs): + """ + alias for self.proj() for backward compatibility + """ + return self.proj(*args, **kwargs) + + def proj(self, *attributes, **renamed_attributes): """ Relational projection operator. :param attributes: a list of attribute names to be included in the result. :return: a new relation with selected fields Primary key attributes are always selected and cannot be excluded. - Therefore obj.project() produces a relation with only the primary key attributes. + Therefore obj.proj() produces a relation with only the primary key attributes. If attributes includes the string '*', all attributes are selected. Each attribute can only be used once in attributes or renamed_attributes. Therefore, the projected relation cannot have more attributes than the original relation. @@ -203,44 +214,112 @@ def aggregate(self, group, *attributes, **renamed_attributes): def __iand__(self, restriction): """ - in-place restriction by a single condition + in-place restriction + + See relational_operand.restrict for more detail. """ self.restrict(restriction) + return self def __and__(self, restriction): """ relational restriction or semijoin :return: a restricted copy of the argument + + See relational_operand.restrict for more detail. """ ret = copy(self) ret.clear_restrictions() - ret.restrict(restriction, *list(self.restrictions)) + ret.restrict(self.restrictions) + ret.restrict(restriction) return ret - def restrict(self, *restrictions): + def __isub__(self, restriction): """ - In-place restriction. Primarily intended for datajoint's internal use. - Users are encouraged to use self & restriction to apply a restriction. - Each condition in restrictions is applied and the conditions are combined with AND. - However, each member of restrictions can be a list of conditions, which are combined with OR. - :param restrictions: list of restrictions. + in-place inverted restriction + + See relational_operand.restrict for more detail. """ - self.restrictions.add(*restrictions) + self.restrict(Not(restriction)) + return self + + def __sub__(self, restriction): + """ + inverse restriction aka antijoin + :return: a restricted copy of the argument + + See relational_operand.restrict for more detail. + """ + return self & Not(restriction) + + def restrict(self, restriction): + """ + In-place restriction. Restricts the relation to a subset of its original tuples. + rel.restrict(restriction) is equivalent to rel = rel & restriction or rel &= restriction + rel.restrict(Not(restriction)) is equivalent to rel = rel - restriction or rel -= restriction + The primary key of the result is unaffected. + Successive restrictions are combined using the logical AND. + The AndList class is provided to play the role of successive restrictions. + Any relation, collection, or sequence other than an AndList are treated as OrLists. + However, the class OrList is still provided for cases when explicitness is required. + Inverse restriction is accomplished by either using the subtraction operator or the Not class. + + The expressions in each row equivalent: + rel & 'TRUE' rel + rel & 'FALSE' the empty relation + rel - cond rel & Not(cond) + rel - 'TRUE' rel & 'FALSE' + rel - 'FALSE' rel + rel & AndList((cond1,cond2)) rel & cond1 & cond2 + rel & AndList() rel + rel & [cond1, cond2] rel & OrList((cond1, cond2)) + rel & [] rel & 'FALSE' + rel & None rel & 'FALSE' + rel & any_empty_relation rel & 'FALSE' + rel - AndList((cond1,cond2)) rel & [Not(cond1), Not(cond2)] + rel - [cond1, cond2] rel & Not(cond1) & Not(cond2) + rel - AndList() rel & 'FALSE' + rel - [] rel + rel - None rel + rel - any_empty_relation rel + + When arg is another relation, the restrictions rel & arg and rel - arg become the relational semijoin and + antijoin operators, respectively. + Then, rel & arg restricts rel to tuples that match at least one tuple in arg (hence arg is treated as an OrList). + Conversely, rel - arg restricts rel to tuples that do not match any tuples in arg. + Two tuples match when their common attributes have equal values or when they have no common attributes. + All shared attributes must be in the primary key of either rel or arg or both or an error will be raised. + + relational_operand.restrict is the only access point that modifies restrictions. All other operators must + ultimately call restrict() + + :param restriction: a sequence or an array (treated as OR list), another relation, an SQL condition string, or + an AndList. + """ + if isinstance(restriction, AndList): + self.restrictions.extend(restriction) + elif is_empty_or_list(restriction): + self.clear_restrictions() + self.restrictions.append('FALSE') + else: + self.restrictions.append(restriction) + + @property + def fetch1(self): + return Fetch1(self) + + @property + def fetch(self): + return Fetch(self) def attributes_in_restrictions(self): """ :return: list of attributes that are probably used in the restrictions. This is used internally for optimizing SQL statements """ - s = self.restrictions.where_clause() # avoid calling multiple times + s = self.where_clause return set(name for name in self.heading.names if name in s) - def __sub__(self, restriction): - """ - inverted restriction aka antijoin - """ - return self & (None if is_empty_set(restriction) else Not(restriction)) - @abc.abstractmethod def _repr_helper(self): """ @@ -253,7 +332,7 @@ def __repr__(self): if self._restrictions: ret += ' & %r' % self._restrictions else: - rel = self.project(*self.heading.non_blobs) # project out blobs + rel = self.proj(*self.heading.non_blobs) # project out blobs limit = config['display.limit'] width = config['display.width'] @@ -262,7 +341,7 @@ def __repr__(self): widths = {f: min(max([len(f)] + [len(str(e)) for e in tups[f]])+4,width) for f in columns} - templates = {f:'%%-%d.%ds' % (widths[f], widths[f]) for f in columns} + templates = {f: '%%-%d.%ds' % (widths[f], widths[f]) for f in columns} repr_string = ' '.join([templates[column] % column for column in columns]) + '\n' repr_string += ' '.join(['+' + '-' * (widths[column] - 2) + '+' for column in columns]) + '\n' for tup in tups: @@ -276,7 +355,7 @@ def __repr__(self): def _repr_html_(self): limit = config['display.limit'] - rel = self.project(*self.heading.non_blobs) # project out blobs + rel = self.proj(*self.heading.non_blobs) # project out blobs columns = rel.heading.names content = dict( head=''.join(columns), @@ -303,7 +382,7 @@ def make_select(self, select_fields=None): return 'SELECT {fields} FROM {from_}{where}{group}'.format( fields=select_fields if select_fields else self.select_fields, from_=self.from_clause, - where=self.restrictions.where_clause(), + where=self.where_clause, group=' GROUP BY `%s`' % '`,`'.join(self.primary_key) if self._grouped else '') def __len__(self): @@ -341,14 +420,6 @@ def cursor(self, offset=0, limit=None, order_by=None, as_dict=False): logger.debug(sql) return self.connection.query(sql, as_dict=as_dict) - @property - def fetch1(self): - return Fetch1(self) - - @property - def fetch(self): - return Fetch(self) - class Not: """ @@ -372,8 +443,8 @@ def __init__(self, arg1, arg2, left=False): self._arg1 = Subquery(arg1) if isinstance(arg1, Projection) else arg1 self._arg2 = Subquery(arg2) if isinstance(arg2, Projection) else arg2 self._heading = self._arg1.heading.join(self._arg2.heading, left=left) - self.restrict(*list(self._arg1.restrictions)) - self.restrict(*list(self._arg2.restrictions)) + self.restrict(self._arg1.restrictions) + self.restrict(self._arg2.restrictions) self._left = left def _repr_helper(self): @@ -402,7 +473,7 @@ def select_fields(self): class Projection(RelationalOperand): def __init__(self, arg, *attributes, **renamed_attributes): """ - See RelationalOperand.project() + See RelationalOperand.proj() """ # parse attributes in the form 'sql_expression -> new_attribute' alias_parser = re.compile( @@ -424,10 +495,10 @@ def __init__(self, arg, *attributes, **renamed_attributes): if use_subquery: self._arg = Subquery(arg) else: - self.restrict(*list(arg.restrictions)) + self.restrict(arg.restrictions) def _repr_helper(self): - return "(%r).project(%r)" % (self._arg, self._attributes) + return "(%r).proj(%r)" % (self._arg, self._attributes) @property def connection(self): @@ -435,7 +506,7 @@ def connection(self): @property def heading(self): - return self._arg.heading.project(*self._attributes, **self._renamed_attributes) + return self._arg.heading.proj(*self._attributes, **self._renamed_attributes) @property def _grouped(self): @@ -452,17 +523,6 @@ def __and__(self, restriction): ret.restrict(restriction) return ret - def restrict(self, *restrictions): - """ - Override restrict: when restricting on renamed attributes, enclose in subquery - """ - has_restriction = any(isinstance(r, RelationalOperand) or r for r in restrictions) - do_subquery = has_restriction and self.heading.computed - if do_subquery: - # TODO fix this for the case (r & key).aggregate(m='compute') - raise DataJointError('In-place restriction on renamed attributes is not allowed') - super().restrict(*restrictions) - class Aggregation(Projection): @property @@ -506,5 +566,10 @@ def _repr_helper(self): return "%r" % self._arg -def is_empty_set(arg): - return isinstance(arg, (list, set, tuple, np.ndarray, RelationalOperand)) and len(arg) == 0 +def is_empty_or_list(arg): + """ + returns true if the argument is equivalent to an empty OR list. + """ + return not isinstance(arg, AndList) and ( + arg is None or + isinstance(arg, (list, set, tuple, np.ndarray, RelationalOperand)) and len(arg) == 0) diff --git a/datajoint/schema.py b/datajoint/schema.py index 7b38639da..bbad08846 100644 --- a/datajoint/schema.py +++ b/datajoint/schema.py @@ -5,12 +5,10 @@ from . import conn, DataJointError from datajoint.utils import to_camel_case from .heading import Heading -from .base_relation import BaseRelation from .user_relations import Part, Computed, Imported, Manual, Lookup import inspect logger = logging.getLogger(__name__) -from warnings import warn class Schema: diff --git a/datajoint/settings.py b/datajoint/settings.py index ff43c490d..8670c7fb6 100644 --- a/datajoint/settings.py +++ b/datajoint/settings.py @@ -12,7 +12,7 @@ from enum import Enum LOCALCONFIG = 'dj_local_conf.json' -GLOBALCONFIG = '._datajoint_config.json' +GLOBALCONFIG = '.datajoint_config.json' validators = collections.defaultdict(lambda: lambda value: True) validators['database.port'] = lambda a: isinstance(a, int) diff --git a/datajoint/user_relations.py b/datajoint/user_relations.py index 139bd2fda..96f7dccc3 100644 --- a/datajoint/user_relations.py +++ b/datajoint/user_relations.py @@ -5,8 +5,6 @@ from .base_relation import BaseRelation from .autopopulate import AutoPopulate from .utils import from_camel_case -from . import DataJointError -import re _base_regexp = r'[a-z]+[a-z0-9]*(_[a-z]+[a-z0-9]*)*' @@ -47,17 +45,10 @@ class Manual(UserRelation): _prefix = r'' _regexp = r'(?P' + _prefix + _base_regexp + ')' - @property - def table_name(self): - """ - :returns: the table name of the table formatted for mysql. - """ - return self._prefix + from_camel_case(self.__class__.__name__) - @classproperty def table_name(cls): """ - :returns: the table name of the table formatted for mysql. + :returns: the table name of the table formatted for SQL. """ return from_camel_case(cls.__name__) @@ -96,13 +87,6 @@ class Imported(UserRelation, AutoPopulate): _prefix = '_' _regexp = r'(?P' + _prefix + _base_regexp + ')' - @property - def table_name(self): - """ - :returns: the table name of the table formatted for mysql. - """ - return self._prefix + from_camel_case(self.__class__.__name__) - @classproperty def table_name(cls): """ @@ -120,17 +104,10 @@ class Computed(UserRelation, AutoPopulate): _prefix = '__' _regexp = r'(?P' + _prefix + _base_regexp + ')' - @property - def table_name(self): - """ - :returns: the table name of the table formatted for mysql. - """ - return self._prefix + from_camel_case(self.__class__.__name__) - @classproperty def table_name(cls): """ - :returns: the table name of the table formatted for mysql. + :returns: the table name of the table formatted for SQL. """ return cls._prefix + from_camel_case(cls.__name__) @@ -143,8 +120,9 @@ class Part(BaseRelation): Part relations are implemented as classes inside classes. """ - _regexp = r'(?P' + '|'.join([c._regexp for c in [Manual, Imported, Computed, Lookup]]) + r'){1,1}' \ - + '__' + r'(?P' + _base_regexp + ')' + _regexp = r'(?P' + '|'.join( + [c._regexp for c in [Manual, Imported, Computed, Lookup]] + ) + r'){1,1}' + '__' + r'(?P' + _base_regexp + ')' _master = None diff --git a/tests/test_autopopulate.py b/tests/test_autopopulate.py index 31bc6496f..0c5a30dc3 100644 --- a/tests/test_autopopulate.py +++ b/tests/test_autopopulate.py @@ -35,7 +35,7 @@ def test_populate(self): # test restricted populate assert_false(self.trial, 'table already filled?') restriction = dict(subject_id=self.subject.project().fetch()['subject_id'][0]) - self.trial.populate(restriction=restriction) + self.trial.populate(restriction) assert_true(self.trial, 'table was not populated') poprel = self.trial.populated_from assert_equal(len(poprel & self.trial), len(poprel & restriction)) diff --git a/tests/test_relational_operand.py b/tests/test_relational_operand.py index 72cd2a275..928f05ba1 100644 --- a/tests/test_relational_operand.py +++ b/tests/test_relational_operand.py @@ -92,16 +92,16 @@ def test_join(): # test pairing # Approach 1: join then restrict - x = A().project(a1='id_a', c1='cond_in_a') - y = A().project(a2='id_a', c2='cond_in_a') + x = A().proj(a1='id_a', c1='cond_in_a') + y = A().proj(a2='id_a', c2='cond_in_a') rel = x*y & 'c1=0' & 'c2=1' assert_equal(len(x & 'c1=0')+len(y & 'c2=1'), len(A()), 'incorrect restriction') assert_equal(len(rel), len(x & 'c1=0')*len(y & 'c2=1'), 'incorrect pairing') # Approach 2: restrict then join - x = (A() & 'cond_in_a=0').project(a1='id_a') - y = (A() & 'cond_in_a=1').project(a2='id_a') + x = (A() & 'cond_in_a=0').proj(a1='id_a') + y = (A() & 'cond_in_a=1').proj(a2='id_a') assert_equal(len(rel), len(x*y)) @staticmethod @@ -186,7 +186,7 @@ def test_datetime(): @staticmethod def test_join_project_optimization(): """Test optimization for join of projected relations with matching non-primary key""" - print(DataA().project() * DataB().project()) + print(DataA().project() * DataB().proj()) print(DataA()) - assert_true(len(DataA().project() * DataB().project()) == len(DataA()) == len(DataB()), + assert_true(len(DataA().project() * DataB().proj()) == len(DataA()) == len(DataB()), "Join of projected relations does not work")