49.2. PL/pgSQL
PL/pgSQL 是一个可装载的 Postgres 数据库系统过程语言。
这个软件包最初由 Jan Wieck 编写。
49.2.1. 概述
PL/pgSQL 的设计目标是创建一种可装载的过程语言,它
可以用于创建函数和触发器过程,
为 SQL 语言增加控制结构,
可以执行复杂计算,
继承所有用户定义的类型、函数和操作符,
可以定义为被服务器信任,
易于使用。
PL/pgSQL 调用处理器在后端第一次调用函数时解析函数的源文本并产生一棵内部二进制指令树。产生的字节码在调用处理器中通过函数的对象 ID 来标识。这确保了通过 DROP/CREATE 序列更改函数无需建立新的数据库连接即可生效。
对于函数中使用的所有表达式和 SQL 语句,PL/pgSQL 字节码解释器都会使用 SPI 管理器的 SPI_prepare() 和 SPI_saveplan() 函数创建预备执行计划。这是在 PL/pgSQL 函数中第一次处理各条语句时完成的。因此,包含许多需要执行计划的语句的条件代码函数,只会预备和保存那些在数据库连接的整个生命周期内真正用到的计划。
除了用户定义类型的输入/输出转换和计算函数之外,C 语言函数中能定义的任何事情都可以用 PL/pgSQL 完成。可以创建复杂的条件计算函数,之后用它们定义操作符或在函数索引中使用。
49.2.2. 描述
49.2.2.1. PL/pgSQL 的结构
PL/pgSQL 语言不区分大小写。所有关键字和标识符都可以混合使用大写和小写。
PL/pgSQL 是一种面向块的语言。块的定义是
[<<label>>]
[DECLARE
declarations]
BEGIN
statements
END;块的语句部分可以有任意数量的子块。子块可以用来对语句块外部隐藏变量。块的声明部分中声明的变量在每次进入该块时都会被初始化为它们的默认值,而不是每次函数调用只初始化一次。
重要的是不要误解 PL/pgSQL 中用于分组语句的 BEGIN/END 与用于事务控制的数据库命令之间的区别。函数和触发器过程不能启动或提交事务,而且 Postgres 没有嵌套事务。
49.2.2.2. 注释
PL/pgSQL 中有两种注释。双横线 '--' 开始一个延伸到行尾的注释。'/*' 开始一个延伸到下一个 '*/' 出现处的块注释。块注释不能嵌套,但双横线注释可以放在块注释内,双横线也可以隐藏块注释定界符 '/*' 和 '*/'。
49.2.2.3. 声明
块或其子块中使用的所有变量、行和记录都必须在块的声明区中声明,唯一例外是在整数范围上迭代的 FOR 循环的循环变量。传给 PL/pgSQL 函数的参数会用通常的标识符 $n 自动声明。声明使用如下语法:
name[ CONSTANT ]type[ NOT NULL ] [ DEFAULT | :=value];声明一个指定基本类型的变量。如果变量声明为 CONSTANT,其值不能更改。如果指定了 NOT NULL,赋 NULL 值将导致运行时错误。由于所有变量的默认值都是 SQL NULL 值,所有声明为 NOT NULL 的变量还必须指定默认值。
默认值在每次调用函数时求值。因此把 '
now' 赋给一个datetime类型的变量,会使该变量拥有实际调用函数时的时间,而不是函数预编译为其字节码时的时间。nameclass%ROWTYPE;声明一个具有给定类的结构的行。类必须是数据库中已存在的表名或视图名。行的字段用点表示法访问。函数的参数可以是复合类型(完整的表行)。在这种情况下,相应的标识符 $n 将是一个行类型,但必须用下面描述的 ALIAS 命令为它起别名。行中只能访问表行的用户属性,不能访问 Oid 或其他系统属性(因此该行可以来自视图,而视图行没有用的系统属性)。
行类型的字段继承表中 char() 等数据类型的字段大小或精度。
nameRECORD;记录类似于行类型,但没有预定义的结构。它们用于选择和 FOR 循环中,保存 SELECT 操作返回的一条实际数据库行。同一个记录可以用于不同的选择。当记录中没有实际行时访问记录或试图给记录字段赋值都会导致运行时错误。
触发器中的 NEW 和 OLD 行会作为记录传给过程。这是必要的,因为在 Postgres 中同一个触发器过程可以处理不同表的触发器事件。
nameALIAS FOR $n;为了让代码更易读,可以为函数的位置参数定义别名。
对于作为参数传给函数的复合类型,这种别名是必需的。SQL 函数中的点表示法 $1.salary 在 PL/pgSQL 中不允许使用。
- RENAME
oldnameTOnewname; 更改变量、记录或行的名称。当触发器过程内部需要用另一个名称引用 NEW 或 OLD 时,这很有用。
49.2.2.4. 数据类型
变量的类型可以是数据库中任何已存在的基本类型。上面声明区中的
type 定义为:
Postgres-basetype
variable%TYPEclass.field%TYPE
variable 是在同一函数中先前声明、在此处可见的变量的名称。
class 是已存在的表或视图的名称,field 是其中某个属性的名称。
使用 class.field%TYPE
会使 PL/pgSQL在后端生命周期内第一次调用函数时查找该属性的定义。假设有一个带 char(20) 属性的表和一些在局部变量中处理其内容的 PL/pgSQL 函数。现在有人认为 char(20) 不够用,转储表、删除它、把这个属性重新定义为
char(40) 再重建表并恢复数据。哈——他忘了那些函数。其中的计算会把值截断为 20 个字符。但如果它们是用
class.field%TYPE
声明定义的,就会自动处理大小变化,包括新表模式把该属性定义为 text 类型的情况。
49.2.2.5. 表达式
PL/pgSQL 语句中使用的所有表达式都由后端的执行器处理。看起来包含常量的表达式实际上可能需要运行时求值(例如 datetime 类型的 'now'),因此 PL/pgSQL 解析器不可能识别除 NULL 关键字之外的真常量值。所有表达式都在内部通过执行查询
SELECT expression
用 SPI 管理器求值。在表达式中,变量标识符的出现被替换为参数,变量的实际值通过参数数组传给执行器。PL/pgSQL 函数中使用的所有表达式都只预备和保存一次。
Postgres 主解析器所做的类型检查对常量值的解释有一些副作用。具体来说,下面两个函数的行为有所不同:
CREATE FUNCTION logfunc1 (text) RETURNS datetime AS '
DECLARE
logtxt ALIAS FOR $1;
BEGIN
INSERT INTO logtable VALUES (logtxt, ''now'');
RETURN ''now'';
END;
' LANGUAGE 'plpgsql';
and
CREATE FUNCTION logfunc2 (text) RETURNS datetime AS '
DECLARE
logtxt ALIAS FOR $1;
curtime datetime;
BEGIN
curtime := ''now'';
INSERT INTO logtable VALUES (logtxt, curtime);
RETURN curtime;
END;
' LANGUAGE 'plpgsql';
在 logfunc1() 的情况中,Postgres 主解析器在为 INSERT 预备计划时知道字符串 'now' 应该被解释为 datetime,因为 logtable 的目标字段是那种类型。这样,它此时就会把它变成一个常量,而这个常量值随后在后端整个生命周期内的所有 logfunc1() 调用中都被使用。不用说,这并不是程序员想要的。
在 logfunc2() 的例子中,Postgres 主解析器不知道 'now' 应该变成什么类型,因此它返回一个包含字符串 'now' 的 text 数据类型。在赋值给局部变量 curtime 时,PL/pgSQL 解释器会调用 text_out() 和 datetime_in() 函数把这个字符串转换为 datetime 类型。
Postgres 主解析器所做的这种类型检查是在 PL/pgSQL 几乎完工之后实现的。这是 6.3 与 6.4 之间的一个差异,影响所有使用 SPI 管理器预备计划特性的函数。目前在 PL/pgSQL 中,按上述方式使用局部变量是让这些值被正确解释的唯一方法。
如果在表达式或语句中使用记录字段,在同一个表达式的多次调用之间字段的数据类型不应改变。在编写处理多个表的事件的触发器过程时,请记住这一点。
49.2.2.6. 语句
PL/pgSQL 解析器按下面的说明不理解的任何内容都会被放进一个查询并发给数据库引擎执行。这种查询不应返回任何数据。
- 赋值
给变量或行/记录字段赋一个值的写法是
identifier:=expression;如果表达式的结果数据类型与变量的数据类型不匹配,或者变量有已知的大小/精度(如 char(20)),结果值将由 PL/pgSQL 字节码解释器使用结果类型的输出函数和变量类型的输入函数隐式转换。注意,这有可能导致类型输入函数在运行时产生错误。
把一个完整的选择结果赋给记录或行可以用
SELECT
expressionsINTOtargetFROM ...;完成。
target可以是记录、行变量或逗号分隔的变量和记录/行字段列表。如果用行或变量列表作为目标,选出的值必须与目标的结构完全匹配,否则会发生运行时错误。FROM 关键字后面可以跟 SELECT 语句允许的任何有效的限定、分组、排序等。
有一个名为 FOUND 的 bool 类型特殊变量,可以在 SELECT INTO 之后立即用来检查赋值是否成功。
SELECT * INTO myrec FROM EMP WHERE empname = myname; IF NOT FOUND THEN RAISE EXCEPTION ''employee % not found'', myname; END IF;如果选择返回多行,只有第一行会被移入目标字段。其余的都被悄悄丢弃。
- 调用另一个函数
Prostgres 数据库中定义的所有函数都返回一个值。因此,调用函数的常规方式是执行 SELECT 查询或做一次赋值(导致一次 PL/pgSQL 内部的 SELECT)。但有时人们并不关心函数的结果。
PERFORM
query通过 SPI 管理器执行 'SELECT
query' 并丢弃结果。局部变量等标识符仍会被替换为参数。- 从函数返回
RETURN
expression函数终止,
expression的值将返回给上层执行器。函数的返回值不能未定义。如果控制流到达函数顶层块的末尾仍未遇到 RETURN 语句,将发生运行时错误。表达式的结果会按赋值一节所述自动转换成函数的返回类型。
- 中止与消息
如上面的示例所示,有一条 RAISE 语句可以把消息抛进 Postgres 的 elog 机制。
RAISE
level''format'' [,identifier[...]];在格式串内部,“%” 用作后续逗号分隔标识符的占位符。可能的级别有 DEBUG(在生产运行的数据库中被悄悄抑制)、NOTICE(写入数据库日志并转发给客户端应用)和 EXCEPTION(写入数据库日志并中止事务)。
- 条件
IF
expressionTHENstatements[ELSEstatements] END IF;expression必须返回一个至少可以转换成布尔类型的值。- 循环
循环有多种类型。
[<<label>>] LOOPstatementsEND LOOP;无条件循环,必须由 EXIT 语句显式终止。可选的标签可以被嵌套循环的 EXIT 语句用来指定应终止哪一层嵌套。
[<<label>>] WHILEexpressionLOOPstatementsEND LOOP;条件循环,只要
expression的求值为真就一直执行。[<<label>>] FORnameIN [ REVERSE ]expression..expressionLOOPstatementsEND LOOP;在一个整数取值范围上迭代的循环。变量
name自动创建为 integer 类型并且只存在于循环内部。给出范围下界和上界的两个表达式只在进入循环时求值一次。迭代步长总是 1。[<<label>>] FORrecord | rowINselect_clauseLOOPstatementsEND LOOP;记录或行会被依次赋予 select 子句产生的所有行,并对每一行执行这些语句。如果循环被 EXIT 语句终止,最后赋值的行在循环之后仍然可以访问。
EXIT [
label] [ WHENexpression];如果没有给出
label,则终止最内层的循环,接下来执行 END LOOP 之后的语句。如果给出了label,它必须是当前层或嵌套循环块上层的标签。然后命名的循环或块被终止,控制流继续到对应循环/块的 END 之后的语句。
49.2.2.7. 触发器过程
PL/pgSQL 可以用来定义触发器过程。它们像平常一样用 CREATE FUNCTION 命令创建为一个没有参数、返回类型为 OPAQUE 的函数。
用作触发器过程的函数有一些 Postgres 特有的细节。
首先它们在顶层块的声明区中自动创建了一些特殊变量。它们是
- NEW
数据类型 RECORD;在行级触发器的 INSERT/UPDATE 操作中保存新数据库行的变量。
- OLD
数据类型 RECORD;在行级触发器的 UPDATE/DELETE 操作中保存旧数据库行的变量。
- TG_NAME
数据类型 name;包含实际触发的触发器名称的变量。
- TG_WHEN
数据类型 text;按触发器的定义为 'BEFORE' 或 'AFTER' 的字符串。
- TG_LEVEL
数据类型 text;按触发器的定义为 'ROW' 或 'STATEMENT' 的字符串。
- TG_OP
数据类型 text;取值为 'INSERT'、'UPDATE' 或 'DELETE' 的字符串,指明触发器是为哪个操作实际触发的。
- TG_RELID
数据类型 oid;导致触发器调用的表的对象 ID。
- TG_RELNAME
数据类型 name;导致触发器调用的表的名称。
- TG_NARGS
数据类型 integer;CREATE TRIGGER 语句中传给触发器过程的参数个数。
- TG_ARGV[]
数据类型 text 数组;来自 CREATE TRIGGER 语句的参数。索引从 0 计数并可以用表达式给出。无效索引(< 0 或 >= tg_nargs)得到 NULL 值。
其次它们必须返回 NULL,或者返回一个恰好具有触发该触发器的表的结构的记录/行。AFTER 触发器总是可以返回 NULL 值而无任何影响。BEFORE 触发器返回 NULL 时会通知触发器管理器跳过这一实际行的操作。否则,返回的记录/行会替换操作中被插入/更新的行。可以直接在 NEW 中替换单个值并返回它,或者构造一个完整的新记录/行返回。
49.2.2.8. 异常
Postgres 没有一个很聪明的异常处理模型。每当解析器、规划器/优化器或执行器判定一条语句无法继续处理时,整个事务都会被中止,系统会跳回主循环去获取客户端应用的下一条查询。
可以挂入错误机制来注意到发生了这种情况。但目前无法判断到底是什么导致了中止(输入/输出转换错误、浮点错误、解析错误)。而且此时数据库后端可能处于不一致状态,返回上层执行器或发出更多命令可能会损坏整个数据库。即便可以,此时事务已中止的信息已经发送给了客户端应用,恢复操作没有任何意义。
因此,PL/pgSQL 目前在函数或触发器过程执行期间遇到中止时,唯一能做的就是写一些额外的 DEBUG 级别日志消息,说明发生在哪个函数的什么地方(行号和语句类型)。
49.2.3. 示例
这里只给出少量函数来演示编写 PL/pgSQL 函数有多么容易。更复杂的例子可以查看 PL/pgSQL 的回归测试。
在 PL/pgSQL 中编写函数的一个痛苦细节是单引号的处理。CREATE FUNCTION 时函数的源文本必须是字面字符串。字面字符串中的单引号必须加倍或用反斜线引用。我们仍在寻找优雅的替代方案。在此之前,应当像下面的例子那样把单引号加倍。这个问题在 Postgres 未来版本中的任何解决方案都将是向上兼容的。
49.2.3.1. 一些简单的 PL/pgSQL 函数
下面两个 PL/pgSQL 函数与 C 语言函数讨论中的对应函数等价。
CREATE FUNCTION add_one (int4) RETURNS int4 AS '
BEGIN
RETURN $1 + 1;
END;
' LANGUAGE 'plpgsql';
CREATE FUNCTION concat_text (text, text) RETURNS text AS '
BEGIN
RETURN $1 || $2;
END;
' LANGUAGE 'plpgsql';
49.2.3.2. 复合类型上的 PL/pgSQL 函数
这同样是 C 函数一节中示例的 PL/pgSQL 等价实现。
CREATE FUNCTION c_overpaid (EMP, int4) RETURNS bool AS '
DECLARE
emprec ALIAS FOR $1;
sallim ALIAS FOR $2;
BEGIN
IF emprec.salary ISNULL THEN
RETURN ''f'';
END IF;
RETURN emprec.salary > sallim;
END;
' LANGUAGE 'plpgsql';
49.2.3.3. PL/pgSQL 触发器过程
这个触发器确保每当表中插入或更新一行时,当前的用户名和时间都会被盖印到该行上。它还确保给出了雇员的名字,并且工资是正值。
CREATE TABLE emp (
empname text,
salary int4,
last_date datetime,
last_user name);
CREATE FUNCTION emp_stamp () RETURNS OPAQUE AS
BEGIN
-- Check that empname and salary are given
IF NEW.empname ISNULL THEN
RAISE EXCEPTION ''empname cannot be NULL value'';
END IF;
IF NEW.salary ISNULL THEN
RAISE EXCEPTION ''% cannot have NULL salary'', NEW.empname;
END IF;
-- Who works for us when she must pay for?
IF NEW.salary < 0 THEN
RAISE EXCEPTION ''% cannot have a negative salary'', NEW.empname;
END IF;
-- Remember who changed the payroll when
NEW.last_date := ''now'';
NEW.last_user := getpgusername();
RETURN NEW;
END;
' LANGUAGE 'plpgsql';
CREATE TRIGGER emp_stamp BEFORE INSERT OR UPDATE ON emp
FOR EACH ROW EXECUTE PROCEDURE emp_stamp();