Skip to content

Cross-Level JOINs Directos (Sin UNION ALL)

Versión: 1.0.0 Fecha: 2026-02-04 Audiencia: Backend Developers Nivel: Intermedio

Tabla de Contenidos

Introducción

Este documento cubre JOINs cross-level directos: queries que acceden a tablas de diferentes schemas en la misma jerarquía, pero en 1 query directa (sin UNION ALL).

Diferencia con multi-schema:

  • Este documento: 1 query con JOIN cross-level directo
  • Multi-schema: N queries consolidadas con UNION ALL → Ver casos-uso-multi-schema.md

Ejemplo Visual

sql
-- Cross-level directo (este documento):
SELECT r.*, f.total
FROM suc0001caja001.recibos r
INNER JOIN suc0001.facturas f ON f.id = r.factura_id;
-- ↑ 1 query, accede a 2 schemas diferentes en jerarquía

-- Multi-schema consolidado (otro documento):
(SELECT ... FROM suc0001caja001.recibos r JOIN suc0001.facturas f ...)
UNION ALL
(SELECT ... FROM suc0001caja002.recibos r JOIN suc0001.facturas f ...)
-- ↑ UNION ALL de queries cross-level

Reglas de Cross-Schema Permitido

✅ PERMITIDO: Cross-Level Jerárquico

JOINs hacia arriba en la jerarquía (mismo branch):

CAJA → SUCURSAL (mismo branch)
  suc0001caja001 → suc0001 ✅

SUCURSAL → EMPRESA (maestros compartidos)
  suc0001 → public ✅

CAJA → EMPRESA (saltando nivel)
  suc0001caja001 → public ✅

❌ NO PERMITIDO: Cross-Schema Horizontal

JOINs entre diferentes branches:

SUCURSAL ↔ SUCURSAL (diferentes branches)
  suc0001 ↔ suc0002 ❌

CAJA ↔ CAJA (diferentes sucursales)
  suc0001caja001 ↔ suc0002caja001 ❌

CAJA ↔ CAJA (misma sucursal, diferentes cajas)
  suc0001caja001 ↔ suc0001caja002 ❌
  (Para esto usar multi-schema con UNION ALL)

Razón: Violación de aislamiento multi-tenant (RA-MT-001), alcance limitado por sucursal (RA-MS-002) y cross-schema horizontal prohibido (RA-JOIN-004).

Caso 1: CAJA → SUCURSAL (Mismo Branch)

Descripción

Listar recibos de una caja específica con información de la factura asociada (nivel SUCURSAL).

Jerarquía:

suc0001caja001.recibos (CAJA)
   ↓ JOIN directo
suc0001.facturas (SUCURSAL)

Implementación

Models

php
final class ReciboModel implements ModelMetadata {
    public static function table(): string { return 'recibos'; }
    public static function alias(): string { return 'r'; }
    public static function primaryKey(): string { return 'id'; }
    // Nivel auto-detectado: CAJA
}

final class FacturaModel implements ModelMetadata {
    public static function table(): string { return 'facturas'; }
    public static function alias(): string { return 'f'; }
    public static function primaryKey(): string { return 'id'; }
    // Nivel auto-detectado: SUCURSAL
}

Query Class (1 Schema, Cross-Level JOIN)

