Skip to content

Reglas Arquitecturales: Patrón de JOINs Declarativos

Versión: 1.0.0 Fecha: 2026-02-04 Ámbito: Backend - Patrón de JOINs

Introducción

Este documento define las reglas arquitecturales (RA) que rigen el uso del patrón unificado de JOINs declarativos en Sistema Bautista. Estas reglas complementan las reglas de arquitectura de base de datos (RA-MT-, RA-MS-, RA-MM-*).

Prefijo de reglas: RA-JOIN-

Tabla de Reglas

CódigoNombreCriticidadAplica a
RA-JOIN-001Separación Model-QueryALTATodos los Models
RA-JOIN-002JoinSpec Directo en QueriesMEDIAQuery Classes
RA-JOIN-003Cross-Schema Jerárquico PermitidoALTAJOINs cross-level
RA-JOIN-004Cross-Schema Horizontal ProhibidoCRÍTICAJOINs entre branches
RA-JOIN-005UNION ALL para Multi-SchemaALTAConsolidación
RA-JOIN-006Auto-Resolución de Schema LevelMEDIAModelMetadata

RA-JOIN-001: Separación Model-Query

Descripción

Los Models DEBEN representar una única tabla sin JOINs hardcodeados. Las queries con JOINs DEBEN implementarse en Query Classes dedicadas.

Implicación

✅ PERMITIDO:

php
// Model sin JOINs
class ClienteModel implements ModelMetadata {
    public function getAll(): array {
        return $this->conn->query("SELECT * FROM clientes")->fetchAll();
    }
}

// Query Class con JOINs
class ClienteOrdenesQuery extends BaseQuery {
    public function execute(): array {
        $sql = "SELECT c.*, o.total FROM clientes c";
        $sql = $this->applyJoins($sql, [
            JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT')
        ]);
        return $this->conn->query($sql)->fetchAll();
    }
}

❌ PROHIBIDO:

php
// Model con JOIN hardcodeado
class ClienteModel {
    public function getAllWithOrdenes(): array {
        // ❌ JOIN no debe estar en Model
        $sql = "SELECT c.*, o.* FROM clientes c LEFT JOIN ordenes o ON ...";
        return $this->conn->query($sql)->fetchAll();
    }
}

Justificación

  • Bajo acoplamiento: Models no dependen de otras tablas
  • Reutilización: JoinSpecs se pueden usar en múltiples queries
  • Testing: Models se testean de forma aislada

Relacionada con

  • RA-MT-001 (Aislamiento por Schema)

RA-JOIN-002: JoinSpec Directo en Queries

Descripción

Los JoinSpecs DEBEN crearse directamente en las Query Classes según necesidad. NO crear catálogos centralizados de relaciones.

Implicación

✅ PERMITIDO:

php
// JoinSpec creado directamente en Query
class ClienteOrdenesQuery extends BaseQuery {
    public function execute(): array {
        $sql = "SELECT c.*, o.total FROM clientes c";

        // Creación directa (sin catálogo)
        $sql = $this->applyJoins($sql, [
            JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT')
        ]);

        return $this->conn->query($sql)->fetchAll();
    }
}

❌ PROHIBIDO (deprecado):

php
// Catálogo centralizado (anti-pattern)
class JoinMap {
    public static function clienteOrdenes(): JoinSpec {
        return JoinSpec::auto('c', ClienteModel::class, OrdenModel::class, 'LEFT');
    }
}

// Uso en Query (más indirección innecesaria)
$joins = [JoinMap::clienteOrdenes()];

Justificación

  • Simplicidad: Menos archivos que mantener
  • Flexibilidad: Cada Query ajusta su JOIN según necesidad
  • Claridad: JOIN visible en contexto de uso

Excepción

En módulos con muchas queries similares, se PUEDE crear factory methods privados dentro de la misma Query Class (NO en catálogo global).


RA-JOIN-003: Cross-Schema Jerárquico Permitido

Descripción

Los JOINs entre niveles jerárquicos ESTÁN PERMITIDOS cuando suben en la jerarquía (hijo → padre) dentro del mismo branch.

