Skip to content

Patron Cross-Schema Queryable

Tipo: Patron Tecnico de Backend Alcance: Todos los servicios con operaciones multi-schema Estado: Implementado Fecha: 2027-01-27 Version: 1.0

Conceptual Foundation: Para entender los CONCEPTOS arquitectónicos de multi-schema querying, consulte Database Architecture: Multi-Schema. Este documento describe la implementación técnica del patrón.

Documento de Requisitos de Negocio: Sistema de Busqueda Multi-Schema para Operaciones Transaccionales

Este documento describe la implementacion tecnica del patron Cross-Schema Queryable, que estandariza las operaciones de consulta a traves de multiples schemas PostgreSQL. Para el contexto de negocio, escenarios problemáticos y reglas arquitecturales, consulte el documento general.


Indice

  1. Problema Tecnico
  2. Descripcion del Patron
  3. Componentes
  4. Estrategias de Consulta
  5. Metodos Helper
  6. Policy: Restauracion del search_path
  7. Uso en Servicios
  8. Dependencias
  9. Testing
  10. Rendimiento
  11. Extension a Nuevos Modulos
  12. Referencia de API

Problema Tecnico

Boilerplate Repetitivo en Servicios

Antes de este patron, cada servicio que necesitaba consultar datos en multiples schemas PostgreSQL debia implementar manualmente toda la logica de:

  1. Determinar el schema actual del usuario desde el JWT
  2. Consultar la configuracion de niveles de la tabla
  3. Resolver los schemas objetivo segun la configuracion
  4. Iterar por cada schema cambiando el search_path
  5. Ejecutar la consulta en cada schema
  6. Acumular o retornar resultados
  7. Adjuntar metadata de schema a cada resultado

Esto representaba entre 15 y 20 lineas de codigo boilerplate por cada metodo de servicio que necesitaba buscar en multiples schemas. Con multiples metodos por servicio y multiples servicios por modulo, la duplicacion era significativa.

Problemas Derivados

  • Duplicacion masiva: Cada servicio reimplementaba la misma logica con variaciones minimas
  • Inconsistencia: Diferentes implementaciones tenian sutiles diferencias de comportamiento
  • Dificultad de mantenimiento: Un cambio en la logica cross-schema requeria modificar todos los servicios
  • Testing complejo: Cada servicio necesitaba tests individuales para la logica cross-schema
  • Falta de optimizacion: No todos los servicios priorizaban el schema actual para rendimiento

Descripcion del Patron

Solucion: Interface + Trait

El patron Cross-Schema Queryable encapsula toda la logica de consulta multi-schema en dos componentes reutilizables:

  1. CrossSchemaQueryableInterface: Define el contrato que deben cumplir los servicios
  2. CrossSchemaQueryable (Trait): Provee la implementacion default

Los servicios implementan la interface y usan el trait, reduciendo el boilerplate de 15-20 lineas a 5-10 lineas por metodo (reduccion del 60-70%).

Principios de Diseno

  • Single Source of Truth: Toda la logica cross-schema vive en un unico lugar
  • Open/Closed: Los servicios extienden el comportamiento sin modificar el trait
  • Interface Segregation: La interface define exactamente dos metodos publicos
  • Composition over Inheritance: Se usa trait (composicion) en lugar de herencia de clases
  • Strategy Pattern: Los dos metodos representan dos estrategias de busqueda

Componentes

1. CrossSchemaQueryableInterface

Ubicacion: Interface/MultiSchema/CrossSchemaQueryableInterface.php

Namespace: App\Interface\MultiSchema

Define el contrato con dos metodos publicos:

php
interface CrossSchemaQueryableInterface
{
    public function queryAcrossSchemas(
        callable $queryCallback,
        string $tableName,
        array $defaultLevels,
        bool $multiSchema = true
    ): array;

    public function findFirstAcrossSchemas(
        callable $queryCallback,
        string $tableName,
        array $defaultLevels,
        bool $multiSchema = true
    );
}

