1. Prometheus Research, LLC
  2. Prometheus
  3. htsql

Commits

Kirill Simonov  committed e71f825

Documenting term nodes.

Also added `__str__` methods for terms, added `ScalarGroupExpression`,
other minor fixes and renames.

  • Participants
  • Parent commits 9780aba
  • Branches default

Comments (0)

Files changed (5)

File src/htsql/tr/assemble.py

View file
 class AssemblingState(object):
 
     def __init__(self):
-        self.next_id = 1
+        self.next_tag = 1
         self.baseline_stack = []
         self.baseline = None
         self.mask_stack = []
         self.mask = None
 
-    def make_id(self):
-        id = self.next_id
-        self.next_id += 1
-        return id
+    def make_tag(self):
+        tag = self.next_tag
+        self.next_tag += 1
+        return tag
 
     def push_baseline(self, baseline):
         assert isinstance(baseline, Space) and baseline.is_inflated
         segment = None
         if self.expression.segment is not None:
             segment = self.state.assemble(self.expression.segment)
-        return QueryTerm(segment, self.expression)
+        return QueryTerm(self.state.make_tag(), segment, self.expression)
 
 
 class AssembleSegment(Assemble):
         order = self.expression.space.ordering()
         codes = self.expression.elements + [code for code, direction in order]
         kid = self.state.inject(kid, codes)
