Las desventuras de lidiar con DBLinks, LONG y cadenas muy muy largas entre Oracle y SQL Server

31 de Mayo de 2023

Para quien no lo sepa, trabajar como desarrollador es aventurarse entre selvas de funciones y procedimientos no documentados, funcionalidades obsoletas (o lo que en nuestra jerga llamamos deprecadas), y un sinfín de infortunios que hacen que nuestro trabajo no sea aburrido (por no decir que lo hacen tortuoso y doloroso). Lo bueno es que cuando encontramos alternativas bastante rebuscadas pero funcionales, lo festejamos como gol de mediacancha :)

Contexto Inicial

Empresa XXXXXX cuenta con dos bases de datos que necesitan comunicarse entre sí: una SQL Server (llamémosle origen) y la otra Oracle (destino). Al “sincronizar” los datos, estos se establecen como “información definitiva” en destino. Para establecer la comunicación, se utiliza DG4ODBC (Database Gateway for ODBC). Aquí se encuentra una guía oficial para la configuración de la pasarela (o puerta de enlace) de Oracle para SQL Server (Windows).

  • De SQL Server se necesita obtener un campo donde se almacena una cadena, cuyo valor ocasionalmente puede exceder los 32767 caracteres.
  • En Oracle, un VARCHAR2 cuenta con una longitud máxima de 4000 bytes o caracteres si MAX_STRING_SIZE = STANDARD, o de 32767 bytes o caracteres si MAX_STRING_SIZE = EXTENDED (esto configurable desde la versión 12c). Si se necesita almacenar cadenas de mayor longitud, se puede utilizar CLOB o BLOB.
  • Como en SQL Server ni existe CLOB ni BLOB, se puede utilizar el equivalente más cercano que es VARCHAR(MAX), cuyo valor máximo es de 2^31-1 bytes (2 GB).

Por criterios arbitrarios, se decidió almacenar en Oracle las cadenas en formato BLOB (Binary Large Object) en lugar de CLOB (Character Large Object), este último como más recomendable.

El Problema

LONG LONG LONG

Oracle promete desde, al menos, la versión 8i, descontinuar LONG en una versión posterior de Oracle. Aún así, hasta ahora (2023) tenemos que lidiar con este tipo de dato (minado de restricciones, por cierto).

Leyendo el manual, en la tabla de mapeo de tipos de datos y restricciones, se observa que SQL_LONGVARCHAR de ODBC se traduce en Oracle a LONG, siempre y cuando el campo cuente con una longitud de entre 4000 y 32740 caracteres, o que cuya longitud sea de hasta 32767 caracteres si Oracle utiliza el VARCHAR extendido (32k). La observación menciona: Si una implementación ANSI SQL define un valor grande para la longitud máxima de los datos VARCHAR, es posible que ANSI VARCHAR se mapee a SQL_LONGVARCHAR y a LONG de Oracle.

Desde SQL Server el tipo de dato del campo era VARCHAR(MAX). Hasta cierto punto se podía manipular tal campo en Oracle, pero si la cadena original excedía los 32767 caracteres, no era posible leer la información. Y empezó la investigación.

Pruebas e intentos hechos

Para los ejemplos, utilizo “VI_VistaRemota” para el nombre de la vista en SQL Server, y “DBLINKSQLSERVER” para el nombre del DBLink. Los campos efectivamente son escritos entrecomillados para realizar la consulta.

El caso de uso es con una cadena de 41635 caracteres. Esta consulta de prueba podía realizarse sin problemas:

SELECT "CampoRemoto"
FROM VI_VistaRemota@DBLINKSQLSERVER
WHERE "IdCampo" = <id_del_campo>

Y esta también, salvo que se descomente la línea en donde se imprime el resultado, ya que devuelve el error ORA-06502 (PL/SQL: numeric or value error: character string buffer too small, o en su mala traducción, error : buffer de cadenas de caracteres demasiado pequeño numérico o de valor):

DECLARE
    resultado LONG;
BEGIN
    SELECT "CampoRemoto"
    INTO   resultado
    FROM   VI_VistaRemota@DBLINKSQLSERVER
    WHERE  "IdCampo" = <id_del_campo>;
    
    -- DBMS_OUTPUT.PUT_LINE(resultado);
EXCEPTION
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE(SQLERRM); -- Como es una prueba, elijo imprimir el error en lugar de hacer RAISE_APPLICATION_ERROR
END;

Como el caso real en Empresa XXXXXX es con SQL dinámico, ya que se cuenta con más de un DBLink, haciendo lo siguiente como prueba:

DECLARE
    TYPE R_REMOTO IS RECORD(
         campo_remoto LONG);
    TYPE T_REMOTO IS TABLE OF R_REMOTO INDEX BY PLS_INTEGER;
    l_remoto   T_REMOTO;
    v_dblink   VARCHAR2(100) := 'DBLINKSQLSERVER';
    v_id_campo NUMBER := < id_campo >;
BEGIN
    EXECUTE IMMEDIATE q'[SELECT  "CampoRemoto" campo_remoto FROM VI_VistaRemota@]' || v_dblink || q'[ WHERE "IdCampo" =]' || v_id_campo BULK COLLECT
        INTO l_remoto;
    FOR ind IN 1 .. l_remoto.COUNT
    LOOP
        DBMS_OUTPUT.PUT_LINE(ind); -- Imprime el índice actual, solo para no escribir "NULL;"
    END LOOP;
    DBMS_OUTPUT.PUT_LINE(LENGTH(l_remoto(1).campo_remoto)); -- Imprime la longitud del campo
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        DBMS_OUTPUT.PUT_LINE(SQLERRM); -- Como es una prueba, elijo imprimir el error en lugar de hacer RAISE_APPLICATION_ERROR
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE(SQLERRM); -- Como es una prueba, elijo imprimir el error en lugar de hacer RAISE_APPLICATION_ERROR
END;

Al imprimir la longitud del campo, se muestra 32760, es decir: la cadena ha sido truncada.

Otras pruebas:

  • Crear una función LONG_TO_CLOB: truncaba la cadena a 32760 caracteres.
  • Utilizar DBMS_XMLGEN o crear un package LONG2CLOB: mismo resultado que el ítem anterior.
  • Estrategia de INSERT... SELECT: Al intentar insertar una cadena LONG en un campo de tipo CLOB (o BLOB) aparecía el error ORA-00997 (uso no válido del tipo de dato LONG, o en inglés, illegal use of LONG datatype).
  • [Convertir la cadena con TO_LOB()]: mismo número de error que el ítem anterior.
  • [Convertir la cadena con TO_CLOB() o TO_BLOB()]: aparece el error ORA-00932 (tipos de dato inconsistentes: se esperaba CHAR se ha obtenido LONG, o en inglés, inconsistent datatypes: expected CHAR got LONG).

La solución (en este caso)