Parametros comunes:

ParametroTipoDescripcion
$queryCallbackcallableFuncion que ejecuta la consulta especifica. Recibe string $schema como argumento.
$tableNamestringNombre de la tabla para determinar configuracion de niveles.
$defaultLevelsarrayNiveles de schema por defecto para esta tabla (ej: [2, 3]).
$multiSchemaboolHabilita/deshabilita la busqueda multi-schema. Default: true.

2. CrossSchemaQueryable (Trait)

Ubicacion: Traits/CrossSchemaQueryable.php

Namespace: App\Traits

Provee la implementacion default de ambos metodos de la interface, mas tres metodos helper protegidos.

Dependencias del trait:

  • Usa el trait ConnectionUtils (para setSchemaContext())
  • Requiere propiedad $this->connections (via trait Conectable) con acceso a ConnectionManager
  • Requiere propiedad $this->multiSchemaService de tipo MultiSchemaService

3. MultiSchemaService

Ubicacion: service/Config/MultiSchemaService.php

Namespace: App\service\Config

Servicio de soporte que resuelve la configuracion de schemas. Provee:

  • getCurrentSchema(): Obtiene el schema actual del JWT payload
  • isMultiSchemaActive($tableName, $defaultLevels): Determina si multi-schema esta activo para una tabla
  • getTargetSchemas($tableName, $defaultLevels, $currentSchema): Resuelve la lista de schemas objetivo

Reglas de activacion (RA-001):

  • Si la tabla tiene un solo nivel Y no es LEVEL_CAJA → multi-schema inactivo
  • Si la tabla tiene multiples niveles → multi-schema activo
  • Si la tabla tiene un solo nivel LEVEL_CAJA → multi-schema activo (busca en todas las cajas)

Niveles soportados:

ConstanteValorSchema
LEVEL_EMPRESA1public
LEVEL_SUCURSAL2sucXXXX
LEVEL_CAJA3sucXXXXcajaXXXX

4. SchemaService

Ubicacion: service/Config/SchemaService.php

Namespace: App\service\Config

Servicio de bajo nivel que consulta information_schema de PostgreSQL:

  • getSchemasWithTable($table): Devuelve todos los schemas que contienen una tabla
  • getSucursales(): Devuelve todos los schemas de tipo sucursal (suc\d{4})
  • getCajas($sucursal): Devuelve todos los schemas de caja para una sucursal

5. ConnectionUtils (Trait)

Ubicacion: connection/ConnectionUtils.php

Namespace: App\connection

Utilidades estaticas para manipulacion de schemas PostgreSQL:

  • extractSucursalSchema($schema): Extrae la porcion de sucursal de un schema de caja
  • buildSearchPath($schema): Construye el search_path jerarquico
  • setSchemaContext($conn, $schema): Ejecuta SET search_path en una conexion PDO
  • formatSchemaName($schema): Convierte nombre tecnico a formato legible

Estrategias de Consulta

Estrategia 1: Batch/Accumulate (queryAcrossSchemas)

Proposito: Acumular resultados de todos los schemas en un unico array.

Caso de uso tipico: Metodos getAll(), listados, reportes consolidados.

Flujo de ejecucion:

1. Obtener schema actual del JWT
2. Resolver schemas objetivo (resolveTargetSchemas)
3. Priorizar schema actual (prioritizeCurrentSchema)
4. Para cada schema:
   a. Cambiar search_path al schema
   b. Ejecutar callback
   c. Adjuntar _schema a cada resultado
   d. Agregar resultados al array acumulado
5. Retornar array con todos los resultados

Implementacion:

