Skip to content

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:

  1. JOINs Declarativos: Relaciones definidas como especificaciones directas y reutilizables
  2. Multi-Schema Support: Consolidación cross-schema para jerarquía de schemas (EMPRESA → SUCURSAL → CAJA)
  3. 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:#ffe1f5

Componentes 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

ModoCuándo UsarON ClauseSchema ResolutionEjemplo
ManualCondiciones complejas, self-joins, composite keysManual explícitoNo aplicableAgregaciones con filtros
AutoFK convencional (tabla_id), mismo schemaAutomáticoNo aplicableCliente → Órdenes
AutoWithSchemaCross-level jerárquico O multi-schema consolidadoAutomáticoDinámicoCAJA → 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_id

Escenario 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:#E0E0E0

Quick 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 NULL

Comparación Rápida

AspectoAntes (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ódigoReglaCriticidad
RA-JOIN-001Separación Model-QueryALTA
RA-JOIN-002JoinSpec Directo en QueriesMEDIA
RA-JOIN-003Cross-Schema Jerárquico PermitidoALTA
RA-JOIN-004Cross-Schema Horizontal ProhibidoCRÍTICA
RA-JOIN-005UNION ALL para Multi-SchemaALTA
RA-JOIN-006Auto-Resolución de Schema LevelMEDIA

Guías Detalladas

Documentación por Caso de Uso

  1. 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
  2. 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
  3. 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
  4. 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ápido

Testing

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

Skills de Claude Code

  • bautista-backend-architecture: Arquitectura 5-layer DDD
  • php-slim-api: Patrones de Slim Framework
  • technical-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:

  1. Fork el repositorio de documentación
  2. Crea branch: feature/joins-[mejora]
  3. Actualiza documentación correspondiente
  4. Incluye ejemplos de código completo
  5. Añade tests si aplica
  6. 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