Si creyeron que se le pudo dar solución desde el lado de Oracle… Se equivocaron :( Yo sí tenía acceso a la base de datos de SQL Server (y lo resalto porque otros usuarios en la red no contaron con la misma suerte en su momento).

Inicialmente busqué cómo trocear cadenas para devolverlas como filas, en orden, en SQL Server. La mayoría de los códigos que encontré fueron utilizando delimitadores, pero este no era el caso, ya que lo que necesitaba era retornar cadenas con una longitud fija (salvo que la última cadena cuente con menos caracteres de lo esperado, claro está). De todos modos, inicialmente le eché ojo a este post que muestra cómo convertir cadenas delimitadas en filas en SQL Server y lo había descartado porque había conceptos que no comprendía en ese momento… Hasta que un término en particular me llamó la atención: Tally Table. Busqué información relacionada, y encontré un post (en español 😭 ✨) que lo explica: Creación de tablas numéricas auxiliares (tally tables). Leyéndolo, encontré a un conocido mío: la cláusula WITH (CTE: Common Table Expression, Expresión de Tabla Común) pero con un concepto distinto: CTE recursivo. Desde allí ya no me fue difícil realizar una versión propia de uno de los ejemplos del primer enlace que cité aquí, ya que ahora sí comprendía qué hacía.

Cabe aclarar que quise crear un procediento almacenado donde pasara como argumento la cantidad de caracteres que deseaba que contenga cada cadena retornada, pero como no lograba que me devuelva correctamente el resultado (¡devolvía solo un caracter!) al llamar al SP con DBMS_HS_PASSTHROUGH, entonces decidí crear una vista (!) que retorne todas las cadenas fragmentadas de una tabla en particular (en este caso, es otra vista). Aquí, se decidió arbitrariamente que la longitud de cada cadena sería de hasta 32700 caracteres.

En los comentarios del código figura la explicación del mismo. La vista se denomina VI_FragmentosCadena.

CREATE OR ALTER VIEW VI_FragmentosCadena
AS
/*
### Funcionamiento ###
* Se retorna cada fragmento de la cadena con un CTE recursivo.
** La primera parte del CTE consiste en obtener la cadena original (en este caso, ColumnaCadena de la vista VI_Vista).
** La segunda parte del CTE consiste en agregar una condición de terminación. En este caso, la función recursiva finaliza cuando la cadena (en este caso, CadenaAFragmentar) ya no contiene caracteres restantes.
** En cada iteración, CadenaAFragmentar va acortándose.
** Los argumentos del CTE son el id de la fila, el orden (sucesión de números desde 1 hasta n, donde n depende de la cantidad de veces en que se fragmentó la cadena), el "remanente" de la cadena troceada, y la cadena original.

### Funciones utilizadas en CTE ###
* LEFT(cadena, <cantidad_caracteres>): Extrae <cantidad_caracteres> cantidad de caracteres de <cadena>, iniciando desde la izquierda.
* STUFF(<cadena>, <inicio>, <cantidad_caracteres>, <nueva_cadena>): Toma <cantidad_caracteres> cantidad de caracteres de <cadena>, iniciando desde la posición <inicio>, y reemplaza su valor por <nueva_cadena>.

### Referencias
- https://www.saurabhmisra.dev/sql-server-convert-delimited-string-into-rows/
- https://geeks.ms/lmblanco/2014/09/01/creacin-de-tablas-numricas-auxiliares-tally-tables-1/
- https://stackoverflow.com/a/5493616

### Información relacionada
- CTE (Common Table Expression)
- Tally Tables
*/

WITH CTE_Split(FilaId, Orden, ValoresSplit, CadenaAFragmentar) AS
(
	-- miembro ancla
    SELECT
        FilaOriginalId,
		1 AS Orden, -- 1 es el valor inicial, y en cada iteración se incrementará en 1
        LEFT(CAST(ColumnaCadena AS varchar(MAX)), 32700),
        STUFF(CAST(ColumnaCadena AS varchar(MAX)), 1, 32700, '')
    FROM VI_Vista
    UNION ALL
	-- miembro recursivo
    SELECT
		FilaId,
        Orden + 1,
        LEFT(CadenaAFragmentar, 32700),
        STUFF(CadenaAFragmentar, 1, 32700, '')
    FROM CTE_Split
    -- condición de terminación
    WHERE CadenaAFragmentar > ''
)
-- invocar al CTE y generar el conjunto de resultados finales
SELECT FilaId, Orden, ValoresSplit
FROM CTE_Split;

GO

Ya en Oracle, se creó una función que recibe como argumentos el id del registro en cuestión y el DBLink, y retorna un BLOB como resultado (incluso puede acortarse el código para retornar un CLOB en su lugar), el cual es la cadena reensamblada. Aquí se llama a la vista creada en SQL Server: a través del WHERE se filtra el id deseado, y a través del ORDER BY se obtienen los fragmentos de cadena ordenados. En esta función, se hace uso de varios subprogramas del paquete DMBS_LOB.

Es fundamental conservar el orden de origen, es decir (en este caso): si las columnas devueltas por la vista son FilaId (1), Orden (2) y ValoresSplit (3), el SELECT debe hacerse en tal orden (omitiéndose o no una que otra columna, como FilaId en el ejemplo). Anteriormente en la función el orden de las columnas era ValoresSplit (3) y luego Orden (2), provocando que la consulta demore entre 8 y 10 segundos en ejecutarse (que para cientos de registros, es 🐢)

CREATE OR REPLACE FUNCTION CADENA_REENSAMBLADA(V_ID_FILA NUMBER
                                               ,V_DBLINK   VARCHAR2) RETURN BLOB IS
    VRESULTADO   BLOB;
    VCADENA_BLOB BLOB;
    VCADENA_CLOB CLOB;
	--------- Para comprender qué son estos parámetros, ver https://docs.oracle.com/cd/B19306_01/appdev.102/b14258/d_lob.htm#i1020356
    O1           INTEGER; -- offset del LOB destino
    O2           INTEGER; -- offset del LOB origen
    C            INTEGER; -- contexto de lenguaje
    W            INTEGER; -- mensaje de advertencia
	---------
    TYPE CADENA_EXTENSA IS RECORD(
         --No cambiar el orden de las columnas al capturarlas en el bulk collect, de lo contrario da error
         --También mantener el orden de las columnas según el origen, caso contrario la consulta es muy lenta
         ORDEN      NUMBER
        ,CADENA_FRAGMENTADA LONG);
    TYPE T_CADENA_EXTENSA IS TABLE OF CADENA_EXTENSA INDEX BY PLS_INTEGER;
    L_CADENA_EXTENSA T_CADENA_EXTENSA; -- La variable L_CADENA_EXTENSA es de tipo T_CADENA_EXTENSA
BEGIN
    BEGIN
        -- Sí o sí se debe agregar en el select la columna con la cual se hace el order by, en caso contrario aparece el error "la base de datos xxxxxxx no soporta order by en este contexto" (database does not support ORDER BY in this context)
        EXECUTE IMMEDIATE q'[SELECT "Orden" ORDEN
                                  , "ValoresSplit" CADENA_FRAGMENTADA
                               FROM VI_FragmentosCadena@]' || V_DBLINK || q'[
                              WHERE "FilaId" =]' || V_ID_FILA || q'[ ORDER BY Orden]' BULK COLLECT
            INTO L_CADENA_EXTENSA;
        -- Se recorren los resultados
        FOR IND IN 1 .. L_CADENA_EXTENSA.COUNT
        LOOP
            -- Se opera sobre el resultado devuelto solo si no es nulo
            IF (L_CADENA_EXTENSA(IND).CADENA_FRAGMENTADA IS NOT NULL) THEN
                IF IND = 1 THEN -- Se crea el temporary en la primera iteración
                    DBMS_LOB.CREATETEMPORARY(VCADENA_CLOB, TRUE); -- TRUE indica que el LOB debe leerse en la caché del búfer
                END IF;
                -- Se concatena a VCADENA_CLOB el contenido de CADENA_FRAGMENTADA
                DBMS_LOB.APPEND(VCADENA_CLOB, L_CADENA_EXTENSA(IND).CADENA_FRAGMENTADA);
            END IF;
        END LOOP;

		-------- Esta sección no es necesaria si se desea retornar un CLOB
        --  Se convierte a BLOB solo si VCADENA_CLOB no es nulo
        IF VCADENA_CLOB IS NOT NULL THEN
            O1 := 1;
            O2 := 1;
            C  := 0;
            W  := 0;
            BEGIN
               -- Se crea el temporary y luego se convierte a BLOB
                DBMS_LOB.CREATETEMPORARY(VCADENA_BLOB, TRUE); -- TRUE indica que el LOB debe leerse en la caché del búfer
                DBMS_LOB.CONVERTTOBLOB(VCADENA_BLOB, VCADENA_CLOB, LENGTH(VCADENA_CLOB), O1, O2, 0, C, W);
            EXCEPTION
                WHEN OTHERS THEN
                    RAISE_APPLICATION_ERROR(-25000, 'Error al intentar hacer la conversión (DBMS_LOB): ' || SQLERRM);
            END;
        END IF;
		--------
		
        -- Se asigna la cadena convertida a BLOB al resultado a retornar
        VRESULTADO := VCADENA_BLOB;
		
		-- Si se desea retornar el CLOB:
		-- VRESULTADO := VCADENA_CLOB;

        BEGIN
          -- Se liberan ambos temporary
          IF VCADENA_CLOB IS NOT NULL THEN
             DBMS_LOB.FREETEMPORARY(VCADENA_CLOB);
          END IF;
          IF VCADENA_BLOB IS NOT NULL THEN
             DBMS_LOB.FREETEMPORARY(VCADENA_BLOB);
          END IF;
        EXCEPTION
            WHEN OTHERS THEN
                RAISE_APPLICATION_ERROR(-20080, 'Error al liberar TEMPORARY (DBMS_LOB): ' || SQLERRM);
        END;
    EXCEPTION
        WHEN NO_DATA_FOUND THEN
            DBMS_OUTPUT.PUT_LINE('Cursor sin datos: ' || L_CADENA_EXTENSA.COUNT);
        WHEN OTHERS THEN
            RAISE_APPLICATION_ERROR(-20090, 'Error en EXECUTE IMMEDIATE: ' || SQLERRM);
    END;

    RETURN(VRESULTADO);
END CADENA_REENSAMBLADA;

Invocar a la función sería algo así como VCADENA := CADENA_REENSAMBLADA(<el_id_como_number>, <el_dblink_como_string>);, ej.: VCADENA := CADENA_REENSAMBLADA(1, 'DBLINKSQLSERVER');

Aprendizajes

  • ¡Nunca subestimen a las documentaciones/tutoriales en español!
  • Algo que pareciera no estar relacionado puede llevar a uno a lo que más adelante podría acercarse a la solución (personal) final.

Agradecimientos

  • A mi jefe que indirecta y sutilmente agregó presión a la tarea cuando dijo algo similar a “de vos depende el funcionamiento de la mitad del sistema” (no fue tan así su comentario, pero por ahí iba) :(
  • A todos los usuarios de la red (toda internet) que tuvieron un problema similar en el pasado y plantearon soluciones. Si bien no funcionaron, daban pistas para encontrar otras soluciones que podrían haber funcionado en nuestro caso particular.
Hugo Theme: "Bulma Hugo Resume", basado en  Hugo Resume