php
public function queryAcrossSchemas(
    callable $queryCallback,
    string $tableName,
    array $defaultLevels,
    bool $multiSchema = true
): array {
    $currentSchema = $this->multiSchemaService->getCurrentSchema();
    $schemas = $this->resolveTargetSchemas($tableName, $defaultLevels, $currentSchema, $multiSchema);
    $schemas = $this->prioritizeCurrentSchema($schemas, $currentSchema);

    $allResults = [];
    try {
        foreach ($schemas as $schema) {
            self::setSchemaContext($this->connections->get('principal'), $schema);
            $results = $queryCallback($schema);
            if (is_array($results)) {
                foreach ($results as $result) {
                    $this->attachSchemaMetadata($result, $schema);
                    $allResults[] = $result;
                }
            }
        }
    } finally {
        $this->connections->resetSchemaContext();
    }
    return $allResults;
}

Ver Policy: Restauracion del search_path para entender por que el try/finally con resetSchemaContext() es obligatorio.

Retorno: array - Array de objetos/DTOs con propiedad _schema adjuntada.

Estrategia 2: Early Exit (findFirstAcrossSchemas)

Proposito: Retornar el primer resultado no nulo encontrado, deteniendo la iteracion.

Caso de uso tipico: Metodos getById(), findByNumero(), busquedas de registro unico.

Flujo de ejecucion:

1. Obtener schema actual del JWT
2. Resolver schemas objetivo (resolveTargetSchemas)
3. Priorizar schema actual (prioritizeCurrentSchema)
4. Para cada schema:
   a. Cambiar search_path al schema
   b. Ejecutar callback
   c. Si resultado != null:
      - Adjuntar _schema al resultado
      - RETORNAR resultado (early exit)
5. Si ninguno encontro resultado → retornar null

Implementacion:

php
public function findFirstAcrossSchemas(
    callable $queryCallback,
    string $tableName,
    array $defaultLevels,
    bool $multiSchema = true
) {
    $currentSchema = $this->multiSchemaService->getCurrentSchema();
    $schemas = $this->resolveTargetSchemas($tableName, $defaultLevels, $currentSchema, $multiSchema);
    $schemas = $this->prioritizeCurrentSchema($schemas, $currentSchema);

    try {
        foreach ($schemas as $schema) {
            self::setSchemaContext($this->connections->get('principal'), $schema);
            $result = $queryCallback($schema);
            if ($result !== null) {
                $this->attachSchemaMetadata($result, $schema);
                return $result;
            }
        }
        return null;
    } finally {
        $this->connections->resetSchemaContext();
    }
}

Ver Policy: Restauracion del search_path para entender por que el try/finally con resetSchemaContext() es obligatorio.

Retorno: mixed|null - Primer objeto/DTO encontrado con _schema, o null.

Optimizacion de rendimiento: Al priorizar el schema actual y usar early exit, en el caso mas comun (registro en schema actual) solo se ejecuta una sola consulta.


Metodos Helper

resolveTargetSchemas()

Visibilidad: protected

Proposito: Determinar la lista de schemas donde se debe buscar.

Logica:

Si multiSchema=true Y isMultiSchemaActive=true:
    → Retornar schemas de MultiSchemaService::getTargetSchemas()
Si no:
    → Retornar [$currentSchema] (solo schema actual)

Parametros:

ParametroTipoDescripcion
$tableNamestringTabla para consultar configuracion
$defaultLevelsarrayNiveles por defecto
$currentSchemastringSchema actual del usuario
$multiSchemaboolFlag habilitacion

Retorno: array de nombres de schemas.

prioritizeCurrentSchema()

Visibilidad: protected

Proposito: Mover el schema actual al inicio del array para optimizar rendimiento.

Logica: Si $currentSchema esta en el array, se mueve al indice 0.

Justificacion: En la mayoria de los casos, el registro buscado esta en el schema actual del usuario. Priorizar este schema reduce la cantidad de consultas necesarias, especialmente con la estrategia early-exit.

Ejemplo:

Input:  ['suc0001caja001', 'suc0001', 'suc0001caja002']
Current: 'suc0001'
Output: ['suc0001', 'suc0001caja001', 'suc0001caja002']

attachSchemaMetadata()

Visibilidad: protected

Proposito: Adjuntar el nombre del schema de origen a cada resultado.

