Appearance
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-levelReglas 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.facturasCó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 publicDiferencia 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_idMulti-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
| Necesidad | Patrón a Usar | Método |
|---|---|---|
| Recibos de caja001 con facturas | Cross-level directo | applyJoins() |
| Recibos de TODAS las cajas con facturas | Multi-schema consolidado | executeMultiSchema() |
| Facturas de suc0001 con productos | Cross-level directo | applyJoins() |
| Facturas de TODAS las sucursales con productos | Multi-schema consolidado | executeMultiSchema() |
Performance
Cross-Level Directo
Round-trips: 1
Latencia: ~50ms
Uso: Queries específicas a 1 schema con datos relacionados en nivel superiorMulti-Schema (comparación)
Round-trips: 1 (con UNION ALL optimizado)
Latencia: ~150ms (más datos, más schemas)
Uso: Consolidación de N schemasConclusió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
- Casos de Uso Simple: JOINs en mismo schema
- Casos de Uso Multi-Schema: Consolidación con UNION ALL
- Guía Completa: Componentes fundamentales
- Reglas Arquitecturales: RA-JOIN-* completas
Arquitectura de Base de Datos
- Multi-Tenant: Schema-based tenancy (RA-MT-*)
- Multi-Schema: Cross-schema querying (RA-MS-*)
Última actualización: 2026-02-04 Versión: 1.0.0 Autor: Sistema Bautista - Arquitectura Backend