|
| 1 | +:sectnums: |
| 2 | +:sectnumlevels: 5 |
| 3 | + |
| 4 | += DBTIMEZONE 实现说明 |
| 5 | + |
| 6 | +== 目的 |
| 7 | + |
| 8 | +本文档详细说明 IvorySQL 中 `DBTIMEZONE` 函数功能的实现原理。该功能提供一个数据库级、非会话级的固定时区值,通过 PostgreSQL 原生的 `ALTER DATABASE ... SET` 机制持久化,实现 Oracle 数据库 `DBTIMEZONE` 函数的语义。 |
| 9 | + |
| 10 | +== 实现说明 |
| 11 | + |
| 12 | +=== 系统分层架构 |
| 13 | + |
| 14 | +`DBTIMEZONE` 的实现分为四个层次,均位于 `contrib/ivorysql_ora`: |
| 15 | + |
| 16 | +``` |
| 17 | +┌───────────────────────────────────────────────────────────┐ |
| 18 | +│ Layer 1: GUC 定义与权限层 (src/guc/guc.c + src/include/guc.h)│ |
| 19 | +│ ─ 新增自定义 GUC ivorysql.dbtimezone(PGC_SUSET) │ |
| 20 | +│ ─ check_dbtimezone():按 GucSource 拒绝会话内 SET/ALTER ROLE,│ |
| 21 | +│ 只允许 ALTER DATABASE ... SET;并校验偏移/区域名格式 │ |
| 22 | +└───────────────────────────────────────────────────────────┘ |
| 23 | + |
| 24 | +┌───────────────────────────────────────────────────────────┐ |
| 25 | +│ Layer 2: 命令层拦截 (src/ivorysql_ora.c) │ |
| 26 | +│ ─ ivorysql_ora_ProcessUtility()(既有 ProcessUtility_hook)│ |
| 27 | +│ 新增 reject_alter_role_dbtimezone():在解析树层面直接 │ |
| 28 | +│ 拦截 ALTER ROLE ... SET/ALTER ROLE ALL SET,早于 Layer 1 │ |
| 29 | +│ 的 check hook 生效,命令本身直接报错,不留 catalog 残留 │ |
| 30 | +└───────────────────────────────────────────────────────────┘ |
| 31 | + |
| 32 | +┌───────────────────────────────────────────────────────────┐ |
| 33 | +│ Layer 3: C 函数层 (src/builtin_functions/ │ |
| 34 | +│ datetime_datatype_functions.c) │ |
| 35 | +│ ─ ora_dbtimezone():读取 ivorysql_dbtimezone 变量并返回 text │ |
| 36 | +│ 与既有的 ora_sessiontimezone()(读取 session_timezone) │ |
| 37 | +│ 紧邻,实现方式一致、语义刻意区分 │ |
| 38 | +└───────────────────────────────────────────────────────────┘ |
| 39 | + |
| 40 | +┌───────────────────────────────────────────────────────────┐ |
| 41 | +│ Layer 4: SQL 目录层 │ |
| 42 | +│ (src/builtin_functions/builtin_functions--1.0.sql) │ |
| 43 | +│ ─ CREATE FUNCTION sys.dbtimezone() ... STABLE │ |
| 44 | +│ 紧邻既有的 sys.sessiontimezone() │ |
| 45 | +└───────────────────────────────────────────────────────────┘ |
| 46 | +``` |
| 47 | + |
| 48 | +本功能没有新增语法(不需要 Oracle 解析器/AST/目录列层面的改动)——`dbtimezone()` 是一个普通的 `STABLE` SQL 函数,配合一个自定义 GUC,复用 PostgreSQL 已有的 per-database 配置机制即可实现。 |
| 49 | + |
| 50 | +=== 设计方案:新增 GUC |
| 51 | + |
| 52 | +GUC 本身不是"per-database 专属存储",而是复用了 PostgreSQL 对任意 GUC 都支持的 `ALTER DATABASE/ROLE ... SET` 通用机制(持久化在 `pg_db_role_setting` 系统表),没有为 `DBTIMEZONE` 单独设计目录字段。 |
| 53 | + |
| 54 | +=== GUC 定义与权限模型 |
| 55 | + |
| 56 | +==== 命名规范 |
| 57 | + |
| 58 | +GUC 名为 `ivorysql.dbtimezone`,与项目现有自定义 GUC 命名惯例保持一致。对应的 C 端变量为 `ivorysql_dbtimezone`。 |
| 59 | + |
| 60 | +==== 权限模型:只能通过 ALTER DATABASE 设置 |
| 61 | + |
| 62 | +`ALTER DATABASE dbname SET <guc> = value` 实际上有两层独立的权限检查:数据库对象本身的权限(是否有权 `ALTER` 这个库,owner 或超级用户即可)与 GUC 参数本身的权限(是否允许"设置"这个参数,与是否拥有该数据库无关)。`ivorysql.dbtimezone` 的 `context` 设为 `PGC_SUSET`,因此第二层默认只有超级用户能通过;若需要委派给普通角色,超级用户需额外执行 `GRANT SET ON PARAMETER ivorysql.dbtimezone TO <role>;`(对应 PostgreSQL 15+ 引入的 `pg_parameter_acl` 目录)。 |
| 63 | + |
| 64 | +仅设置 `context = PGC_SUSET` 不足以区分"会话内 `SET`"和"`ALTER DATABASE ... SET`"——两者内部同样以 `PGC_SUSET` 身份调用 `set_config_option()`。真正能区分调用来源的是 check hook 收到的 `GucSource source` 参数: |
| 65 | + |
| 66 | +[cols="2,3,1"] |
| 67 | +|=== |
| 68 | +| `GucSource` | 触发场景 | 是否允许 |
| 69 | + |
| 70 | +| `PGC_S_SESSION` |
| 71 | +| 会话内 `SET ivorysql.dbtimezone = ...;` |
| 72 | +| 拒绝 |
| 73 | + |
| 74 | +| `PGC_S_USER` |
| 75 | +| `ALTER ROLE rolename SET ...`(不区分数据库) |
| 76 | +| 拒绝 |
| 77 | + |
| 78 | +| `PGC_S_DATABASE_USER` |
| 79 | +| `ALTER ROLE rolename IN DATABASE dbname SET ...` |
| 80 | +| 拒绝 |
| 81 | + |
| 82 | +| `PGC_S_CLIENT` |
| 83 | +| 客户端连接选项(如 `PGOPTIONS`) |
| 84 | +| 拒绝 |
| 85 | + |
| 86 | +| `PGC_S_GLOBAL` |
| 87 | +| `ALTER ROLE ALL SET ...`(全局默认) |
| 88 | +| 拒绝 |
| 89 | + |
| 90 | +| `PGC_S_DATABASE` |
| 91 | +| `ALTER DATABASE dbname SET ...` 在连接建立时生效 |
| 92 | +| 允许 |
| 93 | + |
| 94 | +| `PGC_S_TEST` |
| 95 | +| 执行 `ALTER DATABASE ... SET` 命令本身时的校验阶段 |
| 96 | +| 允许(否则命令本身都执行不了) |
| 97 | + |
| 98 | +| `PGC_S_DEFAULT` / `PGC_S_DYNAMIC_DEFAULT` / `PGC_S_FILE` / `PGC_S_ARGV` / `PGC_S_ENV_VAR` |
| 99 | +| 启动默认值、`postgresql.conf`、`postmaster` 命令行/环境变量 |
| 100 | +| 允许(保证集群启动不受影响) |
| 101 | + |
| 102 | +| `PGC_S_OVERRIDE` |
| 103 | +| 重放一个已校验过的值(如并行 worker 同步) |
| 104 | +| 允许(否则并行查询会报错) |
| 105 | +|=== |
| 106 | + |
| 107 | +==== `check_dbtimezone()` 实现 |
| 108 | + |
| 109 | +`contrib/ivorysql_ora/src/guc/guc.c`: |
| 110 | + |
| 111 | +[source,c] |
| 112 | +---- |
| 113 | +/* Backing variable for ivorysql.dbtimezone, read by dbtimezone(). */ |
| 114 | +char *ivorysql_dbtimezone = NULL; |
| 115 | +
|
| 116 | +static bool |
| 117 | +check_dbtimezone(char **newval, void **extra, GucSource source) |
| 118 | +{ |
| 119 | + char *str = *newval; |
| 120 | +
|
| 121 | + if (source == PGC_S_SESSION || |
| 122 | + source == PGC_S_USER || |
| 123 | + source == PGC_S_DATABASE_USER || |
| 124 | + source == PGC_S_CLIENT || |
| 125 | + source == PGC_S_GLOBAL) |
| 126 | + { |
| 127 | + GUC_check_errcode(ERRCODE_CANT_CHANGE_RUNTIME_PARAM); |
| 128 | + GUC_check_errmsg("parameter \"ivorysql.dbtimezone\" cannot be set"); |
| 129 | + GUC_check_errdetail("\"ivorysql.dbtimezone\" can only be set with " |
| 130 | + "ALTER DATABASE ... SET, not within a session " |
| 131 | + "or per-role."); |
| 132 | + return false; |
| 133 | + } |
| 134 | +
|
| 135 | + /* [+-]HH:MI 格式校验,范围 -12:59 ~ +14:00(与 Oracle 一致) */ |
| 136 | + if (strlen(str) == 6 && ... ) |
| 137 | + { |
| 138 | + ... |
| 139 | + } |
| 140 | +
|
| 141 | + /* 否则必须是合法的时区区域名(复用 pg_tzset() 校验) */ |
| 142 | + if (!pg_tzset(str)) |
| 143 | + { |
| 144 | + GUC_check_errdetail("\"%s\" is not a valid UTC offset (+/-HH:MI) " |
| 145 | + "or time zone name.", str); |
| 146 | + return false; |
| 147 | + } |
| 148 | +
|
| 149 | + return true; |
| 150 | +} |
| 151 | +
|
| 152 | +void |
| 153 | +IvorysqlOraDefineGucs(void) |
| 154 | +{ |
| 155 | + DefineCustomStringVariable("ivorysql.dbtimezone", |
| 156 | + "Sets the database time zone reported by dbtimezone().", |
| 157 | + "Can only be set with ALTER DATABASE ... SET, not with a " |
| 158 | + "plain SET or ALTER ROLE ... SET. Requires superuser, or a " |
| 159 | + "role granted permission via " |
| 160 | + "GRANT SET ON PARAMETER ivorysql.dbtimezone TO <role>.", |
| 161 | + &ivorysql_dbtimezone, |
| 162 | + "+00:00", |
| 163 | + PGC_SUSET, |
| 164 | + 0, |
| 165 | + check_dbtimezone, |
| 166 | + NULL, |
| 167 | + NULL); |
| 168 | +} |
| 169 | +---- |
| 170 | + |
| 171 | +`GUC_check_errcode(ERRCODE_CANT_CHANGE_RUNTIME_PARAM)` + `GUC_check_errmsg(...)` 把会话内 `SET` 被拒绝时的报错改成 `parameter "ivorysql.dbtimezone" cannot be set`,而非泛用的 `invalid value for parameter ...: "..."` ——这类拒绝的原因是"这个参数不能这样设置"而不是"这个值不合法",用专门的 errcode/errmsg 更准确地表达语义;格式/范围校验失败(`GUC_check_errdetail` 但不设 `GUC_check_errmsg`)则仍走默认的 `invalid value for parameter` 提示。 |
| 172 | + |
| 173 | +=== 命令层拦截:`ALTER ROLE ... SET` |
| 174 | + |
| 175 | +`contrib/ivorysql_ora/src/ivorysql_ora.c` 已有一个 `ProcessUtility_hook`,在其中新增: |
| 176 | + |
| 177 | +[source,c] |
| 178 | +---- |
| 179 | +static void |
| 180 | +reject_alter_role_dbtimezone(Node *parsetree) |
| 181 | +{ |
| 182 | + AlterRoleSetStmt *stmt; |
| 183 | + VariableSetStmt *setstmt; |
| 184 | +
|
| 185 | + if (nodeTag(parsetree) != T_AlterRoleSetStmt) |
| 186 | + return; |
| 187 | +
|
| 188 | + stmt = (AlterRoleSetStmt *) parsetree; |
| 189 | + setstmt = stmt->setstmt; |
| 190 | +
|
| 191 | + if (setstmt == NULL || setstmt->name == NULL) |
| 192 | + return; /* RESET ALL, or malformed */ |
| 193 | +
|
| 194 | + if (setstmt->kind == VAR_RESET || setstmt->kind == VAR_RESET_ALL) |
| 195 | + return; /* clearing an override is always fine */ |
| 196 | +
|
| 197 | + if (pg_strcasecmp(setstmt->name, "ivorysql.dbtimezone") == 0) |
| 198 | + ereport(ERROR, |
| 199 | + (errcode(ERRCODE_CANT_CHANGE_RUNTIME_PARAM), |
| 200 | + errmsg("parameter \"ivorysql.dbtimezone\" cannot be set"), |
| 201 | + errdetail("\"ivorysql.dbtimezone\" can only be set with " |
| 202 | + "ALTER DATABASE ... SET, not within a session " |
| 203 | + "or per-role."), |
| 204 | + errhint("Use ALTER DATABASE ... SET ivorysql.dbtimezone " |
| 205 | + "instead, or ALTER ROLE ... RESET " |
| 206 | + "ivorysql.dbtimezone to remove a stale per-role " |
| 207 | + "override."))); |
| 208 | +} |
| 209 | +---- |
| 210 | + |
| 211 | +并在 `ivorysql_ora_ProcessUtility()` 里,调用 `standard_ProcessUtility()`(或上一个已安装的 hook)之前插入这个检查——命令一旦匹配就直接 `ereport(ERROR, ...)`,`ALTER ROLE` 不会被执行到写 catalog 那一步。 |
| 212 | + |
| 213 | +=== SQL 函数层 |
| 214 | + |
| 215 | +==== `ora_dbtimezone()` C 函数 |
| 216 | + |
| 217 | +`contrib/ivorysql_ora/src/builtin_functions/datetime_datatype_functions.c`,紧邻既有的 `ora_sessiontimezone()`: |
| 218 | + |
| 219 | +[source,c] |
| 220 | +---- |
| 221 | +/* |
| 222 | + * returns the time zone of the database, as set by |
| 223 | + * ivorysql.dbtimezone. Unlike sessiontimezone(), this value is |
| 224 | + * independent of the session's TimeZone setting. |
| 225 | + */ |
| 226 | +Datum |
| 227 | +ora_dbtimezone(PG_FUNCTION_ARGS) |
| 228 | +{ |
| 229 | + PG_RETURN_TEXT_P(cstring_to_text(ivorysql_dbtimezone)); |
| 230 | +} |
| 231 | +---- |
| 232 | + |
| 233 | +对比 `ora_sessiontimezone()` 读取的是 `session_timezone`(会话级 `pg_tz *`),`ora_dbtimezone()` 直接读取 Layer 1 定义的 GUC 字符串。 |
| 234 | + |
| 235 | +==== 目录声明 `sys.dbtimezone()` |
| 236 | + |
| 237 | +`contrib/ivorysql_ora/src/builtin_functions/builtin_functions--1.0.sql`,紧邻 `sys.sessiontimezone()`: |
| 238 | + |
| 239 | +[source,sql] |
| 240 | +---- |
| 241 | +CREATE FUNCTION sys.dbtimezone() |
| 242 | +RETURNS text |
| 243 | +AS 'MODULE_PATHNAME','ora_dbtimezone' |
| 244 | +LANGUAGE C |
| 245 | +STRICT |
| 246 | +STABLE; |
| 247 | +---- |
| 248 | + |
| 249 | +标记为 `STABLE` 而非 `IMMUTABLE`:返回值可能因 `ALTER DATABASE ... SET` 而改变(虽然一次连接内不会变),与 `sessiontimezone()` 的标记方式保持一致。该函数最终随扩展脚本 `ivorysql_ora--1.0.sql`分发。 |
| 250 | + |
| 251 | +=== 与 PG_PARSER 的关系 |
| 252 | + |
| 253 | +不同于 `ALTER INDEX ... UNUSABLE` 那种只存在于 Oracle 语法层的新语句,`dbtimezone()` 是普通的 SQL 函数,语法上不受 `compatible_db`/`ivorysql.compatible_mode` 限制:任何解析模式下都可以用 `sys.dbtimezone()` 显式限定调用;只有以裸函数名 `dbtimezone()` 调用(依赖 `search_path` 能解析到 `sys` 模式)时才与 Oracle 兼容模式的 search_path 行为相关,这属于 `sys` schema 本身的可见性问题,非本功能特有。 |
| 254 | + |
| 255 | +== 错误处理 |
| 256 | + |
| 257 | +=== 会话内 SET 被拒绝 |
| 258 | + |
| 259 | +[source,sql] |
| 260 | +---- |
| 261 | +SET ivorysql.dbtimezone = '+08:00'; |
| 262 | +-- ERROR: parameter "ivorysql.dbtimezone" cannot be set |
| 263 | +-- DETAIL: "ivorysql.dbtimezone" can only be set with ALTER DATABASE ... SET, not within a session or per-role. |
| 264 | +---- |
| 265 | +错误由 `check_dbtimezone()` 中 `source == PGC_S_SESSION` 分支主动抛出。 |
| 266 | + |
| 267 | +=== ALTER ROLE ... SET 在命令层直接被拒绝 |
| 268 | + |
| 269 | +[source,sql] |
| 270 | +---- |
| 271 | +ALTER ROLE myrole IN DATABASE mydb SET ivorysql.dbtimezone = '+09:00'; |
| 272 | +-- ERROR: parameter "ivorysql.dbtimezone" cannot be set |
| 273 | +-- DETAIL: "ivorysql.dbtimezone" can only be set with ALTER DATABASE ... SET, not within a session or per-role. |
| 274 | +-- HINT: Use ALTER DATABASE ... SET ivorysql.dbtimezone instead, or ALTER ROLE ... RESET ivorysql.dbtimezone to remove a stale per-role override. |
| 275 | +
|
| 276 | +ALTER ROLE myrole SET ivorysql.dbtimezone = '+09:00'; -- 同样报错 |
| 277 | +ALTER ROLE ALL SET ivorysql.dbtimezone = '+09:00'; -- 同样报错 |
| 278 | +
|
| 279 | +ALTER ROLE myrole IN DATABASE mydb RESET ivorysql.dbtimezone; -- OK,不受影响 |
| 280 | +---- |
| 281 | +错误由 `reject_alter_role_dbtimezone()`(Layer 2,`ivorysql_ora.c` 的 `ProcessUtility_hook`)在解析树层面直接抛出,早于命令真正执行、早于 `pg_db_role_setting` 被写入。详见上文"命令层拦截"小节。 |
| 282 | + |
| 283 | +=== 非法值 / 超出范围偏移在 ALTER DATABASE 阶段即报错 |
| 284 | + |
| 285 | +[source,sql] |
| 286 | +---- |
| 287 | +ALTER DATABASE mydb SET ivorysql.dbtimezone = 'not_a_zone'; |
| 288 | +-- ERROR: invalid value for parameter "ivorysql.dbtimezone": "not_a_zone" |
| 289 | +-- DETAIL: "not_a_zone" is not a valid UTC offset (+/-HH:MI) or time zone name. |
| 290 | +
|
| 291 | +ALTER DATABASE mydb SET ivorysql.dbtimezone = '+15:00'; |
| 292 | +-- ERROR: invalid value for parameter "ivorysql.dbtimezone": "+15:00" |
| 293 | +-- DETAIL: time zone offset "+15:00" is out of range for DBTIMEZONE (-12:59 to +14:00) |
| 294 | +---- |
| 295 | +错误来自 `check_dbtimezone()` 的偏移/区域名格式校验分支,走默认的 `invalid value for parameter` 文案(未设置 `GUC_check_errmsg`)。 |
| 296 | + |
| 297 | +=== 未授权的普通用户执行 ALTER DATABASE |
| 298 | + |
| 299 | +[source,sql] |
| 300 | +---- |
| 301 | +\c mydb normal_user |
| 302 | +ALTER DATABASE mydb SET ivorysql.dbtimezone = '+08:00'; |
| 303 | +-- ERROR: permission denied to set parameter "ivorysql.dbtimezone" |
| 304 | +---- |
| 305 | +`context = PGC_SUSET` 且 `normal_user` 未被 `GRANT SET ON PARAMETER` 授权,权限检查在到达 `check_dbtimezone()` 之前就失败,因此报错文案是 PostgreSQL 通用的 GUC 权限错误,而非本功能自定义的错误信息。 |
| 306 | + |
| 307 | +== 已知限制 |
| 308 | + |
| 309 | +1. **未走 Oracle 的 `CREATE DATABASE ... TIME_ZONE` 语法**:当前只能通过 PostgreSQL 原生的 `ALTER DATABASE ... SET` 设置,没有在 IvorySQL 的 Oracle 语法层(`ora_gram.y`)增加对应的 `CREATE/ALTER DATABASE ... SET TIME_ZONE` 关键字兼容写法。 |
| 310 | +2. **偏移 / 区域名格式未做精细区分**:Oracle 实际上对 `DBTIMEZONE`(数据库级)和 `SESSIONTIMEZONE`/`TIME_ZONE`(会话级)在偏移与区域名的允许范围上有细节差异,本实现为简化起见统一按"偏移或区域名皆可"处理。 |
0 commit comments