Saltar al contenido principal

Daf.Specification — Guía de uso

🌍 Idioma: English | Español

← Volver al README


Introducción

Este paquete contiene la mitad de dominio de la capa de expresiones: un árbol, los operadores que lo construyen, la costura que da nombres, y la especificación y el puerto que lo llevan al otro lado de una frontera. Compila contra Commons y nada más, así que todo esto se usa y se prueba sin base de datos y sin enlazar ninguna unidad de SQL.

La mitad de SQL es Daf.SQLCute.Specification, que emite un árbol a un IQuery de SQLCute y trae el adaptador de repositorio en memoria.

uses Daf.Specification;

var Spec := TSpecification<TProduct>.New
  .Where((Prop('Price') > 100) and (Prop('Active') = True))
  .OrderByDesc('Price');

Índice

Tema Qué cubre
Autoría con operadores Los operadores, la superficie de amplitud, valores y NULL
El proxy de entidad TEntityExpr<T>, resolución de nombres, fallar donde se escribe
El metamodelo opcional TPropExpr<T>, qué aporta y qué no
Especificaciones y el puerto TSpecification<T>, IRepository<T>, composición hexagonal

El modelo de nodos de un vistazo

Seis tipos de nodo y tres enumerados de operadores; ese es todo el modelo:

Clase Interfaz Lleva
Compare ICompareExpr un operador, un objetivo, cero o más operandos, sensibilidad a mayúsculas
Logical ILogicalExpr un operador y dos operandos
Unary IUnaryExpr un operador y un operando
Member IMemberExpr un nombre de propiedad (nunca de columna) y un accesor reservado
Value IValueExpr un Variant
Param IParamExpr un nombre y un Variant

La aridad viaja con el operador, no con el tipo: IsNull no toma operandos, Between toma dos, In toma tantos como tenga el conjunto. Por eso la amplitud crece sin que crezca el modelo — una comparación nueva es un valor nuevo del enumerado y una fila nueva en la tabla de correspondencias del emisor.

El tiempo de vida es conteo de referencias de interfaz de principio a fin. No hay Acquire/Release, ni Free manual, ni un segundo idioma.


Volver a leer un árbol

ExprToText representa un árbol de forma canónica — estructural, estable y ciega a cómo se escribió:

ExprToText(Prop('Price') > 100)
// (m:Price Greater v:100#17)

Sirve para afirmar que dos expresiones son la misma cosa, como clave de caché o en una línea de log. Nunca es un paso hacia la ejecución; el emisor lee el árbol directamente.

TSpecification<T>.Key hace lo mismo con una regla completa — filtro, orden y página juntos — que es lo que permite a un adaptador compilar un plan una vez y reutilizarlo.


Lo que falta a propósito

Ausente Por qué
Un filtro con clausura en una especificación Lo intraducible debe ser irrepresentable; para ese mundo está Daf.Linq
Directivas de carga anticipada (includes) No hay a qué emitirlas, y es el dueño equivocado — montar un agregado es del adaptador
Una restricción T: TEntity Nada aguas abajo la necesita; los registros también funcionan
Metadatos de mapeo entidad↔︎tabla Los nombres se resuelven con INameResolver, y no se mapea nada más
DML dirigido por instancia Necesita el conjunto de miembros persistentes, la clave primaria y seguimiento de cambios, que esta capa no modela
Operadores de SQL crudo y EXISTS Una expresión de dominio no debe poder decirlos

El último lo impone la frontera del paquete, no la disciplina: esta unidad no referencia SQLCute, y su ejecutor de specs enlaza solo la mitad de dominio. En cuanto adquiera una dependencia de SQL, el proyecto deja de compilar.


Autoría con operadores

Autoría con operadores

🌍 Idioma: English | Español

← Volver a la guía


Comparaciones

Prop nombra un miembro; los operadores sobrecargados construyen el nodo.

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

Cada una de ellas es un nodo de comparación que lleva un valor de operador. Prop('Price') > 100 se lee de vuelta como mayor-que, una referencia al miembro Price y un valor 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

Combinar

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