-        kid = OrderTerm(self.state.make_id(), kid, order, None, None,
+        kid = OrderTerm(self.state.make_tag(), kid, order, None, None,
                         kid.space, kid.routes.copy())
-        return SegmentTerm(self.state.make_id(), kid, self.expression.elements,
+        return SegmentTerm(self.state.make_tag(), kid, self.expression.elements,
                            kid.space, kid.routes.copy())
 
 
             routes[self.space] = routes[unmasked_space]
             return self.term.clone(routes=routes)
         if self.term.backbone.concludes(unmasked_space):
-            id = self.state.make_id()
+            tag = self.state.make_tag()
             next_axis = self.term.baseline
             while next_axis.base != unmasked_space:
                 next_axis = next_axis.base
             routes = lkid.routes.copy()
             routes[unmasked_space] = rkid[unmasked_space]
             routes[self.space] = rkid[unmasked_space]
-            return JoinTerm(id, lkid, rkid, [tie], True, lkid.space, routes)
-        id = self.state.make_id()
+            return JoinTerm(tag, lkid, rkid, [tie], True, lkid.space, routes)
+        tag = self.state.make_tag()
         baseline = unmasked_space
         while not baseline.is_inflated:
             baseline = baseline.base
         routes = lkid.routes.copy()
         routes[self.space] = rkid.routes[self.space]
         routes[unmasked_space] = rkid.routes[self.space]
-        return JoinTerm(id, lkid, rkid, ties, is_inner, lkid.space, routes)
+        return JoinTerm(tag, lkid, rkid, ties, is_inner, lkid.space, routes)
 
 
 class AssembleScalar(AssembleSpace):
     adapts(ScalarSpace)
 
     def __call__(self):
-        id = self.state.make_id()
-        routes = { self.space: id }
-        return ScalarTerm(id, self.space, routes)
+        tag = self.state.make_tag()
+        routes = { self.space: tag }
+        return ScalarTerm(tag, self.space, routes)
 
 
 class AssembleProduct(AssembleSpace):
 
     def __call__(self):
         if self.backbone == self.baseline:
-            id = self.state.make_id()
-            routes = { self.space: id, self.backbone: id }
-            return TableTerm(id, self.space, routes)
+            tag = self.state.make_tag()
+            routes = { self.space: tag, self.backbone: tag }
+            return TableTerm(tag, self.space, routes)
         term = self.state.assemble(self.space.base)
         if self.backbone in term.routes and self.space.conforms(term.space):
             routes = term.routes.copy()
             routes[self.space] = routes[self.backbone]
             return term.clone(routes=routes)
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         lkid = term
         rkid = self.state.assemble(self.space, baseline=self.backbone)
         routes = lkid.routes.copy()
         routes[self.space] = rkid.routes[self.space]
         routes[self.backbone] = rkid.routes[self.backbone]
         tie = SeriesTie(self.space)
-        return JoinTerm(id, lkid, rkid, [tie], True, self.space, routes)
+        return JoinTerm(tag, lkid, rkid, [tie], True, self.space, routes)
 
 
 class AssembleFiltered(AssembleSpace):
             routes = term.routes.copy()
             routes[self.space] = routes[self.backbone]
             return term.clone(space=self.space, routes=routes)
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         kid = self.state.inject(term, [self.space.filter])
         routes = kid.routes.copy()
         routes[self.space] = routes[self.backbone]
-        return FilterTerm(id, kid, self.space.filter, self.space, routes)
+        return FilterTerm(tag, kid, self.space.filter, self.space, routes)
 
 
 class AssembleOrdered(AssembleSpace):
             routes = term.routes.copy()
             routes[self.space] = routes[self.backbone]
             return term.clone(space=self.space, routes=routes)
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         kid = self.state.assemble(self.space.base,
                                   baseline=self.space.scalar,
                                   mask=self.space.scalar)
         kid = self.state.inject(kid, codes)
         routes = kid.routes.copy()
         routes[self.space] = routes[self.backbone]
-        return OrderTerm(id, kid, order, self.space.limit, self.space.offset,
+        return OrderTerm(tag, kid, order, self.space.limit, self.space.offset,
                          self.space, routes)
 
 
         if self.unit in self.term.routes:
             return self.term
         if self.space.dominates(self.term.space):
-            term = self.state.inject(self.term, [self.unit.expression])
+            term = self.state.inject(self.term, [self.unit.code])
             if term.is_nullary:
-                term = WrapperTerm(self.state.make_id(), term,
+                term = WrapperTerm(self.state.make_tag(), term,
                                    term.space, term.routes.copy())
             routes = term.routes.copy()
-            routes[self.unit] = term.id
+            routes[self.unit] = term.tag
             return term.clone(routes=routes)
         lkid = self.term
         baseline = self.space.prune(self.term.space)
         rkid = self.state.assemble(self.space,
                                    baseline=baseline,
                                    mask=self.term.space)
-        rkid = self.state.inject(rkid, [self.unit.expression])
+        rkid = self.state.inject(rkid, [self.unit.code])
         if rkid.is_nullary:
-            rkid = WrapperTerm(self.state.make_id(), rkid,
+            rkid = WrapperTerm(self.state.make_tag(), rkid,
                                rkid.space, rkid.routes.copy())
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         ties = []
         if lkid.backbone.concludes(rkid.baseline):
             lkid = self.state.inject(lkid, [rkid.baseline])
             tie = SeriesTie(rkid.baseline)
             ties.append(tie)
         routes = lkid.routes.copy()
-        routes[self.unit] = rkid.id
-        return JoinTerm(id, lkid, rkid, ties, False, lkid.space, routes)
+        routes[self.unit] = rkid.tag
+        return JoinTerm(tag, lkid, rkid, ties, False, lkid.space, routes)
 
 
 class InjectAggregate(Inject):
         plural_term = self.state.assemble(self.unit.plural_space,
                                           baseline=baseline,
                                           mask=ground_term.space)
-        plural_term = self.state.inject(plural_term, [self.unit.composite])
+        plural_term = self.state.inject(plural_term, [self.unit.code])
         projected_space = None
         ties = []
         axes = []
             tie = SeriesTie(axis)
             ties.append(tie)
             axes.append(axis)
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         routes = {}
         for axis in axes:
             routes[axis] = plural_term.routes[axis]
         routes[projected_space] = routes[axes[-1]]
         routes[projected_space.inflate()] = routes[axes[-1]]
-        projected_term = ProjectionTerm(id, plural_term, ties,
+        projected_term = ProjectionTerm(tag, plural_term, ties,
                                         projected_space, routes)
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         lkid = ground_term
         rkid = projected_term
         is_inner = projected_term.space.dominates(ground_term.space)
         routes = lkid.routes.copy()
-        routes[self.unit] = projected_term.id
-        term = JoinTerm(id, lkid, rkid, ties, is_inner, lkid.space, routes)
+        routes[self.unit] = projected_term.tag
+        term = JoinTerm(tag, lkid, rkid, ties, is_inner, lkid.space, routes)
         if is_native:
             return term
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         lkid = self.term
         rkid = term
         ties = []
             ties.append(tie)
         is_inner = rkid.space.dominates(lkid.space)
         routes = lkid.routes.copy()
-        routes[self.unit] = projected_term.id
-        return JoinTerm(id, lkid, rkid, ties, is_inner, lkid.space, routes)
+        routes[self.unit] = projected_term.tag
+        return JoinTerm(tag, lkid, rkid, ties, is_inner, lkid.space, routes)
 
 
 class InjectCorrelated(Inject):
         plural_term = self.state.assemble(self.unit.plural_space,
                                           baseline=baseline,
                                           mask=ground_term.space)
-        plural_term = self.state.inject(plural_term, [self.unit.composite])
+        plural_term = self.state.inject(plural_term, [self.unit.code])
         if plural_term.is_nullary:
-            plural_term = WrapperTerm(self.state.make_id(), plural_term,
+            plural_term = WrapperTerm(self.state.make_tag(), plural_term,
                                       plural_term.space,
                                       plural_term.routes.copy())
         ties = []
             tie = SeriesTie(axis)
             ties.append(tie)
             axes.append(axis)
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         lkid = ground_term
         rkid = plural_term
         routes = lkid.routes.copy()
-        routes[self.unit] = plural_term.id
-        term = CorrelationTerm(id, lkid, rkid, ties, lkid.space, routes)
+        routes[self.unit] = plural_term.tag
+        term = CorrelationTerm(tag, lkid, rkid, ties, lkid.space, routes)
         if is_native:
             return term
-        id = self.state.make_id()
+        tag = self.state.make_tag()
         lkid = self.term
         rkid = term
         ties = []
             ties.append(tie)
         is_inner = rkid.space.dominates(lkid.space)
         routes = lkid.routes.copy()
-        routes[self.unit] = plural_term.id
-        return JoinTerm(id, lkid, rkid, ties, is_inner, lkid.space, routes)
+        routes[self.unit] = plural_term.tag
+        return JoinTerm(tag, lkid, rkid, ties, is_inner, lkid.space, routes)
 
 
 class InjectGroup(Inject):

File src/htsql/tr/code.py

View file
 from ..mark import Mark
 from ..entity import TableEntity, ColumnEntity, Join
 from ..domain import Domain, BooleanDomain
+from .syntax import IdentifierSyntax
 from .binding import Binding, QueryBinding, SegmentBinding
 from .coerce import coerce
 
 
     An expression tree (a DAG) is an intermediate stage of the HTSQL
     translator.  An expression tree is translated from a binding tree by
-    the *encoding* process.  It is then translated to a clause structure
+    the *encoding* process.  It is then translated to a frame structure
     by the *compiling* process.
 
     The following adapters are associated with the encoding process and
     See :class:`htsql.tr.encode.Encode` and :class:`htsql.tr.encode.Relate`
     for more detail.
 
-    The compiling process works as follows.  Space nodes (and selected code
-    nodes) are translated to frame nodes via several intermediate steps::
+    The compiling process works as follows.  Space nodes (and also unit nodes)
+    are translated to frame nodes via several intermediate steps::
 
         Assemble: (Space, AssembleState) -> Term
         Outline: (Term, OutlineState) -> Sketch
     (essential) attributes are equal.  Some attributes may be considered
     not essential and do not participate in comparison.  To facilitate
     expression comparison, :class:`htsql.domain.Domain` objects also support
-    equality by value.  Expression nodes could also be used as dictionary
-    keys.
+    equality by value.  By-value semantics is respected when expression nodes
+    are used as dictionary keys.
 
     The constructor arguments:
 
         assert isinstance(binding, Binding)
         assert isinstance(equality_vector, maybe(oneof(tuple, int, long)))
         # When `equality_vector` is not set, equality by identity
-        # is assumed.  Node that `A is B` <=> `id(A) == id(B)`.
+        # is assumed.  Note that `A is B` <=> `id(A) == id(B)`.
         if equality_vector is None:
             equality_vector = id(self)
         self.binding = binding
 
     def __eq__(self, other):
         # Two nodes are equal if they are of the same type and
-        # their equality vectors are equal.  We compare hashes
-        # before comparing equality vectors to make it work
-        # faster (hopefully) in the more common "not equal"
-        # case.
-        return (isinstance(other, Expression) and
-                self.__class__ is other.__class__ and
-                self.hash == other.hash and
-                self.equality_vector == other.equality_vector)
+        # their equality vectors are equal.  To avoid costly
+        # comparison of equality vectors in the more common
+        # "not equal" case, we compare hashes first.
+        return ((self is other) or
+                (isinstance(other, Expression) and
+                 self.__class__ is other.__class__ and
+                 self.hash == other.hash and
+                 self.equality_vector == other.equality_vector))
 
     def __str__(self):
         # Display the syntex node that gave rise to the expression.
     """
     Represents a space node.
 
-    A space is an expression that represents an (ordered) set of rows.
+    A space is an expression that represents an (ordered multi-) set of rows.
     Among others, we consider the following kinds of spaces:
 
     *The scalar space* `I`
     to a base space.  We could classify the operations (and therefore
     :class:`Space` subclasses) into two groups: those which keep the row
     shape of the base space and those which expand it.  The latter are
-    called *axis spaces*.  We also consider the scalar space an axis space.
+    called *axis spaces*.  We also regard the scalar space as an axis space.
 
     Take an arbitrary space `A` and consider it as a sequence of
     operations applied to the scalar space.  If we then reapply only
         self.is_contracting = is_contracting
         self.is_expanding = is_expanding
         # Indicates that the space itself and all its prefixes are axes.
-        self.is_inflated = (base is None or
+        self.is_inflated = (self.is_scalar or
                             (base.is_inflated and self.is_axis))
-        # Extract the root scalar space from the base; if there is no base,
-        # `self` must be the root itself.
-        self.scalar = (base.scalar if base is not None else self)
+        # Extract the root scalar space from the base.
+        self.scalar = (base.scalar if not self.is_scalar else self)
 
     def ordering(self, with_strong=True, with_weak=True):
         """
         """
         # We rely upon an assumption that the equality vector of a space node
         # is a tuple of all its essential attributes and the first element
-        # of the tuple is the space base.
+        # of the tuple is the space base.  So we skip the base space and
+        # compare the remaining attributes.
         return (isinstance(other, self.__class__) and
                 self.equality_vector[1:] == other.equality_vector[1:])
 
                 my_prefixes.pop()
             else:
                 # The prefixes are both axes and differ from each other.
-                # At this point, The prefixes diverge and are not
+                # At this point, the prefixes diverge and are not
                 # comparable anymore.  Break from the loop.
                 break
         # Reapply the unprocessed prefixes.
         # Sanity check on the argument.
         assert isinstance(other, Space)
         # Shortcut: any space spans itself.
-        if self is other or self == other:
+        if self == other:
             return True
         # Extract axis prefixes from both spaces.
         my_axes = [prefix for prefix in self.unfold() if prefix.is_axis]
         # Sanity check on the argument.
         assert isinstance(other, Space)
         # Shortcut: any space conforms itself.
-        if self is other or self == other:
+        if self == other:
             return True
         # Unfold the spaces into individual operations.
         my_prefixes = self.unfold()
         # Sanity check on the argument.
         assert isinstance(other, Space)
         # Shortcut: any space dominates itself.
-        if self is other or self == other:
+        if self == other:
             return True
         # Unfold the spaces into individual operations.
         my_prefixes = self.unfold()
         """
         # Sanity check on the argument.
         assert isinstance(other, Space)
-        # Shortcut: any space is its own prefix.  We compare by identity
-        # first because `==` is a costly operation (premature optimization?).
-        if self is other or self == other:
-            return True
         # Iterate over all prefixes of the space comparing them with
         # the given other space.
         space = self
     is_scalar = True
 
     def __init__(self, base, binding):
-        # We keep `base` among constructor arguments despite it being
-        # always equal to `None` to make
+        # We keep `base` among constructor arguments despite it always being
+        # equal to `None` to make
         #   space = space.clone(base=new_base)
-        # work even for scalar spaces.
+        # work for all types of spaces.
         assert base is None
         # Note that we must satisfy the assumption that the first element
         # of the equality vector is the space base (used by `Space.resembles`).
                 # of the table.
                 if not columns:
                     columns = list(self.table.columns)
+                # We assign the column units to the inflated space: it makes
+                # it easier to find and eliminate duplicates.
+                space = self.inflate()
                 # Add weak table ordering.
                 for column in columns:
-                    code = ColumnUnit(column, self, self.binding)
+                    # We need to associate the newly generated column unit
+                    # with some binding node.  We use the binding of the space,
+                    # but in order to produce a better string representation,
+                    # we replace the associated syntax node with a new
+                    # identifier named after the column.
+                    identifier = IdentifierSyntax(column.name, self.mark)
+                    binding = self.binding.clone(syntax=identifier)
+                    code = ColumnUnit(column, space, binding)
                     order.append((code, +1))
 
         return order
 
     def __init__(self, base, mask, binding):
         assert isinstance(mask, Space)
-        # FIXME: explain!
-        #is_expanding = base.is_scalar
-        is_expanding = False
         super(MaskedSpace, self).__init__(
                     base=base,
                     table=base.table,
                     is_contracting=True,
-                    is_expanding=is_expanding,
+                    is_expanding=False,
                     binding=binding,
                     equality_vector=(base, mask))
         self.mask = mask
     `order` (a list of pairs `(code, direction)`)
         Expressions to sort the space by.
 
-        Here `code` is an :class:`Code` instance, `direction` is either
+        Here `code` is a :class:`Code` instance, `direction` is either
         ``+1`` (indicates ascending order) or ``-1`` (indicates descending
         order).
 
         self.offset = offset
 
     def ordering(self, with_strong=True, with_weak=True):
+        # Note: explicit ordering is applied after the strong ordering of
+        # the base and before the weak ordering of the base.
         order = []
         if with_strong:
             order += self.base.ordering(with_strong=True, with_weak=False)
     Among all code expressions, we distinguish *unit expressions*:
     elementary functions on spaces.  There are several kinds of units:
     among them are columns and aggregate functions (see :class:`Unit`
-    for more detail).  Every non-unit code could be expressed as
+    for more detail).  A non-unit code could be expressed as
     a composition of a scalar function and one or several units:
 
         `f = F(u(a),v(b),...)`,
 
     where
-    
+
     - `f` is a code expression;
     - `F` is a scalar function;
     - `a`, `b`, ... are elements of spaces `A`, `B`, ...;
     - `u`, `v`, ... are unit expressions on `A`, `B`, ....
 
+    Note: special forms like `COUNT` or `EXISTS` are also expressed
+    as code nodes.  Since they are not regular functions, special care
+    must be taken to properly wrap them with appropriate
+    :class:`ScalarUnit` and/or :class:`AggregateUnit` instances.
+
     `domain` (:class:`htsql.domain.Domain`)
         The co-domain of the code expression.
 
                     # always exists.
                     domain=coerce(BooleanDomain()),
                     # Units of a complex expression are usually a composition
-                    # of argument units.  Note that duplicates are allowed.
+                    # of argument units.  Note that duplicates are possible.
                     units=(lop.units+rop.units),
                     binding=binding,
                     equality_vector=(lop, rop))
     of units; see subclasses :class:`ColumnUnit`, :class:`ScalarUnit`,
     :class:`AggregateUnit`, and :class:`CorrelatedUnit` for more detail.
 
-    Note that it is easy to *trasfer* a unit code from one space to another.
+    Note that it is easy to *lift* a unit code from one space to another.
     Specifically, suppose a unit `u` is defined on a space `A` and `B`
     is another space such that `B` spans `A`.  Then for each row `b`
     from `B` there is no more than one row `a` from `A` such that `a <-> b`.
 
     When a space `B` spans the space `A` of a unit `u`, we say that
     `u` is *singular* on `B`.  By the previous argument, `u` could be
-    transferred to `B`.  Thus any unit is well-defined not only on its
-    own space, but also on any space where it is singular.
+    lifted to `B`.  Thus any unit is well-defined not only on the
+    space where it is originally defined, but also on any space where
+    it is singular.
 
     `space` (:class:`Space`)
         The space on which the unit is defined.
     """
     Represents a scalar unit.
 
-    A scalar unit is a scalar function evaluated in the specified space.
+    A scalar unit is an expression evaluated in the specified space.
 
-    `expression` (:class:`Code`)
+    Recall that any expression has the following form:
+
+        `F(u(a),v(b),...)`,
+
+    where
+
+    - `F` is a scalar function;
+    - `a`, `b`, ... are elements of spaces `A`, `B`, ...;
+    - `u`, `v`, ... are unit expressions on `A`, `B`, ....
+
+    We require that the units of the expression are singular on the given
+    space.  If so, the expression units `u`, `v`, ... could be lifted to
+    the given slace (see :class:`Unit`).  The scalar unit is defined as
+
+        `F(u(x),v(x),...)`,
+
+    where `x` is a row of the space where the scalar unit is defined.
+
+    `code` (:class:`Code`)
         The expression to evaluate.
 
     `space` (:class:`Space`)
         The space on which the unit is defined.
     """
 
-    def __init__(self, expression, space, binding):
-        assert isinstance(expression, Code)
+    def __init__(self, code, space, binding):
+        assert isinstance(code, Code)
         super(ScalarUnit, self).__init__(
                     space=space,
-                    domain=expression.domain,
+                    domain=code.domain,
                     binding=binding,
-                    equality_vector=(expression, space))
-        self.expression = expression
+                    equality_vector=(code, space))
+        self.code = code
 
 
 class AggregateUnitBase(Unit):
     space* of an aggregate unit, and `g` is called *the composite expression*
     of an aggregate unit.
 
-    `composite` (:class:`Code`)
+    `code` (:class:`Code`)
         The composite expression of the aggregate unit.
 
     `plural_space` (:class:`Space`)
         The space on which the unit is defined.
     """
 
-    def __init__(self, composite, plural_space, space, binding):
-        assert isinstance(composite, Code)
+    def __init__(self, code, plural_space, space, binding):
+        assert isinstance(code, Code)
         assert isinstance(plural_space, Space)
         # FIXME: consider lifting the requirement that the plural
         # space spans the unit space.  Is it really necessary?
         assert not space.spans(plural_space)
         super(AggregateUnitBase, self).__init__(
                     space=space,
-                    domain=composite.domain,
+                    domain=code.domain,
                     binding=binding,
-                    equality_vector=(composite, plural_space, space))
-        self.composite = composite
+                    equality_vector=(code, plural_space, space))
+        self.code = code
         self.plural_space = plural_space
         self.space = space
 
         return ", ".join(str(code) for code in self.codes)
 
 
+class ScalarGroupExpression(Expression):
+    """
+    Represents a collection of sclar units sharing the same base space.
+
+    This is an auxiliary expression node used internally by the assembler.
+
+    `space` (:class:`Space`)
+        The base space of the scalar units.
+
+    `units` (a list of :class:`ScalarUnit`)
+        A collection of scalar units.  All units must have the same base
+        space.
+    """
+
+    def __init__(self, space, units, binding):
+        assert isinstance(space, Space)
+        assert isinstance(units, listof(ScalarUnit))
+        assert all(space == unit.space for unit in units)
+        super(ScalarGroupExpression, self).__init__(binding)
+        self.space = space
+        self.units = units
+
+    def __str__(self):
+        # Display the collection:
+        #   <unit>, <unit>, ...: <space>
+        return ("%s: %s"
+                % (", ".join(str(unit) for unit in self.units), self.space))
+
+
 class AggregateGroupExpression(Expression):
     """
     Represents a collection of aggregate units sharing the same base and
     `space` (:class:`Space`)
         The base space of the aggregates.
 
-    `aggregates` (a list of :class:`AggregateUnit`)
+    `units` (a list of :class:`AggregateUnit`)
         A collection of aggregate units.  All units must have the same
         base and plural spaces.
     """
 
-    def __init__(self, plural_space, space, aggregates, binding):
+    def __init__(self, plural_space, space, units, binding):
         assert isinstance(plural_space, Space)
         assert isinstance(space, Space)
-        assert isinstance(aggregates, listof(AggregateUnit))
-        assert all(plural_space == aggregate.plural_space and
-                   space == aggregate.space
-                   for aggregate in aggregates)
+        assert isinstance(units, listof(AggregateUnit))
+        assert all(plural_space == unit.plural_space and space == unit.space
+                   for unit in units)
         super(AggregateGroupExpression, self).__init__(binding)
         self.plural_space = plural_space
         self.space = space
-        self.aggregates = aggregates
+        self.units = units
 
     def __str__(self):
         # Display the collection:
-        #   <aggregate>, <aggregate>, ...: <plural_space> -> <space>
+        #   <unit>, <unit>, ...: <plural_space> -> <space>
         return ("%s: %s -> %s"
-                % (", ".join(str(aggregate) for aggregate in self.aggregates),
+                % (", ".join(str(unit) for unit in self.units),
                    self.plural_space, self.space))
 
 

File src/htsql/tr/encode.py

View file
             direction = self.state.direct(binding)
             if direction is not None:
                 order.append((element, direction))
-        # Augment the segment space by adding explicit ordering node.
-        # FIXME: we add `OrderedSpace` on top even when `order` is empty
-        # because otherwise `ORDER BY` terms will not be assembled.
-        # Fix the assembler?
-        space = OrderedSpace(space, order, None, None, self.binding)
+        # If any direction modifiers are found, augment the segment space
+        # by adding explicit ordering node.
+        if order:
+            space = OrderedSpace(space, order, None, None, self.binding)
         # Construct the expression node.
         return SegmentExpression(space, elements, self.binding)
 

File src/htsql/tr/outliner.py

View file
 class Outliner(object):
 
     def __init__(self):
-        self.sketch_by_term_id = {}
-        self.term_by_term_id = {}
+        self.sketch_by_tag = {}
+        self.term_by_tag = {}
 
     def outline(self, term, *args, **kwds):
-        if isinstance(term, RoutingTerm):
-            self.term_by_term_id[term.id] = term
+        self.term_by_tag[term.tag] = term
         outline = Outline(term, self)
         sketch = outline.outline(*args, **kwds)
-        if isinstance(term, RoutingTerm):
-            self.sketch_by_term_id[term.id] = sketch
+        self.sketch_by_tag[term.tag] = sketch
         return sketch
 
     def delegate(self, unit, term):
     adapts(ColumnUnit, Outliner)
 
     def delegate(self, term):
-        term_id = term.routes[self.unit.space]
-        sketch = self.outliner.sketch_by_term_id[term_id]
+        tag = term.routes[self.unit.space]
+        sketch = self.outliner.sketch_by_tag[tag]
         appointment = LeafAppointment(self.unit.column,
                                       self.unit.mark)
         return Demand(sketch, appointment)
     adapts(ScalarUnit, Outliner)
 
     def delegate(self, term):
-        term_id = term.routes[self.unit]
-        sketch = self.outliner.sketch_by_term_id[term_id]
-        term = self.outliner.term_by_term_id[term_id]
-        appointment = self.outliner.appoint(self.unit.expression, term)
+        tag = term.routes[self.unit]
+        sketch = self.outliner.sketch_by_tag[tag]
+        term = self.outliner.term_by_tag[tag]
+        appointment = self.outliner.appoint(self.unit.code, term)
         return Demand(sketch, appointment)
 
 
     adapts(AggregateUnit, Outliner)
 
     def delegate(self, term):
-        term_id = term.routes[self.unit]
-        sketch = self.outliner.sketch_by_term_id[term_id]
-        term = self.outliner.term_by_term_id[term_id]
-        appointment = self.outliner.appoint(self.unit.composite, term.kids[0])
+        tag = term.routes[self.unit]
+        sketch = self.outliner.sketch_by_tag[tag]
+        term = self.outliner.term_by_tag[tag]
+        appointment = self.outliner.appoint(self.unit.code, term.kids[0])
         return Demand(sketch, appointment)
 
 
     adapts(CorrelatedUnit, Outliner)
 
     def delegate(self, term):
-        term_id = term.routes[self.unit]
-        sketch = self.outliner.sketch_by_term_id[term_id]
-        term = self.outliner.term_by_term_id[term_id]
-        appointment = self.outliner.appoint(self.unit.composite, term)
+        tag = term.routes[self.unit]
+        sketch = self.outliner.sketch_by_tag[tag]
+        term = self.outliner.term_by_tag[tag]
+        appointment = self.outliner.appoint(self.unit.code, term)
         return Demand(sketch, appointment)
 
 
             code = ColumnUnit(column, self.tie.space,
                               self.tie.space.binding)
             right_codes.append(code)
-        if self.tie.is_reverse:
+        if self.tie.is_backward:
             left_codes, right_codes = right_codes, left_codes
         return zip(left_codes, right_codes)
 

File src/htsql/tr/term.py

View file
 
 
 class Term(Node):
+    """
+    Represents a term node.
 
-    def __init__(self, expression):
+    A term represents a relational algebraic expression.  :class:`Term`
+    is an abstract class, each its subclass represents a specific relational
+    operation.
+
+    The term tree is an intermediate stage of the HTSQL translator. A term
+    tree is translated from the expression graph by the *assembling* process.
+    It is then translated to the sketch tree by the *outline* process.
+
+    The following adapters are associated with the assembling process and
+    generate new term nodes::
+
+        Assemble: (Space, AssemblingState) -> Term
+        Inject: (Unit, Term, AssemblingState) -> Term
+
+    See :class:`htsql.tr.assemble.Assemble` and
+    :class:`htsql.tr.assemble.Inject` for more detail.
+
+    The following adapter implements the outline process::
+
+        Outline: (Term, OutliningState) -> Sketch
+
+    See :class:`htsql.tr.outline.Outline` for more detail.
+
+    Each term node has a unique (in the context of the term tree) identifier,
+    called the term *tag*.  Tags are used to refer to term objects indirectly.
+
+    Arguments:
+
+    `tag` (an integer)
+        A unique identifier of the node.
+
+    `expression` (:class:`htsql.tr.code.Expression`)
+        The expression node which gave rise to the term node; used only for
+        presentation or error reporting.
+
+    Other attributes:
+
+    `binding` (:class:`htsql.tr.binding.Binding`)
+        The binding node which gave rise to the term node; for debugging.
+
+    `syntax` (:class:`htsql.tr.syntax.Syntax`)
+        The syntax node which gave rise to the term node; for debugging.
+
+    `mark` (:class:`htsql.mark.Mark`)
+        The location of the node in the original query; for error reporting.
+    """
+
+    def __init__(self, tag, expression):
+        assert isinstance(tag, int)
         assert isinstance(expression, Expression)
+        self.tag = tag
         self.expression = expression
         self.binding = expression.binding
         self.syntax = expression.syntax
 
 
 class RoutingTerm(Term):
+    """
+    Represents a relational algebraic expression.
+
+    There are three classes of terms: nullary, unary and binary.
+    Nullary terms represents terminal expressions (for example,
+    :class:`TableTerm`), unary terms represent relational expressions
+    with a single operand (for example, :class:`FilterTerm`), and binary
+    terms represent relational expressions with two arguments (for example,
+    :class:`JoinTerm`).
+
+    Each term represents some space, called *the term space*.  It means
+    that, *as a part of some relational expression*, the term will produce
+    the rows of the space.  Note that taken alone, the term does not
+    necessarily generates the rows of the space: some of the operations
+    that comprise the space may be missing from the term.  Thus the term
+    space represents a promice: once the term is tied with some other
+    appropriate term, it will generate the rows of the space.
+
+    Each term maintains a table of units it is capable to produce.
+    For each unit, the table contains a reference to a node directly
+    responsible for evaluating the unit.
+
+    Class attributes:
+
+    `is_nullary` (Boolean)
+        Indicates that the term represents a nullary expression.
+
+    `is_unary` (Boolean)
+        Indicates that the term represents a unary expression.
+
+    `is_binary` (Boolean)
+        Indicates that the term represents a binary expression.
+
+    Arguments:
+
+    `tag` (an integer)
+        A unique identifier of the node.
+
+    `kids` (a list of zero, one or two :class:`RoutingTerm` objects)
+        The operands of the relational expression.
+
+    `space` (:class:`htsql.tr.code.Space`)
+        The space represented by the term.
+
+    `routes` (a mapping from :class:`htsql.tr.code.Unit` to term tag)
+        A mapping from unit objects to term tags that specifies the units
+        which the term is capable to produce.
+
+        A key of the mapping is either a :class:`htsql.tr.code.Unit`
+        or a :class:`htsql.tr.code.Space` node.  A value of the mapping
+        is a term tag, either of the term itself or of one of its
+        descendants.
+
+        The presence of a unit object in the `routes` table indicates
+        that the term is able to evaluate the unit.  The respective
+        term tag indicates the term directly responsible for evaluating
+        the unit.
+
+        A space node being a key in the `routes` table indicates that
+        any column of the space could be produced by the term.
+
+    Other attributes:
+
+    `backbone` (:class:`htsql.tr.code.Space`)
+        The inflation of the term space.
+
+    `baseline` (:class:`htsql.tr.code.Space`)
+        The leftmost axis of the term space that the term is capable
+        to produce.
+    """
 
     is_nullary = False
     is_unary = False
     is_binary = False
 
-    def __init__(self, id, kids, space, routes):
-        assert isinstance(id, int)
+    def __init__(self, tag, kids, space, routes):
         assert isinstance(kids, listof(RoutingTerm))
         assert isinstance(space, Space)
         assert isinstance(routes, dictof(oneof(Space, Unit), int))
-        assert space in routes
+        # The iflation of the term space.
         backbone = space.inflate()
-        assert backbone in routes
+        # The lestmost axis exported by the term.
         baseline = backbone
         while baseline.base in routes:
             baseline = baseline.base
-        super(RoutingTerm, self).__init__(space)
-        self.id = id
+        # Verify the validity of the `routes` table.  Note that we only do
+        # a few simple checks.  Here is the full list of assumptions that
+        # must be maintained:
+        # - The term space must be present in `routes`;
+        # - for any space in `routes`, its inflation must also be in `routes`;
+        # - if `axis` is an inflated prefix of the term space and its base
+        #   is in `routes`, `axis` must also be in `routes`.
+        assert space in routes
+        assert backbone in routes
+        axis = baseline.base
+        while axis is not None:
+            assert axis not in routes
+            axis = axis.base
+        super(RoutingTerm, self).__init__(tag, space)
         self.kids = kids
         self.space = space
         self.routes = routes
         self.backbone = backbone
         self.baseline = baseline
 
-    def __str__(self):
-        # Display:
-        #   <baseline> -> <space>
-        return "%s -> %s" % (self.baseline, self.space)
-
 
 class NullaryTerm(RoutingTerm):
+    """
+    Represents a terminal relational algebraic expression.
+    """
 
     is_nullary = True
 
-    def __init__(self, id, space, routes):
-        super(NullaryTerm, self).__init__(id, [], space, routes)
+    def __init__(self, tag, space, routes):
+        super(NullaryTerm, self).__init__(tag, [], space, routes)
 
 
 class UnaryTerm(RoutingTerm):
+    """
+    Represents a unary relational algebraic expression.
+
+    `kid` (:class:`RoutingTerm`)
+        The operand of the expression.
+    """
 
     is_unary = True
 
-    def __init__(self, id, kid, space, routes):
-        super(UnaryTerm, self).__init__(id, [kid], space, routes)
+    def __init__(self, tag, kid, space, routes):
+        super(UnaryTerm, self).__init__(tag, [kid], space, routes)
         self.kid = kid
 
 
 class BinaryTerm(RoutingTerm):
+    """
+    Represents a binary relational algebraic expression.
+
+    `lkid` (:class:`RoutingTerm`)
+        The left operand of the expression.
+
+    `rkid` (:class:`RoutingTerm`)
+        The right operand of the expression.
+    """
 
     is_binary = True
 
-    def __init__(self, id, lkid, rkid, space, routes):
-        super(BinaryTerm, self).__init__(id, [lkid, rkid], space, routes)
+    def __init__(self, tag, lkid, rkid, space, routes):
+        super(BinaryTerm, self).__init__(tag, [lkid, rkid], space, routes)
         self.lkid = lkid
         self.rkid = rkid
 
 
 class ScalarTerm(NullaryTerm):
+    """
+    Represents a scalar term.
 
-    def __init__(self, id, space, routes):
+    A scalar term is a terminal relational expression that produces
+    exactly one row.
+
+    A scalar term generates the following SQL clause::
+
+        (SELECT ... FROM DUAL)
+    """
+
+    def __init__(self, tag, space, routes):
+        # The space itself is not required to be a scalar, but it
+        # should not contain any other axes.
         assert space.table is None
-        super(ScalarTerm, self).__init__(id, space, routes)
+        super(ScalarTerm, self).__init__(tag, space, routes)
+
+    def __str__(self):
+        return "I"
 
 
 class TableTerm(NullaryTerm):
+    """
+    Represents a table term.
 
-    def __init__(self, id, space, routes):
+    A table term is a terminal relational expression that produces
+    all the rows of a table.
+
+    A table term generates the following SQL clause::
+
+        (SELECT ... FROM <table>)
+    """
+
+    def __init__(self, tag, space, routes):
+        # We assume that the table of the term is the prominent table
+        # of the term space.
         assert space.table is not None
-        super(TableTerm, self).__init__(id, space, routes)
+        super(TableTerm, self).__init__(tag, space, routes)
         self.table = space.table
 
+    def __str__(self):
+        # Display:
+        #   <schema>.<table>
+        return str(self.table)
+
 
 class FilterTerm(UnaryTerm):
+    """
+    Represents a filter term.
 
-    def __init__(self, id, kid, filter, space, routes):
+    A filter term is a unary relational expression that produces all the rows
+    of its operand that satisfy the given predicate expression.
+
+    A filter term generates the following SQL clause::
+
+        (SELECT ... FROM <kid> WHERE <filter>)
+
+    `kid` (:class:`RoutingTerm`)
+        The operand of the filter expression.
+
+    `filter` (:class:`htsql.tr.code.Code`)
+        The conditional expression.
+    """
+
+    def __init__(self, tag, kid, filter, space, routes):
         assert (isinstance(filter, Code) and
                 isinstance(filter.domain, BooleanDomain))
-        super(FilterTerm, self).__init__(id, kid, space, routes)
+        super(FilterTerm, self).__init__(tag, kid, space, routes)
         self.filter = filter
 
+    def __str__(self):
+        # Display:
+        #   (<kid> ? <filter>)
+        return "(%s ? %s)" % (self.kid, self.filter)
+
 
 class JoinTerm(BinaryTerm):
+    """
+    Represents a join term.
 
-    def __init__(self, id, lkid, rkid, ties, is_inner, space, routes):
+    A join term takes two operands and produces a set of pairs satisfying
+    the given ties.
+
+    Two types of joins are supported by a join term.  When the join is
+    *inner*, given the operands `A` and `B`, the term produces a set of
+    pairs `(a, b)`, where `a` is from `A`, `b` is from `B` and the pair
+    satisfies the given tie conditions.
+
+    A *left outer joins* produces the same rows as the inner join, but
+    also includes rows of the form `(a, NULL)` for each `a` from `A`
+    such that there are no rows `b` from `B` such that `(a, b)` satisfies
+    the given ties.
+
+    A join term generates the following SQL clause::
+
+        (SELECT ... FROM <lkid> (INNER | LEFT OUTER) JOIN <rkid> ON (<ties>))
+
+    `lkid` (:class:`RoutingTerm`)
+        The left operand of the join.
+
+    `rkid` (:class:`RoutingTerm`)
+        The right operand of the join.
+
+    `ties` (a list of :class:`Tie`)
+        The ties that establish the join condition.
+
+    `is_inner` (Boolean)
+        Indicates whether the join is inner or left outer.
+    """
+
+    def __init__(self, tag, lkid, rkid, ties, is_inner, space, routes):
         assert isinstance(ties, listof(Tie))
         assert isinstance(is_inner, bool)
-        super(JoinTerm, self).__init__(id, lkid, rkid, space, routes)
+        super(JoinTerm, self).__init__(tag, lkid, rkid, space, routes)
         self.ties = ties
         self.is_inner = is_inner
 
+    def __str__(self):
+        # Display, for inner join:
+        #   (<lkid> ++ <rkid> | <tie>, <tie>, ...)
+        # or, for left outer join:
+        #   (<lkid> +* <rkid> | <tie>, <tie>, ...)
+        conditions = ", ".join(str(tie) for tie in self.ties)
+        if conditions:
+            conditions = " | %s" % conditions
+        if self.is_inner:
+            op = "++"
+        else:
+            op = "+*"
+        return "(%s %s %s%s)" % (self.lkid, op, self.rkid, conditions)
+
 
 class CorrelationTerm(BinaryTerm):
+    """
+    Represents a correlation term.
 
-    def __init__(self, id, lkid, rkid, ties, space, routes):
+    A correlation term has a semantics similar to a left outer join term,
+    but is serialized to SQL as a correlated sub-SELECT clause.
+
+    `lkid` (:class:`RoutingTerm`)
+        The left operand of the correlated join.
+
+    `rkid` (:class:`RoutingTerm`)
+        The right operand of the correlated join.
+
+    `ties` (a list of :class:`Tie`)
+        The ties that establish the join condition.
+    """
+
+    def __init__(self, tag, lkid, rkid, ties, space, routes):
         assert isinstance(ties, listof(Tie))
-        super(CorrelationTerm, self).__init__(id, lkid, rkid, space, routes)
+        super(CorrelationTerm, self).__init__(tag, lkid, rkid, space, routes)
         self.ties = ties
 
+    def __str__(self):
+        # Display:
+        #   (<lkid> // <rkid> | <tie>, <tie>, ...)
+        conditions = ", ".join(str(tie) for tie in self.ties)
+        if conditions:
+            conditions = " | %s" % conditions
+        return "(%s // %s%s)" % (self.lkid, self.rkid, conditions)
+
 
 class ProjectionTerm(UnaryTerm):
+    """
+    Represents a projection term.
 
-    def __init__(self, id, kid, ties, space, routes):
+    Given an operand term and tie conditions, the ties naturally establish
+    an equivalence relation on the operand.  A projection term produces
+    rows of the quotient set corresponding to the equivalence relation.
+
+    A projection term generates the following SQL clause::
+
+        (SELECT ... FROM <kid> GROUP BY <ties>)
+
+    `lkid` (:class:`RoutingTerm`)
+        The left operand of the correlated join.
+
+    `rkid` (:class:`RoutingTerm`)
+        The right operand of the correlated join.
+
+    `ties` (a list of :class:`Tie`)
+        The ties that establish the quotient space.
+    """
+
+    def __init__(self, tag, kid, ties, space, routes):
         assert isinstance(ties, listof(Tie))
-        super(ProjectionTerm, self).__init__(id, kid, space, routes)
+        super(ProjectionTerm, self).__init__(tag, kid, space, routes)
         self.ties = ties
 
+    def __str__(self):
+        # Display:
+        #   (<kid> ^ <tie>, <tie>, ...)
+        if not self.ties:
+            return "(%s ^)" % self.kid
+        return "(%s ^ %s)" % (self.kid,
+                              ", ".join(str(tie) for tie in self.ties))
+
 
 class OrderTerm(UnaryTerm):
+    """
+    Represents an order term.
 
-    def __init__(self, id, kid, order, limit, offset, space, routes):
+    An order term reorders the rows of its operand and optionally extracts
+    a slice of the operand.
+
+    An order term generates the following SQL clause::
+
+        (SELECT ... FROM <kid> ORDER BY <order> LIMIT <limit> OFFSET <offset>)
+
+    `kid` (:class:`RoutingTerm`)
+        The operand.
+
+    `order` (a list of pairs `(code, direction)`)
+        Expressions to sort the rows by.
+
+        Here `code` is a :class:`htsql.tr.code.Code` instance, `direction`
+        is either ``+1`` (indicates ascending order) or ``-1`` (indicates
+        descending order).
+
+    `limit` (a non-negative integer or ``None``)
+        If set, the first `limit` rows of the operand are extracted and
+        the remaining rows are discared.
+
+    `offset` (a non-negative integer or ``None``)
+        If set, indicates that when extracting rows from the operand,
+        the first `offset` rows should be skipped.
+    """
+
+    def __init__(self, tag, kid, order, limit, offset, space, routes):
         assert isinstance(order, listof(tupleof(Code, int)))
         assert isinstance(limit, maybe(int))
         assert isinstance(offset, maybe(int))
-        super(OrderTerm, self).__init__(id, kid, space, routes)
+        assert limit is None or limit >= 0
+        assert offset is None or offset >= 0
+        super(OrderTerm, self).__init__(tag, kid, space, routes)
         self.order = order
         self.limit = limit
         self.offset = offset
 
+    def __str__(self):
+        # Display:
+        #   <kid> [<code>,...;<offset>:<limit>+<offset>]
+        # FIXME: duplicated from `OrderedSpace.__str__`.
+        indicators = []
+        if self.order:
+            indicator = ",".join(str(code) for code, dir in self.order)
+            indicators.append(indicator)
+        if self.limit is not None and self.offset is not None:
+            indicator = "%s:%s+%s" % (self.offset, self.offset, self.limit)
+            indicators.append(indicator)
+        elif self.limit is not None:
+            indicator = ":%s" % self.limit
+            indicators.append(indicator)
+        elif self.offset is not None:
+            indicator = "%s:" % self.offset
+            indicators.append(indicator)
+        indicators = ";".join(indicators)
+        return "%s [%s]" % (self.kid, indicators)
+
 
 class WrapperTerm(UnaryTerm):
-    pass
+    """
+    Represents a no-op expression.
+
+    A wrapper term represents exactly the same rows as its operand.  It is
+    used by the assembler to wrap nullary terms when SQL syntax requires
+    a non-terminal expression.
+    """
+
+    def __str__(self):
+        # Display:
+        #   (<kid>)
+        return "(%s)" % self.kid
 
 
 class SegmentTerm(UnaryTerm):
+    """
+    Represents a segment term.
 
-    def __init__(self, id, kid, elements, space, routes):
+    A segment term evaluates the given expressions on the rows of the operand.
+
+    A segment term generates the following SQL clause::
+
+        (SELECT <elements> FROM <kid>)
+
+    `kid` (:class:`RoutingTerm`)
+        The operand.
+
+    `elements` (a list of :class:`htsql.tr.code.Code`)
+        A list of expressions to produce.
+    """
+
+    def __init__(self, tag, kid, elements, space, routes):
         assert isinstance(elements, listof(Code))
-        super(SegmentTerm, self).__init__(id, kid, space, routes)
+        super(SegmentTerm, self).__init__(tag, kid, space, routes)
         self.elements = elements
 
+    def __str__(self):
+        # Display:
+        #   <kid> {<element>,...}
+        return "%s {%s}" % (self.kid, ",".join(str(element)
+                                               for element in self.elements))
+
 
 class QueryTerm(Term):
+    """
+    Represents a whole HTSQL query.
 
-    def __init__(self, segment, expression):
+    `segment` (:class:`SegmentTerm` or ``None``)
+        The query segment.
+    """
+
+    def __init__(self, tag, segment, expression):
         assert isinstance(segment, maybe(SegmentTerm))
         assert isinstance(expression, QueryExpression)
-        super(QueryTerm, self).__init__(expression)
+        super(QueryTerm, self).__init__(tag, expression)
         self.segment = segment
 
 
 class Tie(Node):
+    """
+    Represents a connection between two axes.
+
+    An axis space could be naturally connected with:
+
+    - an identical axis space;
+    - or its base space.
+
+    These two types of connections are called *parallel* and *series*
+    ties respectively.  Typically, a parallel tie is implemented using
+    a primary key constraint while a series tie is implemented using
+    a foreign key constraint, but, in general, it depends on the type
+    of the axis.
+
+    :class:`Tie` is an abstract case class with exactly two subclasses:
+    :class:`ParallelTie` and :class:`SeriesTie`.
+
+    Class attributes:
+
+    `is_parallel` (Boolean)
+        Denotes a parallel tie.
+
+    `is_series` (Boolean)
+        Denotes a series tie.
+    """
 
     is_parallel = False
     is_series = False
 
 
 class ParallelTie(Tie):
+    """
+    Represents a parallel tie.
+
+    A parallel tie is a connection of an axis with itself.
+
+    `space` (:class:`htsql.tr.code.Space`)
+        An axis space.
+    """
 
     is_parallel = True
 
     def __init__(self, space):
-        assert isinstance(space, Space) and space.is_axis
+        assert isinstace(space, Space) and space.is_axis
+        # Technically, non-inflated axis spaces could be permitted, but
+        # since the assembler only generates ties for inflated spaces,
+        # we add a respective check here.
+        assert space.is_inflated
         self.space = space
 
+    def __str__(self):
+        # Display:
+        #   ==<space>
+        return "==%s" % self.space
+
 
 class SeriesTie(Tie):
+    """
+    Represents a series tie.
+
+    A series tie is a connection between an axis and its base.  Note that
+    a series tie is assimetric, that is, depending on the order of the
+    operands, it could connect either an axis to its base, or the base
+    to the axis.
+
+    `space` (:class:`htsql.tr.code.Space`)
+        An axis space.
+
+    `is_backward` (Boolean)
+        If set, indicates that the tie connects the axis base to the axis
+        (i.e. the operands are switched).
+    """
 
     is_series = True
 
     def __init__(self, space, is_backward=False):
         assert isinstance(space, Space) and space.is_axis
+        # Technically, non-inflated axis spaces could be permitted, but
+        # since the assembler only generates ties for inflated spaces,
+        # we add a respective check here.
+        assert space.is_inflated
         assert isinstance(is_backward, bool)
         self.space = space
-        self.is_reverse = is_backward
+        self.is_backward = is_backward
 
+    def __str__(self):
+        # Depending on the direction, display
+        #   =><space>  or  <=<space>
+        if self.is_backward:
+            return "<=%s" % self.space
+        else:
+            return "=>%s" % self.space
 
+