36.13. Tipos definidos por el usuario #

36.13.1. Consideraciones sobre TOAST

Como se describe en la sección Section 36.2, PostgreSQL puede extenderse para admitir nuevos data types. Esta sección describe cómo definir nuevos tipos base, que son tipos de datos definidos por debajo del nivel del lenguaje SQL. La creación de un nuevo tipo base requiere la implementación de funciones para operar sobre el tipo en un lenguaje de bajo nivel, generalmente C.

Los ejemplos de esta sección se pueden encontrar en complex.sql y complex.c en el directorio src/tutorial de la distribución de las fuentes. Consulta el archivo README en ese directorio para obtener instrucciones sobre cómo ejecutar los ejemplos.

Un tipo definido por el usuario siempre debe tener funciones de entrada y salida. Estas funciones determinan cómo aparece el tipo en las cadenas de texto (para la entrada por parte del usuario y la salida hacia el usuario) y cómo se organiza el tipo en la memoria. La función de entrada toma una cadena de caracteres terminada en cero como argumento y devuelve la representación interna (en memoria) del tipo. La función de salida toma la representación interna del tipo como argumento y devuelve una cadena de caracteres terminada en cero. Si queremos hacer algo más con el tipo además de simplemente almacenarlo, debemos proporcionar funciones adicionales para implementar cualquier operación que queramos que tenga el tipo.

Supongamos que queremos definir un tipo complex que represente números complejos. Una forma natural de representar un número complejo en memoria sería la siguiente estructura en C:

typedef struct Complex {
    double      x;
    double      y;
} Complex;

Tendremos que hacer que este sea un tipo por referencia (pass-by-reference), ya que es demasiado grande para caber en un solo valor Datum.

Como representación de cadena externa del tipo, elegimos una cadena con el formato (x,y).

Las funciones de entrada y salida no suelen ser difíciles de escribir, especialmente la función de salida. Sin embargo, al definir la representación de cadena externa del tipo, recuerda que eventualmente tendrás que escribir un analizador (parser) completo y robusto para esa representación como tu función de entrada. Por ejemplo:

PG_FUNCTION_INFO_V1(complex_in);

Datum
complex_in(PG_FUNCTION_ARGS)
{
    char       *str = PG_GETARG_CSTRING(0);
    double      x,
                y;
    Complex    *result;

    if (sscanf(str, " ( %lf , %lf )", &x, &y) != 2)
        ereport(ERROR,
                (errcode(ERRCODE_INVALID_TEXT_REPRESENTATION),
                 errmsg("invalid input syntax for type %s: \"%s\"",
                        "complex", str)));

    result = (Complex *) palloc(sizeof(Complex));
    result->x = x;
    result->y = y;
    PG_RETURN_POINTER(result);
}

La función de salida puede ser simplemente:

PG_FUNCTION_INFO_V1(complex_out);

Datum
complex_out(PG_FUNCTION_ARGS)
{
    Complex    *complex = (Complex *) PG_GETARG_POINTER(0);
    char       *result;

    result = psprintf("(%g,%g)", complex->x, complex->y);
    PG_RETURN_CSTRING(result);
}

Debes tener cuidado de hacer que las funciones de entrada y salida sean inversas entre sí. Si no lo haces, tendrás graves problemas cuando necesites volcar tus datos en un archivo y luego volver a leerlos. Este es un problema particularmente común cuando se trata de números de coma flotante.

Opcionalmente, un tipo definido por el usuario puede proporcionar rutinas de entrada y salida binarias. La E/S binaria normalmente es más rápida pero menos portable que la E/S de texto. Al igual que con la E/S de texto, depende de ti definir exactamente cuál es la representación binaria externa. La mayoría de los tipos de datos integrados intentan proporcionar una representación binaria independiente de la máquina. Para complex, nos apoyaremos en los convertidores de E/S binaria para el tipo float8:

PG_FUNCTION_INFO_V1(complex_recv);

Datum
complex_recv(PG_FUNCTION_ARGS)
{
    StringInfo  buf = (StringInfo) PG_GETARG_POINTER(0);
    Complex    *result;

    result = (Complex *) palloc(sizeof(Complex));
    result->x = pq_getmsgfloat8(buf);
    result->y = pq_getmsgfloat8(buf);
    PG_RETURN_POINTER(result);
}

PG_FUNCTION_INFO_V1(complex_send);

