38.13. Tipos de dados definidos pelo usuário

38.13.1. Considerações sobre TOAST

Conforme descrito em O sistema de tipos de dados do PostgreSQL, o PostgreSQL pode ser estendido para dar suporte a novos tipos de dados. Essa seção descreve como definir novos tipos de dados base, que são tipos de dados definidos abaixo do nível da linguagem SQL. A criação de um novo tipo de dados base requer a implementação de funções para operar no tipo de dados em uma linguagem de baixo nível, geralmente C.

Os exemplos mostrados nessa seção podem ser encontrados nos arquivos complex.sql e complex.c no diretório src/tutorial da distribuição do código-fonte. Veja o arquivo README nesse diretório para obter instruções sobre como executar os exemplos.

Um tipo de dados definido pelo usuário deve ter sempre funções de entrada e de saída. Essas funções determinam como o tipo de dados aparece em cadeias de caracteres (para entrada do usuário, e saída para o usuário), e como o tipo de dados é organizado na memória. A função de entrada usa uma cadeia de caracteres terminada em nulo como seu argumento, e retorna a representação interna (em memória) do tipo de dados. A função de saída usa a representação interna do tipo de dados como argumento, e retorna uma cadeia de caracteres terminada em nulo. Se for desejado fazer algo a mais com o tipo de dados do que apenas armazená-lo, deve-se fornecer funções adicionais para implementar quaisquer outras operações que se deseje ter para esse tipo de dados.

Suponha que se deseje definir o tipo de dados complex, representando números complexos. Uma maneira natural de representar um número complexo na memória seria a seguinte estrutura na linguagem C:

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

É necessário tornar essa estrutura um tipo de dados passado por referência, já que é muito grande para caber em um único valor Datum.

A representação externa escolhida do tipo de dados terá a forma da cadeia de caracteres (x,y).

Geralmente, as funções de entrada e saída não são difíceis de serem escritas, especialmente a função de saída. Mas ao definir a representação externa do tipo de dados por uma cadeia de caracteres, lembre-se de que pode ser necessário escrever um analisador completo e robusto como função de entrada para essa representação. Por exemplo:

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("sintaxe de entrada inválida para o tipo de dados %s: \"%s\"",
                        "complex", str)));

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

A função de saída pode ser só:

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);
}

Deve-se tomar o cuidado de tornar as funções de entrada e saída inversas uma da outra. Se não for assim, haverá sérios problemas quando for necessário fazer uma cópia de segurança dos dados em arquivo e depois lê-los novamente. Esse é um problema muito comum quando estão envolvidos números de ponto flutuante.

Opcionalmente, o tipo de dados definido pelo usuário pode fornecer rotinas de entrada e saída binárias. A E/S binária é geralmente mais rápida, mas menos portável, do que a E/S textual. Assim como na E/S textual, cabe a quem define o tipo de dados especificar exatamente qual é a representação binária externa. A maioria dos tipos de dados nativos tenta fornecer uma representação binária independente de máquina. Para o tipo de dados complex, são usados os conversores de E/S binários para o tipo de dados 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));
}

Após escrever as funções de E/S e as compilar em uma biblioteca compartilhada, pode ser definido o tipo de dados complex no SQL. Primeiro, se declara

CREATE TYPE complex;

que serve de abrigo (shell) para permitir fazer referência ao tipo de dados ao definir suas funções de E/S e, em seguida, pode-se então definir suas funções de E/S:

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

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

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

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

Por fim, é possível fornecer a definição completa do tipo de dados:

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

Quando é definido um novo tipo de dados base, o PostgreSQL oferece suporte automaticamente para as matrizes desse tipo de dados. O tipo de dados da matriz tem geralmente o mesmo nome do tipo de dados base com o caractere de sublinhado (_) anexado.

Depois que o tipo de dados existe, é possível declarar funções adicionais para fornecer operações úteis no tipo de dados. Podem então ser definidos operadores sobre as funções e, se necessário, podem ser criadas classes de operador para oferecer suporte à indexação do tipo de dados. Essas camadas adicionais são discutidas nas próximas seções.

Se a representação interna do tipo de dados for de comprimento variável, a representação interna deve seguir o modelo padrão para dados de comprimento variável: os primeiros quatro bytes devem ser um campo char[4], que nunca é acessado diretamente (normalmente chamado de vl_len_). Deve ser usada a macro SET_VARSIZE() para armazenar o tamanho total do datum (incluindo o próprio campo de comprimento) nesse campo, e VARSIZE() para recuperá-lo. (Essas macros existem porque o campo de comprimento pode estar codificado dependendo da plataforma.)

Para mais detalhes veja a descrição do comando CREATE TYPE.

38.13.1. Considerações sobre TOAST

Se os valores do tipo de dados variarem em tamanho (na forma interna), é geralmente desejável tornar o tipo de dados possível de ser armazenado usando TOAST (a técnica de armazenamento de atributos superdimensionados) (veja a TOAST). Isso deve ser feito mesmo se os valores forem sempre muito pequenos para serem comprimidos ou armazenados externamente, porque o TOAST também pode economizar espaço em dados pequenos, reduzindo a sobrecarga do cabeçalho.