Implicación

✅ PERMITIDO: Cross-Level Hacia Arriba

php
// CAJA → SUCURSAL (mismo branch)
FROM suc0001caja001.recibos r
JOIN suc0001.facturas f ON f.id = r.factura_id
-- suc0001caja001 puede acceder a suc0001

// SUCURSAL → EMPRESA (maestros compartidos)
FROM suc0001.facturas f
JOIN public.productos p ON p.id = f.producto_id
-- suc0001 puede acceder a public

// CAJA → EMPRESA (saltando nivel)
FROM suc0001caja001.movimientos cm
JOIN public.conceptos c ON c.id = cm.concepto_id
-- suc0001caja001 puede acceder a public

Implementación:

php
// JoinSpec cross-level directo
JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'INNER')

// Genera:
// INNER JOIN suc0001.facturas f ON f.id = r.factura_id

Justificación

  • Herencia de datos: Niveles inferiores heredan datos de superiores
  • Search path: PostgreSQL soporta search_path jerárquico
  • Maestros compartidos: public (EMPRESA) es accesible desde todos

Relacionada con

  • RA-MT-003 (Datos Maestros Compartidos)
  • RA-MS-002 (Alcance Limitado por Sucursal)

RA-JOIN-004: Cross-Schema Horizontal Prohibido

Descripción

Los JOINs entre schemas del mismo nivel pero diferentes branches ESTÁN PROHIBIDOS (cross-schema horizontal).

Implicación

❌ PROHIBIDO: Cross-Schema Horizontal

php
// Entre sucursales diferentes
FROM suc0001.facturas f
JOIN suc0002.clientes c ON c.id = f.cliente_id
-- VIOLACIÓN: Cruce entre branches diferentes

// Entre cajas de diferentes sucursales
FROM suc0001caja001.recibos r
JOIN suc0002caja001.recibos r2 ON r2.cliente_id = r.cliente_id
-- VIOLACIÓN: Cruce entre sucursales

// Entre cajas de la misma sucursal
FROM suc0001caja001.recibos r
JOIN suc0001caja002.movimientos m ON m.recibo_id = r.id
-- VIOLACIÓN: Para esto usar multi-schema con UNION ALL

Justificación

  • Aislamiento multi-tenant: Cada tenant tiene datos separados
  • Seguridad: Evitar acceso cruzado no autorizado
  • Integridad: Relaciones deben estar en el mismo schema

Alternativa

Para consolidar datos de múltiples schemas del mismo nivel, usar multi-schema querying con UNION ALL (RA-JOIN-005).

Relacionada con

  • RA-MT-001 (Aislamiento por Schema)
  • RA-MS-002 (Alcance Limitado por Sucursal)

RA-JOIN-005: UNION ALL para Multi-Schema

Descripción

La consolidación de datos de múltiples schemas del mismo nivel DEBE usar UNION ALL mediante executeMultiSchema(). NO usar loops de queries separadas.

Implicación

✅ PERMITIDO: UNION ALL Optimizado

php
class RecibosTodasLasCajasQuery extends BaseQuery {
    public function execute(): array {
        $schemaList = ['suc0001caja001', 'suc0001caja002', 'suc0001caja003'];

        $baseSql = "SELECT r.*, f.total FROM {schema}.recibos r";

        // executeMultiSchema() genera UNION ALL automático
        return $this->executeMultiSchema($baseSql, [
            JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'LEFT')
        ], $schemaList);
    }
}

// SQL generado:
// (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 ...)

❌ PROHIBIDO: Loop Manual

php
// Anti-pattern: N queries separadas
$results = [];
foreach ($schemaList as $schema) {
    $this->conn->exec("SET search_path = $schema");
    $results = array_merge($results, $this->querySchema());
}
// ❌ N round-trips, peor performance

Optimización

Si solo hay 1 schema, executeMultiSchema() optimiza automáticamente a query simple (sin UNION).