Logica: Si el objeto resultado tiene una propiedad _schema, se asigna el nombre del schema. Si no tiene la propiedad, no hace nada (safe operation).

Requisito para DTOs: Los DTOs que seran usados con este patron deben declarar:

php
public ?string $_schema = null;

Policy: Restauracion del search_path

Problema: estado residual del search_path

Al iterar schemas, ConnectionUtils::setSchemaContext() ejecuta SET search_path en una conexion PDO especifica. Si el metodo termina (por early exit, excepcion, o fin del loop) sin restaurar el search_path, la conexion queda apuntando al ultimo schema iterado.

Bug de produccion: SQLSTATE[42P01]: no existe la relacion «caja»

La tabla caja vive en el schema suc0001caja0001 (nivel caja). Cuando findFirstAcrossSchemas() encontraba el registro buscado en un schema intermedio y retornaba, la conexion principal quedaba con search_path = suc0001 (nivel sucursal). La siguiente query que necesitaba caja fallaba porque caja no existe en el schema de sucursal.

Arquitectura del search_path

buildSearchPath('suc0001caja0004') genera el search_path jerarquico:

suc0001caja0004, suc0001, public

Este search_path permite que queries en una caja resuelvan tablas de nivel sucursal y empresa por herencia. Cuando el search_path queda en un schema incorrecto, las tablas de niveles mas bajos no son visibles.

Tres metodos para manipular search_path, con costos distintos:

MetodoScopeCostoCuándo usar
ConnectionUtils::setSchemaContext($pdo, $schema)Una conexion PDOBajo (1 SET)Dentro del loop de iteracion
ConnectionManager::setSchemaContext($schema)Todas las conexiones (fuerza lazy-loading)AltoCambio global de schema del request
ConnectionManager::resetSchemaContext()Solo conexiones ya abiertasBajo (N SETs)Restaurar al terminar la iteracion

Fuente de verdad del schema canonico: ConnectionManager::$config['oficial']['schema']

Este valor lo setea ConnectionMiddleware al inicio del request (via setConfig) con el schema del JWT/X-Schema header. resetSchemaContext() lo lee para saber a que schema restaurar.

Regla obligatoria

Cualquier metodo que itere schemas cambiando search_path DEBE llamar $this->connections->resetSchemaContext() al terminar, envuelto en try/finally.

El try/finally garantiza el restore incluso ante excepciones lanzadas dentro del callback.

Patron correcto:

php
try {
    foreach ($schemas as $schema) {
        self::setSchemaContext($this->connections->get('principal'), $schema);
        $result = $queryCallback($schema);
        // ... procesar resultado
    }
} finally {
    $this->connections->resetSchemaContext(); // siempre se ejecuta
}

Anti-patron (NO hacer):

php
// ❌ Sin restore: la conexion queda en el ultimo schema iterado
foreach ($schemas as $schema) {
    self::setSchemaContext($this->connections->get('principal'), $schema);
    $result = $queryCallback($schema);
    if ($result !== null) {
        return $result; // early exit sin restaurar
    }
}

Checklist para nuevos metodos que iteren schemas

Antes de hacer un PR con un metodo que itera schemas:

  • [ ] El loop de iteracion usa ConnectionUtils::setSchemaContext() (no ConnectionManager::setSchemaContext())
  • [ ] El loop esta envuelto en try/finally
  • [ ] El bloque finally llama $this->connections->resetSchemaContext()
  • [ ] Si el metodo hace early exit (return dentro del loop), el return esta dentro del try (no despues)
  • [ ] Se agrego un test que verifica que resetSchemaContext() es llamado incluso cuando el callback lanza excepcion

Uso en Servicios

Patron de Implementacion Completo

Para que un servicio use el patron Cross-Schema Queryable, debe:

  1. Implementar CrossSchemaQueryableInterface
  2. Usar los traits Conectable, CrossSchemaQueryable
  3. Inyectar ConnectionManager en el constructor
  4. Inicializar $this->multiSchemaService en el constructor
php
<?php