Para dar suporte ao armazenamento TOAST, as funções C que operam no tipo de dados devem ter sempre o cuidado de expandir quaisquer valores TOAST entregues usando a função PG_DETOAST_DATUM. (Esse detalhe é normalmente escondido pela definição das macros GETARG_DATATYPE_P específicas do tipo de dados.) Em seguida, ao executar o comando CREATE TYPE, deve ser especificado o comprimento interno como variable, e selecionada alguma opção de armazenamento apropriada diferente de plain.

Se o alinhamento de dados não for importante (só para uma função específica, ou porque o tipo de dados especifica o alinhamento de bytes de qualquer maneira), então é possível evitar parte da sobrecarga de PG_DETOAST_DATUM. Em vez disso pode ser usado PG_DETOAST_DATUM_PACKED (normalmente escondido pela definição da macro GETARG_DATATYPE_PP), e usada as macros VARSIZE_ANY_EXHDR e VARDATA_ANY para acessar um datum potencialmente comprimido. Novamente, os dados retornados por essas macros não são alinhados, mesmo que a definição do tipo de dados especifique um alinhamento. Se o alinhamento for importante, deve ser usada a interface normal PG_DETOAST_DATUM.

Nota

Os códigos mais antigos frequentemente declaram vl_len_ como um campo int32, em vez de char[4]. Tudo bem desde que a definição da struct tenha outros campos que tenham pelo menos o alinhamento int32. Mas é perigoso usar essa definição de struct ao trabalhar com dados potencialmente não alinhados; o compilador pode tomar a licença de assumir que os dados estão alinhados, levando a descargas (dumps) de memória em arquiteturas que são rígidas quanto ao alinhamento.

Outro recurso, habilitado pelo suporte TOAST, é a capacidade de ter uma representação de dados na memória expandida, que é mais amigável para trabalhar do que o formato armazenado em disco. O formato de armazenamento varlena padrão ou simples (flat) é, em última análise, apenas um conjunto de bytes; por exemplo, ele não pode conter ponteiros, porque pode ser copiado para outros locais na memória. Para tipos de dados complexos, o formato simples pode ser muito custoso para trabalhar, por isso o PostgreSQL fornece uma maneira de expandir o formato simples em uma representação mais adequada para ser usada e, em seguida, passar esse formato na memória entre funções do tipo de dados.

Para usar o armazenamento expandido, o tipo de dados deve definir um formato expandido que siga as regras dadas no arquivo src/include/utils/expandeddatum.h, e fornecer funções para expandir o valor varlena simples (flatten) no formato expandido, e simplificar o formato expandido de volta à representação de varlena padrão. Em seguida, certifique-se de que todas as funções C para o tipo de dados possam aceitar qualquer uma das representações, se possível convertendo uma na outra imediatamente após o recebimento. Isso não requer a correção de todas as funções existentes para o tipo de dados de uma vez, porque a macro PG_DETOAST_DATUM padrão é definida de forma a converter entradas expandidas no formato simples padrão. Por causa disso, as funções existentes que funcionam com o formato simples de varlena continuarão a funcionar, embora com menos eficiência, com entradas expandidas; elas não precisam ser convertidas até, e a menos, que seja importante um melhor desempenho.

As funções C que sabem como trabalhar com a representação expandida, geralmente se enquadram em duas categorias: aquelas que só sabem lidar com o formato expandido, e aquelas que sabem lidar com entradas varlena expandidas ou simples. As primeiras são mais fáceis de escrever, mas podem ser menos eficientes no geral, porque a conversão de uma entrada simples para o formato expandido para uso por uma única função pode custar mais do que o ganho de operar no formato expandido. Quando apenas o formato expandido precisa ser manipulado, a conversão de entradas simples para a forma expandida pode ser ocultada dentro de uma macro de busca de argumentos, de modo que a função não pareça mais complexa do que outra que trabalha com a entrada varlena padrão. Para lidar com os dois tipos de entrada, deve ser escrita uma função de busca de argumento que expanda as entradas varlena externas, de cabeçalho curto e comprimidas, mas não as entradas expandidas. Essa função pode ser definida como o retorno de um ponteiro para uma união do formato varlena simples e o formato expandido. Elas podem usar a macro VARATT_IS_EXPANDED_HEADER() para determinar o formato recebido.

A infra-estrutura TOAST não apenas permite que valores varlena padrão sejam diferenciados de valores expandidos, mas também distingue ponteiros de leitura e escrita e leitura-apenas para valores expandidos. As funções C que precisam apenas examinar um valor expandido, ou apenas o alterar de maneiras segura e não semanticamente visível, não precisam se importar com o tipo de ponteiro que recebem. As funções C que produzem uma versão modificada do valor de entrada, podem modificar o valor de entrada expandido no local se receberem um ponteiro de leitura/escrita, mas não devem modificar a entrada se receberem um ponteiro de leitura-apenas; nesse caso, elas precisam copiar o valor primeiro, produzindo um novo valor para modificar. Uma função C que construiu um novo valor expandido, deve retornar sempre um ponteiro de leitura/escrita para ele. Além disso, uma função C que modifica um valor expandido de leitura/escrita no local, deve ter o cuidado de deixar o valor em um estado saudável se ela falhar no meio do caminho.

Para obter exemplos de como trabalhar com valores expandidos, consulte a infraestrutura de matriz padrão, em particular o arquivo src/backend/utils/adt/array_expanded.c.