Justificación

  • Performance: 1 round-trip vs N round-trips (3-6x más rápido)
  • Optimización DB: PostgreSQL optimiza UNION ALL internamente
  • Consistencia: Snapshot único de datos

Relacionada con

  • RA-MS-001 (Activación Basada en Configuración)
  • RA-MS-002 (Alcance Limitado por Sucursal)

RA-JOIN-006: Auto-Resolución de Schema Level

Descripción

El nivel de schema (EMPRESA/SUCURSAL/CAJA) NO DEBE declararse manualmente en Models. MultiSchemaService DEBE auto-detectarlo consultando information_schema.

Implicación

✅ PERMITIDO:

php
// Model SIN schemaLevel manual
final class MovimientoCajaModel implements ModelMetadata {
    public static function table(): string { return 'movimientos_caja'; }
    public static function alias(): string { return 'mc'; }
    public static function primaryKey(): string { return 'id'; }

    // Nivel auto-detectado: CAJA (no se declara)
}

❌ PROHIBIDO:

php
// Model con schemaLevel hardcodeado (deprecado)
final class MovimientoCajaModel implements ModelMetadata {
    public static function table(): string { return 'movimientos_caja'; }
    public static function alias(): string { return 'mc'; }
    public static function primaryKey(): string { return 'id'; }

    // ❌ NO declarar manualmente (configuración redundante)
    public static function schemaLevel(): int { return 3; }
}

Resolución Dinámica

php
// MultiSchemaService::getTableLevel()
public function getTableLevel(string $tableName): int
{
    // 1. Consultar information_schema para descubrir en qué schemas existe
    $schemas = $this->discoverTableSchemas($tableName);

    // 2. Determinar nivel según patrón de schemas
    // - Si está en public → EMPRESA (nivel 1)
    // - Si está en suc\d+ → SUCURSAL (nivel 2)
    // - Si está en suc\d+caja\d+ → CAJA (nivel 3)

    return $this->inferLevelFromSchemas($schemas);
}

Justificación

  • DRY: No duplicar información que existe en la base de datos
  • Flexibilidad: Cambios de nivel no requieren modificar código
  • Descubrimiento: Estructura de schemas como única fuente de verdad

Excepción

En configuracion_niveles_tablas (JSON) se PUEDE configurar nivel de tabla para casos especiales, pero solo como override opcional.

Relacionada con

  • RA-MT-003 (Datos Maestros Compartidos)

Matriz de Decisión: Qué Patrón Usar

EscenarioSchemasPatrónRegla Aplicable
Cliente → Órdenes (mismo schema)1JoinSpec::auto()RA-JOIN-001
CAJA → SUCURSAL (1 query)2 jerárquicosJoinSpec::autoWithSchema()RA-JOIN-003
Todas las cajas con JOINN horizontalesexecuteMultiSchema()RA-JOIN-005
Entre sucursales diferentes2+ diferentes branches❌ PROHIBIDORA-JOIN-004

Validación de Cumplimiento

Checklist de Code Review

  • [ ] RA-JOIN-001: Models no contienen JOINs hardcodeados
  • [ ] RA-JOIN-002: JoinSpecs creados directamente en Query Classes
  • [ ] RA-JOIN-003: Cross-level solo hacia arriba en jerarquía
  • [ ] RA-JOIN-004: No hay JOINs cross-schema horizontales
  • [ ] RA-JOIN-005: Multi-schema usa executeMultiSchema() con UNION ALL
  • [ ] RA-JOIN-006: Models no declaran schemaLevel() manualmente

Herramientas de Validación

bash
# Detectar JOINs hardcodeados en Models
grep -r "LEFT JOIN\|INNER JOIN" server/models/

# Detectar schemaLevel manual (deprecado)
grep -r "public static function schemaLevel" server/models/

# Detectar loops manuales (anti-pattern)
grep -r "foreach.*schema.*SET search_path" server/service/

Referencias

Documentación de JOINs

Reglas de Arquitectura de Base de Datos


Última actualización: 2026-02-04 Versión: 1.0.0 Autor: Sistema Bautista - Arquitectura Backend