Skip to main content

Daf.Specification — Usage Guide

🌍 Language: English | Español

← Back to README


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.


Authoring with operators

Authoring with operators

🌍 Language: English | Español

← Back to Guide


Comparisons

Prop names a member; the overloaded operators build the node.

Prop('Price') > 100
Prop('Price') >= 100
Prop('Price') < 100
Prop('Price') <= 100
Prop('Active') = True
Prop('Country') <> 'US'

Each of those is one comparison node carrying an operator value. Prop('Price') > 100 reads back as greater-than, a member reference Price, and a value 100:

var C := (Prop('Price') > 100).Node as ICompareExpr;
C.Op                                   // TCompareOp.Greater
(C.Target as IMemberExpr).Name         // 'Price'
(C.Operands[0] as IValueExpr).Value    // 100

Combining

(Prop('Price') > 100) and (Prop('Active') = True)
(Prop('Country') = 'US') or (Prop('Country') = 'UK')
not (Prop('Active') = True)

Parentheses around each comparison are required by Delphi, because = and > return an expression here rather than a Boolean.

Delphi’s own precedence binds and tighter than or, exactly as SQL does, so this needs no parentheses of its own:

A and B or C and D      // (A and B) or (C and D)

The layer implements no precedence. Write explicit grouping when you mean something else, and the emitter will nest it.


The breadth surface

Anything SQLCute already expresses is reached by emitting the clause that says it, so each of these is an operator value and no new node type:

Prop('Name').Like('%son%')
Prop('Name').NotLike('%son%')
Prop('Name').StartsWith('And')
Prop('Name').EndsWith('son')
Prop('Name').Contains('nder')
Prop('Price').Between(10, 200)
Prop('Price').NotBetween(10, 200)
Prop('Price').IsIn([5, 120, 900])
Prop('Price').NotIn([5, 120])
Prop('Price').IsNull
Prop('Price').IsNotNull

The LIKE family is case-insensitive by default, matching SQLCute. Pass True for a case-sensitive match:

Prop('Name').Like('%son%', True)

StartsWith, EndsWith and Contains leave the wildcards % and _ of the value active, as SQLCute does: Prop('Code').Contains('50%') matches any code that contains 50. A specification has no escape character, so it cannot search for a literal % or _.


Values

A value leaf carries a single Variant, which is the representation SQLCute clauses already hold.

A type with no direct Variant form is narrowed where you write it:

Prop('Id') = SomeGuid                        // typed overload → GUIDToString
Prop('Id') = TExprValue.From(SomeGuid)       // the same conversion, explicitly

Narrowing at the edge is what makes both lanes see the same canonical value, which is where this design promises parity.


NULL

NULL has one representation: Commons’ TNullable on the way in, Variant’s Null from the leaf onward. No node carries a null flag of its own.

var Age: TNullable<Integer> := TNullable<Integer>.Null;
Prop('Age') = TExprValue.From<Integer>(Age)      // → Variant Null

To test for NULL, use the predicate rather than a comparison — as in SQL, comparing against NULL is unknown, not true:

Prop('Age').IsNull
Prop('Age').IsNotNull

Both back-ends follow ANSI three-valued logic, so not (Prop('Age') > 18) excludes a NULL age exactly as a database does.


Parameters

A named placeholder carrying its bound value:

Prm('minPrice', 100)
Prop('Price') > 100                 // equivalent for execution

A param leaf is a value leaf with a name. The name is for you — for logging, for building a rule once and reading it back — not a second binding mechanism.


The entity proxy

The entity proxy and name resolution

🌍 Language: English | Español

← Back to Guide


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 type

Properties 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

← Back to Guide


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>.Prop reaches 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 normally

So 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

← Back to Guide


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-rule

Skip(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.