Daf.Specification — Guía de uso
🌍 Idioma: English | Español
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
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 // 100Combinar
(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').IsNotNullLa 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ícitaEstrechar 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 NullPara comprobar NULL usa el predicado, no una comparación — como en SQL, comparar contra NULL es desconocido, no cierto:
Prop('Age').IsNull
Prop('Age').IsNotNullAmbos 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ónUna 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
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 tipoLas 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
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>.Propalcanza 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 normalidadAsí 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
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 completaSkip(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.