namespace App\service\Tesoreria;

use App\Interface\MultiSchema\CrossSchemaQueryableInterface;
use App\Traits\Conectable;
use App\Traits\CrossSchemaQueryable;
use App\connection\ConnectionManager;
use App\service\Config\MultiSchemaService;

class MovimientoCajaService implements CrossSchemaQueryableInterface
{
    use Conectable;
    use CrossSchemaQueryable;

    private MovimientoCaja $model;

    private const LEVEL_SUCURSAL = 2;
    private const LEVEL_CAJA = 3;

    public function __construct(ConnectionManager $manager)
    {
        $this->setConnectionManager($manager);
        $this->multiSchemaService = new MultiSchemaService($manager->get('principal'));
        $this->model = new MovimientoCaja($manager->get('principal'));
    }

    /**
     * Obtener todos los movimientos de caja.
     * Patron Batch/Accumulate: acumula resultados de todos los schemas.
     */
    public function getAll(bool $multiSchema = true): array
    {
        return $this->queryAcrossSchemas(
            queryCallback: fn(string $schema) => $this->model->getAll(),
            tableName: 'movimi',
            defaultLevels: [self::LEVEL_SUCURSAL, self::LEVEL_CAJA],
            multiSchema: $multiSchema
        );
    }

    /**
     * Buscar movimiento por ID.
     * Patron Early Exit: retorna el primer match.
     */
    public function findById(string $id, bool $multiSchema = true): ?MovimientoCajaDTO
    {
        return $this->findFirstAcrossSchemas(
            queryCallback: fn(string $schema) => $this->model->getById($id),
            tableName: 'movimi',
            defaultLevels: [self::LEVEL_SUCURSAL, self::LEVEL_CAJA],
            multiSchema: $multiSchema
        );
    }

    /**
     * Buscar movimientos por numero de comprobante.
     * Patron Batch/Accumulate: puede haber movimientos en multiples schemas.
     */
    public function findByComprobante(
        string $nroComprobante,
        bool $multiSchema = true
    ): array {
        return $this->queryAcrossSchemas(
            queryCallback: fn(string $schema) => $this->model->getByComprobante($nroComprobante),
            tableName: 'movimi',
            defaultLevels: [self::LEVEL_SUCURSAL, self::LEVEL_CAJA],
            multiSchema: $multiSchema
        );
    }
}

Parametro $multiSchema

Todos los metodos del servicio deben exponer un parametro $multiSchema con valor default true:

php
public function getAll(bool $multiSchema = true): array

Esto permite que:

  • Controllers de listado: Usen $multiSchema = true (busqueda consolidada)
  • Controllers de operacion puntual: Puedan pasar $multiSchema = false si ya conocen el schema
  • Tests unitarios: Puedan desactivar multi-schema para simplificar mocks

Propiedad _schema en DTOs

Los DTOs usados en operaciones cross-schema deben incluir:

php
class MovimientoCajaDTO extends FullDTO
{
    public function __construct(
        public int $id,
        public string $fecha,
        public float $importe,
        // ... otros campos ...
        public ?string $_schema = null  // Metadata de schema
    ) {}
}

La propiedad _schema permite al frontend y otros servicios:

  • Saber de que schema proviene cada registro
  • Cambiar el contexto al schema correcto para operaciones relacionadas (RA-003)
  • Mostrar informacion de origen al usuario

Dependencias

Diagrama de Dependencias

CrossSchemaQueryableInterface


CrossSchemaQueryable (Trait)
    │         │         │
    ▼         ▼         ▼
Conectable  ConnectionUtils  MultiSchemaService
    │                            │
    ▼                            ▼
ConnectionManager          SchemaService
    │                            │
    ▼                            ▼
  PDO                    information_schema

Flujo de Resolucion de Schemas