Delphi exige los paréntesis alrededor de cada comparación, porque aquí = y > devuelven una expresión y no un Boolean.

La precedencia propia de Delphi liga and más fuerte que or, exactamente como SQL, así que esto no necesita paréntesis:

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

La capa no implementa ninguna precedencia. Escribe la agrupación explícita cuando quieras otra cosa y el emisor la anidará.


La superficie de amplitud

Todo lo que SQLCute ya expresa se alcanza emitiendo la cláusula que lo dice, así que cada una de estas es un valor de operador y ningún tipo de nodo nuevo:

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

La familia LIKE es insensible a mayúsculas por defecto, igual que SQLCute. Pasa True para una coincidencia sensible:

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

StartsWith, EndsWith y Contains dejan activos los comodines % y _ del valor, igual que SQLCute: Prop('Code').Contains('50%') casa con cualquier código que contenga 50. Una especificación no tiene carácter de escape, así que no puede buscar un % o un _ literal.


Valores

Una hoja de valor lleva un único Variant. Esa es la representación que ya llevan las cláusulas de SQLCute, así que no se reimplementa nada — y no se pierde nada: los modelos de parámetros que esto sustituye tenían el mismo techo, envuelto en decenas de accesores tipados cuya única aportación extra era una pista para el driver.

Un tipo sin forma directa de Variant se estrecha donde lo escribes:

Prop('Id') = SomeGuid                        // sobrecarga tipada → GUIDToString
Prop('Id') = TExprValue.From(SomeGuid)       // la misma conversión, explícita

Estrechar en el borde es lo que hace que los dos carriles vean el mismo valor canónico, que es donde este diseño promete paridad.


NULL

NULL tiene una sola representación: el TNullable de Commons a la entrada, el Null de Variant desde la hoja en adelante. Ningún nodo lleva una marca de nulidad propia.

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

Para comprobar NULL usa el predicado, no una comparación — como en SQL, comparar contra NULL es desconocido, no cierto:

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

Ambos back-ends siguen la lógica trivaluada ANSI, así que not (Prop('Age') > 18) excluye una edad NULL exactamente como lo haría una base de datos.


Parámetros

Un marcador con nombre que lleva su valor:

Prm('minPrice', 100)
Prop('Price') > 100                 // equivalente para la ejecución

Una hoja de parámetro es una hoja de valor con nombre. El nombre es para ti — para logs, para construir una regla y volver a leerla — no un segundo mecanismo de vinculación.


El proxy de entidad

El proxy de entidad y la resolución de nombres

🌍 Idioma: English | Español

← Volver a la guía


Por qué hace falta un proxy

Prop('Price') nombra un miembro y no pregunta nada sobre él. Eso vale para una regla escrita junto a la entidad, pero significa que una errata viaja: el back-end de SQL enviará tranquilamente una columna sin mapear al servidor, mientras el carril en memoria no puede resolverla. El fallo aparece entonces lejos de su causa, y en un solo back-end.

TEntityExpr<T> cierra ese hueco validando el miembro contra el RTTI de la entidad allí donde se escribe la expresión:

var P := TEntityExpr<TProduct>.Create(nil);

P.Prop('Price') > 100         // bien
P.Prop('price') > 100         // también — resuelve a 'Price', como lo escribe el RTTI
P.Prop('Pricee') > 100        // EExprMemberError: nombra el miembro y el tipo

Las propiedades ganan a los campos, igual que en la resolución del propio carril en memoria. Un registro funciona igual que una clase — aquí nada necesita una clase base.


La hoja lleva un nombre de propiedad

No un nombre de columna.

  • Un nombre de columna es vocabulario de persistencia, y este paquete permanece libre de SQL.

  • El nombre de propiedad es la moneda correcta para el carril en memoria, que no necesita mapeo alguno. Solo SQL necesita la columna, y la obtiene en el momento de emitir, en un único sitio, una sola vez.


Una estrategia, dos preguntas

INameResolver responde a las dos preguntas de nombres, porque son la misma clase de pregunta:

INameResolver = interface
  function ColumnFor(EntityType: PTypeInfo; const Member: string): string;
  function TableFor(EntityType: PTypeInfo): string;
end;

Los valores por defecto son deliberadamente mínimos:

Pregunta Por defecto
¿Qué columna para este miembro? El nombre del miembro, sin cambios
¿Qué tabla para este tipo? El nombre del tipo sin la T inicial

La T se quita solo cuando le sigue una mayúscula, así que TProduct → Product mientras Thing se deja en paz. Sin pluralización y sin conversión de caso.

Aportar la tuya es una clase:

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;

Luego, o se le pregunta al proxy:

var P := TEntityExpr<TProduct>.Create(TMappedResolver.Create);
P.Column('Price');    // 'price_eur'
P.Table;              // 'products'

…o se le entrega la estrategia a la emisión, que es donde de verdad hace falta un nombre de columna:

TExprEmitter.Where(Q, MyExpr, TMappedResolver.Create, TypeInfo(TProduct));
TExprEmitter.Apply<TProduct>(Q, Spec, TMappedResolver.Create);

Lo que la estrategia no es

Es una costura para nombres, no un modelo de mapeo: claves, columnas de bloqueo, atributos, mapeo de herencia y DML dirigido por instancia están ausentes.


El accesor reservado

IMemberExpr.Accessor existe y hoy siempre vale nil.

Los dos caminos de autoría lo dejan en nil.


El metamodelo opcional

El metamodelo opcional

🌍 Idioma: English | Español

← Volver a la guía


Declarar uno

Un metamodelo es una clase acompañante escrita a mano que expone los miembros de una entidad como hojas de expresión:

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');     // el nombre de la PROPIEDAD
  Active := TPropExpr<TProduct>.Create('Active');
end;

Y luego se escribe contra él:

ProductMeta.Price > 100
ProductMeta.Active = True
ProductMeta.Price.Between(10, 200)

class var más un class constructor en lugar de funciones de clase: el punto de uso es la lectura de un campo, y la inicialización corre una vez por clase. La entidad no se toca — el metamodelo es un tipo aparte, así que ninguna cadena de persistencia se cuela en el POCO.


Qué aporta, con precisión

Ganancia ¿Real?
Seguridad ante erratas Sí — ProductMeta.Pric no compila
Descubribilidad Sí — el autocompletado lista los miembros
Seguridad ante renombrados No — el campo guarda una cadena, así que renombrar Price sigue compilando

Ser honestos con la tercera importa, porque es la que la gente supone que está comprando. Lo que convierte un nombre obsoleto de fallo tardío en fallo temprano y ruidoso es la autovalidación: la hoja es genérica sobre la entidad, así que comprueba el nombre cuando la clase se inicializa — que es la primera vez que un test la toca.

class constructor StaleMeta.Create;
begin
  Price := TPropExpr<TProduct>.Create('Pricee');   // lanza EExprMemberError aquí
end;

Ojo a la consecuencia del class constructor: corre durante la inicialización de la unidad, así que un metamodelo obsoleto falla al arrancar y no a mitad de una consulta, y se lleva por delante al ejecutor entero antes de que corra un solo escenario. Para dispararlo a propósito en un test, usa un class procedure como inicializador.


Sigue siendo una opción

Esta es la parte que evita que el metamodelo se convierta en un impuesto:

  • El camino en tiempo de ejecución siempre es suficiente. TEntityExpr<T>.Prop alcanza cualquier capacidad, y ninguna funcionalidad del modelo, de las especificaciones o del repositorio exige un metamodelo.
  • Ambos caminos producen nodos idénticos. Una hoja de metamodelo sale del mismo constructor que llama el camino en tiempo de ejecución. Nada aguas abajo — emisor, especificación, repositorio — puede distinguirlas.
  • Las dos formas se mezclan libremente.
var Runtime := TEntityExpr<TProduct>.Create(nil);
(ProductMeta.Price > 100) and (Runtime.Prop('Active') = True)      // compone con normalidad

Así que adoptar un metamodelo para una entidad caliente, o abandonarlo más tarde, es una edición local y no una migración.