Datum
complex_send(PG_FUNCTION_ARGS)
{
    Complex    *complex = (Complex *) PG_GETARG_POINTER(0);
    StringInfoData buf;

    pq_begintypsend(&buf);
    pq_sendfloat8(&buf, complex->x);
    pq_sendfloat8(&buf, complex->y);
    PG_RETURN_BYTEA_P(pq_endtypsend(&buf));
}

Una vez que hayamos escrito las funciones de E/S y las hayamos compilado en una biblioteca compartida, podemos definir el tipo complex en SQL. Primero lo declaramos como un tipo shell (shell type):

CREATE TYPE complex;

Esto sirve como un marcador de posición que nos permite hacer referencia al tipo mientras definimos sus funciones de E/S. Ahora podemos definir las funciones de E/S:

CREATE FUNCTION complex_in(cstring)
    RETURNS complex
    AS 'filename'
    LANGUAGE C IMMUTABLE STRICT;

CREATE FUNCTION complex_out(complex)
    RETURNS cstring
    AS 'filename'
    LANGUAGE C IMMUTABLE STRICT;

CREATE FUNCTION complex_recv(internal)
   RETURNS complex
   AS 'filename'
   LANGUAGE C IMMUTABLE STRICT;

CREATE FUNCTION complex_send(complex)
   RETURNS bytea
   AS 'filename'
   LANGUAGE C IMMUTABLE STRICT;

Finalmente, podemos proporcionar la definición completa del tipo de datos:

CREATE TYPE complex (
   internallength = 16,
   input = complex_in,
   output = complex_out,
   receive = complex_recv,
   send = complex_send,
   alignment = double
 );

Cuando defines un nuevo tipo base, PostgreSQL proporciona automáticamente soporte para arrays de ese tipo. El tipo de array normalmente tiene el mismo nombre que el tipo base precedido por el carácter de subrayado (_).

Una vez que el tipo de datos existe, podemos declarar funciones adicionales para proporcionar operaciones útiles sobre el tipo de datos. Luego se pueden definir operadores sobre las funciones y, si es necesario, se pueden crear clases de operadores para admitir la indexación del tipo de datos. Estas capas adicionales se analizan en las siguientes secciones.

Si la representación interna del tipo de datos es de longitud variable, la representación interna debe seguir el diseño estándar para datos de longitud variable: los primeros cuatro bytes deben ser un campo char[4] al que nunca se accede directamente (habitualmente denominado vl_len_). Debes utilizar la macro SET_VARSIZE() para almacenar el tamaño total del dato (incluyendo el propio campo de longitud) en este campo y VARSIZE() para recuperarlo. (Estas macros existen porque el campo de longitud puede codificarse según la plataforma).

Para más detalles, consulta la descripción del comando CREATE TYPE.

36.13.1. Consideraciones sobre TOAST #

Si los valores de tu tipo de datos varían en tamaño (en su forma interna), por lo general es deseable que el tipo de datos sea compatible con TOAST (consulta la sección Section 66.2). Deberías hacer esto incluso si los valores son siempre demasiado pequeños para ser comprimidos o almacenados externamente, porque TOAST también puede ahorrar espacio en datos pequeños, al reducir la sobrecarga de la cabecera.

Para admitir el almacenamiento TOAST, las funciones C que operan sobre el tipo de datos deben tener siempre cuidado de desempaquetar cualquier valor "toasted" (tostado) que reciban utilizando PG_DETOAST_DATUM. (Este detalle se oculta habitualmente definiendo macros específicas del tipo GETARG_DATATYPE_P). Luego, al ejecutar el comando CREATE TYPE, especifica la longitud interna como variable y selecciona alguna opción de almacenamiento adecuada que no sea plain.

Si la alineación de los datos no es importante (ya sea solo para una función específica o porque el tipo de datos especifica una alineación de bytes de todos modos), entonces es posible evitar parte de la sobrecarga de PG_DETOAST_DATUM. Puedes utilizar PG_DETOAST_DATUM_PACKED en su lugar (habitualmente oculto definiendo una macro GETARG_DATATYPE_PP) y usar las macros VARSIZE_ANY_EXHDR y VARDATA_ANY para acceder a un dato potencialmente empaquetado. De nuevo, los datos devueltos por estas macros no están alineados, incluso si la definición del tipo de datos especifica una alineación. Si la alineación es importante, debes utilizar la interfaz regular PG_DETOAST_DATUM.

