Daf.Specification — Usage Guide
🌍 Language: English | Español
Introduction
This package holds the domain-side half of the expression layer: a tree, the operators that build it, the seam that names things, and the specification and port that carry it across a boundary. It compiles against Commons and nothing else, so everything here is usable and testable with no database and no SQL unit linked.
The SQL-side half is Daf.SQLCute.Specification, which emits a tree into a SQLCute IQuery and ships the in-memory repository adapter.
uses Daf.Specification;
var Spec := TSpecification<TProduct>.New
.Where((Prop('Price') > 100) and (Prop('Active') = True))
.OrderByDesc('Price');Table of Contents
| Topic | What it covers |
|---|---|
| Authoring with operators | The operators, the breadth surface, values and NULL |
| The entity proxy | TEntityExpr<T>, name resolution, failing where it is written |
| The optional metamodel | TPropExpr<T>, what it buys and what it does not |
| Specifications and the port | TSpecification<T>, IRepository<T>, hexagonal composition |
The node model at a glance
Six node types and three operator enums, and that is the whole model:
| Kind | Interface | Carries |
|---|---|---|
Compare |
ICompareExpr |
an operator, a target, zero or more operands, case sensitivity |
Logical |
ILogicalExpr |
an operator and two operands |
Unary |
IUnaryExpr |
an operator and one operand |
Member |
IMemberExpr |
a property name (never a column) and a reserved accessor |
Value |
IValueExpr |
one Variant |
Param |
IParamExpr |
a name and one Variant |
Arity travels with the operator rather than with the type: IsNull takes no operand, Between takes two, In takes as many as the set holds. That is why breadth grows without the model growing — a new comparison is a new enum value and a new row in the emitter’s lookup table.
Lifetime is interface ref-counting throughout. There is no Acquire/Release, no manual Free, and no second idiom.
Reading a tree back
ExprToText renders a tree canonically — structural, stable, and blind to how the tree was authored:
ExprToText(Prop('Price') > 100)
// (m:Price Greater v:100#17)Use it to assert that two expressions are the same thing, as a cache key, or in a log line. It is never a step on the way to execution; the emitter reads the tree itself.
TSpecification<T>.Key does the same for a whole rule, filter and ordering and page together, which is what lets an adapter compile a plan once and reuse it.
What is deliberately absent
| Absent | Why |
|---|---|
| A closure filter on a specification | Untranslatable must be unrepresentable; use Daf.Linq for that world |
Eager-loading directives (includes) |
Nothing to lower into, and the wrong owner — assembling an aggregate is the adapter’s job |
A T: TEntity constraint |
Nothing downstream needs one; records work too |
| Entity↔︎table mapping metadata | Names resolve through INameResolver, and nothing else is mapped |
| Instance-driven DML | Needs the persistent-member set, the primary key and change tracking, which this layer does not model |
Raw SQL and EXISTS operators |
A domain expression must not be able to say them |
The last one is enforced by the package boundary, not by discipline: this unit does not reference SQLCute, and its spec runner links the domain half alone. Acquire a SQL dependency and the project stops compiling.
The entity proxy and name resolution
🌍 Language: English | Español
Why a proxy at all
Prop('Price') names a member and asks nothing about it. That is fine for a rule written next to the entity, but it means a typo travels: the SQL back-end will happily send an unmapped column to the server, while the in-memory lane cannot resolve it. The failure then surfaces far from its cause, and on only one back-end.
TEntityExpr<T> closes that gap by validating the member against the entity’s RTTI where the expression is authored:
var P := TEntityExpr<TProduct>.Create(nil);
P.Prop('Price') > 100 // fine
P.Prop('price') > 100 // fine too — resolves to 'Price', as RTTI spells it
P.Prop('Pricee') > 100 // EExprMemberError: names the member and the typeProperties win over fields, matching the in-memory lane’s own resolution order. A record works as well as a class — nothing here needs a base class.
The leaf carries a property name
Not a column name.
A column name is persistence vocabulary, and this package stays free of SQL.
The property name is the right currency for the in-memory lane, which needs no mapping at all. Only SQL needs the column, and it gets it at emit time, in one place, once.
One strategy, two questions
INameResolver answers both naming questions, because they are the same kind of question:
INameResolver = interface
function ColumnFor(EntityType: PTypeInfo; const Member: string): string;
function TableFor(EntityType: PTypeInfo): string;
end;The defaults are deliberately minimal:
| Question | Default |
|---|---|
| Which column for this member? | The member name, unchanged |
| Which table for this type? | The type name with a leading T removed |
The T is stripped only when an upper-case letter follows, so TProduct → Product while Thing is left alone. No pluralization and no case conversion.
Supplying your own is one class:
TMappedResolver = class(TInterfacedObject, INameResolver)
public
function ColumnFor(EntityType: PTypeInfo; const Member: string): string;
function TableFor(EntityType: PTypeInfo): string;
end;
function TMappedResolver.ColumnFor(EntityType: PTypeInfo; const Member: string): string;
begin
if SameText(Member, 'Price') then Result := 'price_eur' else Result := Member;
end;
function TMappedResolver.TableFor(EntityType: PTypeInfo): string;
begin
Result := 'products';
end;Then either ask the proxy:
var P := TEntityExpr<TProduct>.Create(TMappedResolver.Create);
P.Column('Price'); // 'price_eur'
P.Table; // 'products'…or hand the strategy to the emission, which is where a column name is actually needed:
TExprEmitter.Where(Q, MyExpr, TMappedResolver.Create, TypeInfo(TProduct));
TExprEmitter.Apply<TProduct>(Q, Spec, TMappedResolver.Create);What the strategy is not
It is a seam for names, not a mapping model: keys, lock columns, attributes, inheritance mapping and instance-driven DML are absent.
The reserved accessor
IMemberExpr.Accessor exists and is always nil today.
Both authoring paths leave it nil.
The optional metamodel
The optional metamodel
🌍 Language: English | Español
Declaring one
A metamodel is a hand-written companion class exposing an entity’s members as expression leaves:
ProductMeta = class
public
class var Price: TPropExpr<TProduct>;
class var Active: TPropExpr<TProduct>;
class constructor Create;
end;
class constructor ProductMeta.Create;
begin
Price := TPropExpr<TProduct>.Create('Price'); // the PROPERTY name
Active := TPropExpr<TProduct>.Create('Active');
end;Then author against it:
ProductMeta.Price > 100
ProductMeta.Active = True
ProductMeta.Price.Between(10, 200)class var plus a class constructor rather than class functions: the call site is a field read, and initialization runs once per class. The entity itself is untouched — the metamodel is a separate type, so no persistence strings leak into the POCO.
What it buys, precisely
| Gain | Real? |
|---|---|
| Typo-safety | Yes — ProductMeta.Pric does not compile |
| Discoverability | Yes — code completion lists the members |
| Rename-safety | No — the field holds a string, so renaming Price still compiles |
What converts a stale name from a late failure into an early loud one is self-validation: the leaf is generic over the entity, so it checks the name when the class initializes — which is the first time a test touches it.
class constructor StaleMeta.Create;
begin
Price := TPropExpr<TProduct>.Create('Pricee'); // raises EExprMemberError here
end;Note the consequence of class constructor: it runs during unit initialization, so a stale metamodel fails at startup rather than mid-query, and it takes the whole runner down before any scenario runs. To trigger the failure deliberately in a test, use a class procedure initializer instead.
It stays an option
This is the part that keeps the metamodel from becoming a tax:
- The runtime path is always sufficient.
TEntityExpr<T>.Propreaches every capability, and no feature of the model, of specifications, or of the repository requires a metamodel. - Both paths produce identical nodes. A metamodel leaf comes from the same constructor the runtime path calls. Nothing downstream — emitter, specification, repository — can distinguish them.
- The two forms mix freely.
var Runtime := TEntityExpr<TProduct>.Create(nil);
(ProductMeta.Price > 100) and (Runtime.Prop('Active') = True) // composes normallySo adopting a metamodel for a hot entity, or dropping one later, is a local edit rather than a migration.
When it is worth it
Roughly: for entities queried from many places, where a rename or a typo would otherwise be found by a failing integration test rather than by the compiler. For an entity queried once from one service, Prop('Price') inside a TEntityExpr<T> already validates the name, and the metamodel adds a file to keep in sync for little in return.
Specifications and the port
Specifications and the repository port
🌍 Language: English | Español
A rule as a value
TSpecification<T> bundles a filter expression with optional ordering and paging. It is a value: build it, pass it, store it, assert it.
var Spec := TSpecification<TProduct>.New
.Where((Prop('Price') > 100) and (Prop('Active') = True))
.OrderByDesc('Price')
.Skip(10)
.Take(5);Each builder returns a new specification, so a base rule can be refined without being mutated:
var Active := TSpecification<TProduct>.New.Where(Prop('Active') = True);
var Page1 := Active.OrderBy('Name').Take(20);
var Page2 := Active.OrderBy('Name').Skip(20).Take(20);Ordering members are validated against T when declared, for the same reason member leaves are: a name that resolves to nothing should fail where the rule is written.
Reading it back needs no database:
var Root := Spec.Filter as ILogicalExpr;
Root.Op // TLogicalOp.opAnd
Spec.Order[0].Member // 'Price'
Spec.Order[0].Descending // True
Spec.SkipCount // 10
Spec.TakeCount // 5
Spec.Key // canonical, whole-ruleSkip(0) and Take(0) mean unset, so an unpaged rule adds no clause at all when emitted.
No closures, on purpose
There is no overload of Where that accepts a predicate. A specification carries only the expression tree, which makes it translatable by construction — and leaves no silent client-side fallback to regret later. EF Core shipped one and removed it in 3.0 for exactly this reason.
Nothing is lost. When you genuinely want arbitrary Delphi code over a materialized sequence, that is what Daf.Linq is for:
for var P in Linq.From<TProduct>(Loaded).Where(
function(P: TProduct): Boolean
begin
Result := SomeRuleNoSqlCanExpress(P);
end) do ...The two are siblings. One is translatable; the other is not, and says so by being a different type.
No includes, either
Eager-loading directives are not part of a specification. Beyond having no clause to lower into, they are the wrong owner: a repository returns aggregates, and how an aggregate gets hydrated is the adapter’s business. A specification says what to select.
The port
IRepository<T> = interface
function List(const Spec: TSpecification<T>): TArray<T>;
end;No SQL, no connection, no row, no query in the signature — and no T: TEntity or any other base-class constraint. A plain class with public properties works; so does a record.
A domain service depends on the port and on nothing else:
function TCatalogService.Bargains: TArray<TProduct>;
begin
Result := FProducts.List(
TSpecification<TProduct>.New
.Where((Prop('Price') < 50) and (Prop('Active') = True))
.OrderBy('Price'));
end;That unit links neither SQLCute nor any adapter. It is testable with a five-line stub, and the composition root is the only place that names a concrete repository.
Getting an adapter
This package defines the port; Daf.SQLCute.Specification ships the in-memory one:
uses Daf.Specification, Daf.SQLCute.Specification;
var Repo: IRepository<TProduct> := TInMemoryRepository<TProduct>.Create(Loaded);
var Service := TCatalogService.Create(Repo);The in-memory adapter is not a test stub. Running several rules over one already-loaded collection is a production path — load once, then resolve N specifications against the loaded set, with the same specification type that would go to a database. That is why the “one rule, two back-ends” symmetry has a concrete motive rather than an aesthetic one.
There is no database-backed adapter: SQL execution, driver and row materialization are not part of this package or of Daf.SQLCute.Specification.