168 Query Optimisation
This chapter explains how typed path navigation and the algebraic properties of Predica’s operators enable systematic query optimisation. Much ordinary navigation can be compiled directly. Where a query combines set-valued paths, many apparently distinct operations (such as selection and constraint application) can be expressed as ordinary joins. That part of the query can be treated as a join graph and reordered using associativity and commutativity to minimise intermediate cardinalities.
Compiling path expressions directly
A compiler works with path expressions directly to implement queries; it does not need to translate them into logic frames. The logic denotation provides a precise specification of what each path expression means, ensuring that users and compiler writers share the same definition of the language.
For example, mycircle.centre.x can be implemented as simple navigation
through the attributes of nested data structures: first from
mycircle to its centre, and then from that value to its
x attribute. The compiler can analyse and optimise this navigation
directly. Its implementation is correct when it preserves the meaning given by
the logic denotation in the language specification.
Typed navigation and relational query planning
In relational DBMSs such as PostgreSQL, a query states which results are required without normally prescribing a physical route between tables. When a query joins several tables, the optimiser may need to choose the join order, select among nested-loop, hash, and merge joins, and decide whether each input should be obtained through an index or a scan. These decisions depend on statistical estimates of cardinality and selectivity. The number of possible plans grows rapidly with the number of joined relations, so a substantial part of relational query optimisation consists of searching for a sufficiently inexpensive execution plan.
In Predica, a typed path often supplies a natural route through the model. An
expression such as mycircle.centre.x already describes navigation from
one nested value to another. The compiler does not need to rediscover that
route by considering alternative join trees: it can follow the
centre attribute and then the x attribute, much like
compiling ordinary field access in a programming language. Cost-based choices
remain important where paths are multi-valued, inverse, indexed, or combined
with constraints, but ordinary structural navigation requires much less
planning.
Representation follows the type structure
The mapping from a Predica schema into its physical representation tends to dominate how query implementation is understood. That mapping will often naturally mirror the nested types and local relationships already present in the logical model. The resulting implementation is made from structures such as objects, fields, maps, collections, and pointers, so a path expression is normally understood as navigation through those structures.
For a simple example, suppose a circle and its centre are represented by nested structures:
struct PointFloat32
{
float x;
float y;
};
struct CircleFloat32
{
PointFloat32 centre;
float radius;
};
Here mycircle.centre.x maps directly onto two field accesses. The
layout has already embodied the route expressed by the path, so there is no
choice of join order or join algorithm to recover during query planning.
Now suppose the same values are mapped directly onto unnested relations:
Circle(circle_id, centre_id, radius)
Point(point_id, x, y)
Obtaining the same x value requires matching
Circle.centre_id with Point.point_id. With more levels of
nesting, each level can introduce another relation and another join. The
relational implementation must then decide how to perform those joins and
whether to use indexes or scans. If a query combines several such paths, it may
also have to choose their evaluation order using estimates of selectivity and
intermediate cardinality.
The familiar suppliers, parts, and shipments example shows the difference more clearly. In the relational model it is natural to begin with three relations, with the shipment relation containing supplier and part keys. Predica best practice instead pushes the relationships down into the types that expose them:
entity Supplier
{
Integer id;
Shipment shipment* where shipment.supplier = this;
key {id};
}
entity Part
{
Integer id;
Color color;
Shipment shipment* where shipment.part = this;
key {id};
}
entity Shipment
{
Supplier supplier;
Part part;
Integer qty;
key {supplier, part};
}
entity ShipmentsSystem
{
Supplier supplier*;
Part part*;
→ Shipment shipment* = either supplier.shipment, part.shipment;
}
A natural representation gives the shipments system maps of suppliers and parts. Each supplier and part has a local collection of pointers to shipments, and the pointers from both sides can refer to the same shared shipment object. The shipment in turn refers to its supplier and part. The implementation is therefore more naturally pictured as a typed graph than as three flat tables.
For example, the path
s.supplier.[id = 17].shipment.part.color suggests the following
execution directly:
supplier-map lookup
→ supplier's local shipment collection
→ shared shipment object
→ part pointer
→ color attribute
The path through the data has already been organised by the Predica schema and preserved by its representation. The compiler need not begin with a mental picture of three n-ary relations and then discover a three-way join plan. Pushing relationships down is therefore not merely a surface-syntax convenience: it gives queries direct, typed routes through the model.
Multiple directions without a single hierarchy
An important relational-model argument must nevertheless be taken seriously. An n-ary relation does not privilege one direction of navigation. Given the relations
Supplier(supplier_id, ...)
Part(part_id, ...)
Shipment(supplier_id, part_id, quantity)
a query can proceed from suppliers through shipments to parts, from parts through shipments to suppliers, or directly from shipments to either endpoint. Indexes and join plans can be chosen independently of this symmetric logical representation. This is a genuine strength of the relational model.
If Predica physically stored the same information only as the following tree, the traditional criticism of hierarchical databases would apply:
Supplier
└── Shipment
└── Part
Finding shipments by part would then go against the privileged direction. It might require scanning all suppliers, duplicating data under a second hierarchy, or reorganising the database. A change in the required access direction could consequently force changes to both the physical schema and the programs that traverse it.
The direct representation suggested by the Predica model above is not such a tree. It is a typed graph with useful navigation in several directions:
ShipmentsSystem
├── suppliers: map<SupplierId, Supplier>
├── parts: map<PartId, Part>
│
├── Supplier ──shipment[]──┐
│ ▼
│ Shipment
│ ▲
└── Part ─────shipment[]───┘
Shipment ──supplier──→ Supplier
Shipment ──part──────→ Part
One shared shipment object can appear in the local shipment collections of both its supplier and its part. The following paths are consequently all natural:
supplier.shipment.part
part.shipment.supplier
shipment.supplier
shipment.part
The local collections under Supplier and Part are, in
physical terms, comparable to maintaining indexes on both
Shipment.supplier and Shipment.part. Predica is therefore
not claiming that one arbitrary hierarchy makes every direction efficient. It
allows the logical model and its direct representation to expose several typed
directions over the same entities.
Multiple access directions have a cost
Supporting these directions is not free. If a system contains one hundred
million shipments, efficient navigation in both directions requires physical
structures corresponding to both
supplier → shipments and part → shipments. A relational
implementation might use two B-tree indexes. A direct Predica implementation
might use two local collections of pointers. Either approach consumes storage
and adds work when shipments are inserted, removed, or changed.
If only Supplier.shipment has an efficient representation, then
Part.shipment cannot also be efficient by virtue of the path syntax
alone. It must be supported by another index or local collection, calculated as
an inverse mapping, found by a scan, or obtained using some other planned
operation. Predica does not eliminate the physical-design problem; it gives
common access paths a natural typed expression and, usually, a direct
representation.
The two local shipment collections must not be treated as independent logical
facts or as copied shipment records. They are constrained or derived views of
the same shared Shipment entities. There remains one logical fact that
a shipment has a particular supplier and part. Materialising navigation from
both endpoints is analogous to constructing several indexes over one relation:
it provides several access paths without introducing several independent
sources of truth.
What physical independence means here
Predica’s physical-independence claim is that a query specifies logical navigation without specifying the mechanism that implements each step. For example,
part.shipment.supplier
does not say whether part.shipment is represented by a vector of
pointers, a B-tree, a persistent index, a computed inverse, a cached derived
collection, or a relational join. Similarly, a reference from a shipment to a
part could be a memory pointer, an object identifier, a smart pointer that
loads an object on demand, or a foreign-key lookup. The representation can
change without changing the path or its meaning.
This is physical independence, but it is not physical neutrality and does not promise automatic efficiency. A path can remain valid while performing poorly until an appropriate representation or index is supplied. No representation can make every possible access pattern equally efficient without paying the cost of maintaining the corresponding access structures.
Query optimisation is especially pressing when an implementation maps a nested model onto unnested relations. Navigation already present in the source path must then be reconstructed by a join plan. When the physical layout preserves the nesting and graph relationships, ordinary navigation maps onto structural access and requires fewer planning decisions. More generally, the closer the physical representation is to the navigational shape of common queries, the less of that structure an optimiser must recover.
Predica does not require the physical representation to preserve this nesting. If the supplier model is stored in ordinary unnested SQL tables, the compiler or database backend must reconstruct the same path using joins and inherits the associated planning problem. The logic denotation remains the same in either implementation; it specifies the result, while the representation determines how naturally the source path maps onto an execution route.
Why some schema changes can remain local
Predica’s claims about resilience to schema changes do not follow from nesting alone. Several other properties of the language contribute:
- Entities belong to logical sorts. A
Supplieris logically identified as an entity in theSupplierdomain. Queries need not use an exposed surrogate identifier as the identity of the supplier. - Keys are constraints rather than identity plumbing. A key can support lookup and indexing without requiring every relationship and query to copy and compare the key attributes.
- Relationships are typed attributes.
shipment.partdenotes aPart; the query need not explicitly equateShipment.part_idwithPart.id. - Cardinality constraints are local. They are declared on the relevant attribute instead of being propagated through foreign-key columns and join conditions in many parts of an unnested schema.
- Inverse and derived relationships can expose additional directions. They need not introduce an independent source of truth.
- Collection representations are replaceable. A vector can be replaced by a B-tree, hash map, or persistent collection without changing the logical declaration or the paths that use it.
For example, changing the representation of
ShipmentsSystem.supplier from a vector to a B-tree does not change
queries. Adding an index for Part.color need not change the path
s.part.[color = red]. These are physical changes behind stable
logical expressions.
A genuine logical change can still affect queries. Removing or changing the meaning of an attribute cannot be hidden by physical independence. Changing a singular relationship into a many-valued relationship may also invalidate assumptions made by existing expressions. Predica reduces the propagation of incidental changes to keys, cardinality declarations, and representations; it does not make semantic changes disappear.
Relational planning and typed path optimisation
| Relational model | Predica |
|---|---|
| Relations do not privilege a navigation direction. | Named paths make logical navigation explicit, and a typed graph can expose several directions. |
| Physical indexes support selected directions. | Maps, local collections, inverse mappings, and indexes support selected directions. |
| Joins reconstruct relationships during execution. | Direct representations often embody relationships as references. |
| Queries remain unchanged when indexes change. | Paths remain unchanged when collection and reference representations change. |
| The optimiser chooses among global relational plans. | The compiler usually follows typed paths, with planning at set-valued, inverse, filtered, or indexed steps. |
Preserving nesting therefore does not eliminate optimisation. An attribute or
relationship implemented by a B-tree is comparable to a lookup in a
std::map: an access structure is involved, but that alone does not
create the global planning problem associated with joining many tables. More
substantial choices arise when a path traverses a multi-valued or inverse
relationship, when both an index and a scan are available, or when constraints
and set-valued paths can be evaluated in different orders.
In those cases the optimiser may select access paths, push restrictions earlier, estimate candidate-set sizes, and reorder genuinely set-valued operations. For example, when a path reaches a collection of orders and then filters their items, the compiler may have to choose between scanning the collection and using an index on an item attribute. If two filters apply, it can first apply the one expected to leave the smaller candidate set. A Predica optimiser can therefore be thought of primarily as a typed path optimiser, with relational-style planning used where the structure of a query actually calls for it.
None of this weakens Predica’s grounding in logic. Its logic applies uniformly
to user-defined types such as Circle and Point, to
variants, and even to the attributes of enum values, which are predicates. The
logic denotation fixes the meaning of all these expressions; it does not
prescribe that they be implemented by materialising relations or executing
table joins. Predica’s direct-mapping claim is that the logical type structure
usually has a simple and useful implementation, not that this implementation
is the only possible one or is optimal for every workload.
Selection as join
In Predica, selection is not a primitive operator. Instead, it is expressed as
join with a constraint tuple. For example, the selection
“attribute a must include value v” is represented by the
single-valued constraint tuple { a ↦ {v} }, and selection is:
σa=v(t) = t ⊓ { a ↦ {v} }.
More generally, any finite conjunction of attribute-value constraints can be
represented as a tuple, and applying those constraints is just another join.
This means that selections, filters, and constraints are all instances of the
same operator ⊓.
Join as the central optimisation primitive
Because join ⊓ is commutative, associative, and idempotent, any query
built from joins and selections can be viewed as a set of joins over a
collection of base relations and constraint tuples. The optimiser is therefore
free to:
- Reorder joins: use associativity and commutativity to choose a join order that minimises intermediate result sizes.
- Push constraints down: treat selections as joins with constraint tuples and move them as close as possible to base relations.
- Combine constraints: merge multiple constraint tuples into a single tuple using join, avoiding repeated passes over the data.
Since join never creates Cartesian products (it is a true meet, not “product + filter”), the optimiser does not need special rules to avoid explosive intermediate results. All joins are structurally safe, and the main goal is to reduce cardinalities as early as possible.
Projection and cardinality reduction
Projection πA is domain restriction and distributes over both
join and generalised union:
πA(t₁ ⊓ t₂) = πA(t₁) ⊓ πA(t₂), πA(t₁ ⊔ t₂) = πA(t₁) ⊔ πA(t₂).
This allows the optimiser to push projections down as well, trimming attributes early to reduce the width of intermediate tuples. Combined with early constraint joins, this yields smaller, narrower intermediate relations, which is exactly what a cost-based optimiser wants.
Using expand in optimisation
The expand operator ! is primarily semantic, but its algebraic
properties are still relevant for optimisation. Since expand commutes with join,
generalised union, and projection:
(t₁ ⊓ t₂)! = t₁! ⊓ t₂!, (t₁ ⊔ t₂)! = t₁! ⊔ t₂!, πA(t!) = (πA(t))!,
the optimiser can reason about queries either in the MVA domain or in the expanded “possible worlds” domain, knowing that the two views are consistent. In practice, expand is usually avoided during execution because it increases cardinality, but its laws justify algebraic rewrites and equivalence checking.
Minus and conflict-aware pruning
Minus − removes possibilities inside tuples rather than removing whole
tuples. It is monotone in its first argument and antitone in its second, and
distributes over both join and generalised union:
(t₁ − t₂) ⊓ (t₁ − t₃) = t₁ − (t₂ ⊔ t₃), (t₁ − t₂) ⊔ (t₁ − t₃) = t₁ − (t₂ ⊓ t₃).
These properties allow the optimiser to factor and reorder minus operations,
pushing them closer to the data sources where they can prune possibilities
early. Because Predica retains conflicting information (e.g.
{red} ⊔ {blue} = {red, blue}), minus can be used to remove specific
conflicting possibilities without discarding entire tuples, giving a more
fine-grained form of conflict-aware pruning than classical relational
difference.
Equivalence and rewrite rules
Finally, the expand operator provides a semantic notion of equivalence: two relations are information-equivalent exactly when their expansions coincide:
r₁! = r₂!.
This gives a solid foundation for algebraic rewrite rules: any transformation
the optimiser applies must preserve !-equivalence. Since all core
operators are monotone and commute appropriately with expand, the usual
optimisation rewrites (join reordering, selection pushdown, projection
pushdown, minus factoring) are sound with respect to this semantic criterion.