diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19948c55..b9d00012 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,7 +64,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -279,7 +279,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -386,7 +386,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | @@ -489,7 +489,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/extract-epoch" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Install dependencies run: | diff --git a/changelog.d/66.added.md b/changelog.d/66.added.md new file mode 100644 index 00000000..522788aa --- /dev/null +++ b/changelog.d/66.added.md @@ -0,0 +1 @@ +Added PostgreSQL partitions, routines, types, operators, operator classes/families, identity sequence and identity-column introspection; removed the unsupported ddl CLI type. diff --git a/docs/en_US/postgres_specific_features/introspection.md b/docs/en_US/postgres_specific_features/introspection.md index e480f3ab..29c1c4b7 100644 --- a/docs/en_US/postgres_specific_features/introspection.md +++ b/docs/en_US/postgres_specific_features/introspection.md @@ -19,7 +19,7 @@ The PostgreSQL introspection system is accessible via `backend.introspector` and ## What PostgreSQL Answers Core declares twenty-one `supports_*` probes, and each asks a question about the -engine rather than about the library. PostgreSQL answers nineteen of them `True`. +engine rather than about the library. PostgreSQL answers eighteen of them `True`. The table below is this backend's answer to every one, in the order core declares them, with the catalogue view or statement each answer is read from. @@ -42,15 +42,16 @@ them, with the catalogue view or statement each answer is read from. | `supports_column_data_profile` | `True` | `pg_stats` | | `supports_column_value_sample` | `True` | `pg_stats.most_common_vals` | | `supports_join_coverage` | `True` | Counts distinct values on both sides; no catalogue source | -| `supports_ddl_extraction` | `True` | `pg_class` / `pg_namespace`; the projection carries no DDL text | +| `supports_ddl_extraction` | `False` | No DDL text is produced for an ordinary table (see below) | | `supports_extensions` | `True` | `pg_extension`, through `supports_extension_introspection` | | `supports_table_storage_introspection` | `True` | `pg_total_relation_size()`, `pg_relation_size()`, `pg_indexes_size()` | | `supports_ddl_extraction_native` | `False` | No single native DDL retrieval call (see below) | -Two answers are `False`. One reason is written in the probe itself; the other -is inherited from core's default, and what the default leaves out is stated below. +Three answers are `False`. Two reasons are written in the probes themselves; +the third is inherited from core's default, and what the default leaves out is +stated below. -### The Two False Answers +### The Three False Answers `supports_partition_info` answers `False` through core's default: this dialect does not override the probe and declares no partition-information query @@ -61,14 +62,19 @@ nothing here reports the parent, the children or the bounds. Partition DDL is a separate protocol family (`supports_hash_partitioning`, `supports_attach_partition` and their neighbours), not this introspection probe. +`supports_ddl_extraction` answers `False` because this backend produces no DDL +text for an ordinary table. `pg_get_expr` and `pg_get_viewdef` recover +expressions, but a faithful `CREATE TABLE` also needs storage parameters, the +tablespace and per-column clauses the catalogue keeps only in parts, and the +assembly does not exist yet. Advertising the family would promise text the +answer cannot produce, so the CLI does not offer a `ddl` type; the formatter and +`DDLStatement` stay on the dialect for when a real assembly lands. + `supports_ddl_extraction_native` answers `False`. The probe's docstring is the -reason: PostgreSQL has no single native DDL retrieval call. `pg_get_expr` and -`pg_get_viewdef` recover expressions, but a faithful `CREATE TABLE` also needs -storage parameters, the tablespace and per-column clauses the catalogue keeps -only in parts, so what this backend can offer is an assembly, and `native=False` -is how an assembly is marked. `supports_ddl_extraction` stays `True` because the -family is declared and a statement renders for it; the statement itself is -described under `get_ddl_statement` below. +reason: PostgreSQL has no single native DDL retrieval call. The two probes stay +separate so a caller can tell "can retrieve" from "can retrieve natively" even +though both answer `False` today; the method itself is described under +`get_ddl_statement` below. ### The Probes PostgreSQL Adds @@ -411,18 +417,17 @@ edges to act on. ### `get_ddl_statement` -`supports_ddl_extraction` answers `True` and a statement renders, but the -statement this backend renders is an address, not text. It selects -`pg_class.relname`, `pg_namespace.nspname` and `pg_class.relkind` for the named -relation, and projects no `ddl` column. The parser skips a row carrying no text, -so `get_ddl_statement` returns `None` for a table that exists: "cannot reproduce -this", not "no such table". `supports_ddl_extraction_native` answers `False`, so -a caller who needs the server's own text knows in advance that any assembly here -would be a reconstruction; `pg_get_expr` and `pg_get_viewdef` can recover -expressions, but a faithful `CREATE TABLE` would also need storage parameters, -tablespace and per-column clauses the catalogue keeps only in parts. The CLI -reports `Error: no DDL available for ''` and exits `1`; it does not print -an empty row, because a row with no text would read as an answer. +The method stays on the introspector even though `supports_ddl_extraction` +answers `False`; the statement this backend renders is an address, not text. It +selects `pg_class.relname`, `pg_namespace.nspname` and `pg_class.relkind` for the +named relation, and projects no `ddl` column. The parser skips a row carrying no +text, so `get_ddl_statement` returns `None` for a table that exists: "cannot +reproduce this", not "no such table". `supports_ddl_extraction_native` answers +`False`, so a caller who needs the server's own text knows in advance that any +assembly here would be a reconstruction; `pg_get_expr` and `pg_get_viewdef` can +recover expressions, but a faithful `CREATE TABLE` would also need storage +parameters, tablespace and per-column clauses the catalogue keeps only in parts. +The CLI does not offer a `ddl` type, so `` cannot be asked for one there. ## Column Data Profiles @@ -834,7 +839,7 @@ python -m rhosocial.activerecord.backend.impl.postgres introspect tables \ ### Querying Operational and Data Families -The six operational families and the three data families are their own types: +The five operational families and the three data families are their own types: ```bash # Indexes no query has used since the statistics were reset @@ -855,10 +860,6 @@ python -m rhosocial.activerecord.backend.impl.postgres introspect runtime \ python -m rhosocial.activerecord.backend.impl.postgres introspect dependencies orders \ --database mydb --user postgres -# The DDL that recreates a table (this backend answers "no DDL available") -python -m rhosocial.activerecord.backend.impl.postgres introspect ddl orders \ - --database mydb --user postgres - # Profiles and samples from pg_stats python -m rhosocial.activerecord.backend.impl.postgres introspect column-profiles orders \ --database mydb --user postgres @@ -895,16 +896,15 @@ python -m rhosocial.activerecord.backend.impl.postgres introspect table-storage | `runtime` | One row per backend process from `pg_stat_activity` | Refused | | `index-stats` | Per-index counters and size | Refused | | `dependencies` | Dependency edges, or one object's | Optional: an object name | -| `ddl` | The DDL that recreates one table | Required: a table name | | `column-profiles` | Per-column profiles from `pg_stats` | Required: a table name | | `value-samples` | A column's kept values and frequencies | Required: a table name; `--column` required | | `join-coverage` | Two columns' value-domain overlap | Required: the left table; `--column`, `--right-table`, `--right-column` required | -Ten rows here are new relative to the ten types this page listed before; nine of -them are the families above. `table-storage` asks the declaration layer for one -table's storage split; `unused-indexes`, `table-stats`, `runtime`, `index-stats`, -`dependencies` and `ddl` are the six operational families; `column-profiles`, -`value-samples` and `join-coverage` are the three data families. Each type names, +Nine rows here are new relative to the ten types this page listed before. +`table-storage` asks the declaration layer for one table's storage split; +`unused-indexes`, `table-stats`, `runtime`, `index-stats` and `dependencies` +are the five operational families; `column-profiles`, `value-samples` and +`join-coverage` are the three data families. Each type names, in `_TYPE_CAPABILITIES`, the probe switch that decides whether the connected server can answer it; the advertised list itself is static, and the switch is asked after the dialect has been adapted to the server. A type whose switch is @@ -913,7 +913,7 @@ empty list. `NAME` selects the object the type answers for, and each type has exactly one answer to whether it takes one. `columns`, `indexes`, `foreign-keys`, -`table-storage`, `ddl`, `column-profiles`, `value-samples` and `join-coverage` +`table-storage`, `column-profiles`, `value-samples` and `join-coverage` name the table they are about and require it — for `join-coverage` that is the left table. `triggers` accepts a table name, `dependencies` accepts an object name, and `views`, `materialized-views`, `sequences` and `extensions` accept an @@ -928,13 +928,12 @@ Three types ask about a column or a second table on their own flags. and exits `1`. A flag given to a type that has no question for it exits `2` rather than being ignored, the same rule NAME follows. -`ddl` is the one type that can legitimately report a failure on an existing -table: this backend's projection carries no DDL text, so `get_ddl_statement` -returns `None` and the command prints `Error: no DDL available for ''` -and exits `1`. It does not print an empty row, because a row with no text would -read as an answer. `supports_ddl_extraction_native` is `False` here, so a caller -that needs the server's own text already knows the answer is an assembly, not -a retrieval. +DDL extraction is not offered by this backend: `supports_ddl_extraction` +answers `False` for every PostgreSQL server, so `ddl` is not one of the +advertised types. Asking for it is an argparse usage error — `invalid choice: +'ddl'` — and exits `2`, rather than reaching the handler and reporting +"not supported by this server". The probe and its formatter remain on the +dialect for a future assembly; only the CLI surface omits the type. A run exits `1` when it could not finish -- the connection failed, a query failed, or the object a name asked for is not there -- and `2` when the request diff --git a/docs/zh_CN/postgres_specific_features/introspection.md b/docs/zh_CN/postgres_specific_features/introspection.md index a210fc7f..1e321c16 100644 --- a/docs/zh_CN/postgres_specific_features/introspection.md +++ b/docs/zh_CN/postgres_specific_features/introspection.md @@ -18,7 +18,7 @@ PostgreSQL 内省系统位于 `backend.introspector` 属性中,提供: ## PostgreSQL 回答什么 -核心声明了二十一个 `supports_*` 探针,每个探针问的是关于引擎的问题,而不是关于库的功能开关。PostgreSQL 其中十九个回答 `True`。下表是本后端对每一个探针的回答,顺序与核心声明的一致,并列出每个答案所读取的目录视图或语句。 +核心声明了二十一个 `supports_*` 探针,每个探针问的是关于引擎的问题,而不是关于库的功能开关。PostgreSQL 其中十八个回答 `True`。下表是本后端对每一个探针的回答,顺序与核心声明的一致,并列出每个答案所读取的目录视图或语句。 | 探针 | 回答 | PostgreSQL 的答案来自 | |------|------|----------------------| @@ -39,18 +39,20 @@ PostgreSQL 内省系统位于 `backend.introspector` 属性中,提供: | `supports_column_data_profile` | `True` | `pg_stats` | | `supports_column_value_sample` | `True` | `pg_stats.most_common_vals` | | `supports_join_coverage` | `True` | 统计两侧的不同值;没有目录来源 | -| `supports_ddl_extraction` | `True` | `pg_class` / `pg_namespace`;投影不携带 DDL 文本 | +| `supports_ddl_extraction` | `False` | 普通表不产生 DDL 文本(见下) | | `supports_extensions` | `True` | `pg_extension`,经由 `supports_extension_introspection` | | `supports_table_storage_introspection` | `True` | `pg_total_relation_size()`、`pg_relation_size()`、`pg_indexes_size()` | | `supports_ddl_extraction_native` | `False` | 没有单一的原生 DDL 获取调用(见下) | -有两个回答是 `False`。一个原因写在探针自己身上;另一个继承自核心的默认值,默认值没有写出的部分在下文说明。 +有三个回答是 `False`。其中两个原因写在探针自己身上;第三个继承自核心的默认值,默认值没有写出的部分在下文说明。 -### 两个 False 回答 +### 三个 False 回答 `supports_partition_info` 通过核心的默认值回答 `False`:本方言不覆盖该探针,也没有声明任何分区信息查询表达式或格式化方法。分区表仍然会被回答——表列表会用 `table_type` 标签 `PARTITIONED TABLE` 报告它,来自 `p` 这个 relkind 代码——但那是关系的种类,不是分区树:这里没有任何东西报告父表、子表或边界。分区 DDL 是另一个协议族(`supports_hash_partitioning`、`supports_attach_partition` 及其邻居),不是这个内省探针。 -`supports_ddl_extraction_native` 回答 `False`。探针自己的文档字符串就是原因:PostgreSQL 没有单一的、原生的 DDL 获取调用。`pg_get_expr` 与 `pg_get_viewdef` 能恢复表达式,但一个忠实的 `CREATE TABLE` 还需要存储参数、表空间以及目录只分开保存的逐列子句,所以本后端能提供的只是一次组装,而 `native=False` 正是组装结果的标记方式。`supports_ddl_extraction` 仍为 `True`,因为该族已被声明且有为它渲染的语句;语句本身在下文 `get_ddl_statement` 处描述。 +`supports_ddl_extraction` 回答 `False`,因为本后端不为普通表产生 DDL 文本。`pg_get_expr` 与 `pg_get_viewdef` 能恢复表达式,但一个忠实的 `CREATE TABLE` 还需要存储参数、表空间以及目录只分开保存的逐列子句,而这次组装目前并不存在。对外宣称该族会承诺一个答案无法产生的文本,所以 CLI 不提供 `ddl` 类型;格式化方法与 `DDLStatement` 仍保留在方言上,等待真正的组装实现。 + +`supports_ddl_extraction_native` 回答 `False`。探针自己的文档字符串就是原因:PostgreSQL 没有单一的、原生的 DDL 获取调用。两个探针保持分开,调用方因此能区分“能否获取”与“能否原生获取”,即使今天两者都回答 `False`;方法本身在下文 `get_ddl_statement` 处描述。 ### PostgreSQL 额外的探针 @@ -331,7 +333,7 @@ PostgreSQL 没有为未使用索引提供厂商视图,所以该列表由计数 ### `get_ddl_statement` -`supports_ddl_extraction` 回答 `True`,也确实为它渲染了语句,但本后端渲染的语句是地址,不是文本。它为指定关系选择 `pg_class.relname`、`pg_namespace.nspname` 与 `pg_class.relkind`,并且不投影 `ddl` 列。解析器会跳过没有文本的行,所以 `get_ddl_statement` 对一张存在的表返回 `None`:这是“无法重现这个”,不是“没有这张表”。`supports_ddl_extraction_native` 回答 `False`,所以需要服务器原生文本的调用方事先就知道这里任何组装都只是重建;`pg_get_expr` 与 `pg_get_viewdef` 能恢复表达式,但一个忠实的 `CREATE TABLE` 还需要存储参数、表空间以及目录只分开保存的逐列子句。CLI 会报告 `Error: no DDL available for ''` 并以 `1` 退出;它不会打印空行,因为一行没有文本的记录会被读成一个答案。 +即使 `supports_ddl_extraction` 回答 `False`,该方法仍保留在内省器上;本后端渲染的语句是地址,不是文本。它为指定关系选择 `pg_class.relname`、`pg_namespace.nspname` 与 `pg_class.relkind`,并且不投影 `ddl` 列。解析器会跳过没有文本的行,所以 `get_ddl_statement` 对一张存在的表返回 `None`:这是“无法重现这个”,不是“没有这张表”。`supports_ddl_extraction_native` 回答 `False`,所以需要服务器原生文本的调用方事先就知道这里任何组装都只是重建;`pg_get_expr` 与 `pg_get_viewdef` 能恢复表达式,但一个忠实的 `CREATE TABLE` 还需要存储参数、表空间以及目录只分开保存的逐列子句。CLI 不提供 `ddl` 类型,因此无法在那里为 `` 请求它。 ## 列数据剖析 @@ -642,7 +644,7 @@ python -m rhosocial.activerecord.backend.impl.postgres introspect tables \ ### 查询运维与数据族 -六个运维族和三个数据族都是独立的类型: +五个运维族和三个数据族都是独立的类型: ```bash # Indexes no query has used since the statistics were reset @@ -663,10 +665,6 @@ python -m rhosocial.activerecord.backend.impl.postgres introspect runtime \ python -m rhosocial.activerecord.backend.impl.postgres introspect dependencies orders \ --database mydb --user postgres -# The DDL that recreates a table (this backend answers "no DDL available") -python -m rhosocial.activerecord.backend.impl.postgres introspect ddl orders \ - --database mydb --user postgres - # Profiles and samples from pg_stats python -m rhosocial.activerecord.backend.impl.postgres introspect column-profiles orders \ --database mydb --user postgres @@ -703,18 +701,17 @@ python -m rhosocial.activerecord.backend.impl.postgres introspect table-storage | `runtime` | 来自 `pg_stat_activity` 的每个后端进程一行 | 不接受 | | `index-stats` | 逐索引计数与大小 | 不接受 | | `dependencies` | 依赖边,或单个对象的边 | 可选:对象名 | -| `ddl` | 重建一张表的 DDL | 必填:表名 | | `column-profiles` | 来自 `pg_stats` 的逐列剖析 | 必填:表名 | | `value-samples` | 一列保留的值与频率 | 必填:表名;`--column` 必填 | | `join-coverage` | 两列值域的重叠 | 必填:左表;`--column`、`--right-table`、`--right-column` 必填 | -这里的十个类型是本页此前列出的十个之外新增的;其中九个就是上文那些族。`table-storage` 向声明层询问一张表的存储拆分;`unused-indexes`、`table-stats`、`runtime`、`index-stats`、`dependencies` 与 `ddl` 是六个运维族;`column-profiles`、`value-samples` 与 `join-coverage` 是三个数据族。每个类型都在 `_TYPE_CAPABILITIES` 中指明决定连接服务器能否回答它的探针开关;对外列出的类型清单本身是静态的,开关在方言适配到服务器之后才被询问。开关为 `False` 的类型会以 `2` 退出并提示 "not supported by this server",而不是返回空列表。 +这里的九个类型是本页此前列出的十个之外新增的。`table-storage` 向声明层询问一张表的存储拆分;`unused-indexes`、`table-stats`、`runtime`、`index-stats` 与 `dependencies` 是五个运维族;`column-profiles`、`value-samples` 与 `join-coverage` 是三个数据族。每个类型都在 `_TYPE_CAPABILITIES` 中指明决定连接服务器能否回答它的探针开关;对外列出的类型清单本身是静态的,开关在方言适配到服务器之后才被询问。开关为 `False` 的类型会以 `2` 退出并提示 "not supported by this server",而不是返回空列表。 -`NAME` 指明该类型要回答的对象,每种类型对“是否接受名称”只有一个答案。`columns`、`indexes`、`foreign-keys`、`table-storage`、`ddl`、`column-profiles`、`value-samples` 与 `join-coverage` 的名称是它们所涉及的表,必须给出——对 `join-coverage` 而言是左表。`triggers` 接受表名,`dependencies` 接受对象名,`views`、`materialized-views`、`sequences`、`extensions` 接受对象名;不给名称时各自列出整族。`tables`、`database`、`unused-indexes`、`table-stats`、`runtime` 与 `index-stats` 不接受名称:在那里给名称是错误而不是过滤条件,因为这些计数列表回答的是连接的可见范围,而不是单个对象。 +`NAME` 指明该类型要回答的对象,每种类型对“是否接受名称”只有一个答案。`columns`、`indexes`、`foreign-keys`、`table-storage`、`column-profiles`、`value-samples` 与 `join-coverage` 的名称是它们所涉及的表,必须给出——对 `join-coverage` 而言是左表。`triggers` 接受表名,`dependencies` 接受对象名,`views`、`materialized-views`、`sequences`、`extensions` 接受对象名;不给名称时各自列出整族。`tables`、`database`、`unused-indexes`、`table-stats`、`runtime` 与 `index-stats` 不接受名称:在那里给名称是错误而不是过滤条件,因为这些计数列表回答的是连接的可见范围,而不是单个对象。 有三个类型通过自己的标志询问一列或第二张表。`value-samples` 接受 `--column`,`join-coverage` 接受 `--column`、`--right-table` 与 `--right-column`;缺少其中一个是未完成的调用,会以 `1` 退出。把标志给一个对它没有问题可问的类型,会以 `2` 退出而不是被忽略,规则与 NAME 相同。 -`ddl` 是唯一一个可以在一张存在的表上合理报告失败的类型:本后端的投影不携带 DDL 文本,所以 `get_ddl_statement` 返回 `None`,命令会打印 `Error: no DDL available for ''` 并以 `1` 退出。它不会打印空行,因为一行没有文本的记录会被读成一个答案。这里 `supports_ddl_extraction_native` 为 `False`,所以需要服务器原生文本的调用方事先就知道答案是组装,而不是获取。 +本后端不提供 DDL 提取:`supports_ddl_extraction` 对任何 PostgreSQL 服务器都回答 `False`,所以 `ddl` 不在对外列出的类型中。请求它属于 argparse 用法错误——`invalid choice: 'ddl'`——会以 `2` 退出,而不是进入处理器后报告 "not supported by this server"。探针与格式化方法仍保留在方言上,等待真正的组装实现;只有 CLI 表面不再提供该类型。 运行以 `1` 退出表示没能完成——连接失败、查询失败,或名称所指的对象不存在; 以 `2` 退出表示这个请求在此不成立:未知类型、该类型不接受名称,或本服务器 diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py index c1080da7..3f5225e5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py @@ -5,10 +5,15 @@ the standard types, plus the two schema-object families this backend answers for on its own: materialized views and sequences. The families the catalog and the statistics collector answer beside the schema structure -- -unused indexes, usage counters, runtime metrics, dependencies, DDL and the +unused indexes, usage counters, runtime metrics, dependencies and the data profiles -- are offered too: the dialect declares it can answer them, so the CLI has to be able to ask. +DDL extraction is deliberately not offered here. ``supports_ddl_extraction`` +answers False for every PostgreSQL server, so a ``ddl`` type would advertise a +question this backend can never answer. The formatter and expression stay in +place for when a real assembly lands; only the CLI surface omits the type. + A type either lists a family or, when it names a single catalogue object, answers for that one object; ``NAME`` is required, optional or refused according to which of the two it is, and never silently dropped. The rule per @@ -41,9 +46,7 @@ #: #: One entry per family this backend answers independently, whether the answer #: comes from the catalog, from the statistics collector or from the rows -#: themselves. Partition metadata has a switch too, but no introspector -#: question of its own -- it is read as part of the DDL a partitioned table -#: carries -- so it is not a type here. +#: themselves. _TYPE_CAPABILITIES = { "tables": "supports_table_introspection", "views": "supports_view_introspection", @@ -55,6 +58,14 @@ "extensions": "supports_extension_introspection", "materialized-views": "supports_materialized_view_introspection", "sequences": "supports_sequence_introspection", + # The schema-object families that were previously reachable only through + # hand-written catalog SQL. Each reads its own pg_catalog relation. + "partitions": "supports_partition_introspection", + "routines": "supports_routine_introspection", + "types": "supports_type_introspection", + "operators": "supports_operator_introspection", + "operator-classes": "supports_operator_class_introspection", + "operator-families": "supports_operator_family_introspection", # The per-table storage family answers for one table: the three size # figures ride the listing's projection, and this type asks it for one # name rather than making the caller read the whole listing. @@ -69,11 +80,9 @@ "runtime": "supports_runtime_stats", "index-stats": "supports_index_stats", "dependencies": "supports_object_dependencies", - # The one-object families: the DDL that recreates a named table, and the - # data profiles of one table or one column of it. They read the catalog, - # pg_stats or the rows themselves, and every answer carries how it was - # produced so the caller need not assume which. - "ddl": "supports_ddl_extraction", + # The one-object data families: the profiles of one table or one column + # of it. They read pg_stats or the rows themselves, and every answer + # carries how it was produced so the caller need not assume which. "column-profiles": "supports_column_data_profile", "value-samples": "supports_column_value_sample", "join-coverage": "supports_join_coverage", @@ -108,6 +117,14 @@ "extensions": "optional", "materialized-views": "optional", "sequences": "optional", + # A partition listing asks about one parent table, so the name is the + # question. The rest are catalogue families that a name narrows. + "partitions": "required", + "routines": "optional", + "types": "optional", + "operators": "optional", + "operator-classes": "optional", + "operator-families": "optional", "table-storage": "required", # The four listings over the statistics collector's scope describe what the # connection can see, not one object in it, so none takes a name -- the way @@ -118,9 +135,8 @@ "runtime": "forbidden", "index-stats": "forbidden", "dependencies": "optional", - # The one-object families: the DDL that recreates one table, the profiles - # of its columns, and the samples of one of them. - "ddl": "required", + # The one-object data families: the profiles of one table's columns, + # and the samples of one of them. "column-profiles": "required", "value-samples": "required", # ``join-coverage`` asks about two tables; the left one is NAME, so it is @@ -214,22 +230,6 @@ def _refuse_unused_arguments(args: argparse.Namespace) -> None: sys.exit(2) -def _display_ddl(provider, statement, args) -> None: - """Show a DDL statement in the form that stays readable. - - A ``CREATE TABLE`` is text: a one-row table folds it into a cell that - wraps at the terminal width and interleaves with the other columns. Table - output therefore prints the statement itself, while the structured - formats keep the whole row so JSON and CSV still carry ``object_type`` - and ``native`` beside the text. - """ - if args.output == "table": - print(statement.ddl or "") - return - data = _serialize_for_output([statement]) - provider.display_results(data, title=f"DDL of {statement.object_name}") - - def create_parser(subparsers): """Create the introspect subcommand parser.""" parser = subparsers.add_parser( @@ -271,6 +271,18 @@ def create_parser(subparsers): %(prog)s sequences --database mydb %(prog)s sequences order_id_seq --database mydb + # List a partitioned table's child partitions (and their bounds) + %(prog)s partitions orders --database mydb + + # List routines, types, operators, operator classes and operator families + %(prog)s routines --database mydb + %(prog)s routines app_total --database mydb + %(prog)s types --database mydb + %(prog)s types order_status --database mydb + %(prog)s operators --database mydb + %(prog)s operator-classes --database mydb + %(prog)s operator-families --database mydb + # Get one table's storage split (total, data, index bytes; row estimate) %(prog)s table-storage orders --database mydb @@ -287,9 +299,6 @@ def create_parser(subparsers): %(prog)s dependencies --database mydb %(prog)s dependencies orders --database mydb - # The DDL that recreates one table - %(prog)s ddl orders --database mydb - # Profile every column of a table from its data, and sample one column %(prog)s column-profiles orders --database mydb %(prog)s value-samples orders --column status --database mydb @@ -339,12 +348,13 @@ def create_parser(subparsers): nargs="?", default=None, help="Object name. Required for columns, indexes, foreign-keys, " - "table-storage, ddl and column-profiles (the table they belong to), " - "and for value-samples and join-coverage (the left table); " - "optional for triggers (a table), dependencies (one object) " - "and for views, materialized-views, sequences and extensions " - "(one object); refused for tables, database and the four counter " - "listings (unused-indexes, table-stats, runtime, index-stats). " + "table-storage, column-profiles and partitions (the table they " + "belong to), and for value-samples and join-coverage (the left table); " + "optional for triggers (a table), dependencies (one object), " + "views, materialized-views, sequences, extensions, routines, types, " + "operators, operator-classes and operator-families (one object); " + "refused for tables, database and the four counter listings " + "(unused-indexes, table-stats, runtime, index-stats). " "The rule per type is _NAME_RULE", ) # The three families that ask about a column or a second table carry the @@ -549,6 +559,58 @@ def _handle_introspect_sync(args, backend: PostgresBackend, provider): sequences = introspector.list_sequences(schema=None) data = _serialize_for_output(sequences) provider.display_results(data, title="Sequences") + + elif args.type == "partitions": + # The parent table is the question, so NAME was already required + # by _validate_name; the listing carries each child's bound. + partitions = introspector.list_partitions(args.name, schema=None) + data = _serialize_for_output(partitions) + provider.display_results(data, title=f"Partitions of {args.name}") + + elif args.type == "routines": + # A name narrows the listing rather than selecting one overload; + # functions can share a name and differ by argument list. + routines = introspector.list_routines(schema=None, routine_name=args.name) + data = _serialize_for_output(routines) + title = f"Routines named {args.name}" if args.name else "Routines" + provider.display_results(data, title=title) + + elif args.type == "types": + types = introspector.list_types(schema=None, type_name=args.name) + data = _serialize_for_output(types) + title = f"Types named {args.name}" if args.name else "Types" + provider.display_results(data, title=title) + + elif args.type == "operators": + operators = introspector.list_operators(schema=None, operator_name=args.name) + data = _serialize_for_output(operators) + title = f"Operators named {args.name}" if args.name else "Operators" + provider.display_results(data, title=title) + + elif args.type == "operator-classes": + operator_classes = introspector.list_operator_classes( + schema=None, operator_class_name=args.name + ) + data = _serialize_for_output(operator_classes) + title = ( + f"Operator classes named {args.name}" + if args.name + else "Operator Classes" + ) + provider.display_results(data, title=title) + + elif args.type == "operator-families": + operator_families = introspector.list_operator_families( + schema=None, operator_family_name=args.name + ) + data = _serialize_for_output(operator_families) + title = ( + f"Operator families named {args.name}" + if args.name + else "Operator Families" + ) + provider.display_results(data, title=title) + elif args.type == "table-storage": # The storage figures ride the listing's projection, so the # question asks the listing and picks the row -- one read, and @@ -599,21 +661,9 @@ def _handle_introspect_sync(args, backend: PostgresBackend, provider): title = f"Dependencies of {args.name}" if args.name else "Object Dependencies" provider.display_results(data, title=title) - # ---- the one-object families -------------------------------------- - # The DDL that recreates a table, and the profiles of one table or one - # column of it: the name is the table and the column is named on its - # own flag. - - elif args.type == "ddl": - statement = introspector.get_ddl_statement(args.name, object_type=None, schema=None) - if statement is None: - # Not "no such table": the object exists, and PostgreSQL - # answers with no text rather than an assembled fragment that - # would read as authoritative. The two are worth telling - # apart, so this exits without printing an empty row. - print(f"Error: no DDL available for '{args.name}'", file=sys.stderr) - sys.exit(1) - _display_ddl(provider, statement, args) + # ---- the one-object data families --------------------------------- + # The profiles of one table or one column of it: the name is the table + # and the column is named on its own flag. elif args.type == "column-profiles": profiles = introspector.list_column_data_profiles(args.name, schema=None) @@ -780,6 +830,58 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider sequences = await introspector.list_sequences(schema=None) data = _serialize_for_output(sequences) provider.display_results(data, title="Sequences") + + elif args.type == "partitions": + partitions = await introspector.list_partitions(args.name, schema=None) + data = _serialize_for_output(partitions) + provider.display_results(data, title=f"Partitions of {args.name}") + + elif args.type == "routines": + routines = await introspector.list_routines( + schema=None, routine_name=args.name + ) + data = _serialize_for_output(routines) + title = f"Routines named {args.name}" if args.name else "Routines" + provider.display_results(data, title=title) + + elif args.type == "types": + types = await introspector.list_types(schema=None, type_name=args.name) + data = _serialize_for_output(types) + title = f"Types named {args.name}" if args.name else "Types" + provider.display_results(data, title=title) + + elif args.type == "operators": + operators = await introspector.list_operators( + schema=None, operator_name=args.name + ) + data = _serialize_for_output(operators) + title = f"Operators named {args.name}" if args.name else "Operators" + provider.display_results(data, title=title) + + elif args.type == "operator-classes": + operator_classes = await introspector.list_operator_classes( + schema=None, operator_class_name=args.name + ) + data = _serialize_for_output(operator_classes) + title = ( + f"Operator classes named {args.name}" + if args.name + else "Operator Classes" + ) + provider.display_results(data, title=title) + + elif args.type == "operator-families": + operator_families = await introspector.list_operator_families( + schema=None, operator_family_name=args.name + ) + data = _serialize_for_output(operator_families) + title = ( + f"Operator families named {args.name}" + if args.name + else "Operator Families" + ) + provider.display_results(data, title=title) + elif args.type == "table-storage": # The async twin of the sync branch: the same listing read, # awaited, and the same pick. @@ -825,15 +927,6 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider title = f"Dependencies of {args.name}" if args.name else "Object Dependencies" provider.display_results(data, title=title) - elif args.type == "ddl": - statement = await introspector.get_ddl_statement( - args.name, object_type=None, schema=None - ) - if statement is None: - print(f"Error: no DDL available for '{args.name}'", file=sys.stderr) - sys.exit(1) - _display_ddl(provider, statement, args) - elif args.type == "column-profiles": profiles = await introspector.list_column_data_profiles(args.name, schema=None) data = _serialize_for_output(profiles) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py index 0e198dc5..c51b79f0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/introspection.py @@ -28,6 +28,12 @@ "PostgresMaterializedViewListQueryStatement", "PostgresSequenceInfoQueryStatement", "PostgresSequenceListQueryStatement", + "PostgresPartitionListQueryStatement", + "PostgresRoutineListQueryStatement", + "PostgresTypeListQueryStatement", + "PostgresOperatorListQueryStatement", + "PostgresOperatorClassListQueryStatement", + "PostgresOperatorFamilyListQueryStatement", "PostgresDatabaseSizeFunction", "PostgresDatabaseSizeQueryStatement", "PostgresRelationSizeFunction", @@ -155,6 +161,198 @@ def format_method(self) -> str: return "format_sequence_info_query_statement" +class PostgresPartitionListQueryStatement(IntrospectionExpression): + """List the child partitions of one partitioned table. + + The parent is named by the caller; the catalog answers its partition key + (``pg_get_partkeydef``) and each child's bound + (``pg_get_expr(relpartbound)``). ``include_statistics`` adds the child's + own row estimate and total size, which cost a catalog lookup each but let + a caller report storage without a second question. + + Example: + >>> expr = PostgresPartitionListQueryStatement(dialect, "orders", schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + include_partitions: bool = True, + include_statistics: bool = True, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + table_name: Name of the partitioned parent table. + schema: Optional schema name. Defaults to the connection schema. + include_partitions: Whether to list the child partitions. ``False`` + answers only the parent's key. + include_statistics: Whether to add each child's row estimate and + size in bytes. + """ + super().__init__(dialect, schema=schema) + if not table_name: + raise ValueError("table_name must not be empty") + self.table_name = table_name + self.include_partitions = include_partitions + self.include_statistics = include_statistics + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_list_query_statement" + + +class PostgresRoutineListQueryStatement(IntrospectionExpression): + """List PostgreSQL routines from ``pg_proc``, optionally narrowed by name. + + Example: + >>> expr = PostgresRoutineListQueryStatement(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + routine_name: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: Optional schema name. Defaults to the connection schema. + routine_name: Optional routine name; it narrows the listing. + """ + super().__init__(dialect, schema=schema) + self.routine_name = routine_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_routine_list_query_statement" + + +class PostgresTypeListQueryStatement(IntrospectionExpression): + """List PostgreSQL enum/domain/composite/range/multirange types. + + Example: + >>> expr = PostgresTypeListQueryStatement(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + type_name: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: Optional schema name. Defaults to the connection schema. + type_name: Optional type name; it narrows the listing. + """ + super().__init__(dialect, schema=schema) + self.type_name = type_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_type_list_query_statement" + + +class PostgresOperatorListQueryStatement(IntrospectionExpression): + """List PostgreSQL operators from ``pg_operator``. + + Example: + >>> expr = PostgresOperatorListQueryStatement(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + operator_name: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: Optional schema name. Defaults to the connection schema. + operator_name: Optional operator name; it narrows the listing. + """ + super().__init__(dialect, schema=schema) + self.operator_name = operator_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_operator_list_query_statement" + + +class PostgresOperatorClassListQueryStatement(IntrospectionExpression): + """List PostgreSQL operator classes from ``pg_opclass``. + + Example: + >>> expr = PostgresOperatorClassListQueryStatement(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + operator_class_name: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: Optional schema name. Defaults to the connection schema. + operator_class_name: Optional operator class name; it narrows the + listing. + """ + super().__init__(dialect, schema=schema) + self.operator_class_name = operator_class_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_operator_class_list_query_statement" + + +class PostgresOperatorFamilyListQueryStatement(IntrospectionExpression): + """List PostgreSQL operator families from ``pg_opfamily``. + + Example: + >>> expr = PostgresOperatorFamilyListQueryStatement(dialect, schema="public") + >>> sql, params = expr.to_sql() + """ + + def __init__( + self, + dialect: "SQLDialectBase", + schema: Optional[str] = None, + operator_family_name: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: Optional schema name. Defaults to the connection schema. + operator_family_name: Optional operator family name; it narrows the + listing. + """ + super().__init__(dialect, schema=schema) + self.operator_family_name = operator_family_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_operator_family_list_query_statement" + + class PostgresDatabaseSizeFunction(BaseExpression): """The ``pg_database_size(name)`` call as a node of its own. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index c9cef3bb..f0271326 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -24,9 +24,10 @@ - AsyncPostgreSQLIntrospector: for asynchronous backends """ -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING import json +import re from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, @@ -61,7 +62,19 @@ PostgreSQLStatusIntrospector, AsyncPostgreSQLStatusIntrospector, ) -from .types import SequenceInfo +from .types import ( + SequenceInfo, + PostgresPartitionStrategy, + PostgresPartitionBound, + PostgresPartitionInfo, + PostgresRoutineKind, + PostgresRoutineInfo, + PostgresTypeKind, + PostgresTypeInfo, + PostgresOperatorInfo, + PostgresOperatorClassInfo, + PostgresOperatorFamilyInfo, +) if TYPE_CHECKING: # pragma: no cover from ..expression.introspection import ( @@ -69,6 +82,12 @@ PostgresMaterializedViewListQueryStatement, PostgresSequenceInfoQueryStatement, PostgresSequenceListQueryStatement, + PostgresPartitionListQueryStatement, + PostgresRoutineListQueryStatement, + PostgresTypeListQueryStatement, + PostgresOperatorListQueryStatement, + PostgresOperatorClassListQueryStatement, + PostgresOperatorFamilyListQueryStatement, ) from ..expression.status import PostgresExtensionListQuery @@ -220,6 +239,20 @@ def parse_row_count(row: Dict[str, Any]) -> Optional[int]: return int(live_rows) return None + @staticmethod + def _is_serial_default(default_value: Any) -> bool: + """Whether a column default is the ``nextval(...)`` a SERIAL carries. + + PostgreSQL spells a SERIAL column's default as + ``nextval('table_column_seq'::regclass)``. That is the only signal on + such a column -- it has no ``attidentity`` -- so recognising it is + what lets a caller tell a database-generated primary key apart from + one the application supplies. + """ + if not isinstance(default_value, str): + return False + return default_value.strip().lower().startswith("nextval(") + def parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str) -> List[IntrospectionColumnInfo]: columns = [] for row in rows: @@ -227,6 +260,15 @@ def parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str # Extract base type (remove array brackets, size specifiers) base_type = data_type.split("[")[0].split("(")[0].strip() parsed = DataType.parse_data_type_str(self._backend.dialect, data_type) + default_value = row.get("default_value") + identity_kind = row.get("identity_kind") or "" + generated_kind = row.get("generated_kind") or "" + # Identity columns have attidentity and no pg_attrdef row; SERIAL + # columns have the nextval default and no identity. Both mean the + # database generates the value, and both are what a model + # generator needs to set __pk_auto_generated__ correctly. + is_auto_increment = bool(identity_kind) or self._is_serial_default(default_value) + is_generated = bool(generated_kind) columns.append( IntrospectionColumnInfo( name=row["column_name"], @@ -237,8 +279,11 @@ def parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str data_type_full=data_type, parsed_data_type=parsed, nullable=ColumnNullable.NULLABLE if row.get("is_nullable") else ColumnNullable.NOT_NULL, - default_value=row.get("default_value"), + default_value=default_value, is_primary_key=row.get("is_primary_key", False), + is_auto_increment=is_auto_increment, + is_generated=is_generated, + generated_expression=default_value if is_generated else None, comment=row.get("comment"), # The four shape attributes information_schema.columns # derives from atttypmod. They are absent for a relation @@ -248,6 +293,11 @@ def parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: str numeric_precision=row.get("numeric_precision"), numeric_scale=row.get("numeric_scale"), datetime_precision=row.get("datetime_precision"), + extra=( + {"identity_kind": identity_kind} + if identity_kind + else {} + ), ) ) return columns @@ -666,6 +716,15 @@ def parse_sequence( value -- neither of which the caller should have to normalise. """ cycle = row.get("cycle") + if isinstance(cycle, bool): + # ``pg_sequences.cycle`` is a boolean; information_schema's + # ``cycle_option`` is the ``YES``/``NO`` text. Both spellings mean + # the same fact, so the parser accepts either. + cycle_value = cycle + elif cycle is None: + cycle_value = False + else: + cycle_value = str(cycle).strip().upper() == "YES" return SequenceInfo( name=row.get("sequence_name") or default_name, schema=row.get("schema_name") or default_schema, @@ -674,7 +733,7 @@ def parse_sequence( minimum_value=row.get("minimum_value"), maximum_value=row.get("maximum_value"), increment=row.get("increment"), - cycle=str(cycle).strip().upper() == "YES" if cycle is not None else False, + cycle=cycle_value, cache_size=row.get("cache_size"), last_value=row.get("last_value"), ) @@ -735,6 +794,407 @@ def _sequence_cache_key( extra=str(include_pg_sequences), ) + # ------------------------------------------------------------------ # + # Schema-object families: partitions, routines, types, operators + # ------------------------------------------------------------------ # + + #: ``FOR VALUES FROM (...) TO (...)`` on a RANGE partition. + _RANGE_BOUND = re.compile(r"FOR VALUES FROM\s*\((.+)\)\s*TO\s*\((.+)\)", re.I | re.S) + #: ``FOR VALUES IN (...)`` on a LIST partition. + _LIST_BOUND = re.compile(r"FOR VALUES IN\s*\((.+)\)", re.I | re.S) + #: ``FOR VALUES WITH (MODULUS n, REMAINDER r)`` on a HASH partition. + _HASH_BOUND = re.compile( + r"FOR VALUES WITH\s*\(\s*MODULUS\s+(\d+)\s*,\s*REMAINDER\s+(\d+)\s*\)", + re.I, + ) + #: ``RANGE (created_at)`` / ``LIST (status)`` / ``HASH (user_id)``. + _PARTITION_KEY = re.compile(r"(RANGE|LIST|HASH)\s*\((.*)\)", re.I | re.S) + + @staticmethod + def _split_top_level(text: str) -> List[str]: + """Split a comma-separated SQL fragment outside parentheses/quotes.""" + parts: List[str] = [] + current: List[str] = [] + depth = 0 + quote: Optional[str] = None + for char in text: + if quote: + current.append(char) + if char == quote: + quote = None + elif char in ("'", '"'): + quote = char + current.append(char) + elif char == "(": + depth += 1 + current.append(char) + elif char == ")": + depth = max(depth - 1, 0) + current.append(char) + elif char == "," and depth == 0: + parts.append("".join(current).strip()) + current = [] + else: + current.append(char) + if current: + parts.append("".join(current).strip()) + return [part for part in parts if part] + + @staticmethod + def _unquote_bound_value(value: str) -> str: + """Strip the SQL quotes a bound literal carries, if it is quoted.""" + value = value.strip() + if len(value) >= 2 and value[0] == "'" and value[-1] == "'": + return value[1:-1].replace("''", "'") + if len(value) >= 2 and value[0] == '"' and value[-1] == '"': + return value[1:-1].replace('""', '"') + return value + + @classmethod + def _parse_partition_key( + cls, key: Optional[str] + ) -> Tuple[PostgresPartitionStrategy, List[str], Optional[str]]: + """Split ``pg_get_partkeydef``'s text into strategy and key. + + ``RANGE (created_at)`` is a column list; ``RANGE (date_trunc(...))`` + is an expression. A key that is not one of those forms is still a + partition, so the strategy falls back to ``UNKNOWN`` rather than + raising. + """ + if not key: + return PostgresPartitionStrategy.UNKNOWN, [], None + match = cls._PARTITION_KEY.search(str(key)) + if match is None: + return PostgresPartitionStrategy.UNKNOWN, [], str(key) + strategy = { + "RANGE": PostgresPartitionStrategy.RANGE, + "LIST": PostgresPartitionStrategy.LIST, + "HASH": PostgresPartitionStrategy.HASH, + }.get(match.group(1).upper(), PostgresPartitionStrategy.UNKNOWN) + inner = match.group(2).strip() + parts = cls._split_top_level(inner) + simple = all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_$]*", part) for part in parts) + if simple: + return strategy, parts, None + return strategy, [], inner or None + + @classmethod + def _parse_partition_bound(cls, bound: Optional[str]) -> Optional[PostgresPartitionBound]: + """Decode the one bound expression ``pg_get_expr(relpartbound)`` gives. + + The text is the engine's own spelling; the types are words, not rows, + because a RANGE endpoint is a SQL literal in the catalog and cannot be + safely converted without the column's type. ``text`` always carries + the original so nothing is lost when a shape is not recognised. + """ + if not bound: + return None + text = str(bound).strip() + if text.upper() == "DEFAULT": + return PostgresPartitionBound(text=text) + match = cls._RANGE_BOUND.search(text) + if match: + return PostgresPartitionBound( + lower=cls._unquote_bound_value(match.group(1)), + upper=cls._unquote_bound_value(match.group(2)), + text=text, + ) + match = cls._LIST_BOUND.search(text) + if match: + return PostgresPartitionBound( + values=[ + cls._unquote_bound_value(value) + for value in cls._split_top_level(match.group(1)) + ], + text=text, + ) + match = cls._HASH_BOUND.search(text) + if match: + return PostgresPartitionBound( + modulus=int(match.group(1)), + remainder=int(match.group(2)), + text=text, + ) + return PostgresPartitionBound(text=text) + + def parse_partitions( + self, + rows: List[Dict[str, Any]], + table_name: str, + schema: str, + ) -> List[PostgresPartitionInfo]: + """Turn the parent/child rows into one record per child partition.""" + partitions: List[PostgresPartitionInfo] = [] + strategy = PostgresPartitionStrategy.UNKNOWN + key_columns: List[str] = [] + key_expression: Optional[str] = None + for row in rows or []: + name = row.get("name") + if not name: + continue + if not partitions: + strategy, key_columns, key_expression = self._parse_partition_key( + row.get("partition_key") + ) + row_count = row.get("row_count") + if row_count is not None and row_count < 0: + row_count = None + elif row_count is not None: + row_count = int(row_count) + partitions.append( + PostgresPartitionInfo( + table=row.get("parent_table") or table_name, + name=str(name), + schema=row.get("schema_name") or schema, + strategy=strategy, + key_columns=key_columns, + key_expression=key_expression, + parent=row.get("parent_table") or table_name, + bound=self._parse_partition_bound(row.get("bound")), + row_count=row_count, + size_bytes=row.get("size_bytes"), + comment=row.get("comment"), + ) + ) + return partitions + + @staticmethod + def parse_routine(row: Dict[str, Any], default_schema: str) -> PostgresRoutineInfo: + """Build one routine record from a ``pg_proc`` row.""" + kind = { + "f": PostgresRoutineKind.FUNCTION, + "p": PostgresRoutineKind.PROCEDURE, + "a": PostgresRoutineKind.AGGREGATE, + "w": PostgresRoutineKind.WINDOW, + }.get(row.get("kind"), PostgresRoutineKind.UNKNOWN) + volatility = { + "i": "immutable", + "s": "stable", + "v": "volatile", + }.get(row.get("volatility"), row.get("volatility")) + return PostgresRoutineInfo( + name=row.get("name") or "", + schema=row.get("schema_name") or default_schema, + kind=kind, + identity_arguments=row.get("identity_arguments"), + result_type=row.get("result_type"), + language=row.get("language"), + volatility=volatility, + security_definer=bool(row.get("security_definer")), + comment=row.get("comment"), + ) + + def parse_routines( + self, rows: List[Dict[str, Any]], schema: str + ) -> List[PostgresRoutineInfo]: + return [self.parse_routine(row, schema) for row in rows or []] + + @staticmethod + def parse_types(rows: List[Dict[str, Any]], schema: str) -> List[PostgresTypeInfo]: + """Group the type/label/field rows into one record per type. + + The statement joins ``pg_enum`` and ``pg_attribute`` to one type query; + each type therefore answers with as many rows as it has labels *or* + fields. Grouping by schema and name is what turns those rows back into + the record a caller reads. + """ + builders: Dict[Tuple[str, str], Dict[str, Any]] = {} + for row in rows or []: + key = (row.get("schema_name") or schema, row.get("name") or "") + builder = builders.get(key) + if builder is None: + builder = {"row": row, "labels": [], "fields": []} + builders[key] = builder + if row.get("label") is not None: + builder["labels"].append((row.get("label_order") or 0, row["label"])) + if row.get("field_name") is not None: + builder["fields"].append((row.get("field_order") or 0, row["field_name"])) + + results: List[PostgresTypeInfo] = [] + for (type_schema, type_name), builder in builders.items(): + row = builder["row"] + kind = { + "e": PostgresTypeKind.ENUM, + "d": PostgresTypeKind.DOMAIN, + "c": PostgresTypeKind.COMPOSITE, + "r": PostgresTypeKind.RANGE, + "m": PostgresTypeKind.MULTIRANGE, + }.get(row.get("type_code"), PostgresTypeKind.UNKNOWN) + extra: Dict[str, Any] = {} + if row.get("relation_kind") is not None: + extra["relation_kind"] = row["relation_kind"] + if row.get("relation_name") is not None: + extra["relation_name"] = row["relation_name"] + results.append( + PostgresTypeInfo( + name=type_name, + schema=type_schema, + kind=kind, + labels=[label for _, label in sorted(builder["labels"])], + fields=[field for _, field in sorted(builder["fields"])], + base_type=row.get("base_type"), + domain_not_null=( + bool(row.get("domain_not_null")) + if row.get("domain_not_null") is not None + else None + ), + domain_default=row.get("domain_default"), + comment=row.get("comment"), + extra=extra, + ) + ) + return results + + @staticmethod + def parse_operator(row: Dict[str, Any], default_schema: str) -> PostgresOperatorInfo: + """Build one operator record from a ``pg_operator`` row.""" + return PostgresOperatorInfo( + name=row.get("name") or "", + schema=row.get("schema_name") or default_schema, + operator_kind={ + "b": "binary", + "l": "prefix", + "r": "postfix", + }.get(row.get("operator_kind"), row.get("operator_kind")), + left_type=row.get("left_type"), + right_type=row.get("right_type"), + result_type=row.get("result_type"), + function_name=row.get("function_name"), + owner=row.get("owner"), + comment=row.get("comment"), + ) + + def parse_operators( + self, rows: List[Dict[str, Any]], schema: str + ) -> List[PostgresOperatorInfo]: + return [self.parse_operator(row, schema) for row in rows or []] + + @staticmethod + def parse_operator_class( + row: Dict[str, Any], default_schema: str + ) -> PostgresOperatorClassInfo: + """Build one operator-class record from a ``pg_opclass`` row.""" + return PostgresOperatorClassInfo( + name=row.get("name") or "", + schema=row.get("schema_name") or default_schema, + method=row.get("method"), + input_type=row.get("input_type"), + key_type=row.get("key_type"), + is_default=bool(row.get("is_default")), + family=row.get("family"), + owner=row.get("owner"), + comment=row.get("comment"), + ) + + def parse_operator_classes( + self, rows: List[Dict[str, Any]], schema: str + ) -> List[PostgresOperatorClassInfo]: + return [self.parse_operator_class(row, schema) for row in rows or []] + + @staticmethod + def parse_operator_family( + row: Dict[str, Any], default_schema: str + ) -> PostgresOperatorFamilyInfo: + """Build one operator-family record from a ``pg_opfamily`` row.""" + return PostgresOperatorFamilyInfo( + name=row.get("name") or "", + schema=row.get("schema_name") or default_schema, + method=row.get("method"), + owner=row.get("owner"), + comment=row.get("comment"), + ) + + def parse_operator_families( + self, rows: List[Dict[str, Any]], schema: str + ) -> List[PostgresOperatorFamilyInfo]: + return [self.parse_operator_family(row, schema) for row in rows or []] + + @staticmethod + def _schema_family_cache_key( + cache, + family: str, + schema: Optional[str], + name: Optional[str] = None, + extra: Optional[str] = None, + ) -> str: + """One cache-key shape for the families that share ``SCHEMA`` scope.""" + parts = family if not extra else f"{family}:{extra}" + return cache.key( + IntrospectionScope.SCHEMA, + name or "*", + schema=schema, + extra=parts, + ) + + def partition_list_statement( + self, + *, + table_name: str, + schema: Optional[str], + include_partitions: bool = True, + include_statistics: bool = True, + ) -> "PostgresPartitionListQueryStatement": + """Return the statement that lists one table's child partitions.""" + from ..expression.introspection import PostgresPartitionListQueryStatement + + return PostgresPartitionListQueryStatement( + self.dialect, + table_name, + schema=schema, + include_partitions=include_partitions, + include_statistics=include_statistics, + ) + + def routine_list_statement( + self, *, schema: Optional[str], routine_name: Optional[str] = None + ) -> "PostgresRoutineListQueryStatement": + """Return the statement that lists routines in one schema.""" + from ..expression.introspection import PostgresRoutineListQueryStatement + + return PostgresRoutineListQueryStatement( + self.dialect, schema=schema, routine_name=routine_name + ) + + def type_list_statement( + self, *, schema: Optional[str], type_name: Optional[str] = None + ) -> "PostgresTypeListQueryStatement": + """Return the statement that lists user types in one schema.""" + from ..expression.introspection import PostgresTypeListQueryStatement + + return PostgresTypeListQueryStatement( + self.dialect, schema=schema, type_name=type_name + ) + + def operator_list_statement( + self, *, schema: Optional[str], operator_name: Optional[str] = None + ) -> "PostgresOperatorListQueryStatement": + """Return the statement that lists operators in one schema.""" + from ..expression.introspection import PostgresOperatorListQueryStatement + + return PostgresOperatorListQueryStatement( + self.dialect, schema=schema, operator_name=operator_name + ) + + def operator_class_list_statement( + self, *, schema: Optional[str], operator_class_name: Optional[str] = None + ) -> "PostgresOperatorClassListQueryStatement": + """Return the statement that lists operator classes in one schema.""" + from ..expression.introspection import PostgresOperatorClassListQueryStatement + + return PostgresOperatorClassListQueryStatement( + self.dialect, schema=schema, operator_class_name=operator_class_name + ) + + def operator_family_list_statement( + self, *, schema: Optional[str], operator_family_name: Optional[str] = None + ) -> "PostgresOperatorFamilyListQueryStatement": + """Return the statement that lists operator families in one schema.""" + from ..expression.introspection import PostgresOperatorFamilyListQueryStatement + + return PostgresOperatorFamilyListQueryStatement( + self.dialect, schema=schema, operator_family_name=operator_family_name + ) + # ------------------------------------------------------------------ # # Extension introspection # ------------------------------------------------------------------ # @@ -1021,6 +1481,232 @@ def sequence_exists( """Return True when the named sequence exists.""" return self.get_sequence_info(sequence_name, schema) is not None + # ------------------------------------------------------------------ # + # Partitions, routines, types, operators + # ------------------------------------------------------------------ # + + def list_partitions( + self, + table_name: str, + schema: Optional[str] = None, + include_partitions: bool = True, + include_statistics: bool = True, + ) -> List[PostgresPartitionInfo]: + """List one partitioned table's child partitions. + + Args: + table_name: The partitioned parent table. + schema: Schema to inspect. Defaults to the connection default schema. + include_partitions: Whether to list the children (default True). + include_statistics: Whether to fill each child's row/size (default True). + + Returns: + One record per child partition, ordered by name. An empty list is + a real answer: the table may be partitioned with no children yet. + """ + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "partitions", schema, name=table_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.partition_list_statement( + table_name=table_name, + schema=schema, + include_partitions=include_partitions, + include_statistics=include_statistics, + ).to_sql() + result = self.parse_partitions(self._executor.execute(sql, params), table_name, target) + self._cache.set(key, result) + return result + + def list_routines( + self, + schema: Optional[str] = None, + routine_name: Optional[str] = None, + ) -> List[PostgresRoutineInfo]: + """List functions, procedures, aggregates and window functions. + + Args: + schema: Schema to inspect. Defaults to the connection default schema. + routine_name: Optional name to narrow the listing (overloads are kept). + + Returns: + List of PostgresRoutineInfo, ordered by schema, name and OID. + """ + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "routines", schema, name=routine_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.routine_list_statement( + schema=schema, routine_name=routine_name + ).to_sql() + result = self.parse_routines(self._executor.execute(sql, params), target) + self._cache.set(key, result) + return result + + def get_routine_info( + self, routine_name: str, schema: Optional[str] = None + ) -> Optional[PostgresRoutineInfo]: + """Return the first routine named ``routine_name``, or None.""" + return next( + ( + routine + for routine in self.list_routines( + schema=schema, routine_name=routine_name + ) + ), + None, + ) + + def list_types( + self, + schema: Optional[str] = None, + type_name: Optional[str] = None, + ) -> List[PostgresTypeInfo]: + """List the schema's enum, domain, composite, range and multirange types. + + Args: + schema: Schema to inspect. Defaults to the connection default schema. + type_name: Optional name to narrow the listing. + + Returns: + List of PostgresTypeInfo. A table's row type is included as a + composite, with ``extra['relation_kind']`` naming the relation it + belongs to. + """ + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "types", schema, name=type_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.type_list_statement( + schema=schema, type_name=type_name + ).to_sql() + result = self.parse_types(self._executor.execute(sql, params), target) + self._cache.set(key, result) + return result + + def get_type_info( + self, type_name: str, schema: Optional[str] = None + ) -> Optional[PostgresTypeInfo]: + """Return the type named ``type_name``, or None.""" + return next( + ( + type_info + for type_info in self.list_types(schema=schema, type_name=type_name) + ), + None, + ) + + def list_operators( + self, + schema: Optional[str] = None, + operator_name: Optional[str] = None, + ) -> List[PostgresOperatorInfo]: + """List the operators in one schema.""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "operators", schema, name=operator_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.operator_list_statement( + schema=schema, operator_name=operator_name + ).to_sql() + result = self.parse_operators(self._executor.execute(sql, params), target) + self._cache.set(key, result) + return result + + def get_operator_info( + self, operator_name: str, schema: Optional[str] = None + ) -> Optional[PostgresOperatorInfo]: + """Return the operator named ``operator_name``, or None.""" + return next( + ( + operator + for operator in self.list_operators( + schema=schema, operator_name=operator_name + ) + ), + None, + ) + + def list_operator_classes( + self, + schema: Optional[str] = None, + operator_class_name: Optional[str] = None, + ) -> List[PostgresOperatorClassInfo]: + """List the operator classes in one schema.""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "operator-classes", schema, name=operator_class_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.operator_class_list_statement( + schema=schema, operator_class_name=operator_class_name + ).to_sql() + result = self.parse_operator_classes(self._executor.execute(sql, params), target) + self._cache.set(key, result) + return result + + def get_operator_class_info( + self, operator_class_name: str, schema: Optional[str] = None + ) -> Optional[PostgresOperatorClassInfo]: + """Return the operator class named ``operator_class_name``, or None.""" + return next( + ( + operator_class + for operator_class in self.list_operator_classes( + schema=schema, operator_class_name=operator_class_name + ) + ), + None, + ) + + def list_operator_families( + self, + schema: Optional[str] = None, + operator_family_name: Optional[str] = None, + ) -> List[PostgresOperatorFamilyInfo]: + """List the operator families in one schema.""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "operator-families", schema, name=operator_family_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.operator_family_list_statement( + schema=schema, operator_family_name=operator_family_name + ).to_sql() + result = self.parse_operator_families(self._executor.execute(sql, params), target) + self._cache.set(key, result) + return result + + def get_operator_family_info( + self, operator_family_name: str, schema: Optional[str] = None + ) -> Optional[PostgresOperatorFamilyInfo]: + """Return the operator family named ``operator_family_name``, or None.""" + return next( + ( + operator_family + for operator_family in self.list_operator_families( + schema=schema, operator_family_name=operator_family_name + ) + ), + None, + ) + # ------------------------------------------------------------------ # # Extension introspection # ------------------------------------------------------------------ # @@ -1223,6 +1909,204 @@ async def sequence_exists( """Return True when the named sequence exists (async).""" return await self.get_sequence_info(sequence_name, schema) is not None + # ------------------------------------------------------------------ # + # Partitions, routines, types, operators + # ------------------------------------------------------------------ # + + async def list_partitions( + self, + table_name: str, + schema: Optional[str] = None, + include_partitions: bool = True, + include_statistics: bool = True, + ) -> List[PostgresPartitionInfo]: + """List one partitioned table's child partitions (async counterpart).""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "partitions", schema, name=table_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.partition_list_statement( + table_name=table_name, + schema=schema, + include_partitions=include_partitions, + include_statistics=include_statistics, + ).to_sql() + rows = await self._executor.execute(sql, params) + result = self.parse_partitions(rows, table_name, target) + self._cache.set(key, result) + return result + + async def list_routines( + self, + schema: Optional[str] = None, + routine_name: Optional[str] = None, + ) -> List[PostgresRoutineInfo]: + """List PostgreSQL routines (async counterpart).""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "routines", schema, name=routine_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.routine_list_statement( + schema=schema, routine_name=routine_name + ).to_sql() + rows = await self._executor.execute(sql, params) + result = self.parse_routines(rows, target) + self._cache.set(key, result) + return result + + async def get_routine_info( + self, routine_name: str, schema: Optional[str] = None + ) -> Optional[PostgresRoutineInfo]: + """Return the first routine named ``routine_name``, or None (async).""" + return next( + iter( + await self.list_routines( + schema=schema, routine_name=routine_name + ) + ), + None, + ) + + async def list_types( + self, + schema: Optional[str] = None, + type_name: Optional[str] = None, + ) -> List[PostgresTypeInfo]: + """List PostgreSQL user types (async counterpart).""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "types", schema, name=type_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.type_list_statement( + schema=schema, type_name=type_name + ).to_sql() + rows = await self._executor.execute(sql, params) + result = self.parse_types(rows, target) + self._cache.set(key, result) + return result + + async def get_type_info( + self, type_name: str, schema: Optional[str] = None + ) -> Optional[PostgresTypeInfo]: + """Return the type named ``type_name``, or None (async).""" + return next( + iter( + await self.list_types(schema=schema, type_name=type_name) + ), + None, + ) + + async def list_operators( + self, + schema: Optional[str] = None, + operator_name: Optional[str] = None, + ) -> List[PostgresOperatorInfo]: + """List PostgreSQL operators (async counterpart).""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "operators", schema, name=operator_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.operator_list_statement( + schema=schema, operator_name=operator_name + ).to_sql() + rows = await self._executor.execute(sql, params) + result = self.parse_operators(rows, target) + self._cache.set(key, result) + return result + + async def get_operator_info( + self, operator_name: str, schema: Optional[str] = None + ) -> Optional[PostgresOperatorInfo]: + """Return the operator named ``operator_name``, or None (async).""" + return next( + iter( + await self.list_operators( + schema=schema, operator_name=operator_name + ) + ), + None, + ) + + async def list_operator_classes( + self, + schema: Optional[str] = None, + operator_class_name: Optional[str] = None, + ) -> List[PostgresOperatorClassInfo]: + """List PostgreSQL operator classes (async counterpart).""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "operator-classes", schema, name=operator_class_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.operator_class_list_statement( + schema=schema, operator_class_name=operator_class_name + ).to_sql() + rows = await self._executor.execute(sql, params) + result = self.parse_operator_classes(rows, target) + self._cache.set(key, result) + return result + + async def get_operator_class_info( + self, operator_class_name: str, schema: Optional[str] = None + ) -> Optional[PostgresOperatorClassInfo]: + """Return the named operator class, or None (async).""" + return next( + iter( + await self.list_operator_classes( + schema=schema, operator_class_name=operator_class_name + ) + ), + None, + ) + + async def list_operator_families( + self, + schema: Optional[str] = None, + operator_family_name: Optional[str] = None, + ) -> List[PostgresOperatorFamilyInfo]: + """List PostgreSQL operator families (async counterpart).""" + target = schema if schema is not None else self.default_schema + key = self._schema_family_cache_key( + self._cache, "operator-families", schema, name=operator_family_name + ) + cached = self._cache.get(key) + if cached is not None: + return cached + sql, params = self.operator_family_list_statement( + schema=schema, operator_family_name=operator_family_name + ).to_sql() + rows = await self._executor.execute(sql, params) + result = self.parse_operator_families(rows, target) + self._cache.set(key, result) + return result + + async def get_operator_family_info( + self, operator_family_name: str, schema: Optional[str] = None + ) -> Optional[PostgresOperatorFamilyInfo]: + """Return the named operator family, or None (async).""" + return next( + iter( + await self.list_operator_families( + schema=schema, operator_family_name=operator_family_name + ) + ), + None, + ) + # ------------------------------------------------------------------ # # Extension introspection # ------------------------------------------------------------------ # diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/types.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/types.py index 4775fc92..846ecd0f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/types.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/types.py @@ -11,11 +11,25 @@ """ from dataclasses import dataclass, field +from enum import Enum from typing import Any, Dict, List, Optional from rhosocial.activerecord.backend.introspection.types import ColumnDataProfile -__all__ = ["SequenceInfo", "PostgresColumnDataProfile"] +__all__ = [ + "SequenceInfo", + "PostgresColumnDataProfile", + "PostgresPartitionStrategy", + "PostgresPartitionBound", + "PostgresPartitionInfo", + "PostgresRoutineKind", + "PostgresRoutineInfo", + "PostgresTypeKind", + "PostgresTypeInfo", + "PostgresOperatorInfo", + "PostgresOperatorClassInfo", + "PostgresOperatorFamilyInfo", +] @dataclass(frozen=True) @@ -48,6 +62,203 @@ class SequenceInfo: extra: Dict[str, Any] = field(default_factory=dict) +class PostgresPartitionStrategy(str, Enum): + """How PostgreSQL distributes rows across a partitioned table. + + ``RELKIND`` is not the strategy: ``pg_get_partkeydef`` returns the strategy + as the leading word of its text (``RANGE (created_at)``), and that is the + only place the catalog states it. ``UNKNOWN`` is an answer, not an error -- + a future strategy this parser does not know is still a partition. + """ + + RANGE = "range" + LIST = "list" + HASH = "hash" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class PostgresPartitionBound: + """A partition's bound, decoded from ``pg_get_expr(relpartbound)``. + + The bound is text in the catalog. Its shape is one of three forms, and + which one applies follows the parent's strategy: + + * ``FOR VALUES FROM (...) TO (...)`` -- ``RANGE``; ``lower`` and ``upper`` + hold the two endpoints. + * ``FOR VALUES IN (...)`` -- ``LIST``; ``values`` holds the members. + * ``FOR VALUES WITH (MODULUS n, REMAINDER r)`` -- ``HASH``; ``modulus`` + and ``remainder`` hold the two numbers. + + ``DEFAULT`` is a partition too, and it fits none of the three. It keeps + ``text`` and leaves the typed slots empty, which says "this partition + catches what the others do not" rather than inventing a bound. + """ + + lower: Optional[str] = None + upper: Optional[str] = None + values: List[str] = field(default_factory=list) + modulus: Optional[int] = None + remainder: Optional[int] = None + text: Optional[str] = None + + +@dataclass(frozen=True) +class PostgresPartitionInfo: + """One child partition of a partitioned PostgreSQL table. + + The parent is the table a model should map to; ``name`` is the physical + child. ``strategy`` and ``key_columns`` come from the parent's + ``pg_get_partkeydef`` text, and ``bound`` from the child's own + ``relpartbound`` expression. ``row_count`` and ``size_bytes`` are the + child's own storage facts, when the query was asked for them. + + ``extra`` carries catalog facts this common shape has no slot for, such as + the parent OID or the child's ``relkind``. + """ + + table: str + name: str + schema: Optional[str] = None + strategy: PostgresPartitionStrategy = PostgresPartitionStrategy.UNKNOWN + key_columns: List[str] = field(default_factory=list) + key_expression: Optional[str] = None + parent: Optional[str] = None + bound: Optional[PostgresPartitionBound] = None + row_count: Optional[int] = None + size_bytes: Optional[int] = None + comment: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +class PostgresRoutineKind(str, Enum): + """What ``pg_proc.prokind`` says a routine is.""" + + FUNCTION = "function" + PROCEDURE = "procedure" + AGGREGATE = "aggregate" + WINDOW = "window" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class PostgresRoutineInfo: + """One function, procedure, aggregate or window function. + + ``identity_arguments`` is the argument list that identifies the routine + among its overloads, as PostgreSQL itself spells it. ``result_type`` is + the ``pg_get_function_result`` text; for a procedure it is empty because + procedures return no value. ``volatility`` is the catalog's word + (``immutable``/``stable``/``volatile``), not a boolean. + """ + + name: str + schema: Optional[str] = None + kind: PostgresRoutineKind = PostgresRoutineKind.FUNCTION + identity_arguments: Optional[str] = None + result_type: Optional[str] = None + language: Optional[str] = None + volatility: Optional[str] = None + security_definer: bool = False + comment: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +class PostgresTypeKind(str, Enum): + """The user-visible kinds of PostgreSQL type this family reports. + + ``pg_type.typtype`` also has base (``b``) and pseudo (``p``) codes, but + the introspection family is about the kinds a schema defines itself: + enums, domains, composites, ranges and multiranges. A system base type + belongs to ``pg_catalog`` and is not the schema's object. + """ + + ENUM = "enum" + DOMAIN = "domain" + COMPOSITE = "composite" + RANGE = "range" + MULTIRANGE = "multirange" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class PostgresTypeInfo: + """One enum, domain, composite, range or multirange type. + + ``labels`` is the enum's ordered value list. ``fields`` is the composite's + ordered attribute list -- for a table's row type these are the table's + columns, which is why ``extra['relation_kind']`` also travels: it tells a + real ``CREATE TYPE ... AS (...)`` (``typrelid`` points at a ``'c'`` + relation) apart from a table row type (``'r'``/``'p'``/...). + + ``base_type`` is the domain's underlying type or the range's subtype. + ``domain_not_null``/``domain_default`` are ``None`` unless the row really + is a domain; ``None`` here means "not a domain fact" rather than an + unknown one. + """ + + name: str + schema: Optional[str] = None + kind: PostgresTypeKind = PostgresTypeKind.UNKNOWN + labels: List[str] = field(default_factory=list) + fields: List[str] = field(default_factory=list) + base_type: Optional[str] = None + domain_not_null: Optional[bool] = None + domain_default: Optional[str] = None + comment: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PostgresOperatorInfo: + """One row of ``pg_operator``. + + ``operator_kind`` is the catalog's ``oprkind`` word: ``binary``, ``prefix`` + or ``postfix``. ``left_type``/``right_type`` are ``None`` for a position + the operator does not have -- a prefix operator has no left operand, and + ``format_type(0, NULL)`` would be a different type rather than "absent". + """ + + name: str + schema: Optional[str] = None + operator_kind: Optional[str] = None + left_type: Optional[str] = None + right_type: Optional[str] = None + result_type: Optional[str] = None + function_name: Optional[str] = None + owner: Optional[str] = None + comment: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PostgresOperatorClassInfo: + """One row of ``pg_opclass``: the operator class an index method uses.""" + + name: str + schema: Optional[str] = None + method: Optional[str] = None + input_type: Optional[str] = None + key_type: Optional[str] = None + is_default: bool = False + family: Optional[str] = None + owner: Optional[str] = None + comment: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PostgresOperatorFamilyInfo: + """One row of ``pg_opfamily``: a family of operator classes.""" + + name: str + schema: Optional[str] = None + method: Optional[str] = None + owner: Optional[str] = None + comment: Optional[str] = None + extra: Dict[str, Any] = field(default_factory=dict) + + @dataclass class PostgresColumnDataProfile(ColumnDataProfile): """A column profile carrying the extra facts ``pg_stats`` supplies. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index e5da13c6..e32cd985 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -54,6 +54,12 @@ PostgresMaterializedViewListQueryStatement, PostgresSequenceInfoQueryStatement, PostgresSequenceListQueryStatement, + PostgresPartitionListQueryStatement, + PostgresRoutineListQueryStatement, + PostgresTypeListQueryStatement, + PostgresOperatorListQueryStatement, + PostgresOperatorClassListQueryStatement, + PostgresOperatorFamilyListQueryStatement, PostgresDatabaseSizeFunction, PostgresDatabaseSizeQueryStatement, PostgresIndexesSizeFunction, @@ -136,6 +142,44 @@ def supports_trigger_introspection(self) -> bool: """PostgreSQL supports trigger introspection via pg_trigger.""" return True + def supports_partition_introspection(self) -> bool: + """PostgreSQL answers child partitions from ``pg_inherits``. + + Declarative partitioning arrived in PostgreSQL 10, which is also when + the catalog began carrying ``pg_get_partkeydef`` and + ``relpartbound`` for it. Before that a partitioned table cannot exist, + so the family is absent rather than empty. + """ + return self.supports_table_partitioning() + + def supports_partition_info(self) -> bool: + """Core's name for the partition family, delegating to PostgreSQL's. + + Core declares the probe; this backend answers it. Kept as a delegation + rather than a second ``True`` so both spellings are one fact. + """ + return self.supports_partition_introspection() + + def supports_routine_introspection(self) -> bool: + """PostgreSQL supports routine introspection via ``pg_proc``.""" + return True + + def supports_type_introspection(self) -> bool: + """PostgreSQL supports type introspection via ``pg_type``/``pg_enum``.""" + return True + + def supports_operator_introspection(self) -> bool: + """PostgreSQL supports operator introspection via ``pg_operator``.""" + return True + + def supports_operator_class_introspection(self) -> bool: + """PostgreSQL supports operator-class introspection via ``pg_opclass``.""" + return True + + def supports_operator_family_introspection(self) -> bool: + """PostgreSQL supports operator-family introspection via ``pg_opfamily``.""" + return True + def supports_table_storage_introspection(self) -> bool: """PostgreSQL reports each table's storage split. @@ -212,14 +256,17 @@ def supports_object_dependencies(self) -> bool: return True def supports_ddl_extraction(self) -> bool: - """PostgreSQL's DDL text is assembled here, not retrieved natively. + """False: no DDL text is produced for an ordinary PostgreSQL table. ``pg_get_expr`` and ``pg_get_viewdef`` recover expressions, but a faithful ``CREATE TABLE`` needs storage parameters, tablespace and - per-column clauses the catalog keeps only in parts. The honest answer - is an assembly marked ``native=False``. + per-column clauses the catalog keeps only in parts. This backend does + not reassemble them yet, so advertising the family would promise text + the answer cannot produce. The probe stays False until a real + assembly exists; a caller then gets a refusal rather than a successful + run with nothing in it. """ - return True + return False def supports_ddl_extraction_native(self) -> bool: """False: PostgreSQL has no single native DDL retrieval call. @@ -920,6 +967,20 @@ def format_column_info_query_statement(self, expr: "ColumnInfoQueryStatement") - self._catalog_column("numeric_scale", "ic", alias="numeric_scale"), self._catalog_column("datetime_precision", "ic", alias="datetime_precision"), ] + # Identity columns arrived in PostgreSQL 10 and generated columns in + # 12. A 9.x server has neither catalog column, and asking for it would + # fail the whole query; the columns are therefore projected only when + # the server in front of us has them. The parser reads an absent + # column as "no identity/generation" -- which on those releases is the + # truth, because the feature does not exist there. + if self.version_at_least((10, 0, 0)): + projection.append( + self._catalog_column("attidentity", "a", alias="identity_kind") + ) + if self.version_at_least((12, 0, 0)): + projection.append( + self._catalog_column("attgenerated", "a", alias="generated_kind") + ) attribute_join = JoinClause( self, @@ -1664,61 +1725,48 @@ def format_materialized_view_info_query_statement( ) def _sequence_source(self, include_pg_sequences: bool = True): - """``information_schema.sequences s`` with ``pg_sequences p`` beside it. - - The standard view is the object list: it exists on every server this - backend supports. ``pg_sequences`` carries the state the standard view - omits, and arrived in PostgreSQL 10, so it is joined only when the - caller says the server has it. The join is a LEFT JOIN because - ``pg_sequences`` omits sequences the current role cannot read; those - rows must still be listed, with the withheld columns as NULL. + """The relation the sequence listing reads its objects from. + + ``pg_sequences`` is both the object list and the attribute source on + PostgreSQL 10+: it lists identity sequences, which + ``information_schema.sequences`` does not. On 9.x the catalog view does + not exist and identity columns do not either, so the SQL-standard view + is the object list; the runtime columns are reported absent rather + than guessed. """ - from rhosocial.activerecord.backend.expression import ( - ComparisonPredicate, - JoinClause, - LogicalPredicate, - ) - - sequences = self._catalog_relation("sequences", "s", schema="information_schema") - if not include_pg_sequences: - return sequences - - return JoinClause( - self, - left_table=sequences, - right_table=self._catalog_relation("pg_sequences", "p"), - join_type="LEFT JOIN", - condition=LogicalPredicate( - self, - "AND", - ComparisonPredicate( - self, - "eq", - self._catalog_column("sequence_schema", "s"), - self._catalog_column("schemaname", "p"), - ), - ComparisonPredicate( - self, - "eq", - self._catalog_column("sequence_name", "s"), - self._catalog_column("sequencename", "p"), - ), - ), - ) + if include_pg_sequences: + return self._catalog_relation("pg_sequences", "p") + return self._catalog_relation("sequences", "s", schema="information_schema") def _sequence_projection(self, include_pg_sequences: bool = True) -> "list": - """The sequence row shape. + """The sequence row shape, in the columns the chosen source has. - ``information_schema`` reports the numeric attributes as - ``character_data``, so each is cast to ``bigint`` -- the type the - server actually stores -- rather than left for the caller to parse. + ``pg_sequences`` already types its numeric columns as ``bigint``; the + standard view reports them as ``character_data``, so on 9.x each is + cast to ``bigint`` -- the type the server actually stores -- rather + than left for the caller to parse. The projection aliases both shapes + to the same names, which is what lets one parser read either row. """ from rhosocial.activerecord.backend.expression.types import BigIntType + if include_pg_sequences: + return [ + self._catalog_column("schemaname", "p", alias="schema_name"), + self._catalog_column("sequencename", "p", alias="sequence_name"), + self._catalog_column("data_type", "p", alias="data_type"), + self._catalog_column("start_value", "p", alias="start_value"), + self._catalog_column("min_value", "p", alias="minimum_value"), + self._catalog_column("max_value", "p", alias="maximum_value"), + self._catalog_column("increment_by", "p", alias="increment"), + self._catalog_column("cycle", "p", alias="cycle"), + self._catalog_column("cache_size", "p", alias="cache_size"), + self._catalog_column("last_value", "p", alias="last_value"), + ] + def numeric(name: str): return self._catalog_column(name, "s").cast(BigIntType(self)) - projection = [ + return [ self._catalog_column("sequence_schema", "s", alias="schema_name"), self._catalog_column("sequence_name", "s", alias="sequence_name"), self._catalog_column("data_type", "s", alias="data_type"), @@ -1728,14 +1776,13 @@ def numeric(name: str): numeric("increment").as_("increment"), self._catalog_column("cycle_option", "s", alias="cycle"), ] + + @staticmethod + def _sequence_identity_columns(include_pg_sequences: bool) -> "tuple": + """The schema/name columns and their alias for the chosen source.""" if include_pg_sequences: - projection.extend( - [ - self._catalog_column("cache_size", "p", alias="cache_size"), - self._catalog_column("last_value", "p", alias="last_value"), - ] - ) - return projection + return "p", "schemaname", "sequencename" + return "s", "sequence_schema", "sequence_name" def format_sequence_list_query_statement( self, expr: "PostgresSequenceListQueryStatement" @@ -1753,6 +1800,9 @@ def format_sequence_list_query_statement( params = expr.get_params() schema = params.get("schema") or self.default_schema include_pg_sequences = params.get("include_pg_sequences", True) + alias, schema_column, name_column = self._sequence_identity_columns( + include_pg_sequences + ) return self._catalog_select( self._sequence_projection(include_pg_sequences), @@ -1761,11 +1811,11 @@ def format_sequence_list_query_statement( ComparisonPredicate( self, "eq", - self._catalog_column("sequence_schema", "s"), + self._catalog_column(schema_column, alias), self._catalog_value(schema), ) ], - [self._catalog_column("sequence_name", "s")], + [self._catalog_column(name_column, alias)], ) def format_sequence_info_query_statement( @@ -1785,6 +1835,9 @@ def format_sequence_info_query_statement( sequence_name = params.get("sequence_name", "") schema = params.get("schema") or self.default_schema include_pg_sequences = params.get("include_pg_sequences", True) + alias, schema_column, name_column = self._sequence_identity_columns( + include_pg_sequences + ) return self._catalog_select( self._sequence_projection(include_pg_sequences), @@ -1793,13 +1846,13 @@ def format_sequence_info_query_statement( ComparisonPredicate( self, "eq", - self._catalog_column("sequence_schema", "s"), + self._catalog_column(schema_column, alias), self._catalog_value(schema), ), ComparisonPredicate( self, "eq", - self._catalog_column("sequence_name", "s"), + self._catalog_column(name_column, alias), self._catalog_value(sequence_name), ), ], @@ -3035,6 +3088,859 @@ def format_object_dependency_query_statement( ] return self._catalog_select(projections, source, conditions, None) + # ========== Schema-object families (partitions, routines, types, operators) + # + # Every statement below is built from the same catalog node helpers the + # families above use: ``_catalog_relation`` for the relation, ``_catalog_column`` + # for the column, ``_catalog_value`` for caller data and ``_catalog_literal`` + # for a catalog fact. The projection aliases are the parser's contract. + + @staticmethod + def _type_name_call( + mixin, + oid_column, + alias: Optional[str] = None, + typmod_column=None, + ): + """``pg_catalog.format_type(oid, typmod)`` for one catalog row.""" + from rhosocial.activerecord.backend.expression import FunctionCall + + args = [oid_column] + args.append(typmod_column if typmod_column is not None else mixin._catalog_literal(None)) + return FunctionCall(mixin, "pg_catalog.format_type", *args, alias=alias) + + @staticmethod + def _optional_type_name_call( + mixin, + oid_column, + alias: Optional[str] = None, + ): + """``format_type(oid, NULL)``, or NULL when the OID position is absent. + + An operator's left or right operand type is OID 0 when the operator + has no operand there. ``format_type(0, NULL)`` would answer with a + made-up type, so the absence is kept as NULL. + """ + from rhosocial.activerecord.backend.expression import ComparisonPredicate + from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression + + return CaseExpression( + mixin, + cases=[ + ( + ComparisonPredicate( + mixin, "eq", oid_column, mixin._catalog_literal(0) + ), + mixin._catalog_literal(None), + ) + ], + else_result=mixin._type_name_call(mixin, oid_column), + alias=alias, + ) + + def format_partition_list_query_statement( + self, expr: "PostgresPartitionListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the child-partition listing for one partitioned table. + + The parent is named by the caller; ``pg_get_partkeydef`` names its + strategy and key, and ``pg_get_expr(relpartbound)`` spells each child's + bound. A partitioned parent is ``relkind = 'p'``; ordinary inheritance + (``relkind = 'r'``) is not declarative partitioning and is deliberately + not listed here. + + Args: + expr: Partition list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + + Raises: + UnsupportedFeatureError: The connected server predates PostgreSQL 10. + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + FunctionCall, + JoinClause, + ) + from rhosocial.activerecord.backend.expression.types import BigIntType + + if not self.supports_table_partitioning(): + raise UnsupportedFeatureError( + self.name, + "partition introspection", + "Partition introspection requires PostgreSQL 10+.", + ) + + params = expr.get_params() + table_name = params.get("table_name", "") + schema = params.get("schema") or self.default_schema + include_partitions = params.get("include_partitions", True) + include_statistics = params.get("include_statistics", True) + + source = JoinClause( + self, + left_table=self._catalog_relation("pg_class", "pc"), + right_table=self._catalog_relation("pg_namespace", "pn"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("relnamespace", "pc"), + self._catalog_column("oid", "pn"), + ), + ) + source = source.join( + self._catalog_relation("pg_inherits", "i"), + join_type="LEFT", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("inhparent", "i"), + self._catalog_column("oid", "pc"), + ), + ) + source = source.join( + self._catalog_relation("pg_class", "ch"), + join_type="LEFT", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("oid", "ch"), + self._catalog_column("inhrelid", "i"), + ), + ) + + projection = [ + self._catalog_column("relname", "pc", alias="parent_table"), + FunctionCall( + self, + "pg_get_partkeydef", + self._catalog_column("oid", "pc"), + alias="partition_key", + ), + ] + if include_partitions: + projection.extend( + [ + self._catalog_column("relname", "ch", alias="name"), + FunctionCall( + self, + "pg_get_expr", + self._catalog_column("relpartbound", "ch"), + self._catalog_column("oid", "ch"), + alias="bound", + ), + FunctionCall( + self, + "pg_catalog.obj_description", + self._catalog_column("oid", "ch"), + alias="comment", + ), + ] + ) + if include_statistics: + projection.extend( + [ + self._catalog_column("reltuples", "ch") + .cast(BigIntType(self)) + .as_("row_count"), + FunctionCall( + self, + "pg_total_relation_size", + self._catalog_column("oid", "ch"), + alias="size_bytes", + ), + ] + ) + + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("relname", "pc"), self._catalog_value(table_name) + ), + ComparisonPredicate( + self, "eq", self._catalog_column("nspname", "pn"), self._catalog_value(schema) + ), + ComparisonPredicate( + self, "eq", self._catalog_column("relkind", "pc"), self._catalog_literal("p") + ), + ] + order_by = [self._catalog_column("relname", "ch")] if include_partitions else None + return self._catalog_select(projection, source, conditions, order_by) + + def format_routine_list_query_statement( + self, expr: "PostgresRoutineListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the routine listing from ``pg_proc``. + + ``prokind`` distinguishes functions, procedures, aggregates and window + functions; ``pg_get_function_identity_arguments`` is the overload + identity, and ``pg_get_function_result`` the return type. The language + is joined from ``pg_language`` because the catalog stores its OID. + + Args: + expr: Routine list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + FunctionCall, + JoinClause, + ) + + params = expr.get_params() + schema = params.get("schema") or self.default_schema + routine_name = params.get("routine_name") + + source = JoinClause( + self, + left_table=self._catalog_relation("pg_proc", "p"), + right_table=self._catalog_relation("pg_namespace", "n"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("pronamespace", "p"), + self._catalog_column("oid", "n"), + ), + ) + source = source.join( + self._catalog_relation("pg_language", "l"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("prolang", "p"), + self._catalog_column("oid", "l"), + ), + ) + + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("nspname", "n"), self._catalog_value(schema) + ) + ] + if routine_name: + conditions.append( + ComparisonPredicate( + self, + "eq", + self._catalog_column("proname", "p"), + self._catalog_value(routine_name), + ) + ) + + if self.version_at_least((11, 0, 0)): + kind_projection = self._catalog_column("prokind", "p", alias="kind") + else: + # ``prokind`` arrived in PostgreSQL 11; before it the same fact + # was two booleans. A procedure cannot exist on those releases, + # so the two old flags answer the whole question they had. + from rhosocial.activerecord.backend.expression.advanced_functions import ( + CaseExpression, + ) + + kind_projection = CaseExpression( + self, + cases=[ + ( + self._catalog_column("proiswindow", "p"), + self._catalog_literal("w"), + ), + ( + self._catalog_column("proisagg", "p"), + self._catalog_literal("a"), + ), + ], + else_result=self._catalog_literal("f"), + alias="kind", + ) + + projection = [ + self._catalog_column("proname", "p", alias="name"), + self._catalog_column("nspname", "n", alias="schema_name"), + kind_projection, + FunctionCall( + self, + "pg_get_function_identity_arguments", + self._catalog_column("oid", "p"), + alias="identity_arguments", + ), + FunctionCall( + self, + "pg_get_function_result", + self._catalog_column("oid", "p"), + alias="result_type", + ), + self._catalog_column("lanname", "l", alias="language"), + self._catalog_column("provolatile", "p", alias="volatility"), + self._catalog_column("prosecdef", "p", alias="security_definer"), + FunctionCall( + self, + "pg_catalog.obj_description", + self._catalog_column("oid", "p"), + self._catalog_literal("pg_proc"), + alias="comment", + ), + ] + return self._catalog_select( + projection, + source, + conditions, + [ + self._catalog_column("nspname", "n"), + self._catalog_column("proname", "p"), + self._catalog_column("oid", "p"), + ], + ) + + def format_type_list_query_statement( + self, expr: "PostgresTypeListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the user-type listing (enum, domain, composite, range). + + One query answers the whole family: ``pg_type.typtype`` names the kind, + ``pg_enum`` supplies the labels in their declared order, and + ``pg_attribute`` supplies a composite's fields. The joins are LEFT + joins because a type has labels *or* fields, never both, and the parser + groups the rows back into one record per type. + + A multirange's subtype lives on the row whose ``rngmultitypid`` points + at it, a column that arrived in PostgreSQL 14; the join is written for + the server in front of us and omits that edge on older releases. + + Args: + expr: Type list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + FunctionCall, + JoinClause, + LogicalPredicate, + ) + from rhosocial.activerecord.backend.expression.advanced_functions import CaseExpression + from rhosocial.activerecord.backend.expression.predicates import InPredicate + + params = expr.get_params() + schema = params.get("schema") or self.default_schema + type_name = params.get("type_name") + + source = JoinClause( + self, + left_table=self._catalog_relation("pg_type", "t"), + right_table=self._catalog_relation("pg_namespace", "n"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("typnamespace", "t"), + self._catalog_column("oid", "n"), + ), + ) + source = source.join( + self._catalog_relation("pg_enum", "e"), + join_type="LEFT", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("enumtypid", "e"), + self._catalog_column("oid", "t"), + ), + ) + source = source.join( + self._catalog_relation("pg_attribute", "a"), + join_type="LEFT", + condition=LogicalPredicate( + self, + "AND", + ComparisonPredicate( + self, + "eq", + self._catalog_column("attrelid", "a"), + self._catalog_column("typrelid", "t"), + ), + ComparisonPredicate( + self, + "gt", + self._catalog_column("attnum", "a"), + self._catalog_literal(0), + ), + LogicalPredicate(self, "NOT", self._catalog_column("attisdropped", "a")), + ), + ) + has_range_types = self.version_at_least((9, 2, 0)) + if has_range_types: + if self.supports_multirange_type(): + range_condition = LogicalPredicate( + self, + "OR", + ComparisonPredicate( + self, + "eq", + self._catalog_column("rngtypid", "r"), + self._catalog_column("oid", "t"), + ), + ComparisonPredicate( + self, + "eq", + self._catalog_column("rngmultitypid", "r"), + self._catalog_column("oid", "t"), + ), + ) + else: + range_condition = ComparisonPredicate( + self, + "eq", + self._catalog_column("rngtypid", "r"), + self._catalog_column("oid", "t"), + ) + source = source.join( + self._catalog_relation("pg_range", "r"), + join_type="LEFT", + condition=range_condition, + ) + source = source.join( + self._catalog_relation("pg_class", "c"), + join_type="LEFT", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("oid", "c"), + self._catalog_column("typrelid", "t"), + ), + ) + + base_type_cases = [ + ( + ComparisonPredicate( + self, "eq", self._catalog_column("typtype", "t"), self._catalog_literal("d") + ), + self._type_name_call( + self, + self._catalog_column("typbasetype", "t"), + typmod_column=self._catalog_column("typtypmod", "t"), + ), + ), + ] + if has_range_types: + base_type_cases.append( + ( + InPredicate( + self, + self._catalog_column("typtype", "t"), + self._catalog_literal(("r", "m")), + ), + self._type_name_call( + self, + self._catalog_column("rngsubtype", "r"), + ), + ) + ) + base_type = CaseExpression( + self, + cases=base_type_cases, + else_result=self._catalog_literal(None), + alias="base_type", + ) + + projection = [ + self._catalog_column("typname", "t", alias="name"), + self._catalog_column("nspname", "n", alias="schema_name"), + self._catalog_column("typtype", "t", alias="type_code"), + self._catalog_column("enumlabel", "e", alias="label"), + self._catalog_column("enumsortorder", "e", alias="label_order"), + self._catalog_column("attname", "a", alias="field_name"), + self._catalog_column("attnum", "a", alias="field_order"), + base_type, + CaseExpression( + self, + cases=[ + ( + ComparisonPredicate( + self, + "eq", + self._catalog_column("typtype", "t"), + self._catalog_literal("d"), + ), + self._catalog_column("typnotnull", "t"), + ) + ], + else_result=self._catalog_literal(None), + alias="domain_not_null", + ), + self._catalog_column("typdefault", "t", alias="domain_default"), + FunctionCall( + self, + "pg_catalog.obj_description", + self._catalog_column("oid", "t"), + self._catalog_literal("pg_type"), + alias="comment", + ), + self._catalog_column("relkind", "c", alias="relation_kind"), + self._catalog_column("relname", "c", alias="relation_name"), + ] + + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("nspname", "n"), self._catalog_value(schema) + ), + InPredicate( + self, + self._catalog_column("typtype", "t"), + self._catalog_literal( + ("e", "d", "c", "r", "m") + if self.supports_multirange_type() + else ("e", "d", "c", "r") + ), + ), + ] + if type_name: + conditions.append( + ComparisonPredicate( + self, + "eq", + self._catalog_column("typname", "t"), + self._catalog_value(type_name), + ) + ) + + return self._catalog_select( + projection, + source, + conditions, + [ + self._catalog_column("nspname", "n"), + self._catalog_column("typname", "t"), + self._catalog_column("enumsortorder", "e"), + self._catalog_column("attnum", "a"), + ], + ) + + def format_operator_list_query_statement( + self, expr: "PostgresOperatorListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the operator listing from ``pg_operator``. + + ``oprkind`` names the shape (binary/prefix/postfix); an operand the + operator does not have is OID 0 and is reported as NULL rather than + formatted as a type. The implementing function is joined by OID and + the owner from ``pg_roles``. + + Args: + expr: Operator list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + FunctionCall, + JoinClause, + ) + + params = expr.get_params() + schema = params.get("schema") or self.default_schema + operator_name = params.get("operator_name") + + source = JoinClause( + self, + left_table=self._catalog_relation("pg_operator", "o"), + right_table=self._catalog_relation("pg_namespace", "n"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("oprnamespace", "o"), + self._catalog_column("oid", "n"), + ), + ) + source = source.join( + self._catalog_relation("pg_proc", "p"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("oprcode", "o"), + self._catalog_column("oid", "p"), + ), + ) + source = source.join( + self._catalog_relation("pg_roles", "r"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("oprowner", "o"), + self._catalog_column("oid", "r"), + ), + ) + + projection = [ + self._catalog_column("oprname", "o", alias="name"), + self._catalog_column("nspname", "n", alias="schema_name"), + self._catalog_column("oprkind", "o", alias="operator_kind"), + self._optional_type_name_call( + self, self._catalog_column("oprleft", "o"), alias="left_type" + ), + self._optional_type_name_call( + self, self._catalog_column("oprright", "o"), alias="right_type" + ), + self._type_name_call( + self, self._catalog_column("oprresult", "o"), alias="result_type" + ), + self._catalog_column("proname", "p", alias="function_name"), + self._catalog_column("rolname", "r", alias="owner"), + FunctionCall( + self, + "pg_catalog.obj_description", + self._catalog_column("oid", "o"), + self._catalog_literal("pg_operator"), + alias="comment", + ), + ] + + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("nspname", "n"), self._catalog_value(schema) + ) + ] + if operator_name: + conditions.append( + ComparisonPredicate( + self, + "eq", + self._catalog_column("oprname", "o"), + self._catalog_value(operator_name), + ) + ) + + return self._catalog_select( + projection, + source, + conditions, + [ + self._catalog_column("nspname", "n"), + self._catalog_column("oprname", "o"), + self._catalog_column("oid", "o"), + ], + ) + + def format_operator_class_list_query_statement( + self, expr: "PostgresOperatorClassListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the operator-class listing from ``pg_opclass``. + + ``opcmethod`` is the index access method (btree, gist, ...), joined + through ``pg_am``; ``opcintype`` is the input type and ``opckeytype`` + the storage key type, absent when zero. + + Args: + expr: Operator-class list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + FunctionCall, + JoinClause, + ) + + params = expr.get_params() + schema = params.get("schema") or self.default_schema + operator_class_name = params.get("operator_class_name") + + source = JoinClause( + self, + left_table=self._catalog_relation("pg_opclass", "oc"), + right_table=self._catalog_relation("pg_namespace", "n"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opcnamespace", "oc"), + self._catalog_column("oid", "n"), + ), + ) + source = source.join( + self._catalog_relation("pg_am", "am"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opcmethod", "oc"), + self._catalog_column("oid", "am"), + ), + ) + source = source.join( + self._catalog_relation("pg_opfamily", "f"), + join_type="LEFT", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opcfamily", "oc"), + self._catalog_column("oid", "f"), + ), + ) + source = source.join( + self._catalog_relation("pg_roles", "r"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opcowner", "oc"), + self._catalog_column("oid", "r"), + ), + ) + + projection = [ + self._catalog_column("opcname", "oc", alias="name"), + self._catalog_column("nspname", "n", alias="schema_name"), + self._catalog_column("amname", "am", alias="method"), + self._type_name_call( + self, self._catalog_column("opcintype", "oc"), alias="input_type" + ), + self._optional_type_name_call( + self, self._catalog_column("opckeytype", "oc"), alias="key_type" + ), + self._catalog_column("opcdefault", "oc", alias="is_default"), + self._catalog_column("opfname", "f", alias="family"), + self._catalog_column("rolname", "r", alias="owner"), + FunctionCall( + self, + "pg_catalog.obj_description", + self._catalog_column("oid", "oc"), + self._catalog_literal("pg_opclass"), + alias="comment", + ), + ] + + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("nspname", "n"), self._catalog_value(schema) + ) + ] + if operator_class_name: + conditions.append( + ComparisonPredicate( + self, + "eq", + self._catalog_column("opcname", "oc"), + self._catalog_value(operator_class_name), + ) + ) + + return self._catalog_select( + projection, + source, + conditions, + [ + self._catalog_column("nspname", "n"), + self._catalog_column("opcname", "oc"), + self._catalog_column("oid", "oc"), + ], + ) + + def format_operator_family_list_query_statement( + self, expr: "PostgresOperatorFamilyListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the operator-family listing from ``pg_opfamily``. + + Args: + expr: Operator-family list expression with parameters. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + ComparisonPredicate, + FunctionCall, + JoinClause, + ) + + params = expr.get_params() + schema = params.get("schema") or self.default_schema + operator_family_name = params.get("operator_family_name") + + source = JoinClause( + self, + left_table=self._catalog_relation("pg_opfamily", "f"), + right_table=self._catalog_relation("pg_namespace", "n"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opfnamespace", "f"), + self._catalog_column("oid", "n"), + ), + ) + source = source.join( + self._catalog_relation("pg_am", "am"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opfmethod", "f"), + self._catalog_column("oid", "am"), + ), + ) + source = source.join( + self._catalog_relation("pg_roles", "r"), + join_type="JOIN", + condition=ComparisonPredicate( + self, + "eq", + self._catalog_column("opfowner", "f"), + self._catalog_column("oid", "r"), + ), + ) + + projection = [ + self._catalog_column("opfname", "f", alias="name"), + self._catalog_column("nspname", "n", alias="schema_name"), + self._catalog_column("amname", "am", alias="method"), + self._catalog_column("rolname", "r", alias="owner"), + FunctionCall( + self, + "pg_catalog.obj_description", + self._catalog_column("oid", "f"), + self._catalog_literal("pg_opfamily"), + alias="comment", + ), + ] + + conditions = [ + ComparisonPredicate( + self, "eq", self._catalog_column("nspname", "n"), self._catalog_value(schema) + ) + ] + if operator_family_name: + conditions.append( + ComparisonPredicate( + self, + "eq", + self._catalog_column("opfname", "f"), + self._catalog_value(operator_family_name), + ) + ) + + return self._catalog_select( + projection, + source, + conditions, + [ + self._catalog_column("nspname", "n"), + self._catalog_column("opfname", "f"), + self._catalog_column("oid", "f"), + ], + ) + def format_ddl_statement_query_statement( self, expr: "DDLStatementQueryStatement" ) -> Tuple[str, tuple]: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_capabilities.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_capabilities.py index adc0a143..c5b91259 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_capabilities.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_capabilities.py @@ -19,7 +19,7 @@ from rhosocial.activerecord.backend.impl.postgres.cli import introspect as introspect_mod from rhosocial.activerecord.backend.impl.postgres.cli.introspect import INTROSPECT_TYPES from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.introspection.types import DDLStatement, JoinCoverage +from rhosocial.activerecord.backend.introspection.types import JoinCoverage def parse_introspect_args(argv): @@ -113,6 +113,37 @@ def list_sequences(self, schema=None): self._record("list_sequences", schema) return [{"name": "order_id_seq"}] + # The schema-object families added for catalogue coverage. Each answers a + # listing; the optional name narrows rather than selects. + + def list_partitions( + self, table_name, schema=None, include_partitions=True, include_statistics=True + ): + self._record( + "list_partitions", table_name, schema, include_partitions, include_statistics + ) + return [{"name": "orders_p2026_11", "table": table_name}] + + def list_routines(self, schema=None, routine_name=None): + self._record("list_routines", schema, routine_name) + return [{"name": routine_name or "app_total"}] + + def list_types(self, schema=None, type_name=None): + self._record("list_types", schema, type_name) + return [{"name": type_name or "order_status"}] + + def list_operators(self, schema=None, operator_name=None): + self._record("list_operators", schema, operator_name) + return [{"name": operator_name or "==="}] + + def list_operator_classes(self, schema=None, operator_class_name=None): + self._record("list_operator_classes", schema, operator_class_name) + return [{"name": operator_class_name or "gist_trgm_ops"}] + + def list_operator_families(self, schema=None, operator_family_name=None): + self._record("list_operator_families", schema, operator_family_name) + return [{"name": operator_family_name or "gist_trgm_ops"}] + # The operational families: each answers a listing of its own, from the # statistics collector rather than the catalog. @@ -136,16 +167,6 @@ def list_object_dependencies(self, object_name=None, schema=None): self._record("list_object_dependencies", object_name, schema) return [{"dependent": "users_v", "referenced": "users"}] - def get_ddl_statement(self, object_name, object_type=None, schema=None): - self._record("get_ddl_statement", object_name, object_type, schema) - return DDLStatement( - schema="public", - object_type="r", - object_name=object_name, - ddl=f"CREATE TABLE {object_name} (id integer);", - native=False, - ) - # The data families: one table, or one column of it. def list_column_data_profiles(self, table_name, schema=None): @@ -252,6 +273,12 @@ async def disconnect(self): "extensions": "list_extensions", "materialized-views": "list_materialized_views", "sequences": "list_sequences", + "partitions": "list_partitions", + "routines": "list_routines", + "types": "list_types", + "operators": "list_operators", + "operator-classes": "list_operator_classes", + "operator-families": "list_operator_families", # The storage figures ride the listing's projection, so the CLI asks the # listing and picks the row -- one read, not the four a per-table info # answer would spend. @@ -261,7 +288,6 @@ async def disconnect(self): "runtime": "list_runtime_metrics", "index-stats": "list_index_usage_stats", "dependencies": "list_object_dependencies", - "ddl": "get_ddl_statement", "column-profiles": "list_column_data_profiles", "value-samples": "sample_column_values", "join-coverage": "estimate_join_coverage", @@ -274,8 +300,8 @@ async def disconnect(self): "columns": ["users"], "indexes": ["users"], "foreign-keys": ["users"], + "partitions": ["users"], "table-storage": ["users"], - "ddl": ["users"], "column-profiles": ["users"], "value-samples": ["users", "--column", "status"], "join-coverage": [ @@ -356,6 +382,35 @@ def test_every_family_the_dialect_answers_for_is_askable(self): def test_the_aggregate_table_type_is_gone(self): assert "table" not in INTROSPECT_TYPES + def test_the_new_families_have_a_supported_switch(self): + dialect = PostgresDialect(version=(16, 0)) + for switch in ( + "supports_partition_introspection", + "supports_routine_introspection", + "supports_type_introspection", + "supports_operator_introspection", + "supports_operator_class_introspection", + "supports_operator_family_introspection", + ): + assert getattr(dialect, switch)() is True + + def test_partition_info_delegates_to_the_family_switch(self): + dialect = PostgresDialect(version=(16, 0)) + assert dialect.supports_partition_info() is True + assert PostgresDialect(version=(9, 6)).supports_partition_info() is False + + def test_ddl_is_not_advertised_when_no_text_is_produced(self): + """I-05: the family that returns None for ordinary tables says so.""" + assert PostgresDialect(version=(16, 0)).supports_ddl_extraction() is False + assert "ddl" not in INTROSPECT_TYPES + + def test_ddl_is_rejected_as_an_invalid_choice(self, run_cli): + """The removed type is an argparse usage error, not a handler refusal.""" + _, err, exc = run_cli(["introspect", "ddl", "users", "--database", "mydb"]) + + assert exc is not None and exc.code == 2 + assert "invalid choice: 'ddl'" in err + def test_help_lists_every_type(self, run_cli): out, _, exc = run_cli(["introspect", "--help"]) assert exc is not None and exc.code == 0 @@ -421,6 +476,45 @@ def _calls(self, run_cli, argv): assert exc is None, f"stderr: {err}" return FakeBackend.last_instance.introspector.calls + def test_partitions_asks_for_the_named_parent(self, run_cli): + assert self._calls(run_cli, ["partitions", "orders"]) == [ + ("list_partitions", "orders", None, True, True) + ] + + def test_routines_list_and_narrow_by_name(self, run_cli): + assert self._calls(run_cli, ["routines"]) == [("list_routines", None, None)] + assert self._calls(run_cli, ["routines", "app_total"]) == [ + ("list_routines", None, "app_total") + ] + + def test_types_list_and_narrow_by_name(self, run_cli): + assert self._calls(run_cli, ["types"]) == [("list_types", None, None)] + assert self._calls(run_cli, ["types", "order_status"]) == [ + ("list_types", None, "order_status") + ] + + def test_operators_list_and_narrow_by_name(self, run_cli): + assert self._calls(run_cli, ["operators"]) == [("list_operators", None, None)] + assert self._calls(run_cli, ["operators", "==="]) == [ + ("list_operators", None, "===") + ] + + def test_operator_classes_list_and_narrow_by_name(self, run_cli): + assert self._calls(run_cli, ["operator-classes"]) == [ + ("list_operator_classes", None, None) + ] + assert self._calls(run_cli, ["operator-classes", "gist_trgm_ops"]) == [ + ("list_operator_classes", None, "gist_trgm_ops") + ] + + def test_operator_families_list_and_narrow_by_name(self, run_cli): + assert self._calls(run_cli, ["operator-families"]) == [ + ("list_operator_families", None, None) + ] + assert self._calls(run_cli, ["operator-families", "gist_trgm_ops"]) == [ + ("list_operator_families", None, "gist_trgm_ops") + ] + def test_unused_indexes_reads_the_statistics_collector(self, run_cli): assert self._calls(run_cli, ["unused-indexes"]) == [("list_unused_indexes", None)] @@ -441,17 +535,6 @@ def test_dependencies_narrows_to_one_object_by_name(self, run_cli): ("list_object_dependencies", "orders", None) ] - def test_ddl_asks_for_the_named_table(self, run_cli): - assert self._calls(run_cli, ["ddl", "users"]) == [ - ("get_ddl_statement", "users", None, None) - ] - - def test_ddl_prints_the_statement_in_table_mode(self, run_cli): - """Table output is the SQL itself, not a one-cell table around it.""" - out, err, exc = run_cli(["introspect", "ddl", "users", "--database", "mydb"]) - assert exc is None, f"stderr: {err}" - assert "CREATE TABLE users" in out - def test_column_profiles_asks_for_the_named_table(self, run_cli): assert self._calls(run_cli, ["column-profiles", "users"]) == [ ("list_column_data_profiles", "users", None) @@ -517,12 +600,6 @@ def test_join_coverage_without_the_right_side_exits_one(self, run_cli): assert "--right-table and --right-column are required" in err assert FakeBackend.last_instance.introspector.calls == [] - def test_ddl_without_a_table_name_exits_one(self, run_cli): - _, err, exc = run_cli(["introspect", "ddl", "--database", "mydb"]) - assert exc is not None and exc.code == 1 - assert "Table name is required for 'ddl'" in err - assert FakeBackend.last_instance.introspector.calls == [] - def test_column_on_a_type_with_no_column_exits_two(self, run_cli): _, err, exc = run_cli(["introspect", "tables", "--column", "status", "--database", "mydb"]) assert exc is not None and exc.code == 2 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_names.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_names.py index a08e990e..2c0e5944 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_names.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_cli_names.py @@ -286,13 +286,18 @@ def test_the_table_says_what_each_types_name_means(self): "extensions": "optional", "materialized-views": "optional", "sequences": "optional", + "partitions": "required", + "routines": "optional", + "types": "optional", + "operators": "optional", + "operator-classes": "optional", + "operator-families": "optional", "table-storage": "required", "unused-indexes": "forbidden", "table-stats": "forbidden", "runtime": "forbidden", "index-stats": "forbidden", "dependencies": "optional", - "ddl": "required", "column-profiles": "required", "value-samples": "required", "join-coverage": "required", @@ -325,8 +330,8 @@ def test_a_refused_type_is_refused_by_the_table(self, type_, capsys): "columns", "indexes", "foreign-keys", + "partitions", "table-storage", - "ddl", "column-profiles", "value-samples", "join-coverage", @@ -352,6 +357,11 @@ def test_a_required_name_is_required_by_the_table(self, type_, capsys): "extensions", "materialized-views", "sequences", + "routines", + "types", + "operators", + "operator-classes", + "operator-families", "dependencies", ], ) @@ -369,8 +379,8 @@ class TestANameIsRequiredEndToEnd: "columns", "indexes", "foreign-keys", + "partitions", "table-storage", - "ddl", "column-profiles", ], ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_coverage_families.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_coverage_families.py new file mode 100644 index 00000000..2b6320be --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_coverage_families.py @@ -0,0 +1,613 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_coverage_families.py +"""Offline tests for the catalogue families added for introspection coverage. + +The statements are rendered trees and the parsers are pure Python, so +everything here runs without a PostgreSQL server. What is pinned is the two +halves that used to be hand-written SQL: the expression each family renders, +and the record each row becomes. +""" + +from types import SimpleNamespace + +import pytest + +from rhosocial.activerecord.backend.impl.postgres.cli.introspect import ( + INTROSPECT_TYPES, + _NAME_RULE, + _TYPE_CAPABILITIES, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.introspection import ( + PostgresOperatorClassListQueryStatement, + PostgresOperatorFamilyListQueryStatement, + PostgresOperatorListQueryStatement, + PostgresPartitionListQueryStatement, + PostgresRoutineListQueryStatement, + PostgresSequenceInfoQueryStatement, + PostgresSequenceListQueryStatement, + PostgresTypeListQueryStatement, +) +from rhosocial.activerecord.backend.impl.postgres.introspection.introspector import ( + PostgreSQLIntrospectorMixin, +) +from rhosocial.activerecord.backend.impl.postgres.introspection.types import ( + PostgresOperatorClassInfo, + PostgresOperatorFamilyInfo, + PostgresOperatorInfo, + PostgresPartitionBound, + PostgresPartitionInfo, + PostgresPartitionStrategy, + PostgresRoutineInfo, + PostgresRoutineKind, + PostgresTypeInfo, + PostgresTypeKind, + SequenceInfo, +) + + +def _dialect(version=(17, 0, 0)): + return PostgresDialect(version=version) + + +class _FakeBackend: + def __init__(self, dialect, database="app"): + self.dialect = dialect + self._cached_version = None + self.config = SimpleNamespace(database=database) + + def get_cached_server_version(self): + return self._cached_version + + +class _ParsingIntrospector(PostgreSQLIntrospectorMixin): + """An introspector with no executor: parsing never asks for one.""" + + def __init__(self, backend): + self._backend = backend + + +def _parser(version=(17, 0, 0)): + return _ParsingIntrospector(_FakeBackend(_dialect(version))) + + +class TestSequenceSource: + """I-01: the object list is pg_sequences, not only the standard view.""" + + def test_list_reads_pg_sequences_both_as_list_and_state(self): + sql, params = PostgresSequenceListQueryStatement( + _dialect(), schema="public", include_pg_sequences=True + ).to_sql() + assert "FROM pg_sequences AS p" in sql + assert "information_schema.sequences" not in sql + assert "p.schemaname AS \"schema_name\"" in sql + assert "p.sequencename AS \"sequence_name\"" in sql + assert "p.cycle AS \"cycle\"" in sql + assert params == ("public",) + + def test_info_narrows_to_one_sequence(self): + sql, params = PostgresSequenceInfoQueryStatement( + _dialect(), "order_id_seq", schema="public", include_pg_sequences=True + ).to_sql() + assert "p.sequencename = %s" in sql + assert params == ("public", "order_id_seq") + + def test_the_narrow_shape_is_the_standard_view(self): + sql, params = PostgresSequenceListQueryStatement( + _dialect((9, 6, 0)), schema="public", include_pg_sequences=False + ).to_sql() + assert "FROM information_schema.sequences AS s" in sql + assert "s.cycle_option AS \"cycle\"" in sql + assert "s.start_value::BIGINT" in sql + assert params == ("public",) + + @pytest.mark.parametrize( + "cycle,expected", + [(True, True), (False, False), ("YES", True), ("NO", False), (None, False)], + ) + def test_cycle_accepts_both_catalog_spellings(self, cycle, expected): + parser = _parser() + info = parser.parse_sequence( + { + "sequence_name": "seq_demo", + "schema_name": "public", + "cycle": cycle, + }, + "public", + ) + assert isinstance(info, SequenceInfo) + assert info.cycle is expected + + +class TestColumnIdentity: + """I-02: identity and SERIAL both answer is_auto_increment.""" + + @staticmethod + def _row(**overrides): + row = { + "column_name": "id", + "ordinal_position": 1, + "data_type": "integer", + "is_nullable": False, + "default_value": None, + "is_primary_key": True, + "comment": None, + "character_maximum_length": None, + "numeric_precision": None, + "numeric_scale": None, + "datetime_precision": None, + "identity_kind": "", + "generated_kind": "", + } + row.update(overrides) + return row + + def _parse(self, rows): + return _parser().parse_columns(rows, "orders", "public")[0] + + def test_the_statement_selects_identity_and_generated_flags(self): + from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + ) + + sql, _ = ColumnInfoQueryStatement( + _dialect(), table_name="orders", schema="public" + ).to_sql() + assert 'a.attidentity AS "identity_kind"' in sql + assert 'a.attgenerated AS "generated_kind"' in sql + + def test_pre_10_statement_omits_the_identity_column(self): + from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + ) + + sql, _ = ColumnInfoQueryStatement( + _dialect((9, 6, 0)), table_name="orders", schema="public" + ).to_sql() + assert "attidentity" not in sql + assert "attgenerated" not in sql + + def test_pre_12_statement_keeps_identity_but_omits_generated(self): + from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + ) + + sql, _ = ColumnInfoQueryStatement( + _dialect((11, 0, 0)), table_name="orders", schema="public" + ).to_sql() + assert 'a.attidentity AS "identity_kind"' in sql + assert "attgenerated" not in sql + + @pytest.mark.parametrize("identity", ["a", "d"]) + def test_identity_columns_are_auto_increment(self, identity): + column = self._parse([self._row(identity_kind=identity)]) + assert column.is_auto_increment is True + + def test_serial_nextval_default_is_auto_increment(self): + column = self._parse( + [self._row(default_value="nextval('orders_id_seq'::regclass)")] + ) + assert column.is_auto_increment is True + + def test_plain_default_is_not_auto_increment(self): + column = self._parse([self._row(default_value="42")]) + assert column.is_auto_increment is False + + def test_generated_column_reports_its_expression(self): + column = self._parse( + [self._row(generated_kind="s", default_value="(amount * 2)")] + ) + assert column.is_generated is True + assert column.generated_expression == "(amount * 2)" + + +class TestPartitions: + """I-04/I-10: the child partitions are a family of their own.""" + + def test_statement_reads_the_key_and_the_children(self): + sql, params = PostgresPartitionListQueryStatement( + _dialect(), "orders", schema="public" + ).to_sql() + assert "PG_GET_PARTKEYDEF(pc.oid) AS \"partition_key\"" in sql + assert "pg_inherits AS i" in sql + assert 'ch.relname AS "name"' in sql + assert 'PG_GET_EXPR(ch.relpartbound, ch.oid) AS "bound"' in sql + assert "PG_TOTAL_RELATION_SIZE(ch.oid) AS \"size_bytes\"" in sql + assert "pc.relkind = 'p'" in sql + assert params == ("orders", "public") + + def test_statistics_can_be_left_out(self): + sql, _ = PostgresPartitionListQueryStatement( + _dialect(), "orders", schema="public", include_statistics=False + ).to_sql() + assert "pg_total_relation_size" not in sql.lower() + assert 'ch.reltuples' not in sql + + @pytest.mark.parametrize( + "key,strategy,columns", + [ + ("RANGE (created_at)", PostgresPartitionStrategy.RANGE, ["created_at"]), + ( + "RANGE (tenant_id, created_at)", + PostgresPartitionStrategy.RANGE, + ["tenant_id", "created_at"], + ), + ("LIST (status)", PostgresPartitionStrategy.LIST, ["status"]), + ("HASH (user_id)", PostgresPartitionStrategy.HASH, ["user_id"]), + ], + ) + def test_key_text_splits_into_strategy_and_columns(self, key, strategy, columns): + parsed = _parser()._parse_partition_key(key) + assert parsed[0] is strategy + assert parsed[1] == columns + assert parsed[2] is None + + def test_key_expression_stays_an_expression(self): + parsed = _parser()._parse_partition_key("RANGE (date_trunc('month', created_at))") + assert parsed[0] is PostgresPartitionStrategy.RANGE + assert parsed[1] == [] + assert "date_trunc" in parsed[2] + + @pytest.mark.parametrize( + "text,bound", + [ + ( + "FOR VALUES FROM ('2026-11-01') TO ('2026-12-01')", + PostgresPartitionBound(lower="2026-11-01", upper="2026-12-01"), + ), + ( + "FOR VALUES IN ('active', 'pending')", + PostgresPartitionBound(values=["active", "pending"]), + ), + ( + "FOR VALUES WITH (MODULUS 4, REMAINDER 1)", + PostgresPartitionBound(modulus=4, remainder=1), + ), + ("DEFAULT", PostgresPartitionBound()), + ], + ) + def test_bounds_decode_to_their_shape(self, text, bound): + parsed = _parser()._parse_partition_bound(text) + assert parsed is not None + assert parsed.lower == bound.lower + assert parsed.upper == bound.upper + assert parsed.values == bound.values + assert parsed.modulus == bound.modulus + assert parsed.remainder == bound.remainder + + def test_rows_become_one_record_per_child(self): + rows = [ + { + "parent_table": "orders", + "partition_key": "RANGE (created_at)", + "name": "orders_p2026_11", + "bound": "FOR VALUES FROM ('2026-11-01') TO ('2026-12-01')", + "comment": None, + "row_count": 10, + "size_bytes": 8192, + }, + { + "parent_table": "orders", + "partition_key": "RANGE (created_at)", + "name": "orders_p2026_12", + "bound": "FOR VALUES FROM ('2026-12-01') TO ('2027-01-01')", + "comment": None, + "row_count": -1, + "size_bytes": 4096, + }, + ] + partitions = _parser().parse_partitions(rows, "orders", "public") + assert [p.name for p in partitions] == ["orders_p2026_11", "orders_p2026_12"] + assert all(isinstance(p, PostgresPartitionInfo) for p in partitions) + assert partitions[0].strategy is PostgresPartitionStrategy.RANGE + assert partitions[0].key_columns == ["created_at"] + assert partitions[0].parent == "orders" + assert partitions[0].bound.lower == "2026-11-01" + assert partitions[0].row_count == 10 + assert partitions[1].row_count is None + + def test_a_parent_with_no_child_is_an_empty_list(self): + rows = [ + { + "parent_table": "orders", + "partition_key": "RANGE (created_at)", + "name": None, + } + ] + assert _parser().parse_partitions(rows, "orders", "public") == [] + + +class TestRoutines: + """I-03: functions, procedures, aggregates and windows.""" + + def test_statement_reads_pg_proc_and_pg_language(self): + sql, params = PostgresRoutineListQueryStatement( + _dialect(), schema="public" + ).to_sql() + assert "FROM (pg_proc AS p JOIN pg_namespace AS n" in sql + assert "pg_language AS l" in sql + assert "PG_GET_FUNCTION_IDENTITY_ARGUMENTS(p.oid) AS \"identity_arguments\"" in sql + assert "PG_GET_FUNCTION_RESULT(p.oid) AS \"result_type\"" in sql + assert params == ("public",) + + def test_a_name_narrows_the_listing(self): + sql, params = PostgresRoutineListQueryStatement( + _dialect(), schema="public", routine_name="app_total" + ).to_sql() + assert "p.proname = %s" in sql + assert params == ("public", "app_total") + + def test_pre_11_uses_the_two_legacy_kind_flags(self): + sql, _ = PostgresRoutineListQueryStatement( + _dialect((10, 0, 0)), schema="public" + ).to_sql() + assert "p.prokind" not in sql + assert "p.proiswindow" in sql and "p.proisagg" in sql + + @pytest.mark.parametrize( + "code,kind", + [ + ("f", PostgresRoutineKind.FUNCTION), + ("p", PostgresRoutineKind.PROCEDURE), + ("a", PostgresRoutineKind.AGGREGATE), + ("w", PostgresRoutineKind.WINDOW), + ], + ) + def test_prokind_maps_to_the_record_kind(self, code, kind): + routine = _parser().parse_routine( + { + "name": "app_total", + "schema_name": "public", + "kind": code, + "identity_arguments": "integer", + "result_type": "integer", + "language": "sql", + "volatility": "i", + "security_definer": False, + "comment": None, + }, + "public", + ) + assert isinstance(routine, PostgresRoutineInfo) + assert routine.kind is kind + assert routine.volatility == "immutable" + + +class TestTypes: + """I-03: enum/domain/composite/range/multirange, one family.""" + + def test_statement_reads_type_enum_and_attribute_catalogs(self): + sql, params = PostgresTypeListQueryStatement( + _dialect(), schema="public" + ).to_sql() + assert "pg_type AS t" in sql + assert "pg_enum AS e" in sql + assert "pg_attribute AS a" in sql + assert "pg_range AS r" in sql + assert "r.rngmultitypid = t.oid" in sql + assert "t.typtype IN ('e', 'd', 'c', 'r', 'm')" in sql + assert params == ("public",) + + def test_pre_14_omits_the_multirange_edge(self): + sql, _ = PostgresTypeListQueryStatement( + _dialect((13, 0, 0)), schema="public" + ).to_sql() + assert "rngmultitypid" not in sql + assert "t.typtype IN ('e', 'd', 'c', 'r')" in sql + + def test_rows_group_into_one_record_per_type(self): + rows = [ + { + "name": "order_status", + "schema_name": "public", + "type_code": "e", + "label": "pending", + "label_order": 1, + "field_name": None, + "field_order": None, + "base_type": None, + "domain_not_null": None, + "domain_default": None, + "comment": None, + "relation_kind": None, + "relation_name": None, + }, + { + "name": "order_status", + "schema_name": "public", + "type_code": "e", + "label": "active", + "label_order": 2, + "field_name": None, + "field_order": None, + "base_type": None, + "domain_not_null": None, + "domain_default": None, + "comment": None, + "relation_kind": None, + "relation_name": None, + }, + { + "name": "order_total", + "schema_name": "public", + "type_code": "d", + "label": None, + "label_order": None, + "field_name": None, + "field_order": None, + "base_type": "numeric(10,2)", + "domain_not_null": True, + "domain_default": "0", + "comment": None, + "relation_kind": None, + "relation_name": None, + }, + ] + types = _parser().parse_types(rows, "public") + status = next(t for t in types if t.name == "order_status") + total = next(t for t in types if t.name == "order_total") + assert status.kind is PostgresTypeKind.ENUM + assert status.labels == ["pending", "active"] + assert total.kind is PostgresTypeKind.DOMAIN + assert total.base_type == "numeric(10,2)" + assert total.domain_not_null is True + assert total.domain_default == "0" + + def test_composite_fields_are_ordered_and_keep_relation_kind(self): + rows = [ + { + "name": "address", + "schema_name": "public", + "type_code": "c", + "label": None, + "label_order": None, + "field_name": "street", + "field_order": 1, + "base_type": None, + "domain_not_null": None, + "domain_default": None, + "comment": None, + "relation_kind": "c", + "relation_name": "address", + }, + { + "name": "address", + "schema_name": "public", + "type_code": "c", + "label": None, + "label_order": None, + "field_name": "zip", + "field_order": 2, + "base_type": None, + "domain_not_null": None, + "domain_default": None, + "comment": None, + "relation_kind": "c", + "relation_name": "address", + }, + ] + type_info = _parser().parse_types(rows, "public")[0] + assert isinstance(type_info, PostgresTypeInfo) + assert type_info.kind is PostgresTypeKind.COMPOSITE + assert type_info.fields == ["street", "zip"] + assert type_info.extra["relation_kind"] == "c" + + +class TestOperators: + """I-03: operators, operator classes and operator families.""" + + def test_operator_statement_reads_pg_operator(self): + sql, params = PostgresOperatorListQueryStatement( + _dialect(), schema="public" + ).to_sql() + assert "pg_operator AS o" in sql + assert "PG_CATALOG.FORMAT_TYPE(o.oprleft, NULL)" in sql + assert "CASE WHEN o.oprleft = 0 THEN NULL" in sql + assert "p.proname AS \"function_name\"" in sql + assert params == ("public",) + + def test_operator_kind_and_absent_operands(self): + operator = _parser().parse_operator( + { + "name": "!", + "schema_name": "public", + "operator_kind": "l", + "left_type": None, + "right_type": "integer", + "result_type": "integer", + "function_name": "int4not", + "owner": "postgres", + "comment": None, + }, + "public", + ) + assert isinstance(operator, PostgresOperatorInfo) + assert operator.operator_kind == "prefix" + assert operator.left_type is None + assert operator.right_type == "integer" + + def test_operator_class_statement_reads_pg_opclass(self): + sql, params = PostgresOperatorClassListQueryStatement( + _dialect(), schema="public" + ).to_sql() + assert "pg_opclass AS oc" in sql + assert "pg_am AS am" in sql + assert "pg_opfamily AS f" in sql + assert params == ("public",) + + def test_operator_class_default_and_key_type(self): + operator_class = _parser().parse_operator_class( + { + "name": "gist_trgm_ops", + "schema_name": "public", + "method": "gist", + "input_type": "text", + "key_type": None, + "is_default": False, + "family": "gist_trgm_ops", + "owner": "postgres", + "comment": None, + }, + "public", + ) + assert isinstance(operator_class, PostgresOperatorClassInfo) + assert operator_class.method == "gist" + assert operator_class.input_type == "text" + assert operator_class.key_type is None + assert operator_class.is_default is False + + def test_operator_family_statement_reads_pg_opfamily(self): + sql, params = PostgresOperatorFamilyListQueryStatement( + _dialect(), schema="public" + ).to_sql() + assert "pg_opfamily AS f" in sql + assert params == ("public",) + + def test_operator_family_record(self): + family = _parser().parse_operator_family( + { + "name": "gist_trgm_ops", + "schema_name": "public", + "method": "gist", + "owner": "postgres", + "comment": None, + }, + "public", + ) + assert isinstance(family, PostgresOperatorFamilyInfo) + assert family.method == "gist" + + +class TestCliVocabulary: + """The CLI offers every new family and knows what its NAME means.""" + + @pytest.mark.parametrize( + "type_name,switch", + [ + ("partitions", "supports_partition_introspection"), + ("routines", "supports_routine_introspection"), + ("types", "supports_type_introspection"), + ("operators", "supports_operator_introspection"), + ("operator-classes", "supports_operator_class_introspection"), + ("operator-families", "supports_operator_family_introspection"), + ], + ) + def test_the_family_is_advertised_and_gated(self, type_name, switch): + assert type_name in INTROSPECT_TYPES + assert _TYPE_CAPABILITIES[type_name] == switch + assert type_name in _NAME_RULE + + def test_partitions_requires_a_parent_name(self): + assert _NAME_RULE["partitions"] == "required" + + @pytest.mark.parametrize( + "type_name", + [ + "routines", + "types", + "operators", + "operator-classes", + "operator-families", + ], + ) + def test_the_catalog_families_narrow_by_name(self, type_name): + assert _NAME_RULE[type_name] == "optional"