php
class ReciboConFacturaQuery extends BaseQuery
{
    public function execute(): array
    {
        // Request está en suc0001caja001 (multi-tenant normal)
        // El query accede a suc0001 mediante JOIN cross-level

        $sql = sprintf(
            "SELECT
                r.id,
                r.monto,
                r.fecha,
                f.numero AS factura_numero,
                f.total AS factura_total
            FROM %s r",
            ReciboModel::table() // Solo nombre, search_path resuelve schema
        );

        // JoinSpec cross-level (NO usa executeMultiSchema)
        $sql = $this->applyJoins($sql, [
            JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'INNER')
        ], true); // useSchema = true para prefijos

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

SQL Generado

sql
SELECT
    r.id,
    r.monto,
    r.fecha,
    f.numero AS factura_numero,
    f.total AS factura_total
FROM recibos r
INNER JOIN suc0001.facturas f ON f.id = r.factura_id
-- ↑ PostgreSQL usa search_path: suc0001caja001, suc0001, public
--   'recibos' se encuentra en suc0001caja001
--   'facturas' se especifica explícitamente como suc0001.facturas

Cómo Funciona el Schema Resolution

php
// BaseQuery::applyJoins() con useSchema=true
protected function applyJoins(string $sql, array $joins, bool $useSchema = false): string
{
    foreach ($joins as $join) {
        if ($join->requiresMultiSchema() && $useSchema) {
            // Determinar schema de tabla derecha
            $rightSchema = $this->resolveSchemaForTable($join->rightTable);
            $sql .= ' ' . $join->toSQLWithSchema($rightSchema);
        } else {
            $sql .= ' ' . $join->toSQL();
        }
    }
    return $sql;
}

// MultiSchemaService::resolveSchemaForTable()
public function resolveSchemaForTable(string $tableName): string
{
    // 1. Si tabla está en schema actual, usar ese
    if ($this->tableExistsInSchema($tableName, $this->currentSchema)) {
        return $this->currentSchema;
    }

    // 2. Subir un nivel: CAJA → SUCURSAL
    if (preg_match('/^(suc\d+)caja\d+$/', $this->currentSchema, $matches)) {
        $parentSchema = $matches[1]; // ej: suc0001
        if ($this->tableExistsInSchema($tableName, $parentSchema)) {
            return $parentSchema;
        }
    }

    // 3. Nivel EMPRESA
    if ($this->tableExistsInSchema($tableName, 'public')) {
        return 'public';
    }

    throw new \RuntimeException("Tabla {$tableName} no encontrada en jerarquía");
}

Caso 2: SUCURSAL → EMPRESA (Maestros Compartidos)

Descripción

Listar facturas de una sucursal con información de productos compartidos (nivel EMPRESA).

Jerarquía:

suc0001.facturas (SUCURSAL)
   ↓ JOIN directo
public.productos (EMPRESA)

Implementación

php
class FacturaConProductosQuery extends BaseQuery
{
    public function execute(): array
    {
        // Request está en suc0001 (multi-tenant normal)
        // El query accede a public mediante JOIN cross-level

        $sql = sprintf(
            "SELECT
                f.id,
                f.numero,
                f.total,
                p.codigo AS producto_codigo,
                p.nombre AS producto_nombre
            FROM %s f
            INNER JOIN %s fi ON fi.factura_id = f.id",
            FacturaModel::table(),
            FacturaItemModel::table()
        );

        // JoinSpec cross-level SUCURSAL → EMPRESA
        $sql = $this->applyJoins($sql, [
            JoinSpec::autoWithSchema('fi', FacturaItemModel::class, ProductoModel::class, 'INNER')
        ], true);

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

SQL Generado

sql
SELECT
    f.id,
    f.numero,
    f.total,
    p.codigo AS producto_codigo,
    p.nombre AS producto_nombre
FROM facturas f
INNER JOIN factura_items fi ON fi.factura_id = f.id
INNER JOIN public.productos p ON p.id = fi.producto_id
-- ↑ 'facturas' y 'factura_items' en suc0001 (search_path)
--   'productos' explícitamente en public

Diferencia con Multi-Schema (UNION ALL)

Cross-Level Directo (Este Documento)

php
// 1 query, 2 schemas en jerarquía
class ReciboConFacturaQuery extends BaseQuery {
    public function execute(): array {
        $sql = "SELECT r.*, f.total FROM recibos r";
        $sql = $this->applyJoins($sql, [
            JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'INNER')
        ], true);

        return $this->conn->query($sql)->fetchAll();
        // ↑ NO usa executeMultiSchema()
    }
}

// SQL: 1 query directa
// SELECT r.*, f.total
// FROM recibos r  -- suc0001caja001 (search_path)
// INNER JOIN suc0001.facturas f ON f.id = r.factura_id

Multi-Schema Consolidado (Otro Documento)

php
// N queries consolidadas con UNION ALL
class RecibosConFacturasTodasLasCajasQuery extends BaseQuery {
    public function execute(): array {
        $schemaList = ['suc0001caja001', 'suc0001caja002', 'suc0001caja003'];

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

        return $this->executeMultiSchema($sql, [
            JoinSpec::autoWithSchema('r', ReciboModel::class, FacturaModel::class, 'INNER')
        ], $schemaList);
        // ↑ USA executeMultiSchema() → genera UNION ALL
    }
}

// SQL: UNION ALL de queries cross-level
// (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 ...)

Cuándo Usar Cada Uno

NecesidadPatrón a UsarMétodo
Recibos de caja001 con facturasCross-level directoapplyJoins()
Recibos de TODAS las cajas con facturasMulti-schema consolidadoexecuteMultiSchema()
Facturas de suc0001 con productosCross-level directoapplyJoins()
Facturas de TODAS las sucursales con productosMulti-schema consolidadoexecuteMultiSchema()

Performance

Cross-Level Directo

Round-trips: 1
Latencia: ~50ms
Uso: Queries específicas a 1 schema con datos relacionados en nivel superior

Multi-Schema (comparación)

Round-trips: 1 (con UNION ALL optimizado)
Latencia: ~150ms (más datos, más schemas)
Uso: Consolidación de N schemas

Conclusión: Cross-level directo es más eficiente cuando solo necesitas datos de 1 schema.

Reglas Arquitecturales Aplicables

Este documento cubre casos de uso regidos por:

  • RA-JOIN-003: Cross-Schema Jerárquico Permitido (✅ hacia arriba)
  • RA-JOIN-004: Cross-Schema Horizontal Prohibido (❌ entre branches)
  • RA-JOIN-006: Auto-Resolución de Schema Level

Ver: Reglas Arquitecturales para detalles completos.

Recursos Relacionados

Documentación de JOINs

Arquitectura de Base de Datos


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