1. CrossSchemaQueryable.queryAcrossSchemas()

   ├─► MultiSchemaService.getCurrentSchema()
   │     └─► Lee $GLOBALS['payload']['schema'] (JWT)

   ├─► CrossSchemaQueryable.resolveTargetSchemas()
   │     │
   │     ├─► MultiSchemaService.isMultiSchemaActive()
   │     │     └─► MultiSchemaService.getEffectiveLevels()
   │     │           └─► MultiSchemaService.getTableLevelConfig()
   │     │                 └─► SELECT FROM sistema WHERE bd = :database
   │     │
   │     └─► MultiSchemaService.getTargetSchemas()
   │           └─► MultiSchemaService.buildSchemaList()
   │                 ├─► SchemaService.getSucursales()
   │                 ├─► SchemaService.getCajas()
   │                 └─► SchemaService.getSchemasWithTable()

   ├─► CrossSchemaQueryable.prioritizeCurrentSchema()

   └─► Para cada schema:
         ├─► ConnectionUtils.setSchemaContext() → SET search_path
         ├─► $queryCallback($schema)
         └─► CrossSchemaQueryable.attachSchemaMetadata()

Testing

Estrategia de Testing

El patron esta disenado para ser testeable en aislamiento:

  • Trait tests: Usan clase anonima para probar el trait directamente
  • MultiSchemaService tests: Mockean PDO y SchemaService
  • Service tests: Mockean MultiSchemaService y Models

Tests del Trait (CrossSchemaQueryableTest)

Ubicacion: Tests/Unit/Traits/CrossSchemaQueryableTest.php

Setup: Usa clase anonima que combina Conectable + CrossSchemaQueryable:

php
$this->testService = new class {
    use Conectable;
    use CrossSchemaQueryable;

    // Expose protected methods for testing
    public function publicResolveTargetSchemas(...) { ... }
    public function publicAttachSchemaMetadata(...) { ... }
    public function publicPrioritizeCurrentSchema(...) { ... }
};

Casos de prueba cubiertos:

CategoriaTestDescripcion
queryAcrossSchemastestQueryAcrossSchemas_AccumulatesResultsFromMultipleSchemasVerifica acumulacion de resultados de 3 schemas
testQueryAcrossSchemas_MultiSchemaFalse_UsesOnlyCurrentSchemaVerifica que multiSchema=false usa solo schema actual
testQueryAcrossSchemas_HandlesEmptyResultsFromSomeSchemasManeja schemas que retornan arrays vacios
findFirstAcrossSchemastestFindFirstAcrossSchemas_ReturnsFirstMatchRetorna primer resultado no-null y detiene iteracion
testFindFirstAcrossSchemas_ReturnsNullWhenNoMatchFoundRetorna null cuando ningun schema tiene resultados
testFindFirstAcrossSchemas_MultiSchemaFalse_UsesOnlyCurrentSchemaUsa solo schema actual con flag deshabilitado
resolveTargetSchemastestResolveTargetSchemas_MultiSchemaFalse_ReturnsCurrentSchemaFlag false retorna solo schema actual
testResolveTargetSchemas_MultiSchemaTrue_ReturnsMultipleSchemasFlag true retorna multiples schemas
testResolveTargetSchemas_IsMultiSchemaActiveFalse_ReturnsCurrentSchemaTabla de un solo nivel retorna schema actual
attachSchemaMetadatatestAttachSchemaMetadata_SetsSchemaPropertyAsigna _schema cuando la propiedad existe
testAttachSchemaMetadata_DoesNothingIfPropertyDoesNotExistNo agrega _schema si el objeto no tiene la propiedad
prioritizeCurrentSchematestPrioritizeCurrentSchema_MovesCurrentSchemaToFrontMueve schema actual al inicio
testPrioritizeCurrentSchema_DoesNothingIfCurrentSchemaNotInListNo modifica si schema actual no esta en la lista
testPrioritizeCurrentSchema_HandlesCurrentSchemaAlreadyFirstNo modifica si ya esta primero

Tests del MultiSchemaService (MultiSchemaServiceTest)

Ubicacion: Tests/Unit/Config/MultiSchemaServiceTest.php

Casos de prueba cubiertos:

TestDescripcion
testGetTargetSchemas_SingleLevel_ReturnsCurrentSchemaOnlyTabla con un nivel retorna solo schema actual (RA-001)
testGetTargetSchemas_MultipleLevels_ReturnsAllSucursalSchemasMultiples niveles retorna todos los schemas de la sucursal
testGetTargetSchemas_RespectsSucursalBoundaryNunca retorna schemas de otras sucursales (RA-002)
testIsMultiSchemaActive_SingleLevel_ReturnsFalseUn solo nivel (no-caja) retorna false
testIsMultiSchemaActive_MultipleLevels_ReturnsTrueMultiples niveles retorna true
testGetCurrentSchema_ReturnsSchemaFromGlobalsLee schema del JWT payload
testGetCurrentSchema_ThrowsExceptionWhenNotSetLanza RuntimeException sin schema
testGetTargetSchemas_UsesCustomConfigurationUsa configuracion personalizada cuando existe
testGetTargetSchemas_LevelEmpresa_ReturnsPublicNivel empresa incluye schema public
testGetTargetSchemas_RemovesDuplicatesElimina schemas duplicados

Ejecucion de Tests

bash
# Tests del trait
vendor/bin/phpunit Tests/Unit/Traits/CrossSchemaQueryableTest.php --testdox

# Tests del MultiSchemaService
vendor/bin/phpunit Tests/Unit/Config/MultiSchemaServiceTest.php --testdox

# Todos los tests relacionados
vendor/bin/phpunit Tests/Unit/Traits/ Tests/Unit/Config/ --testdox

Rendimiento

Optimizaciones Implementadas

  1. Priorizacion de schema actual: prioritizeCurrentSchema() mueve el schema actual al inicio del array. Esto significa que para la estrategia early-exit, en el caso mas comun (registro en schema actual) solo se ejecuta una consulta.

  2. Flag $multiSchema: Permite desactivar la busqueda multi-schema cuando no es necesaria, evitando completamente la resolucion de schemas.

  3. Resolucion condicional: resolveTargetSchemas() solo consulta MultiSchemaService cuando multiSchema=true Y la tabla esta configurada para multi-schema.

Complejidad por Operacion

EscenarioConsultas a BDComplejidad
multiSchema=false1O(1)
Early exit, registro en schema actual1O(1)
Early exit, registro en otro schema2-NO(N) peor caso
Batch, N schemasNO(N) siempre

Donde N = cantidad de schemas configurados (tipicamente 2-5 para una sucursal).

Overhead del Patron

  • Resolucion de schemas: 1 consulta a tabla sistema + 1-2 consultas a information_schema
  • Cambio de search_path: 1 SET search_path por schema
  • Overhead total: Minimo comparado con las consultas de negocio

Extension a Nuevos Modulos

Checklist para Agregar un Nuevo Servicio

Para agregar soporte cross-schema a un servicio existente:

1. Modificar el servicio:

php
// ANTES
class MiServicio
{
    use Conectable;

    public function __construct(ConnectionManager $manager)
    {
        $this->setConnectionManager($manager);
    }

    public function getAll(): array
    {
        return $this->model->getAll();
    }
}

// DESPUES
class MiServicio implements CrossSchemaQueryableInterface
{
    use Conectable;
    use CrossSchemaQueryable;

    private const LEVEL_SUCURSAL = 2;
    private const LEVEL_CAJA = 3;

    public function __construct(ConnectionManager $manager)
    {
        $this->setConnectionManager($manager);
        $this->multiSchemaService = new MultiSchemaService($manager->get('principal'));
    }

    public function getAll(bool $multiSchema = true): array
    {
        return $this->queryAcrossSchemas(
            queryCallback: fn(string $schema) => $this->model->getAll(),
            tableName: 'mi_tabla',
            defaultLevels: [self::LEVEL_SUCURSAL, self::LEVEL_CAJA],
            multiSchema: $multiSchema
        );
    }
}

2. Agregar _schema al DTO:

php
class MiDTO extends FullDTO
{
    public function __construct(
        // ... campos existentes ...
        public ?string $_schema = null
    ) {}
}

3. Agregar tests:

  • Mockear MultiSchemaService en los tests del servicio
  • Verificar que multiSchema=true consulta multiples schemas
  • Verificar que multiSchema=false consulta solo schema actual

3b. Si implementas un metodo de iteracion custom (no usando queryAcrossSchemas/findFirstAcrossSchemas):

Seguir el patron de restauracion de search_path documentado en Policy: Restauracion del search_path.

4. No se requieren cambios en:

  • La interface CrossSchemaQueryableInterface (no modificar)
  • El trait CrossSchemaQueryable (no modificar)
  • El MultiSchemaService (no modificar)
  • La infraestructura base (RA-005)

Modulos Candidatos

ModuloTablasNiveles TipicosEstado
Tesoreriamovimi, caja[2, 3]✅ Implementado
CtaCterecfac, rectra, recche[1, 2, 3]✅ Implementado
Tesoreriaiteban[1, 2]Pendiente
VentasTablas transaccionalesPor definirPendiente
ComprasTablas transaccionalesPor definirPendiente
StockTablas transaccionalesPor definirPendiente

Referencia de API

CrossSchemaQueryableInterface

MetodoParametrosRetornoDescripcion
queryAcrossSchemascallable, string, array, boolarrayBatch: acumula resultados de todos los schemas
findFirstAcrossSchemascallable, string, array, boolmixed|nullEarly exit: retorna primer match

CrossSchemaQueryable (Trait) - Metodos Protegidos

MetodoParametrosRetornoDescripcion
resolveTargetSchemasstring, array, string, boolarrayResuelve lista de schemas objetivo
prioritizeCurrentSchemaarray, stringarrayMueve schema actual al inicio
attachSchemaMetadataobject, stringvoidAdjunta _schema al resultado

MultiSchemaService

MetodoParametrosRetornoDescripcion
getCurrentSchema-stringLee schema actual del JWT
isMultiSchemaActivestring, arrayboolVerifica si multi-schema esta activo
getTargetSchemasstring, array, stringarrayResuelve schemas objetivo

SchemaService

MetodoParametrosRetornoDescripcion
getSchemasWithTablestringarraySchemas que contienen una tabla
getSucursales-arraySchemas de tipo sucursal
getCajasstringarraySchemas de caja para una sucursal

ConnectionUtils (Trait) - Metodos Estaticos

MetodoParametrosRetornoDescripcion
extractSucursalSchemastringstringExtrae sucursal de schema de caja
buildSearchPathstringstringConstruye search_path jerarquico
setSchemaContextPDO, stringvoidEjecuta SET search_path
formatSchemaNamestringstringFormato legible para usuario

Reglas Arquitecturales Implementadas

Este patron implementa directamente las siguientes reglas del documento arquitectural:

  • RA-001: Activacion basada en configuracion de niveles (via resolveTargetSchemas + MultiSchemaService.isMultiSchemaActive)
  • RA-002: Alcance limitado por sucursal (via MultiSchemaService.getTargetSchemas que filtra por sucursal)
  • RA-003: Consistencia de schema en recursos relacionados (via propiedad _schema en DTOs)
  • RA-005: Extensibilidad por modulo (nuevos servicios solo implementan interface + trait)

Historial de Cambios

FechaVersionDescripcion
2026-03-191.2Agregada seccion "Policy: Restauracion del search_path". Actualizadas implementaciones de queryAcrossSchemas y findFirstAcrossSchemas para reflejar patron try/finally + resetSchemaContext(). Bug de produccion 42P01 documentado como caso real.
2027-01-271.1Agregado ReciboRelationsService como ejemplo de implementación exitosa. Actualizada tabla de módulos candidatos.
2027-01-271.0Creacion del documento tecnico. Documenta interface, trait, servicios de soporte, estrategias de consulta, testing y patron de extension.