Cuándo merece la pena

A grandes rasgos: para entidades consultadas desde muchos sitios, donde un renombrado o una errata los encontraría un test de integración fallido en lugar del compilador. Para una entidad que se consulta una vez desde un servicio, Prop('Price') dentro de un TEntityExpr<T> ya valida el nombre, y el metamodelo añade un fichero que mantener sincronizado a cambio de poco.


Especificaciones y el puerto

Especificaciones y el puerto de repositorio

🌍 Idioma: English | Español

← Volver a la guía


Una regla como valor

TSpecification<T> agrupa una expresión de filtro con orden y paginación opcionales. Es un valor: se construye, se pasa, se guarda, se afirma.

var Spec := TSpecification<TProduct>.New
  .Where((Prop('Price') > 100) and (Prop('Active') = True))
  .OrderByDesc('Price')
  .Skip(10)
  .Take(5);

Cada constructor devuelve una especificación nueva, así que una regla base se puede refinar sin mutarla:

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);

Los miembros de ordenación se validan contra T al declararlos, por la misma razón que las hojas de miembro: un nombre que no resuelve a nada debe fallar donde se escribe la regla.

Volver a leerla no necesita base de datos:

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                                       // canónica, de la regla completa

Skip(0) y Take(0) significan sin fijar, así que una regla sin paginar no añade ninguna cláusula al emitirla.


Sin clausuras, a propósito

No existe una sobrecarga de Where que acepte un predicado. Una especificación lleva solo el árbol de expresión, lo que la hace traducible por construcción — y no deja ningún recurso silencioso de evaluación en el cliente del que arrepentirse después. EF Core lo llevó y lo quitó en la 3.0 justo por esto.

No se pierde nada. Cuando de verdad quieras código Delphi arbitrario sobre una secuencia materializada, para eso está Daf.Linq:

for var P in Linq.From<TProduct>(Loaded).Where(
    function(P: TProduct): Boolean
    begin
      Result := AlgunaReglaQueNingunSQLExpresa(P);
    end) do ...

Los dos son hermanos. Uno es traducible; el otro no, y lo dice siendo un tipo distinto.


Tampoco hay includes

Las directivas de carga anticipada no forman parte de una especificación. Más allá de que no hay cláusula a la que emitirlas, son el dueño equivocado: un repositorio devuelve agregados, y cómo se hidrata un agregado es asunto del adaptador. Una especificación dice qué seleccionar.


El puerto

IRepository<T> = interface
  function List(const Spec: TSpecification<T>): TArray<T>;
end;

Ni SQL, ni conexión, ni fila, ni consulta en la firma — y ningún T: TEntity ni otra restricción de clase base. Una clase corriente con propiedades públicas funciona; un registro también.

Un servicio de dominio depende del puerto y de nada más:

function TCatalogService.Bargains: TArray<TProduct>;
begin
  Result := FProducts.List(
    TSpecification<TProduct>.New
      .Where((Prop('Price') < 50) and (Prop('Active') = True))
      .OrderBy('Price'));
end;

Esa unidad no enlaza ni SQLCute ni ningún adaptador. Es comprobable con un doble de cinco líneas, y la raíz de composición es el único sitio que nombra un repositorio concreto.


Conseguir un adaptador

Este paquete define el puerto; Daf.SQLCute.Specification trae el de memoria:

uses Daf.Specification, Daf.SQLCute.Specification;

var Repo: IRepository<TProduct> := TInMemoryRepository<TProduct>.Create(Loaded);
var Service := TCatalogService.Create(Repo);

El adaptador en memoria no es un doble de test. Ejecutar varias reglas sobre una colección ya cargada es un camino de producción — cargar una vez y resolver N especificaciones contra el conjunto cargado, con el mismo tipo de especificación que iría a una base de datos. Por eso la simetría “una regla, dos back-ends” tiene un motivo concreto y no estético.

No hay adaptador respaldado por base de datos: la ejecución SQL, el driver y la materialización de filas no están ni en este paquete ni en Daf.SQLCute.Specification.