Appearance
Patrón Unificado de JOINs Declarativos
Versión: 2.0.0 Fecha: 2026-02-04 Estado: Arquitectura Unificada
Resumen Ejecutivo
Este documento presenta una arquitectura unificada para manejar relaciones entre tablas (JOINs) en el backend de Bautista, combinando:
- JOINs Declarativos: Relaciones definidas como especificaciones directas y reutilizables
- Multi-Schema Support: Consolidación cross-schema para jerarquía de schemas (EMPRESA → SUCURSAL → CAJA)
- Auto-Detection: ON clause automático basado en convenciones
Problema que resuelve: Eliminar JOINs hardcodeados en Models manteniendo bajo acoplamiento, mientras soporta consolidación multi-schema (queries que abarcan N schemas simultáneamente) en arquitectura con jerarquía de schemas.
Arquitectura simplificada: Solo 3 componentes (ModelMetadata + JoinSpec + BaseQuery), sin catálogo centralizado de relaciones.
Arquitectura Simplificada (3 Componentes)
mermaid
graph TB
subgraph query["Capa de Query"]
Query[Custom Query Class]
BaseQuery[BaseQuery Builder]
end
subgraph models["Capa de Models"]
ModelA[Model A<br/>implements ModelMetadata]
ModelB[Model B<br/>implements ModelMetadata]
ModelMetadata[ModelMetadata Interface<br/>table, alias, primaryKey]
end
subgraph especificacion["Capa de Especificación"]
JoinSpec[JoinSpec Class<br/>Manual, Auto, AutoWithSchema]
end
subgraph multitenant["Capa Multi-Tenant"]
MultiSchema[MultiSchemaService]
end
subgraph basedatos["Base de Datos"]
DB[(PostgreSQL<br/>Schemas: public, suc0001, suc0001caja001)]
end
Query -->|1. Crea JoinSpec directo| JoinSpec
JoinSpec -->|2. Lee metadata| ModelA
JoinSpec -->|2. Lee metadata| ModelB
Query -->|3. Construye SQL| BaseQuery
BaseQuery -->|4. Si multi-schema| MultiSchema
MultiSchema -->|5. Resuelve schemas| DB
BaseQuery -->|6. Ejecuta| DB
style Query fill:#e1f5ff
style JoinSpec fill:#fff4e1
style BaseQuery fill:#f0f0f0
style ModelMetadata fill:#e8f5e9
style MultiSchema fill:#ffe1f5Componentes Principales (3)
1. ModelMetadata Interface
Define metadata mínima para construcción automática de JOINs:
php
interface ModelMetadata
{
public static function table(): string; // 'clientes'
public static function alias(): string; // 'c'
public static function primaryKey(): string; // 'id' (default)
}Nota importante: El nivel de schema (EMPRESA/SUCURSAL/CAJA) NO se define manualmente en esta interface. MultiSchemaService lo descubre automáticamente consultando qué schemas contienen cada tabla.
2. JoinSpec Class
Especificación declarativa de UNA relación (creada directamente en queries):
php
// Tres modos de construcción:
// Modo 1: Manual ON (casos complejos)
new JoinSpec('c', 'ordenes', 'o', 'o.cliente_id = c.id', 'LEFT');
// Modo 2: Auto ON (convención FK: tabla_id)
JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT');
// Modo 3: Auto con Multi-Schema
JoinSpec::autoWithSchema('c', ClienteModel::class, OrdenModel::class, 'LEFT');Sin catálogo: Los JoinSpecs se crean directamente en cada Query Class según necesidad.
3. BaseQuery Builder
Constructor de queries con soporte para JOINs declarativos.
Importante: El sistema soporta dos tipos de conexión:
- DBAL QueryBuilder (Doctrine DBAL) - Para models que usan
Connection - PDO - Para models legacy que usan PDO directamente
El BaseQuery detecta automáticamente el tipo de conexión y genera el SQL apropiado:
- Si recibe
Connection: Usa QueryBuilder de Doctrine - Si recibe
PDO: Genera SQL string manualmente
Ejemplo con DBAL QueryBuilder:
php
class CustomQuery extends BaseQuery
{
private Connection $conn;
public function execute(): array
{
$qb = $this->conn->createQueryBuilder();
$qb->select('c.*', 'o.total')
->from('clientes', 'c');
// Apply JOINs usando QueryBuilder
$this->applyJoinsDBAL($qb, [
JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT')
]);
return $qb->executeQuery()->fetchAllAssociative();
}
}Ejemplo con PDO:
php
class LegacyQuery extends BaseQuery
{
private PDO $conn;
public function execute(): array
{
$sql = "SELECT c.*, o.total FROM clientes c";
// Apply JOINs generando SQL string
$sql = $this->applyJoinsPDO($sql, [
JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT')
]);
$stmt = $this->conn->query($sql);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}Nota: La implementación de applyJoinsDBAL() y applyJoinsPDO() están en la clase base BaseQuery y seleccionan automáticamente el método correcto según el tipo de conexión inyectada.
**Ejemplo básico:
php
class CustomQuery extends BaseQuery
{
public function execute(): array
{
$sql = "SELECT c.*, o.total FROM clientes c";
// Single-schema JOIN
$sql = $this->applyJoins($sql, [
JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT')
]);
// O multi-schema JOIN con UNION ALL
$results = $this->executeMultiSchema($sql, [
JoinSpec::autoWithSchema('cm', CajaMovimientoModel::class, MovimientoBancarioModel::class, 'LEFT')
], $schemaList);
return $results;
}
}Modos de Construcción
Tabla Comparativa
| Modo | Cuándo Usar | ON Clause | Schema Resolution | Ejemplo |
|---|---|---|---|---|
| Manual | Condiciones complejas, self-joins, composite keys | Manual explícito | No aplicable | Agregaciones con filtros |
| Auto | FK convencional (tabla_id), mismo schema | Automático | No aplicable | Cliente → Órdenes |
| AutoWithSchema | Cross-level jerárquico O multi-schema consolidado | Automático | Dinámico | CAJA → SUCURSAL |
Diferencia: Cross-Level Directo vs Multi-Schema Consolidado
IMPORTANTE: AutoWithSchema se usa en DOS escenarios diferentes:
Escenario 1: Cross-Level Directo (SIN UNION ALL)
JOIN entre niveles jerárquicos en 1 query directa:
php
// CAJA → SUCURSAL (mismo branch)
// Query en suc0001caja001, JOIN con suc0001
JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'INNER')
// SQL generado (1 query):
// SELECT r.*, f.total
// FROM suc0001caja001.recibos r
// INNER JOIN suc0001.facturas f ON f.id = r.factura_idEscenario 2: Multi-Schema Consolidado (CON UNION ALL)
Consolidación de N schemas + JOIN cross-level en cada uno:
php
// Consolidar TODAS las cajas + JOIN con sucursal
$schemaList = ['suc0001caja001', 'suc0001caja002', 'suc0001caja003'];
$this->executeMultiSchema($sql, [
JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'INNER')
], $schemaList);
// SQL generado (UNION ALL):
// (SELECT ... FROM suc0001caja001.recibos r JOIN suc0001.facturas f ...)
// UNION ALL
// (SELECT ... FROM suc0001caja002.recibos r JOIN suc0001.facturas f ...)
// UNION ALL
// (SELECT ... FROM suc0001caja003.recibos r JOIN suc0001.facturas f ...)Diferencia clave: Mismo JoinSpec, pero executeMultiSchema() determina si usa UNION ALL o no.
Decision Tree
mermaid
graph TD
Start{"¿Necesito JOIN?"}
Start -->|Sí| Same{"¿Mismo schema?"}
Start -->|No| NoJoin[Query simple sin JOIN]
Same -->|Sí| Convention{"¿FK convencional?"}
Same -->|No| CrossLevel[Modo 3: AutoWithSchema]
Convention -->|Sí| Simple[Modo 2: Auto]
Convention -->|No| Complex{"¿ON complejo?"}
Complex -->|Sí| Manual[Modo 1: Manual ON]
Complex -->|No| Simple
CrossLevel -->|CAJA→SUCURSAL| Mode3[Modo 3: AutoWithSchema]
CrossLevel -->|SUCURSAL→EMPRESA| Mode3
style Simple fill:#90EE90
style Mode3 fill:#87CEEB
style Manual fill:#FFD700
style NoJoin fill:#E0E0E0Quick Start (3 Pasos)
1. Model Básico (Implementar ModelMetadata)
php
final class ClienteModel implements ModelMetadata
{
public static function table(): string { return 'clientes'; }
public static function alias(): string { return 'c'; }
public static function primaryKey(): string { return 'id'; }
// Solo métodos de SU tabla (sin JOINs)
public function getAll(): array { ... }
}2. Crear Query Class con JoinSpec Directo
php
class ClienteOrdenesQuery extends BaseQuery
{
public function execute(): array
{
$sql = "SELECT c.*, o.id as orden_id, o.total
FROM " . ClienteModel::table() . " " . ClienteModel::alias();
// JoinSpec creado directamente en la query
$sql = $this->applyJoins($sql, [
JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT')
]);
$sql = $this->applyFilters($sql);
return $this->conn->query($sql)->fetchAll();
}
}3. SQL Generado
sql
SELECT c.*, o.id as orden_id, o.total
FROM clientes c
LEFT JOIN ordenes o ON o.cliente_id = c.id
WHERE deleted_at IS NULLComparación Rápida
| Aspecto | Antes (Hardcoded) | Después (Unificado) |
|---|---|---|
| Acoplamiento | ❌ Alto | ✅ Bajo |
| Reutilización | ❌ Nula | ✅ Alta |
| Multi-Schema | ⚠️ Manual | ✅ Automático |
| Auto ON | ❌ No | ✅ Sí |
| Testing | ❌ Complejo | ✅ Simple |
| Mantenibilidad | ❌ Difícil | ✅ Fácil |
| Performance | ✅ Directo | ✅ Equivalente |
Reglas Arquitecturales
El patrón de JOINs declarativos está regido por 6 reglas arquitecturales (RA-JOIN-*) que garantizan:
- Separación de responsabilidades (Models vs Queries)
- Uso correcto de cross-schema (jerárquico vs horizontal)
- Optimización con UNION ALL para consolidación
- Auto-resolución de niveles de schema
Ver: Reglas Arquitecturales Completas para detalles, ejemplos y justificaciones.
Resumen de Reglas
| Código | Regla | Criticidad |
|---|---|---|
| RA-JOIN-001 | Separación Model-Query | ALTA |
| RA-JOIN-002 | JoinSpec Directo en Queries | MEDIA |
| RA-JOIN-003 | Cross-Schema Jerárquico Permitido | ALTA |
| RA-JOIN-004 | Cross-Schema Horizontal Prohibido | CRÍTICA |
| RA-JOIN-005 | UNION ALL para Multi-Schema | ALTA |
| RA-JOIN-006 | Auto-Resolución de Schema Level | MEDIA |
Guías Detalladas
Documentación por Caso de Uso
Guía Completa (1,500+ líneas)
- Componentes principales (código completo)
- Tres modos de construcción
- Implementación detallada
- Edge cases y soluciones
- Testing strategy
- Guía de adopción paso a paso
Casos de Uso Simple (800+ líneas)
- JOINs en mismo schema (multi-tenant normal)
- JOIN 1:1, 1:N, LEFT, Self-JOIN
- Múltiples JOINs
- Agregaciones y GROUP BY
- Tests unitarios
Cross-Level JOINs Directos (NUEVO)
- JOINs cross-level sin UNION ALL (1 query directa)
- CAJA → SUCURSAL (mismo branch)
- SUCURSAL → EMPRESA (maestros)
- Reglas de cross-schema permitido vs prohibido
Casos de Uso Multi-Schema (800+ líneas)
- Consolidación con UNION ALL (N schemas)
- JOINs cross-level en cada query del UNION
- MultiSchemaService integration
- Schema resolution
- Tests de integración
Migración desde Arquitectura Anterior
Nota: Esta arquitectura reemplaza completamente los patrones anteriores de JOINs. Si estás migrando código existente, consulta la Guía de Adopción para migración paso a paso.
Performance
Optimizaciones Incluidas
- UNION ALL inteligente: Solo si 2+ schemas
- COUNT separado: Query de count independiente del data query
- Indexes requeridos: Documentados en casos de uso
- Evitar N+1: Queries consolidadas, no loops
Benchmarks
Escenario: 3 schemas, 1000 registros/schema, JOIN con filtros
Antes (N queries):
- 3 round-trips a DB
- ~300-500ms total
- Merge en PHP
Después (UNION ALL):
- 1 round-trip a DB
- ~80-150ms total
- PostgreSQL optimiza
Mejora: 3-5x más rápidoTesting
Models (Unit Tests)
php
// Test del Model sin JOINs
public function test_getAll_returns_all_clientes()
{
$model = new ClienteModel($this->conn);
$results = $model->getAll();
$this->assertCount(3, $results);
}JoinSpec (Spec Tests)
php
// Test de especificación
public function test_joinSpec_generates_correct_sql()
{
$spec = JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT');
$this->assertEquals(
'LEFT JOIN ordenes o ON o.cliente_id = c.id',
$spec->toSQL()
);
}Queries (Integration Tests)
php
// Test con DB real
public function test_execute_returns_clientes_with_ordenes()
{
$query = new ClienteOrdenesQuery($this->conn);
$results = $query->execute();
$this->assertNotEmpty($results);
$this->assertArrayHasKey('orden_id', $results[0]);
}Recursos Adicionales
Documentación Relacionada
- Guía Completa: Documentación exhaustiva (1,500+ líneas)
- Casos Simple: Ejemplos sin multi-schema
- Casos Multi-Schema: Ejemplos multi-tenant
- Criterios de Abstracción: Cuándo abstraer
- Inmutabilidad en Servicios: Servicios inmutables
Skills de Claude Code
bautista-backend-architecture: Arquitectura 5-layer DDDphp-slim-api: Patrones de Slim Frameworktechnical-documentation: Documentar arquitectura y patrones técnicos
FAQ
Q: ¿Debo usar este patrón para todas las queries? A: No. Solo para queries con JOINs que necesiten reutilización o testing robusto. Queries triviales de 1 tabla usan directamente el Model.
Q: ¿Funciona con arquitectura multi-tenant (schema-based)? A: Sí. Es compatible con search_path de PostgreSQL, X-Schema header, y consolidación multi-schema (UNION ALL).
Q: ¿Qué pasa si mi FK no sigue la convención tabla_id? A: Usa el Modo 1 (Manual ON) con la condición explícita.
Q: ¿Puedo mezclar modos en la misma query? A: Sí. applyJoins() acepta un array de JoinSpecs de cualquier modo.
Q: ¿Cómo debuggeo el SQL generado? A: Agrega método buildSQL() en tu Query para inspeccionar el SQL antes de ejecutar.
Roadmap
Versión Actual (v2.0.0)
- ✅ JoinSpec con tres modos de construcción
- ✅ ModelMetadata simplificado (3 métodos: table, alias, primaryKey)
- ✅ Auto-resolución de schema level con MultiSchemaService
- ✅ BaseQuery con UNION ALL automático
- ✅ MultiSchemaService integration
- ✅ Documentación unificada
Futuras Mejoras
- 🔮 Join query builder fluent (ej:
Query::from(ClienteModel::class)->join(OrdenModel::class)) - 🔮 Cache de schema mapping para performance
- 🔮 Query analyzer para detectar N+1 queries
- 🔮 Generator de código para relaciones comunes
Contribuir
Para agregar nuevos patrones o mejorar la documentación:
- Fork el repositorio de documentación
- Crea branch:
feature/joins-[mejora] - Actualiza documentación correspondiente
- Incluye ejemplos de código completo
- Añade tests si aplica
- Crea PR con descripción detallada
Última actualización: 2026-02-04 Versión: 2.0.0 Autor: Sistema Bautista - Arquitectura Backend Revisor: php-architecture-expert agent