如 Section 36.2 所述,PostgreSQL 可以被扩展以支持新的数据类型。本节介绍如何定义新的基础类型,即定义在 SQL 语言层级之下的数据类型。创建新的基础类型需要用低层级语言(通常是 C)实现对类型进行操作的函数。
本节中的示例可以在源码发行版 src/tutorial 目录下的
complex.sql 与 complex.c 中找到。运行这些示例的说明请参见该目录中的
README 文件。
用户定义类型必须始终具备输入与输出函数。这些函数决定类型在字符串中的表现形式(供用户输入与向用户输出),以及类型在内存中的组织方式。输入函数把一个以空字符结尾的字符串作为参数,返回该类型的内部(内存中)表示。输出函数把该类型的内部表示作为参数,返回一个以空字符结尾的字符串。如果我们想对这个类型做的不仅仅是存储它,就必须提供额外的函数来实现我们希望该类型支持的任何操作。
假设我们想定义一个表示复数的类型 complex。在内存中表示复数的一种自然方式是如下 C 结构:
typedef struct Complex {
double x;
double y;
} Complex;
我们需要把它做成按引用传递的类型,因为它太大,无法放入单个
Datum 值。
作为该类型的外部字符串表示,我们选择形如 (x,y) 的字符串。
输入与输出函数通常不难编写,输出函数尤其如此。但在定义类型的外部字符串表示时,请记住你最终必须为该表示编写一个完整且健壮的解析器作为输入函数。例如:
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);
}
输出函数可以简单写成:
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);
}
你应当小心地使输入与输出函数互为逆操作。否则,当你需要把数据转储到文件再读回时将出现严重问题。当涉及浮点数时,这是特别常见的问题。
用户定义类型可以选择性地提供二进制输入与输出例程。二进制 I/O 通常比文本 I/O 更快,但可移植性较差。与文本 I/O 一样,外部二进制表示的确切形式由你定义。大多数内建数据类型都尽量提供与机器无关的二进制表示。对于
complex,我们将借用 float8 类型的二进制 I/O 转换器:
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));
}
写完 I/O 函数并把它们编译为共享库之后,我们就可以在 SQL 中定义
complex 类型了。首先把它声明为壳类型:
CREATE TYPE complex;
它充当一个占位符,让我们能在定义其 I/O 函数时引用该类型。现在我们可以定义这些 I/O 函数:
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;
最后,我们可以提供该数据类型的完整定义:
CREATE TYPE complex ( internallength = 16, input = complex_in, output = complex_out, receive = complex_recv, send = complex_send, alignment = double );
当你定义新的基础类型时,PostgreSQL 会自动提供对这种类型数组的支持。数组类型的名称通常与基础类型相同,只是前面加了下划线字符(_)。
一旦该数据类型存在,我们就可以声明额外的函数,为该数据类型提供有用的操作。随后可以在这些函数之上定义操作符,并且在需要时创建操作符类来支持为该数据类型建立索引。这些额外的层次将在后续章节讨论。
如果数据类型的内部表示是变长的,内部表示必须遵循变长数据的标准布局:前四个字节必须是一个从不被直接访问的
char[4] 字段(习惯上命名为
vl_len_)。你必须使用
SET_VARSIZE() 宏把 datum 的总尺寸(包括长度字段自身)存入该字段,并使用
VARSIZE() 检索它。(存在这些宏是因为长度字段可能依平台而采用不同的编码。)
更多细节请参见 CREATE TYPE 命令的描述。
如果你的数据类型的值(内部形式)大小可变,通常应当把该数据类型做成支持 TOAST 的(参见 Section 66.2)。即使这些值总是小到不会被压缩或外部存储,你也应当这样做,因为 TOAST 也能通过减少头部开销来节省小数据的空间。
为支持 TOAST 存储,操作该数据类型的 C 函数必须始终小心地用
PG_DETOAST_DATUM 解包交给它们的任何被外置的值。(这一细节习惯上通过定义类型专用的
GETARG_DATATYPE_P 宏来隐藏。)然后,运行
CREATE TYPE 命令时,把内部长度指定为
variable,并选择 plain 之外的某个适当存储选项。
如果数据对齐无关紧要(无论是只针对某个特定函数,还是因为该数据类型本来就指定了字节对齐),那么可以避免
PG_DETOAST_DATUM 的部分开销。你可以改用
PG_DETOAST_DATUM_PACKED(习惯上通过定义
GETARG_DATATYPE_PP 宏隐藏),并使用宏
VARSIZE_ANY_EXHDR 与
VARDATA_ANY 访问可能已打包的 datum。同样,这些宏返回的数据即使在该数据类型定义指定了对齐时也是未对齐的。如果对齐很重要,你必须走常规的
PG_DETOAST_DATUM 接口。
较老的代码经常把 vl_len_ 声明为
int32 字段而非 char[4]。只要该结构定义拥有其他至少具有
int32 对齐的字段,这是可以的。但在处理可能未对齐的 datum 时使用这样的结构定义是危险的;编译器可能据此假定该 datum 确实是对齐的,从而在严格要求对齐的架构上导致核心转储。
支持 TOAST 带来的另一项特性,是可以拥有一种 展开式 内存数据表示,它比存储在磁盘上的格式更便于操作。常规的(即 “扁平”)varlena 存储格式归根结底只是一块字节;它例如不能包含指针,因为它可能被复制到内存中的其他位置。对复杂的数据类型而言,扁平格式可能相当昂贵,因此 PostgreSQL 提供了一种把扁平格式 “展开” 为更适合计算的表示、然后把这种格式在该数据类型的函数之间以内存方式传递的途径。
要使用展开式存储,数据类型必须定义遵循
src/include/utils/expandeddatum.h 所给规则的展开格式,并提供把扁平 varlena 值
“展开” 为展开格式,以及把展开格式
“压扁” 回常规 varlena 表示的函数。然后确保该数据类型的所有 C 函数都能接受这两种表示,或者在收到时立即把一种转换为另一种。这不需要一次性修改该数据类型的所有现有函数,因为标准的
PG_DETOAST_DATUM 宏被定义为把展开的输入转换为常规扁平格式。因此,处理扁平 varlena 格式的现有函数在收到展开输入时仍能继续工作,只是效率略低;只有在性能变得重要时才需要转换它们。
知道如何处理展开表示的 C 函数通常分为两类:只能处理展开格式的函数,以及既能处理展开格式又能处理扁平 varlena 输入的函数。前者更易编写,但整体上可能效率较低,因为为了供单个函数使用而把扁平输入转换为展开形式的代价,可能超过以展开格式操作所节省的开销。当只需要处理展开格式时,可以把扁平输入到展开形式的转换隐藏在取参数宏内部,这样该函数看起来与处理传统 varlena 输入的函数一样简单。要处理两种类型的输入,编写一个会对外置、短头部与压缩的 varlena 输入解除外置、但对展开输入不加处理的取参数函数。这样的函数可以定义为返回一个指向扁平 varlena 格式与展开格式联合体的指针。调用方可以使用
VARATT_IS_EXPANDED_HEADER() 宏来判断自己收到的是哪种格式。
TOAST 基础设施不仅能区分常规 varlena 值与展开值,还区分指向展开值的 “读写” 指针与 “只读” 指针。只需要查看展开值,或只会以安全且语义不可见的方式更改它的 C 函数,无需关心收到的是哪种指针。产生输入值修改版本的 C 函数,在收到读写指针时可以原位修改展开的输入值,但在收到只读指针时不得修改输入;在后一种情况下,它们必须先复制该值,产生一个新值供修改。构造了新展开值的 C 函数应当始终返回指向它的读写指针。此外,原位修改读写展开值的 C 函数应当注意:如果中途失败,要让该值保持在合理状态。
处理展开值的工作示例请参见标准数组基础设施,特别是
src/backend/utils/adt/array_expanded.c。