Note

Los códigos más antiguos con frecuencia declaran vl_len_ como un campo int32 en lugar de char[4]. Esto está bien siempre que la definición de la estructura tenga otros campos que tengan al menos una alineación int32. Pero es peligroso utilizar tal definición de estructura cuando se trabaja con un dato potencialmente no alineado; el compilador puede tomarlo como licencia para asumir que el dato realmente está alineado, lo que provoca volcados de memoria (core dumps) en arquitecturas que son estrictas con respecto a la alineación.

Otra característica que se habilita mediante el soporte de TOAST es la posibilidad de tener una representación de datos en memoria expandida (expanded) que sea más conveniente para trabajar que el formato que se almacena en el disco. El formato de almacenamiento varlena regular o plano (flat) es, en última instancia, solo un bloque de bytes; no puede, por ejemplo, contener punteros, ya que puede copiarse a otras ubicaciones en memoria. Para tipos de datos complejos, el formato plano puede ser bastante costoso con el que trabajar, por lo que PostgreSQL proporciona una forma de expandir el formato plano en una representación que sea más adecuada para el cálculo, y luego pasar ese formato en memoria entre funciones del tipo de datos.

Para utilizar el almacenamiento expandido, un tipo de datos debe definir un formato expandido que siga las reglas indicadas en src/include/utils/expandeddatum.h, y proporcionar funciones para expandir un valor varlena plano a un formato expandido y aplanar (flatten) el formato expandido de vuelta a la representación varlena regular. Luego, asegúrate de que todas las funciones C para el tipo de datos puedan aceptar cualquiera de las dos representaciones, posiblemente convirtiendo una en la otra inmediatamente después de recibirla. Esto no requiere corregir todas las funciones existentes para el tipo de datos a la vez, porque la macro estándar PG_DETOAST_DATUM está definida para convertir entradas expandidas en formato plano regular. Por lo tanto, las funciones existentes que trabajan con el formato varlena plano seguirán funcionando, aunque de manera ligeramente ineficiente, con entradas expandidas; no es necesario convertirlas hasta y a menos que un mejor rendimiento sea importante.

Las funciones C que saben cómo trabajar con una representación expandida suelen dividirse en dos categorías: aquellas que solo pueden manejar el formato expandido y aquellas que pueden manejar entradas varlena tanto expandidas como planas. Las primeras son más fáciles de escribir pero pueden ser menos eficientes en general, porque convertir una entrada plana a su forma expandida para que la use una sola función puede costar más de lo que se ahorra al operar en el formato expandido. Cuando solo se necesita manejar el formato expandido, la conversión de entradas planas a la forma expandida se puede ocultar dentro de una macro de obtención de argumentos, de modo que la función no parezca más compleja que una que trabaja con la entrada varlena tradicional. Para manejar ambos tipos de entrada, escribe una función de obtención de argumentos que desempaquete (detoast) las entradas varlena externas, de cabecera corta y comprimidas, pero no las entradas expandidas. Dicha función se puede definir como que devuelve un puntero a una unión del formato varlena plano y el formato expandido. Quienes la llamen pueden usar la macro VARATT_IS_EXPANDED_HEADER() para determinar qué formato recibieron.

La infraestructura TOAST no solo permite distinguir los valores varlena regulares de los valores expandidos, sino que también distingue los punteros de lectura y escritura (read-write) y de solo lectura (read-only) a valores expandidos. Las funciones C que solo necesitan examinar un valor expandido, o que solo lo cambiarán de manera segura y no semánticamente visible, no necesitan preocuparse por qué tipo de puntero reciben. Las funciones C que producen una versión modificada de un valor de entrada tienen permitido modificar un valor de entrada expandido in-situ si reciben un puntero de lectura y escritura, pero no deben modificar la entrada si reciben un puntero de solo lectura; en ese caso, deben copiar el valor primero, produciendo un nuevo valor para modificar. Una función C que ha construido un nuevo valor expandido siempre debería devolver un puntero de lectura y escritura al mismo. Además, una función C que esté modificando un valor expandido de lectura y escritura in-situ debería tener cuidado de dejar el valor en un estado sano si falla a mitad de camino.

Para ver ejemplos de cómo trabajar con valores expandidos, consulta la infraestructura de array estándar, en particular src/backend/utils/adt/array_expanded.c.