diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dfe75766..4846a5e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,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" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database run: | @@ -253,7 +253,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" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database run: | @@ -360,7 +360,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" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@fix/column-constraint-gates" - name: Create and test database run: | diff --git a/.gitignore b/.gitignore index 31784d7c..17684a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -212,4 +212,9 @@ tests/config/mysql_scenarios.yml # Claude Code temporary files (local only) .claude/tmp/ .claude/plans/ -.claude/plan/ \ No newline at end of file +.claude/plan/ +# Worktrees for feature/* and fix/* branches. Never tracked: a `git add -A` +# from the repo root would commit each branch's whole toolchain -- the venv, the +# build directory and the test artifacts -- and the directory disappears when the +# branch merges, so the history would keep a second copy of the source tree. +.worktree/ diff --git a/README.md b/README.md index 0869b665..5069c8bf 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,11 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.model import ActiveRecord @@ -77,7 +77,7 @@ User.configure( # DDL is expressed, not stringified. Note that every node takes the dialect as # its first argument, and column types are DataType instances, not strings. dialect = User.__backend__.dialect -create_users = CreateTableExpression( +create_users = CreateTableStatement( dialect, "users", columns=[ @@ -86,8 +86,7 @@ create_users = CreateTableExpression( constraints=[ ColumnConstraint( dialect, - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True), ) ], ), diff --git a/changelog.d/62.added.md b/changelog.d/62.added.md new file mode 100644 index 00000000..a403d5ec --- /dev/null +++ b/changelog.d/62.added.md @@ -0,0 +1 @@ +The introspect CLI offers one type per catalogue question and derives the list from `_TYPE_CAPABILITIES`, so a family cannot be listed without a dialect switch behind it; `views NAME` answers for one view. Typed value nodes with MySQL's own literal spellings, and `SHOW` rendered through its own expressions. diff --git a/changelog.d/62.changed.md b/changelog.d/62.changed.md new file mode 100644 index 00000000..6c43ea97 --- /dev/null +++ b/changelog.d/62.changed.md @@ -0,0 +1 @@ +Every catalogue question is a statement class plus a dialect `format_*_query_statement`, and the `SHOW` parameters are stored under their declared names. A `NAME` the type cannot use is refused with exit 2 instead of being ignored, and a missing required name exits 1. diff --git a/changelog.d/62.fixed.md b/changelog.d/62.fixed.md new file mode 100644 index 00000000..223a38f1 --- /dev/null +++ b/changelog.d/62.fixed.md @@ -0,0 +1 @@ +`introspect database` no longer crashes on a fresh session (the parser read a private version that no adaptation had set), the introspect command adapts after connecting, a non-boolean `unsigned` is refused where the type is rendered, and `LIKE` takes its operator enum rather than the SQL keyword. diff --git a/changelog.d/62.internal.md b/changelog.d/62.internal.md new file mode 100644 index 00000000..3fe85f3b --- /dev/null +++ b/changelog.d/62.internal.md @@ -0,0 +1 @@ +The expression round-trip harness is vendored beside the matrix rather than imported from the test suite, the expression area runs in parallel, and CI installs core from `release/v1.0.0.dev30` now that core #127 has merged. The test suite stays on its own branch reference until that branch merges. diff --git a/docs/en_US/ddl/README.md b/docs/en_US/ddl/README.md index 114748d4..7009cb30 100644 --- a/docs/en_US/ddl/README.md +++ b/docs/en_US/ddl/README.md @@ -6,14 +6,14 @@ The MySQL backend supports the same type-safe DDL expressions as the core librar | Operation | MySQL Support | Notes | |----------|--------------|-------| -| `CreateTableExpression` | ✅ Full | PRIMARY KEY, NOT NULL, UNIQUE, etc. | -| `DropTableExpression` | ✅ Full | IF EXISTS support | -| `AlterTableExpression` | ✅ Full | ADD/DROP COLUMN | -| `CreateIndexExpression` | ✅ Full | Index types (BTREE, HASH) | -| `DropIndexExpression` | ✅ Full | | -| `CreateViewExpression` | ✅ Full | MySQL ALGORITHM options | -| `DropViewExpression` | ✅ Full | | -| `CreateTableExpression` | ✅ Full | Partition support (RANGE, LIST, HASH, KEY) | +| `CreateTableStatement` | ✅ Full | PRIMARY KEY, NOT NULL, UNIQUE, etc. | +| `DropTableStatement` | ✅ Full | IF EXISTS support | +| `AlterTableStatement` | ✅ Full | ADD/DROP COLUMN | +| `CreateIndexStatement` | ✅ Full | Index types (BTREE, HASH) | +| `DropIndexStatement` | ✅ Full | | +| `CreateViewStatement` | ✅ Full | MySQL ALGORITHM options | +| `DropViewStatement` | ✅ Full | | +| `CreateTableStatement` | ✅ Full | Partition support (RANGE, LIST, HASH, KEY) | | `PartitionByRange` | ✅ Full | RANGE and RANGE COLUMNS partitioning | | `PartitionByList` | ✅ Full | LIST and LIST COLUMNS partitioning | | `PartitionByHash` | ✅ Full | HASH and LINEAR HASH partitioning | @@ -39,7 +39,7 @@ partition = MySQLPartitionByRange( ], ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="orders", columns=[...], @@ -54,7 +54,7 @@ MySQL views support ALGORITHM to control execution: ```python from rhosocial.activerecord.backend.expression import ViewOptions, ViewAlgorithm -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, view_name="optimized_view", query=query, @@ -67,7 +67,7 @@ create_view = CreateViewExpression( MySQL supports specifying storage engine: ```python -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="users", columns=columns, diff --git a/docs/en_US/mysql_specific_features/dialect.md b/docs/en_US/mysql_specific_features/dialect.md index 9b8b535f..bdb6b531 100644 --- a/docs/en_US/mysql_specific_features/dialect.md +++ b/docs/en_US/mysql_specific_features/dialect.md @@ -11,11 +11,11 @@ MySQL has some specific SQL syntax and functions. This section covers commonly u MySQL supports copying table structure using the `LIKE` clause. This is useful for creating table backups or test tables with identical structure. ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect # Basic usage - copy table structure -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -24,7 +24,7 @@ create_expr = CreateTableExpression( # Generates: CREATE TABLE `users_copy` LIKE `users` # With schema-qualified source table -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -33,7 +33,7 @@ create_expr = CreateTableExpression( # Generates: CREATE TABLE `users_copy` LIKE `production`.`users` # With TEMPORARY and IF NOT EXISTS -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="temp_users", columns=[], @@ -59,7 +59,7 @@ from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, ColumnConstraintType, + ColumnDefinition, ColumnConstraint, ColumnDefaultConstraint, ) # Correct: Use factory functions for SQL:2003 niladic functions @@ -67,8 +67,8 @@ ColumnDefinition( name='created_at', data_type='TIMESTAMP', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), # Generates: `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP # (no parentheses — SQL:2003 niladic form) ], @@ -79,8 +79,8 @@ ColumnDefinition( name='updated_at', data_type='DATETIME', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=now(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, now(dialect))), # Generates: `updated_at` DATETIME DEFAULT NOW() ], ) @@ -90,7 +90,7 @@ ColumnDefinition( name='is_active', data_type='TINYINT(1)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), # Generates: `is_active` TINYINT(1) DEFAULT 1 ], ) @@ -100,7 +100,7 @@ ColumnDefinition( name='status', data_type='VARCHAR(20)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='active'), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'active')), # Generates: `status` VARCHAR(20) DEFAULT 'active' ], ) @@ -111,7 +111,7 @@ ColumnDefinition( ```python # WRONG: Passing SQL keywords as Python strings # This would generate DEFAULT 'CURRENT_TIMESTAMP' (quoted, treated as string literal) -ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='CURRENT_TIMESTAMP') +ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'CURRENT_TIMESTAMP')) ``` **Common statement-level constants reference:** @@ -176,17 +176,17 @@ class GroupConcat(FunctionExpression): ## Querying Runtime Functions and Constants -MySQL supports querying runtime functions without a data source, such as `SELECT CURRENT_TIMESTAMP`, `SELECT NOW()`, `SELECT VERSION()`. Use `QueryExpression` without specifying `from_` to achieve this. +MySQL supports querying runtime functions without a data source, such as `SELECT CURRENT_TIMESTAMP`, `SELECT NOW()`, `SELECT VERSION()`. Use `QueryStatement` without specifying `from_` to achieve this. ```python -from rhosocial.activerecord.backend.expression import QueryExpression +from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, current_date, current_time, ) # SELECT CURRENT_TIMESTAMP (niladic — no parentheses) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[current_timestamp(dialect)], ) @@ -194,14 +194,14 @@ sql, params = query.to_sql() # Generates: SELECT CURRENT_TIMESTAMP # SELECT NOW() -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[now(dialect)], ) # Generates: SELECT NOW() # SELECT CURRENT_DATE, CURRENT_TIME (niladic — no parentheses) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ current_date(dialect), @@ -211,7 +211,7 @@ query = QueryExpression( # Generates: SELECT CURRENT_DATE, CURRENT_TIME # Multi-function query with aliases -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ now(dialect).as_('current_time'), @@ -222,7 +222,7 @@ query = QueryExpression( # Generates: SELECT NOW() AS `current_time`, DATABASE() AS `db_name`, VERSION() AS `db_version` # Function calls with arguments -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ FunctionCall(dialect, 'DATE_FORMAT', diff --git a/docs/en_US/mysql_specific_features/introspection.md b/docs/en_US/mysql_specific_features/introspection.md index bd8963d6..47d3173f 100644 --- a/docs/en_US/mysql_specific_features/introspection.md +++ b/docs/en_US/mysql_specific_features/introspection.md @@ -457,8 +457,8 @@ Supports configuration via command-line arguments or environment variables: ### Querying Table Details ```bash -# Get complete table info (columns, indexes, foreign keys) -python -m rhosocial.activerecord.backend.impl.mysql introspect table users \ +# Describe one view +python -m rhosocial.activerecord.backend.impl.mysql introspect views users_v \ --database mydb --user root # Query only column information @@ -484,16 +484,19 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect triggers users \ ### Introspection Types -| Type | Description | Table Name Required | -|------|-------------|---------------------| -| `tables` | List all tables | No | -| `views` | List all views | No | -| `database` | Database information | No | -| `table` | Complete table details (columns, indexes, foreign keys) | Yes | -| `columns` | Column information | Yes | -| `indexes` | Index information | Yes | -| `foreign-keys` | Foreign key information | Yes | -| `triggers` | Trigger information | Optional | +| Type | Description | Name | +|------|-------------|------| +| `tables` | List all tables | not accepted | +| `views` | List all views, or describe one | optional (view) | +| `database` | Database information | not accepted | +| `columns` | Column information | required (table) | +| `indexes` | Index information | required (table) | +| `foreign-keys` | Foreign key information | required (table) | +| `triggers` | Trigger information | optional (table) | +| `table-options` | Engine, row format, collation and byte sizes of one table | optional (table) | +| `partitions` | One table's partitions | required (table) | + +A name the type cannot use is refused (exit 2) rather than ignored. ### Output Formats diff --git a/docs/examples/chapter_04_ddl/ddl.py b/docs/examples/chapter_04_ddl/ddl.py index ca872d0a..f9734f7e 100644 --- a/docs/examples/chapter_04_ddl/ddl.py +++ b/docs/examples/chapter_04_ddl/ddl.py @@ -1,6 +1,6 @@ """ DDL Test Script for MySQL Backend -Tests CreateTableExpression, DropTableExpression, AlterTableExpression, etc. +Tests CreateTableStatement, DropTableStatement, AlterTableStatement, etc. """ import sys @@ -13,15 +13,16 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.expression import ( ColumnDefinition, - CreateTableExpression, - DropTableExpression, - AlterTableExpression, + CreateTableStatement, + DropTableStatement, + AlterTableStatement, AddColumn, DropColumn, ) from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) @@ -68,21 +69,21 @@ def main(): ColumnDefinition( "id", "INTEGER", - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)] + constraints=[ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None))] ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)] + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))] ), ColumnDefinition( "email", "VARCHAR(255)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)] + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))] ), ] - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table_name="ddl_test_users", columns=columns, @@ -104,7 +105,7 @@ def main(): # Test 2: Create Table with IF NOT EXISTS # ============================================================ print("=== Test 2: Create Table with IF NOT EXISTS ===") - create2 = CreateTableExpression( + create2 = CreateTableStatement( dialect=dialect, table_name="ddl_test_users", columns=columns, @@ -124,7 +125,7 @@ def main(): # Test 3: Alter Table - Add Column # ============================================================ print("=== Test 3: Alter Table - Add Column ===") - alter = AlterTableExpression( + alter = AlterTableStatement( dialect, table_name="ddl_test_users", actions=[ @@ -150,7 +151,7 @@ def main(): # ============================================================ print("=== Test 4: Alter Table - Drop Column ===") # First add a column to drop - alter_add = AlterTableExpression( + alter_add = AlterTableStatement( dialect, table_name="ddl_test_users", actions=[AddColumn(ColumnDefinition("temp_field", "TEXT"))] @@ -158,7 +159,7 @@ def main(): sql_str, params_str = alter_add.to_sql() backend.execute(sql_str, params_str) - alter_drop = AlterTableExpression( + alter_drop = AlterTableStatement( dialect, table_name="ddl_test_users", actions=[DropColumn("temp_field")] @@ -180,7 +181,7 @@ def main(): # Test 5: Drop Table # ============================================================ print("=== Test 5: Drop Table ===") - drop = DropTableExpression( + drop = DropTableStatement( dialect, table_name="ddl_test_users", if_exists=True, diff --git a/docs/examples/chapter_12_named_procedure/queries/order_queries.py b/docs/examples/chapter_12_named_procedure/queries/order_queries.py index 2ae371ea..4dc89b3e 100644 --- a/docs/examples/chapter_12_named_procedure/queries/order_queries.py +++ b/docs/examples/chapter_12_named_procedure/queries/order_queries.py @@ -14,7 +14,7 @@ from rhosocial.activerecord.backend.expression import ( Column, Literal, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.sources import NamedRelationRef @@ -22,7 +22,7 @@ def get_order(dialect, order_id: int): """获取订单详情。""" - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "id"), @@ -37,7 +37,7 @@ def get_order(dialect, order_id: int): def check_inventory(dialect, order_id: int): """检查库存可用数量。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "available")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -47,7 +47,7 @@ def check_inventory(dialect, order_id: int): def reserve_inventory(dialect, order_id: int): """预留库存。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "reserved")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -58,7 +58,7 @@ def reserve_inventory(dialect, order_id: int): def send_notification(dialect, user_id: int, type: str): """发送通知。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "notifications")), @@ -68,7 +68,7 @@ def send_notification(dialect, user_id: int, type: str): def process_payment(dialect, order_id: int, amount: float): """处理支付。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=NamedRelationRef(dialect, Table(dialect, "payments")), @@ -78,7 +78,7 @@ def process_payment(dialect, order_id: int, amount: float): def release_inventory(dialect, order_id: int): """释放库存(回滚)。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), @@ -88,7 +88,7 @@ def release_inventory(dialect, order_id: int): def create_order_record(dialect, order_id: int, user_id: int, amount: float): """创建订单记录。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=NamedRelationRef(dialect, Table(dialect, "order_records")), @@ -98,7 +98,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): def confirm_inventory(dialect, order_id: int): """确认库存(最终确认)。""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=NamedRelationRef(dialect, Table(dialect, "inventory")), diff --git a/docs/zh_CN/ddl/README.md b/docs/zh_CN/ddl/README.md index 21682aa5..c24b775f 100644 --- a/docs/zh_CN/ddl/README.md +++ b/docs/zh_CN/ddl/README.md @@ -6,14 +6,14 @@ MySQL 后端支持与核心库相同的类型安全 DDL 表达式。 | 操作 | MySQL 支持 | 备注 | |----------|--------------|-------| -| `CreateTableExpression` | ✅ 完整 | PRIMARY KEY, NOT NULL, UNIQUE 等 | -| `DropTableExpression` | ✅ 完整 | IF EXISTS 支持 | -| `AlterTableExpression` | ✅ 完整 | ADD/DROP COLUMN | -| `CreateIndexExpression` | ✅ 完整 | 索引类型 (BTREE, HASH) | -| `DropIndexExpression` | ✅ 完整 | | -| `CreateViewExpression` | ✅ 完整 | MySQL ALGORITHM 选项 | -| `DropViewExpression` | ✅ 完整 | | -| `CreateTableExpression` | ✅ 完整 | 分区支持(RANGE, LIST, HASH, KEY) | +| `CreateTableStatement` | ✅ 完整 | PRIMARY KEY, NOT NULL, UNIQUE 等 | +| `DropTableStatement` | ✅ 完整 | IF EXISTS 支持 | +| `AlterTableStatement` | ✅ 完整 | ADD/DROP COLUMN | +| `CreateIndexStatement` | ✅ 完整 | 索引类型 (BTREE, HASH) | +| `DropIndexStatement` | ✅ 完整 | | +| `CreateViewStatement` | ✅ 完整 | MySQL ALGORITHM 选项 | +| `DropViewStatement` | ✅ 完整 | | +| `CreateTableStatement` | ✅ 完整 | 分区支持(RANGE, LIST, HASH, KEY) | | `PartitionByRange` | ✅ 完整 | RANGE 和 RANGE COLUMNS 分区 | | `PartitionByList` | ✅ 完整 | LIST 和 LIST COLUMNS 分区 | | `PartitionByHash` | ✅ 完整 | HASH 和 LINEAR HASH 分区 | @@ -39,7 +39,7 @@ partition = MySQLPartitionByRange( ] ) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="orders", columns=[...], @@ -54,7 +54,7 @@ MySQL 视图支持 ALGORITHM 来控制执行方式: ```python from rhosocial.activerecord.backend.expression import ViewOptions, ViewAlgorithm -create_view = CreateViewExpression( +create_view = CreateViewStatement( dialect, view_name="optimized_view", query=query, @@ -67,7 +67,7 @@ create_view = CreateViewExpression( MySQL 支持指定存储引擎: ```python -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect, table_name="users", columns=columns, diff --git a/docs/zh_CN/mysql_specific_features/dialect.md b/docs/zh_CN/mysql_specific_features/dialect.md index cb01ba64..b4fe786f 100644 --- a/docs/zh_CN/mysql_specific_features/dialect.md +++ b/docs/zh_CN/mysql_specific_features/dialect.md @@ -11,11 +11,11 @@ MySQL 有一些特定的 SQL 语法和函数,本节介绍常用的 MySQL 特 MySQL 支持使用 `LIKE` 子句复制表结构。这对于创建具有相同结构的表备份或测试表非常有用。 ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect # 基本用法 - 复制表结构 -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -24,7 +24,7 @@ create_expr = CreateTableExpression( # 生成: CREATE TABLE `users_copy` LIKE `users` # 带模式限定的源表 -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="users_copy", columns=[], @@ -33,7 +33,7 @@ create_expr = CreateTableExpression( # 生成: CREATE TABLE `users_copy` LIKE `production`.`users` # 带临时表和 IF NOT EXISTS -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=MySQLDialect(), table_name="temp_users", columns=[], @@ -59,7 +59,7 @@ from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, ColumnConstraintType, + ColumnDefinition, ColumnConstraint, ColumnDefaultConstraint, ) # 正确:使用工厂函数传递 SQL:2003 零元函数 @@ -67,8 +67,8 @@ ColumnDefinition( name='created_at', data_type='TIMESTAMP', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), # 生成: `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP # (无括号 — SQL:2003 零元形式) ], @@ -79,8 +79,8 @@ ColumnDefinition( name='updated_at', data_type='DATETIME', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, - default_value=now(dialect)), + ColumnConstraint(dialect, + branch=ColumnDefaultConstraint(dialect, now(dialect))), # 生成: `updated_at` DATETIME DEFAULT NOW() ], ) @@ -90,7 +90,7 @@ ColumnDefinition( name='is_active', data_type='TINYINT(1)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1)), # 生成: `is_active` TINYINT(1) DEFAULT 1 ], ) @@ -100,7 +100,7 @@ ColumnDefinition( name='status', data_type='VARCHAR(20)', constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='active'), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'active')), # 生成: `status` VARCHAR(20) DEFAULT 'active' ], ) @@ -111,7 +111,7 @@ ColumnDefinition( ```python # 错误:将 SQL 关键字作为 Python 字符串传入 # 这会导致生成 DEFAULT 'CURRENT_TIMESTAMP'(被引号包裹,变成字符串字面量) -ColumnConstraint(ColumnConstraintType.DEFAULT, default_value='CURRENT_TIMESTAMP') +ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 'CURRENT_TIMESTAMP')) ``` **常见语句级常量对照表:** @@ -207,7 +207,7 @@ MySQL 方言支持标准化的日期时间间隔表达式: ```python from rhosocial.activerecord.backend.expression.datetime import ( ExtractExpression, DateTimeField, IntervalExpression, IntervalUnit, - DateTimeAddExpression, DateTimeSubtractExpression, DateTimeDiffExpression, + DatetimeAddExpression, DatetimeSubtractExpression, DatetimeDiffExpression, ) # EXTRACT @@ -219,17 +219,17 @@ interval = IntervalExpression(dialect, 7, IntervalUnit.DAY) # 生成: INTERVAL 7 DAY # 日期加法 -expr = DateTimeAddExpression(dialect, Column(dialect, "created_at"), +expr = DatetimeAddExpression(dialect, Column(dialect, "created_at"), IntervalExpression(dialect, 30, IntervalUnit.DAY)) # 生成: DATE_ADD(`created_at`, INTERVAL 30 DAY) # 日期减法 -expr = DateTimeSubtractExpression(dialect, Column(dialect, "created_at"), +expr = DatetimeSubtractExpression(dialect, Column(dialect, "created_at"), IntervalExpression(dialect, 7, IntervalUnit.DAY)) # 生成: DATE_SUB(`created_at`, INTERVAL 7 DAY) # 日期差 -expr = DateTimeDiffExpression(dialect, DateTimeField.DAY, +expr = DatetimeDiffExpression(dialect, DateTimeField.DAY, Column(dialect, "start"), Column(dialect, "end")) # 生成: TIMESTAMPDIFF(DAY, `start`, `end`) ``` @@ -264,12 +264,12 @@ MySQL 9.7+ 支持 JSON 二象性视图(JSON Duality View): ```python from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, DropJsonDualityViewStatement, DualityObjectSpec, DualityDMLTag, DualityColumnMapping, ) # 创建 JSON 二象性视图 -create = CreateJsonDualityViewExpression( +create = CreateJsonDualityViewStatement( dialect, view_name="user_details", root_spec=DualityObjectSpec( @@ -291,17 +291,17 @@ if dialect.supports_json_duality_view(): ## 查询运行时函数与常量 -MySQL 支持不涉及数据源的纯函数查询,如 `SELECT CURRENT_TIMESTAMP`、`SELECT NOW()`、`SELECT VERSION()` 等。使用 `QueryExpression` 不指定 `from_` 子句即可实现。 +MySQL 支持不涉及数据源的纯函数查询,如 `SELECT CURRENT_TIMESTAMP`、`SELECT NOW()`、`SELECT VERSION()` 等。使用 `QueryStatement` 不指定 `from_` 子句即可实现。 ```python -from rhosocial.activerecord.backend.expression import QueryExpression +from rhosocial.activerecord.backend.expression import QueryStatement from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal from rhosocial.activerecord.backend.expression.functions.datetime import ( current_timestamp, now, current_date, current_time, ) # SELECT CURRENT_TIMESTAMP(零元函数 — 无括号) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[current_timestamp(dialect)], ) @@ -309,14 +309,14 @@ sql, params = query.to_sql() # 生成: SELECT CURRENT_TIMESTAMP # SELECT NOW() -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[now(dialect)], ) # 生成: SELECT NOW() # SELECT CURRENT_DATE, CURRENT_TIME(零元函数 — 无括号) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ current_date(dialect), @@ -326,7 +326,7 @@ query = QueryExpression( # 生成: SELECT CURRENT_DATE, CURRENT_TIME # 带别名的多函数查询 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ now(dialect).as_('current_time'), @@ -337,7 +337,7 @@ query = QueryExpression( # 生成: SELECT NOW() AS `current_time`, DATABASE() AS `db_name`, VERSION() AS `db_version` # 带参数的函数调用 -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ FunctionCall(dialect, 'DATE_FORMAT', diff --git a/docs/zh_CN/mysql_specific_features/introspection.md b/docs/zh_CN/mysql_specific_features/introspection.md index 0ff33226..ec938aab 100644 --- a/docs/zh_CN/mysql_specific_features/introspection.md +++ b/docs/zh_CN/mysql_specific_features/introspection.md @@ -456,8 +456,8 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect tables \ ### 查询表详情 ```bash -# 获取表的完整信息(列、索引、外键) -python -m rhosocial.activerecord.backend.impl.mysql introspect table users \ +# 描述单个视图 +python -m rhosocial.activerecord.backend.impl.mysql introspect views users_v \ --database mydb --user root # 仅查询列信息 @@ -483,16 +483,19 @@ python -m rhosocial.activerecord.backend.impl.mysql introspect triggers users \ ### 内省类型 -| 类型 | 说明 | 是否需要表名 | -|------|------|-------------| -| `tables` | 列出所有表 | 否 | -| `views` | 列出所有视图 | 否 | -| `database` | 数据库信息 | 否 | -| `table` | 表完整详情(列、索引、外键) | 是 | -| `columns` | 列信息 | 是 | -| `indexes` | 索引信息 | 是 | -| `foreign-keys` | 外键信息 | 是 | -| `triggers` | 触发器信息 | 可选 | +| 类型 | 说明 | 名称参数 | +|------|------|---------| +| `tables` | 列出所有表 | 不接受 | +| `views` | 列出所有视图,或描述单个视图 | 可选(视图名) | +| `database` | 数据库信息 | 不接受 | +| `columns` | 列信息 | 必填(表名) | +| `indexes` | 索引信息 | 必填(表名) | +| `foreign-keys` | 外键信息 | 必填(表名) | +| `triggers` | 触发器信息 | 可选(表名) | +| `table-options` | 单表的引擎、行格式、排序规则与字节数 | 可选(表名) | +| `partitions` | 单表的分区 | 必填(表名) | + +类型不接受名称参数时会报错(退出码 2),而不是静默忽略。 ### 输出格式 diff --git a/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py index e5d04e0d..029a878c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py @@ -28,7 +28,7 @@ class AsyncMySQLTransactionManager(MySQLTransactionMixin, AsyncTransactionManage START TRANSACTION when a specific isolation level is needed. This class overrides _do_begin() to handle this sequencing. - The format_begin_transaction() in MySQLDialect returns only "START TRANSACTION", + The format_begin_transaction_statement() in MySQLDialect returns only "START TRANSACTION", while this class handles the SET TRANSACTION step separately. Non-I/O methods (isolation_level, _build_set_isolation_sql, _ISOLATION_LEVELS) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py index 329e7cd4..4dd3798a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/backend/async_backend.py @@ -365,8 +365,10 @@ async def get_server_version(self) -> Tuple[int, int, int]: cursor = None try: + from ..expression.system import version_query + cursor = await self._get_cursor() - await cursor.execute("SELECT VERSION()") + await cursor.execute(*version_query(self.dialect).to_sql()) version_row = await cursor.fetchone() version_str = version_row[0] if version_row else "8.0.0" @@ -433,8 +435,10 @@ async def ping(self, reconnect: bool = True) -> bool: # reconnect=True: verify connection with SELECT 1 try: + from ..expression.system import ping_query + cursor = await self._get_cursor() - await cursor.execute("SELECT 1") + await cursor.execute(*ping_query(self.dialect).to_sql()) await cursor.fetchone() await cursor.close() return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py b/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py index 199efa80..27bc604d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/backend/backend.py @@ -351,8 +351,10 @@ def get_server_version(self) -> Tuple[int, int, int]: cursor = None try: + from ..expression.system import version_query + cursor = self._get_cursor() - cursor.execute("SELECT VERSION()") + cursor.execute(*version_query(self.dialect).to_sql()) version_row = cursor.fetchone() version_str = version_row[0] if version_row else "8.0.0" @@ -419,8 +421,10 @@ def ping(self, reconnect: bool = True) -> bool: # reconnect=True: verify connection with SELECT 1 try: + from ..expression.system import ping_query + cursor = self._get_cursor() - cursor.execute("SELECT 1") + cursor.execute(*ping_query(self.dialect).to_sql()) cursor.fetchone() cursor.close() return True diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md new file mode 100644 index 00000000..bd412455 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/README.md @@ -0,0 +1,99 @@ +# `introspect` — MySQL metadata, from the command line + +The `introspect` subcommand asks a live MySQL server what it holds, and prints +the answer. Every question runs through the backend's introspector, so what the +CLI reports is what the library sees — the same objects, the same types, the +same parsing — rather than a second implementation that can drift from it. + +```bash +python -m rhosocial.activerecord.backend.impl.mysql introspect [NAME] [OPTIONS] +``` + +## What it can ask about + +| Type | Reads | Notes | +| :--- | :--- | :--- | +| `tables` | `information_schema.tables` | Base tables. `--include-system` adds MySQL's own metadata databases. | +| `views` | `information_schema.views` | Views, with their definitions. `NAME` narrows it to one view. | +| `columns` | `information_schema.columns` | One table's columns: name, type, nullability, default. | +| `indexes` | `information_schema.statistics` | One table's indexes, including the primary key. | +| `foreign-keys` | `information_schema.key_column_usage` | One table's constraints and the columns each side names. | +| `triggers` | `information_schema.triggers` | Triggers, optionally narrowed to one table. | +| `table-options` | `information_schema.tables` | The storage facts about a table: engine, row format, collation, auto-increment, comment, and the byte sizes `information_schema` reports. `NAME` narrows it to one table. | +| `partitions` | `information_schema.partitions` | One table's partitions and subpartitions, in the engine's own order. An empty answer means the table is not partitioned. | +| `database` | `information_schema.schemata`, `variables` | Server version, charset, and the databases visible to this role. | + +### What `NAME` means + +`NAME` is one argument with three possible meanings, decided by the type: + +| Meaning | Types | +| :--- | :--- | +| A **table**, required | `columns`, `indexes`, `foreign-keys`, `partitions` | +| The **object itself**, optional — given, it answers for that one object and exits 1 if there is no such object | `views`, `triggers`, `table-options` | +| **Refused** — the type answers for the whole database | `tables`, `database` | + +A `NAME` a type cannot use is an error (exit 2), never a silently ignored +argument: a run that prints the whole catalogue while the caller believes it +asked about one object is the worst of the possible answers. + + +## Options + +| Option | Default | Description | +| :--- | :--- | :--- | +| `-o, --output` | `table` | `table` (rich), `json`, `csv`, `tsv`. | +| `--database` | required (env: `MYSQL_DATABASE`) | The database to connect to **and** the database to read. MySQL has one namespace level and calls it a database, so there is no second flag: the run connects to a database and asks about that database. | +| `--include-system` | off | Include system tables. | +| `--host`, `--port`, `--database`, `--user`, `--password` | env or defaults | Connection. `MYSQL_*` env vars are honoured, and arguments override them. | +| `--charset` | _None_ | Connection charset. | +| `--ssl` | off | Use SSL for the connection. | +| `--async` | off | Drive the asynchronous backend. | +| `--named-connection` | _None_ | A connection saved by the `named-connection` subcommand. | +| `--conn-param` | _None_ | Extra `key=value` connection parameters; repeatable. | +| `--rich-ascii` | off | ASCII characters for table borders. | + +## Notes + +* **Read-only.** `introspect` never writes. It does not create, drop, or + alter anything, so it is safe to run against a database whose schema + matters. +* **There is no namespace flag, and that is deliberate.** MySQL has one + namespace level and calls it a database — `CREATE SCHEMA` is a synonym for + `CREATE DATABASE`, and nothing sits inside a database under a further name. + So `--database` is the whole answer: the run connects to a database and + asks about that database. There is no `--schema`, and it has not been left + as an alias, so a caller cannot end up asking about a level that does not + exist here. +* **`data_length` and `index_length` are estimates.** InnoDB reports + approximate byte sizes for the clustered index, not exact file sizes. They + are printed as reported; the caveat travels with the number. +* **One question per invocation.** A CLI process is one connection that + opens, answers, and closes — there is no cache carried between runs, so + the answer reflects the server as it is *now*. +* **`introspector.show` is reachable from the library only, not from here.** + Its nineteen methods (`create_table`, `variables`, `status`, `processlist`, + `grants`, `engines`, …) are the raw `SHOW` verbs. Most of them are not + catalogue metadata at all — `variables` is server configuration, `status` + is running counters, `processlist` is current activity, `warnings` and + `errors` are this session's leftovers — and the `create_*` ones return DDL + text where `columns`, `views` and `triggers` already give the structured + answer to the same question. `introspect` asks *what the database holds*; + a second door onto the same facts would only raise the question of which + one is authoritative. + +## Examples + +```bash +# Storage facts and partitions for the order tables +python -m rhosocial.activerecord.backend.impl.mysql introspect table-options --database shop +python -m rhosocial.activerecord.backend.impl.mysql introspect table-options orders --database shop +python -m rhosocial.activerecord.backend.impl.mysql introspect partitions orders --database shop -o json + +# One view, with its definition +python -m rhosocial.activerecord.backend.impl.mysql introspect views orders_v --database shop + +# A script asks the question and reads the answer +export MYSQL_HOST=localhost MYSQL_DATABASE=shop MYSQL_USER=root MYSQL_PASSWORD=secret +python -m rhosocial.activerecord.backend.impl.mysql introspect tables -o json | jq '.[].name' +``` diff --git a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py index c71c1c97..ffe1087a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py @@ -1,5 +1,15 @@ # src/rhosocial/activerecord/backend/impl/mysql/cli/introspect.py -"""introspect subcommand - Database introspection.""" +"""introspect subcommand - Database introspection. + +There is no namespace flag here, and that is deliberate. MySQL has exactly one +namespace level and calls it a *database* -- ``CREATE SCHEMA`` is a synonym for +``CREATE DATABASE`` and nothing sits inside a database under a further name. So +``--database``, which the connection already takes, is the whole answer: the run +connects to a database and asks about that database. An earlier version accepted +``--schema`` as well, which named the same thing under a word MySQL does not use; +it is gone rather than aliased, so a caller cannot quietly end up asking about a +different level than the one it is connected to. +""" import argparse import asyncio @@ -17,7 +27,44 @@ OUTPUT_CHOICES = ["table", "json", "csv", "tsv"] -INTROSPECT_TYPES = ["tables", "views", "table", "columns", "indexes", "foreign-keys", "triggers", "database"] +#: The single place a type name maps to the dialect capability that decides +#: whether this backend can answer it. ``INTROSPECT_TYPES`` is derived from it, +#: so a family cannot be offered without a switch saying it may be -- and the +#: order below is the order the parser and ``--help`` print. +_TYPE_CAPABILITIES = { + "tables": "supports_table_introspection", + "views": "supports_view_introspection", + "columns": "supports_column_introspection", + "indexes": "supports_index_introspection", + "foreign-keys": "supports_foreign_key_introspection", + "triggers": "supports_trigger_introspection", + "table-options": "supports_table_options_introspection", + "partitions": "supports_partition_introspection", + "database": "supports_database_info", +} + +INTROSPECT_TYPES = list(_TYPE_CAPABILITIES) + +#: What the optional NAME positional means for each type. +#: +#: ``required`` -- the type asks about one table and cannot be answered without +#: it. ``optional`` -- a catalogue family that can also answer for one member, +#: so a given name narrows it (and a name that matches nothing is an error, not +#: an empty list). ``forbidden`` -- the family has no single-member question +#: here. ``tables`` must stay one: answering for one table would fill in its +#: columns, indexes and foreign keys, which is the aggregate this command +#: deliberately dropped. +_NAME_RULE = { + "tables": "forbidden", + "views": "optional", + "columns": "required", + "indexes": "required", + "foreign-keys": "required", + "triggers": "optional", + "table-options": "optional", + "partitions": "required", + "database": "forbidden", +} def create_parser(subparsers): @@ -29,18 +76,27 @@ def create_parser(subparsers): # List all tables in database %(prog)s tables --database mydb - # List all views + # List all views, or describe one of them %(prog)s views --database mydb - - # Get detailed table info (columns, indexes, foreign keys) - %(prog)s table users --database mydb + %(prog)s views users_v --database mydb # Get column details for a table %(prog)s columns users --database mydb + # Get storage options (engine, row format, collation, sizes) + %(prog)s table-options --database mydb + %(prog)s table-options users --database mydb + + # Get the partitions of one table + %(prog)s partitions orders --database mydb + # Output as JSON %(prog)s tables --database mydb -o json + # NAME is a table for columns/indexes/foreign-keys/partitions, the object + # itself for views/triggers/table-options, and is refused for + # tables/database -- those answer for the whole database. + # Using environment variables for connection export MYSQL_HOST=localhost MYSQL_DATABASE=mydb MYSQL_USER=root MYSQL_PASSWORD=secret %(prog)s tables @@ -71,18 +127,20 @@ def create_parser(subparsers): parser.add_argument( "type", choices=INTROSPECT_TYPES, - help="Introspection type: tables, views, table, columns, indexes, foreign-keys, triggers, database", + help=( + "Introspection type: tables, views, columns, indexes, " + "foreign-keys, triggers, table-options, partitions, database" + ), ) parser.add_argument( "name", nargs="?", default=None, - help="Table/view name (required for some types)", - ) - parser.add_argument( - "--schema", - default=None, - help="Schema name (for databases that support schemas)", + help=( + "Object name: a table for columns/indexes/foreign-keys/partitions, " + "the object itself for views/triggers/table-options. Refused for " + "tables/database." + ), ) parser.add_argument( "--include-system", @@ -97,6 +155,8 @@ def handle(args): """Handle the introspect subcommand.""" provider = create_provider(args.output, ascii_borders=args.rich_ascii) + _validate_name(args) + named_conn = getattr(args, "named_connection", None) if not named_conn and not args.database: print("Error: --database is required for introspection", file=sys.stderr) @@ -141,69 +201,130 @@ def _serialize_for_output(obj: Any) -> Any: return str(obj) +def _validate_name(args) -> None: + """Refuse a NAME the type cannot use, before anything connects. + + A silently ignored argument is the worst of the three outcomes: the run + succeeds, prints a full listing, and reads as though it had been narrowed. + So the rule table above is applied here, once, instead of being left to + each branch to remember. + """ + rule = _NAME_RULE.get(args.type) + if rule is None: + return + if rule == "required" and not args.name: + # The same code a missing ``--database`` gets: an incomplete + # invocation, not a request this backend refuses. 2 is reserved for + # the two things the CLI newly declines to do -- answer for a family + # the server cannot, and accept a NAME the type has no use for. + print( + f"Error: Table name is required for '{args.type}' introspection", + file=sys.stderr, + ) + sys.exit(1) + if rule == "forbidden" and args.name: + print( + f"Error: '{args.type}' introspection answers for the whole database " + f"and takes no name", + file=sys.stderr, + ) + sys.exit(2) + + +def _require_capability(args, backend) -> None: + """Refuse a family this server cannot answer. + + Runs after ``introspect_and_adapt()``, so the switch answers for the server + in front of us rather than for the newest release this backend knows. A + stub standing in for the backend (the offline CLI tests) may carry no + dialect at all; a real one always does. + """ + switch = _TYPE_CAPABILITIES.get(args.type) + if switch is None: + return + dialect = getattr(backend, "dialect", None) + if dialect is None: + return + supported = getattr(dialect, switch, None) + if supported is None or supported(): + return + print( + f"Error: {args.type} introspection is not supported by this server", + file=sys.stderr, + ) + sys.exit(2) + + def _handle_introspect_sync(args, backend: MySQLBackend, provider): """Handle introspect subcommand synchronously.""" try: backend.connect() + # Connect alone leaves the dialect unadapted. Everything below -- the + # database record's version, and any statement gated on a capability + # -- then has no version to answer from, so the run would report + # "unknown" at best and fail on a capability probe at worst. The + # SQLite CLI adapts after connecting for the same reason. + backend.introspect_and_adapt() + _require_capability(args, backend) introspector = backend.introspector if args.type == "tables": - tables = introspector.list_tables(schema=args.schema, include_system=args.include_system) + tables = introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = introspector.list_views(schema=args.schema) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") - - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - info = introspector.get_table_info(args.name, schema=args.schema) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results( - _serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}" - ) + if args.name: + view = introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": - if not args.name: - print("Error: Table name is required for 'columns' introspection", file=sys.stderr) - sys.exit(1) - columns = introspector.list_columns(args.name, schema=args.schema) + columns = introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") elif args.type == "indexes": - if not args.name: - print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) - sys.exit(1) - indexes = introspector.list_indexes(args.name, schema=args.schema) + indexes = introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") elif args.type == "foreign-keys": - if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) - sys.exit(1) - fks = introspector.list_foreign_keys(args.name, schema=args.schema) + fks = introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") elif args.type == "triggers": - triggers = introspector.list_triggers(table_name=args.name, schema=args.schema) + triggers = introspector.list_triggers(table_name=args.name, schema=None) data = _serialize_for_output(triggers) provider.display_results(data, title="Triggers") + elif args.type == "table-options": + if args.name: + options = introspector.get_table_options(args.name, schema=None) + if options is None: + print(f"Error: Table '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + _serialize_for_output([options]), title=f"Table options of {args.name}" + ) + else: + options = introspector.list_table_options(schema=None) + provider.display_results(_serialize_for_output(options), title="Table options") + + elif args.type == "partitions": + partitions = introspector.list_partitions(args.name, schema=None) + provider.display_results( + _serialize_for_output(partitions), title=f"Partitions of {args.name}" + ) + elif args.type == "database": info = introspector.get_database_info() data = _serialize_for_output(info) @@ -227,67 +348,69 @@ async def _handle_introspect_async(args, backend: AsyncMySQLBackend, provider): """Handle introspect subcommand asynchronously.""" try: await backend.connect() + await backend.introspect_and_adapt() + _require_capability(args, backend) introspector = backend.introspector if args.type == "tables": - tables = await introspector.list_tables_async(schema=args.schema, include_system=args.include_system) + tables = await introspector.list_tables(schema=None, include_system=args.include_system) data = _serialize_for_output(tables) provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views_async(schema=args.schema) - data = _serialize_for_output(views) - provider.display_results(data, title="Views") - - elif args.type == "table": - if not args.name: - print("Error: Table name is required for 'table' introspection", file=sys.stderr) - sys.exit(1) - info = await introspector.get_table_info_async(args.name, schema=args.schema) - if info: - provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") - if info.indexes: - provider.display_results(_serialize_for_output(info.indexes), title=f"Indexes of {args.name}") - if info.foreign_keys: - provider.display_results( - _serialize_for_output(info.foreign_keys), title=f"Foreign Keys of {args.name}" - ) + if args.name: + view = await introspector.get_view_info(args.name, schema=None) + if view is None: + print(f"Error: View '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results(_serialize_for_output([view]), title=f"View {args.name}") else: - print(f"Error: Table '{args.name}' not found", file=sys.stderr) - sys.exit(1) + views = await introspector.list_views(schema=None) + data = _serialize_for_output(views) + provider.display_results(data, title="Views") elif args.type == "columns": - if not args.name: - print("Error: Table name is required for 'columns' introspection", file=sys.stderr) - sys.exit(1) - columns = await introspector.list_columns_async(args.name, schema=args.schema) + columns = await introspector.list_columns(args.name, schema=None) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") elif args.type == "indexes": - if not args.name: - print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) - sys.exit(1) - indexes = await introspector.list_indexes_async(args.name, schema=args.schema) + indexes = await introspector.list_indexes(args.name, schema=None) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") elif args.type == "foreign-keys": - if not args.name: - print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) - sys.exit(1) - fks = await introspector.list_foreign_keys_async(args.name, schema=args.schema) + fks = await introspector.list_foreign_keys(args.name, schema=None) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") elif args.type == "triggers": - triggers = await introspector.list_triggers_async(table_name=args.name, schema=args.schema) + triggers = await introspector.list_triggers(table_name=args.name, schema=None) data = _serialize_for_output(triggers) provider.display_results(data, title="Triggers") + elif args.type == "table-options": + if args.name: + options = await introspector.get_table_options(args.name, schema=None) + if options is None: + print(f"Error: Table '{args.name}' not found", file=sys.stderr) + sys.exit(1) + provider.display_results( + _serialize_for_output([options]), title=f"Table options of {args.name}" + ) + else: + options = await introspector.list_table_options(schema=None) + provider.display_results(_serialize_for_output(options), title="Table options") + + elif args.type == "partitions": + partitions = await introspector.list_partitions(args.name, schema=None) + provider.display_results( + _serialize_for_output(partitions), title=f"Partitions of {args.name}" + ) + elif args.type == "database": - info = await introspector.get_database_info_async() + info = await introspector.get_database_info() data = _serialize_for_output(info) provider.display_results([data], title="Database Info") diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index 3d127843..f37e70fd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -37,8 +37,6 @@ TransactionControlSupport, SQLFunctionSupport, DataTypeSupport, - # Column Class Support Protocol - ColumnTypeSupport, TypeObjectSupport, CreateTypeSupport, AlterTypeSupport, @@ -170,6 +168,7 @@ MySQLGeneratedColumnMixin, MySQLFunctionMixin, MySQLColumnTypeMixin, + MySQLLiteralValueMixin, ) from .mixins.object_kind import require_kind from .reserved_words import MYSQL_RESERVED_WORDS @@ -177,11 +176,25 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, + InsertStatement, ) class MySQLDialect( + # The bases below come in bundles: a backend mixin, then the core bundle + # it stands in for. The backend's answer has to come first -- a mixin + # placed after the class it overrides never wins -- so a new mixin goes + # immediately before the core mixin it overrides, and the pairing is read + # off the list. Two positions sit outside that pairing and are called out + # where they occur: a mixin that overrides a method ``SQLDialectBase`` + # defines has to precede the base, and a mixin that subclasses a core + # mixin has to precede it too (C3: a subclass precedes its base). + # MySQL's tier-2 value literals (bytes X'...' and the quoted UUID text) + # override format_literal, and this is the one base that defines it, so + # the mixin is placed ahead of SQLDialectBase: a mixin placed after the + # class it overrides never wins. Everything else about this mixin is the + # base's own tiering, which super() reaches. + MySQLLiteralValueMixin, SQLDialectBase, # MySQL's own namespace rules first, and before NamespaceMixin for the C3 # reason: MySQLNamespaceMixin overrides supports_catalog, @@ -452,7 +465,7 @@ def create_schema_differ(self): return MySQLSchemaDiffer() - def _escape_sql_string(self, value: str) -> str: + def escape_sql_string(self, value: str) -> str: """Escape string for MySQL with backslash support. MySQL default SQL mode (STRICT_TRANS_TABLES) treats backslash as an @@ -501,7 +514,7 @@ def format_identifier(self, identifier: str, need_quote: bool = True) -> str: def supports_json_arrow_operators(self) -> bool: """Check if MySQL version supports -> and ->> operators.""" - return self.version >= (5, 7, 9) + return self.version_at_least((5, 7, 9)) def supports_upsert(self) -> bool: """Whether UPSERT (ON DUPLICATE KEY UPDATE) is supported.""" @@ -527,7 +540,7 @@ def supports_multiple_on_conflict_clauses(self) -> bool: """MySQL's ON DUPLICATE KEY UPDATE allows only a single clause.""" return False - def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: + def format_insert_statement(self, expr: "InsertStatement") -> Tuple[str, tuple]: """Format INSERT statement with MySQL-specific options. Extends the base implementation to support the typed flags on @@ -535,8 +548,14 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] - ``ignore=True`` → ``INSERT IGNORE`` - ``replace=True`` → ``REPLACE INTO`` + The flags are this backend's own derived-node contract, so they are + read off ``MySQLInsertExpression`` by type; a plain core + ``InsertStatement`` spells neither and renders a plain ``INSERT``. + Their mutual-exclusion rules are enforced by the derived node's + constructor, not here. + Args: - expr: InsertExpression instance + expr: InsertStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -548,24 +567,19 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] Sequence in the INTO slot renders through its own formatter -- a well-formed INSERT writing to something the caller never named. - ValueError: If both 'ignore' and 'replace' are specified, or if - 'replace' is used with 'on_conflict' """ # Perform strict parameter validation if self.strict_validation: expr.validate(strict=True) - require_kind(expr.into, Table, "InsertExpression.into") - - # Check for conflicting options - is_replace = getattr(expr, "replace", False) - is_ignore = getattr(expr, "ignore", False) + require_kind(expr.into, Table, "InsertStatement.into") - if is_replace and is_ignore: - raise ValueError("Cannot use both 'replace' and 'ignore' options together") + is_replace = is_ignore = False + from .expression.dml import MySQLInsertExpression - if is_replace and expr.on_conflict: - raise ValueError("REPLACE INTO does not support ON CONFLICT clause") + if isinstance(expr, MySQLInsertExpression): + is_replace = expr.replace + is_ignore = expr.ignore all_params: List[Any] = [] table_sql, table_params = expr.into.to_sql() @@ -592,16 +606,13 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] if isinstance(expr.source, DefaultValuesSource): parts.append("DEFAULT VALUES") elif isinstance(expr.source, ValuesSource): - all_rows_sql = [] - for row in expr.source.values_list: - row_sql, row_params = [], [] - for val in row: - s, p = val.to_sql() - row_sql.append(s) - row_params.extend(p) - all_rows_sql.append(f"({', '.join(row_sql)})") - all_params.extend(row_params) - parts.append("VALUES " + ", ".join(all_rows_sql)) + # A cell is either an expression or a bare host value, and the + # base formatter owns that judgement in one place -- the local + # loop this used to be wrote ``val.to_sql()`` per cell and could + # not accept a bare value at all. + values_sql, values_params = self.format_insert_values_source(expr.source) + parts.append(values_sql) + all_params.extend(values_params) elif isinstance(expr.source, SelectSource): s_sql, s_params = expr.source.select_query.to_sql() parts.append(s_sql) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py index 1f30dab3..2a937757 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/conftest.py @@ -78,7 +78,7 @@ "max_version": "9.6", }, "ddl/drop_table.py": { - "title": "DROP TABLE using DropTableExpression", + "title": "DROP TABLE using DropTableStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", @@ -128,21 +128,21 @@ "note": "MySQL does not support RETURNING clause; use LAST_INSERT_ID() instead", }, "delete/basic.py": { - "title": "DELETE using DeleteExpression", + "title": "DELETE using DeleteStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", "max_version": "9.6", }, "update/basic.py": { - "title": "UPDATE using UpdateExpression", + "title": "UPDATE using UpdateStatement", "dialect_protocols": [], "priority": 10, "min_version": "5.6", "max_version": "9.6", }, "query/union.py": { - "title": "UNION using SetOperationExpression", + "title": "UNION using SetOperation", "dialect_protocols": [], "priority": 10, "min_version": "5.6", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py index 1a497876..4744bcad 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/connection/quickstart.py @@ -14,10 +14,10 @@ import os from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, + QueryStatement, ValuesSource, WhereClause, ) @@ -25,8 +25,9 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig @@ -60,7 +61,7 @@ def execute_expression(expression, options=None): def create_demo_tables(): - users_table = CreateTableExpression( + users_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "quickstart_users"), columns=[ @@ -68,14 +69,14 @@ def create_demo_tables(): "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("status", "VARCHAR(20)"), ], @@ -83,7 +84,7 @@ def create_demo_tables(): ) execute_expression(users_table, ddl_options) - logs_table = CreateTableExpression( + logs_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "quickstart_logs"), columns=[ @@ -91,14 +92,14 @@ def create_demo_tables(): "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "message", "VARCHAR(255)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ], if_not_exists=True, @@ -107,7 +108,7 @@ def create_demo_tables(): def seed_demo_data(): - insert_users = InsertExpression( + insert_users = InsertStatement( dialect=dialect, into=Table(dialect, "quickstart_users"), columns=["name", "status"], @@ -135,7 +136,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Queries # ============================================================ -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -170,7 +171,7 @@ def seed_demo_data(): # ============================================================ # SECTION: Execute Parameterized Queries (Recommended) # ============================================================ -filtered_query = QueryExpression( +filtered_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -201,7 +202,7 @@ def seed_demo_data(): # SECTION: Handle Transactions # ============================================================ with backend.transaction(): - insert_log = InsertExpression( + insert_log = InsertStatement( dialect=dialect, into=Table(dialect, "quickstart_logs"), columns=["message"], @@ -209,7 +210,7 @@ def seed_demo_data(): ) execute_expression(insert_log) -logs_query = QueryExpression( +logs_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "message")], from_=Table(dialect, "quickstart_logs"), @@ -221,7 +222,7 @@ def seed_demo_data(): # SECTION: Error Handling # ============================================================ try: - invalid_query = QueryExpression( + invalid_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id")], from_=Table(dialect, "nonexistent_table"), @@ -233,10 +234,10 @@ def seed_demo_data(): # ============================================================ # SECTION: Disconnect # ============================================================ -drop_logs = DropTableExpression(dialect=dialect, table=Table(dialect, "quickstart_logs"), if_exists=True) +drop_logs = DropTableStatement(dialect=dialect, table=Table(dialect, "quickstart_logs"), if_exists=True) execute_expression(drop_logs, ddl_options) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, "quickstart_users"), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, "quickstart_users"), if_exists=True) execute_expression(drop_users, ddl_options) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py index d3d08c75..e809cd01 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/alter_table.py @@ -16,16 +16,18 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) config = MySQLConnectionConfig( @@ -40,15 +42,15 @@ backend.connect() dialect = backend.dialect -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -56,8 +58,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -67,7 +69,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -79,7 +81,7 @@ sql, params = insert.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -94,11 +96,11 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import AlterTableExpression # noqa: E402 -from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddColumn # noqa: E402 +from rhosocial.activerecord.backend.expression import AlterTableStatement # noqa: E402 +from rhosocial.activerecord.backend.expression.statements.ddl_alter import AddColumnAction # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -add_col_action = AddColumn( +add_col_action = AddColumnAction( dialect=dialect, column=ColumnDefinition( name="email", @@ -106,7 +108,7 @@ ), ) -add_col_expr = AlterTableExpression( +add_col_expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[add_col_action], @@ -119,21 +121,21 @@ backend.execute(sql, params) print("Column email added successfully") -add_age_action = AddColumn( +add_age_action = AddColumnAction( dialect=dialect, column=ColumnDefinition( "age", "INT", constraints=[ ColumnConstraint( - ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, 0), - ), - ], - ), -) - -add_age_expr = AlterTableExpression( + None, + branch=ColumnDefaultConstraint(None, Literal(dialect, 0)) + ), + ], + ), + ) + +add_age_expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[add_age_action], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py index 66d44f5b..46dd44ff 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_index.py @@ -8,11 +8,12 @@ import os from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig -from rhosocial.activerecord.backend.expression import CreateTableExpression, DropTableExpression +from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) config = MySQLConnectionConfig( @@ -28,11 +29,11 @@ dialect = backend.dialect # Drop table first for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "products"), columns=[ @@ -40,8 +41,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -56,18 +57,18 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression, DropIndexExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateIndexStatement, DropIndexStatement # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Index, Table # Drop index first if exists (MySQL does not support IF NOT EXISTS in CREATE INDEX) try: - drop_idx = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_category_price")) + drop_idx = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_category_price")) sql, params = drop_idx.to_sql() backend.execute(sql, params) except Exception: pass -create_idx = CreateIndexExpression( +create_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_category_price"), table=Table(dialect, "products"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py index ff6fe330..a8de4b1e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py @@ -17,14 +17,16 @@ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( - DropTableExpression, - CreateTableExpression, + DropTableStatement, + CreateTableStatement, ) -from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression import functions as F from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( IndexDefinition, @@ -43,7 +45,7 @@ dialect = backend.dialect # Drop if exists for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) @@ -56,22 +58,22 @@ name="id", data_type="INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( name="name", data_type="VARCHAR(200)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( name="price", data_type="DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( @@ -82,14 +84,14 @@ name="is_active", data_type="TINYINT(1)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 1)), ], ), ColumnDefinition( name="created_at", data_type="TIMESTAMP", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, F.current_timestamp(dialect))), ], ), ] @@ -102,7 +104,7 @@ ] # Create table with MySQL-specific ENGINE and CHARSET options -create_expr = CreateTableExpression( +create_expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "products"), columns=columns, @@ -130,7 +132,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "products"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "products"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -142,5 +144,5 @@ # 1. Use ColumnConstraint with is_auto_increment=True for AUTO_INCREMENT # 2. MySQL table options (engine/charset) are typed on MySQLCreateTableOptions # 3. IndexDefinition creates inline indexes within CREATE TABLE -# 4. Use current_timestamp(dialect) for SQL niladic functions (no parentheses) +# 4. Use F.current_timestamp(dialect) for SQL niladic functions (no parentheses) # 5. Use introspector.get_columns() to verify table structure diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py index 5fa5a7d6..89c57436 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/drop_table.py @@ -1,5 +1,5 @@ """ -DROP TABLE using DropTableExpression - MySQL. +DROP TABLE using DropTableStatement - MySQL. This example demonstrates: 1. DROP TABLE @@ -25,25 +25,25 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -51,7 +51,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ], @@ -64,7 +64,7 @@ # ============================================================ # SECTION: DROP TABLE # ============================================================ -drop_expr = DropTableExpression( +drop_expr = DropTableStatement( dialect=dialect, table=Table(dialect, "users"), ) @@ -76,7 +76,7 @@ # ============================================================ # SECTION: DROP TABLE IF EXISTS # ============================================================ -drop_expr_if_exists = DropTableExpression( +drop_expr_if_exists = DropTableStatement( dialect=dialect, table=Table(dialect, "users"), if_exists=True, @@ -94,6 +94,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use DropTableExpression to drop tables +# 1. Use DropTableStatement to drop tables # 2. if_exists=True prevents error if table doesn't exist # 3. CASCADE and RESTRICT options available diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py index 9979e463..f1b79312 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/unique_index.py @@ -24,23 +24,23 @@ backend.connect() dialect = backend.dialect -from rhosocial.activerecord.backend.expression import CreateTableExpression, DropTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateTableStatement, DropTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -48,7 +48,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(100)"), @@ -63,18 +63,18 @@ # ============================================================ # SECTION: CREATE UNIQUE INDEX # ============================================================ -from rhosocial.activerecord.backend.expression import CreateIndexExpression, DropIndexExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateIndexStatement, DropIndexStatement # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Index, Table # Drop index first if exists (MySQL does not support IF NOT EXISTS in CREATE INDEX) try: - drop_idx = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_users_email_unique")) + drop_idx = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_users_email_unique")) sql, params = drop_idx.to_sql() backend.execute(sql, params) except Exception: pass -unique_idx = CreateIndexExpression( +unique_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email_unique"), table=Table(dialect, "users"), @@ -91,13 +91,13 @@ # ============================================================ # Drop composite index first if exists try: - drop_idx2 = DropIndexExpression(dialect=dialect, index=Index(dialect, "idx_users_name_email")) + drop_idx2 = DropIndexStatement(dialect=dialect, index=Index(dialect, "idx_users_name_email")) sql, params = drop_idx2.to_sql() backend.execute(sql, params) except Exception: pass -composite_idx = CreateIndexExpression( +composite_idx = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_name_email"), table=Table(dialect, "users"), @@ -110,11 +110,11 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -123,6 +123,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use CreateIndexExpression with unique=True +# 1. Use CreateIndexStatement with unique=True # 2. Use columns=[col1, col2] for composite index -# 3. Use DropIndexExpression to drop +# 3. Use DropIndexStatement to drop diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py index 981979e5..b0fe4e12 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/view.py @@ -28,10 +28,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -39,15 +39,15 @@ ) # Drop dependent tables first for clean setup (orders may reference users via FK) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_users = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_users = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_users.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -60,7 +60,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -80,20 +80,20 @@ # SECTION: CREATE VIEW # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, - CreateViewExpression, - DropViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, ) -from rhosocial.activerecord.backend.expression.core import WildcardExpression # noqa: E402 +from rhosocial.activerecord.backend.expression.core import AllColumnsWildcard # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table, View -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=Table(dialect, "users"), ) -view_expr = CreateViewExpression( +view_expr = CreateViewStatement( dialect=dialect, view=View(dialect, "user_names"), query=query, @@ -105,9 +105,9 @@ options = ExecutionOptions(stmt_type=StatementType.DDL) backend.execute(sql, params, options=options) -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "user_names"), ) options = ExecutionOptions(stmt_type=StatementType.DQL) @@ -118,7 +118,7 @@ # ============================================================ # SECTION: CREATE OR REPLACE VIEW # ============================================================ -view_expr_replace = CreateViewExpression( +view_expr_replace = CreateViewStatement( dialect=dialect, view=View(dialect, "user_names"), query=query, @@ -131,7 +131,7 @@ # ============================================================ # SECTION: DROP VIEW # ============================================================ -drop_view = DropViewExpression( +drop_view = DropViewStatement( dialect=dialect, view=View(dialect, "user_names"), ) @@ -142,11 +142,11 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -155,6 +155,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use CreateViewExpression to create views +# 1. Use CreateViewStatement to create views # 2. Use replace=True to replace existing view -# 3. Use DropViewExpression to drop views +# 3. Use DropViewStatement to drop views diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py index 7dd7a158..0ba611c3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/delete/basic.py @@ -1,8 +1,8 @@ """ -DELETE using DeleteExpression - MySQL. +DELETE using DeleteStatement - MySQL. This example demonstrates: -1. Delete single row using DeleteExpression +1. Delete single row using DeleteStatement 2. Delete with WHERE condition 3. Delete all rows """ @@ -28,28 +28,29 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -57,15 +58,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ], @@ -75,7 +76,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name"], @@ -95,11 +96,11 @@ # ============================================================ # SECTION: Delete Single Row # ============================================================ -from rhosocial.activerecord.backend.expression import DeleteExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import DeleteStatement # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -delete_expr = DeleteExpression( +delete_expr = DeleteStatement( dialect=dialect, tables=Table(dialect, "users"), where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), @@ -115,7 +116,7 @@ # ============================================================ # SECTION: Delete with Condition # ============================================================ -delete_expr = DeleteExpression( +delete_expr = DeleteStatement( dialect=dialect, tables=Table(dialect, "users"), where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Bob")), @@ -128,7 +129,7 @@ # ============================================================ # SECTION: Delete All Rows # ============================================================ -delete_expr = DeleteExpression(dialect=dialect, tables=Table(dialect, "users")) +delete_expr = DeleteStatement(dialect=dialect, tables=Table(dialect, "users")) sql, params = delete_expr.to_sql() print(f"Delete all SQL: {sql}") result = backend.execute(sql, params, options=options) @@ -137,11 +138,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -150,7 +151,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use DeleteExpression to build DELETE query +# 1. Use DeleteStatement to build DELETE query # 2. Use ComparisonPredicate for WHERE conditions # 3. Omit where parameter to delete all rows # 4. affected_rows shows number of deleted rows diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py index 1d036d2e..e18b7e73 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/batch.py @@ -9,14 +9,16 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) -from rhosocial.activerecord.backend.expression.functions.datetime import current_timestamp +from rhosocial.activerecord.backend.expression import functions as F from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -34,11 +36,11 @@ dialect = backend.dialect # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "logs"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "logs"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "logs"), columns=[ @@ -46,8 +48,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("level", "VARCHAR(20)"), @@ -57,14 +59,14 @@ "TIMESTAMP", constraints=[ ColumnConstraint( - ColumnConstraintType.DEFAULT, - default_value=current_timestamp(dialect), - ), - ], - ), - ], - if_not_exists=True, -) + None, + branch=ColumnDefaultConstraint(None, F.current_timestamp(dialect)) + ), + ], + ), + ], + if_not_exists=True, + ) sql, params = create_table.to_sql() backend.execute(sql, params) @@ -72,15 +74,15 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - InsertExpression, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column # noqa: E402 +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "logs"), source=ValuesSource( @@ -105,9 +107,9 @@ result = backend.execute(sql, params) print(f"Affected rows: {result.affected_rows}") -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "logs"), order_by=OrderByClause(dialect, [Column(dialect, "id")]), ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py index 57d527dd..11c697a9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/single.py @@ -4,7 +4,7 @@ This example demonstrates: 1. INSERT a single row with explicit column values 2. INSERT with AUTO_INCREMENT primary key -3. Verify inserted data using QueryExpression +3. Verify inserted data using QueryStatement """ # ============================================================ @@ -14,18 +14,19 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + QueryStatement, + InsertStatement, ValuesSource, ) -from rhosocial.activerecord.backend.expression.core import Literal, WildcardExpression, Column +from rhosocial.activerecord.backend.expression.core import Literal, AllColumnsWildcard, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions @@ -46,15 +47,15 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -62,15 +63,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(200)"), @@ -85,7 +86,7 @@ # ============================================================ # 1. Insert a single row with explicit column values -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), source=ValuesSource( @@ -107,9 +108,9 @@ print(f"Affected rows: {result.affected_rows}") # Verify the inserted row -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, "users"), where=ComparisonPredicate( dialect, @@ -123,7 +124,7 @@ print(f"Inserted row: {result.data}") # 2. Insert another row (AUTO_INCREMENT id will be assigned automatically) -insert_expr2 = InsertExpression( +insert_expr2 = InsertStatement( dialect=dialect, into=Table(dialect, "users"), source=ValuesSource( @@ -139,7 +140,7 @@ print(f"Second insert affected rows: {result.affected_rows}") # Verify all rows -all_query = QueryExpression( +all_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, "users"), @@ -152,11 +153,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -165,7 +166,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use InsertExpression with ValuesSource containing a single row list +# 1. Use InsertStatement with ValuesSource containing a single row list # 2. AUTO_INCREMENT columns are omitted from the insert columns list -# 3. Verify inserts using QueryExpression with WHERE clause +# 3. Verify inserts using QueryStatement with WHERE clause # 4. MySQL returns affected_rows = 1 for successful single insert diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py index ac46719d..c7a2f0e4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/upsert.py @@ -27,18 +27,21 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, OnConflictClause, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 @@ -48,15 +51,15 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -64,30 +67,30 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "username", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnUniqueConstraint(None)), ], ), ColumnDefinition( "email", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "login_count", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], @@ -102,7 +105,7 @@ # MySQL uses ON DUPLICATE KEY UPDATE, which is handled via OnConflictClause # Initial insert -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -117,7 +120,7 @@ backend.execute(sql, params) # Verify initial insert -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -133,7 +136,7 @@ print(f"Initial insert: {result.data}") # Insert again with ON DUPLICATE KEY UPDATE - will update -upsert_expr = InsertExpression( +upsert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -165,7 +168,7 @@ # ============================================================ # affected_rows: 1 = inserted, 2 = updated -insert_result = InsertExpression( +insert_result = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -180,7 +183,7 @@ result = backend.execute(sql, params) print(f"Insert affected_rows: {result.affected_rows}") -upsert_result = InsertExpression( +upsert_result = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -207,7 +210,7 @@ # ============================================================ # MySQL supports multiple row VALUES() -multi_upsert = InsertExpression( +multi_upsert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["username", "email", "login_count"], @@ -233,11 +236,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py index 657a668a..05e217f4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/insert/with_returning.py @@ -14,17 +14,18 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column, FunctionCall from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -43,11 +44,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -55,15 +56,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(200)"), @@ -78,7 +79,7 @@ # ============================================================ # 1. Insert a row -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "email"], @@ -101,7 +102,7 @@ # 2. Retrieve auto-generated ID using LAST_INSERT_ID() # MySQL does NOT support RETURNING clause (unlike PostgreSQL/SQLite 3.35+) # Use SELECT LAST_INSERT_ID() instead -query_id = QueryExpression( +query_id = QueryStatement( dialect=dialect, select=[FunctionCall(dialect, "LAST_INSERT_ID").as_("generated_id")], ) @@ -111,7 +112,7 @@ print(f"Generated ID: {result.data}") # 3. Verify by querying the inserted row -verify_query = QueryExpression( +verify_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, "users"), @@ -123,7 +124,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py new file mode 100644 index 00000000..ae04609f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py @@ -0,0 +1,334 @@ +# src/rhosocial/activerecord/backend/impl/mysql/examples/introspection_table_options.py +"""What MySQL knows about a table's storage, and what the cache buys. + +Run:: + + python -m rhosocial.activerecord.backend.impl.mysql.examples.introspection_table_options + +The example creates a small partitioned table, asks the introspector for +its storage options (``list_table_options`` / ``get_table_options``) and +its partitions (``list_partitions``), and prints what the server answered. +It then walks the schema twice, counting the questions each walk actually +puts to the server: + +* **no cache** -- the manager holds a cache whose TTL is 0, so every + question is answered by asking; +* **warm** -- the default cache, on a manager that has walked this schema + before, so every question is answered from memory. + +Unlike SQLite, the questions here cross a socket, so the point of the +counts is not microseconds but round trips: the cold walk is what a schema +walk costs a server-backed application, and the warm walk is what the same +walk costs once the answer is remembered. + +Configuration is the same as the test suite's: the first scenario in +``tests/config/mysql_scenarios.yaml`` (or the file named by +``MYSQL_SCENARIOS_CONFIG_PATH``) is used when one is present, otherwise +the ``MYSQL_HOST`` / ``MYSQL_PORT`` / ``MYSQL_DATABASE`` / ``MYSQL_USER`` +/ ``MYSQL_PASSWORD`` environment variables. If no server can be reached, +the example says how to configure one and exits non-zero; it never +invents output. +""" + +import os +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend +from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig +from rhosocial.activerecord.backend.introspection import InMemoryIntrospectionCache +from rhosocial.activerecord.backend.introspection.executor import SyncIntrospectorExecutor + +DEMO_TABLE = "mysql_introspection_demo" +DEMO_PARTITIONS = ("p_before_2020", "p_before_2024", "p_max") + + +# --------------------------------------------------------------------- # +# Connecting the way the tests do +# --------------------------------------------------------------------- # + + +def _scenario_settings() -> Optional[Dict[str, Any]]: + """Return the first scenario from the test suite's YAML, if readable. + + ``MYSQL_SCENARIOS_CONFIG_PATH`` wins over the repository-local file, + which is the same precedence the test conftest applies. ``MYSQL_SCENARIO`` + picks a scenario by name when the file holds more than one. + """ + try: + import yaml + except ImportError: # pragma: no cover - PyYAML ships with the test extras + return None + + explicit = os.getenv("MYSQL_SCENARIOS_CONFIG_PATH") + candidates: List[Path] = [] + if explicit: + candidates.append(Path(explicit)) + else: + candidates.append(Path.cwd() / "tests" / "config" / "mysql_scenarios.yaml") + + for path in candidates: + if not path.is_file(): + continue + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + continue + scenarios = data.get("scenarios") or {} + if not scenarios: + continue + wanted = os.getenv("MYSQL_SCENARIO") + if wanted and wanted in scenarios: + return dict(scenarios[wanted]) + return dict(next(iter(scenarios.values()))) + return None + + +def connection_config() -> MySQLConnectionConfig: + """Build the connection config from a scenario file or environment.""" + scenario = _scenario_settings() + if scenario is not None: + # The scenario names the fields exactly as MySQLConnectionConfig + # does; drop nothing, because ssl_disabled is part of the test + # suite's local-server settings. + return MySQLConnectionConfig(**scenario) + + ssl_setting = os.getenv("MYSQL_SSL", "auto") + return MySQLConnectionConfig( + host=os.getenv("MYSQL_HOST", "localhost"), + port=int(os.getenv("MYSQL_PORT", "3306")), + database=os.getenv("MYSQL_DATABASE"), + username=os.getenv("MYSQL_USER", "root"), + password=os.getenv("MYSQL_PASSWORD", ""), + charset=os.getenv("MYSQL_CHARSET", "utf8mb4"), + ssl_disabled=ssl_setting == "disabled", + ) + + +def _explain_configuration(reason: str) -> None: + """Say how to point this example at a server, and stop.""" + print(f"Could not connect to MySQL: {reason}", file=sys.stderr) + print("", file=sys.stderr) + print("Configure a server in one of these ways:", file=sys.stderr) + print( + " 1. tests/config/mysql_scenarios.yaml (the file the test suite reads),", + file=sys.stderr, + ) + print( + " or set MYSQL_SCENARIOS_CONFIG_PATH to such a file " + "(MYSQL_SCENARIO picks one by name).", + file=sys.stderr, + ) + print( + " 2. MYSQL_HOST, MYSQL_PORT, MYSQL_DATABASE, MYSQL_USER, " + "MYSQL_PASSWORD, MYSQL_CHARSET, MYSQL_SSL.", + file=sys.stderr, + ) + print("", file=sys.stderr) + print( + "Then re-run: python -m " + "rhosocial.activerecord.backend.impl.mysql.examples.introspection_table_options", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------- # +# The demo schema, and what is asked about it +# --------------------------------------------------------------------- # + + +def build_demo_table(backend: MySQLBackend) -> None: + """Create a partitioned table, replacing any leftovers from a prior run. + + The low-level cursor is used on purpose: this is setup for a + measurement about introspection, so it should not pay the execution + hooks and logging of the public ``execute`` path. + """ + cursor = backend._get_cursor() + try: + cursor.execute(f"DROP TABLE IF EXISTS `{DEMO_TABLE}`") + cursor.execute( + f"CREATE TABLE `{DEMO_TABLE}` (" + " id INT NOT NULL AUTO_INCREMENT," + " created_at DATE NOT NULL," + " note VARCHAR(64) DEFAULT NULL," + " PRIMARY KEY (id, created_at)" + ") ENGINE=InnoDB ROW_FORMAT=DYNAMIC COMMENT='introspection demo' " + "PARTITION BY RANGE (YEAR(created_at)) (" + " PARTITION p_before_2020 VALUES LESS THAN (2020)," + " PARTITION p_before_2024 VALUES LESS THAN (2024)," + " PARTITION p_max VALUES LESS THAN MAXVALUE" + ")" + ) + finally: + cursor.close() + + +def drop_demo_table(backend: MySQLBackend) -> None: + """Remove the demo table, tolerating a connection that already went away.""" + try: + cursor = backend._get_cursor() + try: + cursor.execute(f"DROP TABLE IF EXISTS `{DEMO_TABLE}`") + finally: + cursor.close() + except Exception: + pass + + +def walk_schema(backend: MySQLBackend) -> int: + """Ask the introspector everything it knows about this schema's storage. + + Returns the number of values answered, so the report can say what the + questions bought rather than only how many there were. + """ + introspector = backend.introspector + answers = 0 + for option in introspector.list_table_options(): + answers += 1 + one = introspector.get_table_options(option.table_name) + answers += 1 if one is not None else 0 + answers += len(introspector.list_partitions(option.table_name)) + return answers + + +# --------------------------------------------------------------------- # +# Counting what the executor is asked to do +# --------------------------------------------------------------------- # + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that also reports how many questions it was asked.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return super().execute(sql, params) + + +def uncached(backend: MySQLBackend) -> CountingExecutor: + """Install a cache that keeps nothing, and a counting executor.""" + introspector = backend.introspector + introspector._cache = InMemoryIntrospectionCache(ttl=0) + executor = CountingExecutor(backend) + introspector._executor = executor + return executor + + +def cached(backend: MySQLBackend) -> CountingExecutor: + """Install the default cache, and a counting executor.""" + introspector = backend.introspector + introspector._cache = InMemoryIntrospectionCache() + executor = CountingExecutor(backend) + introspector._executor = executor + return executor + + +def measure(backend: MySQLBackend) -> Tuple[float, int, int]: + """Time one walk, and report what it cost in questions and answers.""" + executor = backend.introspector._executor + before = executor.calls + started = time.perf_counter() + answers = walk_schema(backend) + elapsed = time.perf_counter() - started + return elapsed, executor.calls - before, answers + + +# --------------------------------------------------------------------- # +# Report +# --------------------------------------------------------------------- # + + +def _ms(seconds: float) -> str: + return f"{seconds * 1000:9.2f} ms" + + +def print_demo(backend: MySQLBackend) -> None: + """Print the demo table's storage options and partitions as answered.""" + introspector = backend.introspector + options = introspector.get_table_options(DEMO_TABLE) + if options is None: + print(f"{DEMO_TABLE} was not found by the server; nothing to report.") + return + + print(f"Table options for {options.schema}.{options.table_name}") + print(f" engine {options.engine}") + print(f" row format {options.row_format}") + print(f" collation {options.table_collation}") + print(f" auto increment {options.auto_increment}") + print(f" comment {options.table_comment!r}") + print(f" create options {options.create_options!r}") + print(f" data length {options.data_length} bytes (server estimate)") + print(f" index length {options.index_length} bytes (server estimate)") + + partitions = introspector.list_partitions(DEMO_TABLE) + print(f"\nPartitions of {DEMO_TABLE} ({len(partitions)}):") + for partition in partitions: + print( + f" {partition.partition_ordinal_position}. " + f"{partition.partition_name:<16} " + f"method={partition.partition_method} " + f"expression={partition.partition_expression} " + f"boundary={partition.partition_description} " + f"rows={partition.table_rows} " + f"data={partition.data_length}B index={partition.index_length}B" + ) + + +def report(backend: MySQLBackend) -> None: + """Measure cold and warm walks and print the comparison.""" + table_count = len(backend.introspector.list_tables()) + + # No cache: every question is put to the server, every time. + uncached(backend) + measure(backend) # warm whatever the process caches on its own + cold, questions, answers = measure(backend) + + # Default cache: the same questions, answered from memory. + cached(backend) + measure(backend) # fill the cache + warm, warm_questions, _ = measure(backend) + + print( + f"\nMySQL introspection over {table_count} tables, " + f"{answers} values answered, {questions} questions put to the server" + ) + print(f" no cache {_ms(cold)} {questions} questions") + print(f" cached {_ms(warm)} {warm_questions} questions") + if cold > 0: + print( + f"\n cache saves {_ms(cold - warm)} " + f"({(cold - warm) / cold * 100:.0f}% of the uncached pass)" + ) + + +def main() -> None: + backend = MySQLBackend(connection_config=connection_config()) + try: + backend.connect() + except Exception as exc: + _explain_configuration(str(exc)) + + try: + # The dialect gates its statements on a version; an unadapted + # dialect has none, and the introspector builds SQL through it. + backend.introspect_and_adapt() + build_demo_table(backend) + # The table did not exist when the schema was listed. + backend.introspector.clear_cache() + + print_demo(backend) + report(backend) + finally: + drop_demo_table(backend) + backend.disconnect() + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py index 2dbdfce9..33979789 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_expressions/order_expressions.py @@ -22,14 +22,14 @@ Column, ComparisonPredicate, Literal, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.objects import Table def get_order(dialect, order_id: int): """Get order details by ID.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], from_=Table(dialect, "orders"), @@ -39,7 +39,7 @@ def get_order(dialect, order_id: int): def check_inventory(dialect, order_id: int): """Check available inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "available")], from_=Table(dialect, "inventory"), @@ -51,7 +51,7 @@ def check_inventory(dialect, order_id: int): def reserve_inventory(dialect, order_id: int): """Reserve inventory for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "available")], from_=Table(dialect, "inventory"), @@ -63,7 +63,7 @@ def reserve_inventory(dialect, order_id: int): def send_notification(dialect, user_id: int, type: str): """Send notification to a user.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "notifications"), @@ -75,7 +75,7 @@ def send_notification(dialect, user_id: int, type: str): def process_payment(dialect, order_id: int, amount: float): """Process payment for an order.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=Table(dialect, "payments"), @@ -87,7 +87,7 @@ def process_payment(dialect, order_id: int, amount: float): def release_inventory(dialect, order_id: int): """Release reserved inventory.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), @@ -99,7 +99,7 @@ def release_inventory(dialect, order_id: int): def create_order_record(dialect, order_id: int, user_id: int, amount: float): """Create an order record.""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=Table(dialect, "order_records"), @@ -111,7 +111,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): def confirm_inventory(dialect, order_id: int): """Confirm inventory (final confirmation).""" - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py index 3f693897..1fdcf243 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/named_migrations/expressions.py @@ -7,11 +7,11 @@ """ from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableExpression, - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, - DropTableExpression, + ColumnDefinition, + ColumnPrimaryKeyConstraint, + CreateTableStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.types import ( MySQLIntType, @@ -22,7 +22,7 @@ def create_users_table(dialect): """CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, name TEXT, email TEXT).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, "users"), columns=[ @@ -32,9 +32,9 @@ def create_users_table(dialect): MySQLIntType(dialect), constraints=[ ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + None, + branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True) +), ], ), ColumnDefinition(dialect, "name", MySQLTextType(dialect)), @@ -45,12 +45,12 @@ def create_users_table(dialect): def drop_users_table(dialect): """DROP TABLE IF EXISTS users.""" - return DropTableExpression(dialect, table=Table(dialect, "users"), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, "users"), if_exists=True) def create_posts_table(dialect): """CREATE TABLE posts (id INT PRIMARY KEY AUTO_INCREMENT, title TEXT, user_id INT).""" - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, "posts"), columns=[ @@ -60,9 +60,9 @@ def create_posts_table(dialect): MySQLIntType(dialect), constraints=[ ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + None, + branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True) +), ], ), ColumnDefinition(dialect, "title", MySQLTextType(dialect)), @@ -73,7 +73,7 @@ def create_posts_table(dialect): def drop_posts_table(dialect): """DROP TABLE IF EXISTS posts.""" - return DropTableExpression(dialect, table=Table(dialect, "posts"), if_exists=True) + return DropTableStatement(dialect, table=Table(dialect, "posts"), if_exists=True) def create_custom_table(dialect, table_name: str = "custom_table"): @@ -82,7 +82,7 @@ def create_custom_table(dialect, table_name: str = "custom_table"): This expression accepts an extra ``table_name`` parameter, allowing the migration to control the target table name at runtime. """ - return CreateTableExpression( + return CreateTableStatement( dialect, table=Table(dialect, table_name), columns=[ @@ -92,9 +92,9 @@ def create_custom_table(dialect, table_name: str = "custom_table"): MySQLIntType(dialect), constraints=[ ColumnConstraint( - ColumnConstraintType.PRIMARY_KEY, - is_auto_increment=True, - ), + None, + branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True) +), ], ), ColumnDefinition(dialect, "value", MySQLTextType(dialect)), @@ -104,4 +104,4 @@ def create_custom_table(dialect, table_name: str = "custom_table"): def drop_custom_table(dialect, table_name: str = "custom_table"): """DROP TABLE IF EXISTS .""" - return DropTableExpression(dialect, table=Table(dialect, table_name), if_exists=True) \ No newline at end of file + return DropTableStatement(dialect, table=Table(dialect, table_name), if_exists=True) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py index f012df86..bd2c0925 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/aggregate.py @@ -24,23 +24,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales"), columns=[ @@ -48,8 +49,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("product", "VARCHAR(100)"), @@ -62,7 +63,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales"), columns=["product", "quantity", "price", "region"], @@ -84,20 +85,22 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, GroupByHavingClause, ) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +# SUM and AVG are classed nodes, not a generic call carrying a name string: +# the identity of the aggregate travels on the node. +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "product"), - FunctionCall(dialect, "SUM", Column(dialect, "quantity")).as_("total_qty"), - FunctionCall(dialect, "AVG", Column(dialect, "price")).as_("avg_price"), + F.sum_(dialect, Column(dialect, "quantity"), alias="total_qty"), + F.avg(dialect, Column(dialect, "price"), alias="avg_price"), ], from_=Table(dialect, "sales"), group_by_having=GroupByHavingClause( @@ -106,7 +109,7 @@ having=ComparisonPredicate( dialect, ">", - FunctionCall(dialect, "SUM", Column(dialect, "quantity")), + F.sum_(dialect, Column(dialect, "quantity")), Literal(dialect, 10), ), ), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py index 771c8a4c..8c49e4d2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/basic.py @@ -24,27 +24,28 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -52,14 +53,14 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("age", "INT"), ColumnDefinition("status", "VARCHAR(20)"), @@ -70,7 +71,7 @@ print(f"Create table SQL: {sql}, params: {params}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "age", "status"], @@ -90,7 +91,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, OrderByClause, @@ -99,7 +100,7 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py index 3efa0520..d7177aca 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/constants.py @@ -35,22 +35,17 @@ # SECTION: SELECT CURRENT_TIMESTAMP # ============================================================ # Use factory functions for SQL statement-level constants. -# current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) +# F.current_timestamp(dialect) generates CURRENT_TIMESTAMP (no parentheses) # which is the SQL:2003 standard niladic form, valid in both DDL DEFAULT # and SELECT contexts. MySQL also accepts CURRENT_TIMESTAMP() with parentheses. -from rhosocial.activerecord.backend.expression import QueryExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import QueryStatement # noqa: E402 from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 -from rhosocial.activerecord.backend.expression.functions.datetime import ( # noqa: E402 - current_timestamp, - now, - current_date, - current_time, -) +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 -query_ts = QueryExpression( +query_ts = QueryStatement( dialect=dialect, - select=[current_timestamp(dialect)], + select=[F.current_timestamp(dialect)], ) sql, params = query_ts.to_sql() print(f"CURRENT_TIMESTAMP SQL: {sql}") @@ -60,9 +55,9 @@ # ============================================================ # SECTION: SELECT NOW() # ============================================================ -query_now = QueryExpression( +query_now = QueryStatement( dialect=dialect, - select=[now(dialect)], + select=[F.now(dialect)], ) sql, params = query_now.to_sql() print(f"NOW() SQL: {sql}") @@ -72,11 +67,11 @@ # ============================================================ # SECTION: SELECT CURRENT_DATE, CURRENT_TIME # ============================================================ -query_date = QueryExpression( +query_date = QueryStatement( dialect=dialect, select=[ - current_date(dialect), - current_time(dialect), + F.current_date(dialect), + F.current_time(dialect), ], ) sql, params = query_date.to_sql() @@ -89,11 +84,11 @@ # ============================================================ from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 -query_multi = QueryExpression( +query_multi = QueryStatement( dialect=dialect, select=[ - now(dialect).as_("current_time"), - current_date(dialect).as_("current_date"), + F.now(dialect).as_("current_time"), + F.current_date(dialect).as_("current_date"), FunctionCall(dialect, "DATABASE").as_("db_name"), FunctionCall(dialect, "VERSION").as_("db_version"), ], @@ -108,10 +103,10 @@ # ============================================================ # FunctionCall supports arguments, e.g., DATE_FORMAT, ROUND -query_format = QueryExpression( +query_format = QueryStatement( dialect=dialect, select=[ - FunctionCall(dialect, "DATE_FORMAT", now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), + FunctionCall(dialect, "DATE_FORMAT", F.now(dialect), Literal(dialect, "%Y-%m-%d")).as_("formatted_date"), ], ) sql, params = query_format.to_sql() @@ -122,7 +117,7 @@ # ============================================================ # SECTION: SELECT with UUID() (MySQL 8.0+) # ============================================================ -query_uuid = QueryExpression( +query_uuid = QueryStatement( dialect=dialect, select=[FunctionCall(dialect, "UUID").as_("uuid_value")], ) @@ -141,7 +136,7 @@ # ============================================================ # Key points: # 1. Use factory functions (current_timestamp, now, current_date, etc.) for SQL niladic functions -# 2. QueryExpression without from_ clause generates "SELECT ..." without FROM +# 2. QueryStatement without from_ clause generates "SELECT ..." without FROM # 3. Use FunctionCall(dialect, 'FUNC', arg1, arg2) for regular functions with arguments # 4. Use .as_('alias') for column aliases in SELECT # 5. SQL:2003 niladic functions (CURRENT_TIMESTAMP, CURRENT_DATE, etc.) omit parentheses diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py index d297c685..b490916e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/cte.py @@ -26,20 +26,20 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - QueryExpression, - CTEExpression, - WithQueryExpression, + DropTableStatement, + QueryStatement, + CTEClause, + WithStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 @@ -48,11 +48,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) # Drop table first for clean setup -drop = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -60,7 +60,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -74,7 +74,7 @@ delete_sql = "DELETE FROM employees" backend.execute(delete_sql) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["id", "name", "manager_id"], @@ -97,10 +97,10 @@ # ============================================================ # CTE simplifies complex queries by defining temporary named result sets -high_earners_cte = CTEExpression( +high_earners_cte = CTEClause( dialect=dialect, name="high_earners", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "employees"), @@ -108,10 +108,10 @@ ), ) -cte_query = WithQueryExpression( +cte_query = WithStatement( dialect=dialect, ctes=[high_earners_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "high_earners"), @@ -128,7 +128,7 @@ # Recursive CTE for hierarchical data (organizational chart) # Base case: top-level employees -base_query = QueryExpression( +base_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -140,17 +140,17 @@ where=ComparisonPredicate(dialect, "IS", Column(dialect, "manager_id"), Literal(dialect, None)), ) -org_cte = CTEExpression( +org_cte = CTEClause( dialect=dialect, name="org_chart", query=base_query, columns=["id", "name", "manager_id", "level"], ) -recursive_query = WithQueryExpression( +recursive_query = WithStatement( dialect=dialect, ctes=[org_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "manager_id")], from_=Table(dialect, "org_chart"), @@ -169,10 +169,10 @@ # ============================================================ # Multiple CTEs can be defined in a single WITH clause -active_cte = CTEExpression( +active_cte = CTEClause( dialect=dialect, name="active_employees", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "employees"), @@ -180,10 +180,10 @@ ), ) -top_cte = CTEExpression( +top_cte = CTEClause( dialect=dialect, name="top_employees", - query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "active_employees"), @@ -191,10 +191,10 @@ ), ) -multi_cte_query = WithQueryExpression( +multi_cte_query = WithStatement( dialect=dialect, ctes=[active_cte, top_cte], - main_query=QueryExpression( + query=QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "top_employees"), @@ -208,7 +208,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -218,7 +218,7 @@ # ============================================================ # Key points: # 1. CTE requires MySQL 8.0+ -# 2. Use CTEExpression to define CTEs -# 3. Use WithQueryExpression to combine CTEs with a main query +# 2. Use CTEClause to define CTEs +# 3. Use WithStatement to combine CTEs with a main query # 4. Set recursive=True for recursive CTEs (hierarchical queries) -# 5. Multiple CTEs can be defined in a single WithQueryExpression +# 5. Multiple CTEs can be defined in a single WithStatement diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py index 4bdd4b9e..e825c13a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/distinct.py @@ -27,10 +27,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -38,15 +38,15 @@ ) # Drop dependent tables first for clean setup -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -59,7 +59,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -80,12 +80,12 @@ # SECTION: SELECT DISTINCT # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, SelectModifier, ) from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=Table(dialect, "users"), @@ -102,7 +102,7 @@ # ============================================================ # SECTION: SELECT DISTINCT with multiple columns # ============================================================ -query_distinct = QueryExpression( +query_distinct = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -116,11 +116,11 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -129,6 +129,6 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SelectModifier.DISTINCT in QueryExpression +# 1. Use SelectModifier.DISTINCT in QueryStatement # 2. SELECT DISTINCT removes duplicate rows # 3. Works with multiple columns (distinct combo) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py index 839f742f..cf12e3ac 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/explain.py @@ -15,19 +15,20 @@ from rhosocial.activerecord.backend.impl.mysql.backend import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - QueryExpression, - ExplainExpression, - CreateIndexExpression, - DropTableExpression, + QueryStatement, + ExplainStatement, + CreateIndexStatement, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.explain import ExplainOptions from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate @@ -44,11 +45,11 @@ backend.connect() dialect = backend.dialect -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -56,15 +57,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("email", "VARCHAR(200)"), @@ -74,7 +75,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -create_index = CreateIndexExpression( +create_index = CreateIndexStatement( dialect=dialect, index=Index(dialect, "idx_users_email"), table=Table(dialect, "users"), @@ -90,7 +91,7 @@ ("Charlie", "charlie@example.com"), ] for name, email in users: - insert_expr = InsertExpression( + insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "email"], @@ -104,7 +105,7 @@ # ============================================================ # 1. EXPLAIN for table scan (no index on name column) -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=Table(dialect, "users"), @@ -115,7 +116,7 @@ Literal(dialect, "Alice"), ), ) -explain_scan = ExplainExpression( +explain_scan = ExplainStatement( dialect=dialect, statement=query1, options=ExplainOptions(), @@ -128,7 +129,7 @@ print(f" {row}") # 2. EXPLAIN for index search (email has index) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Column(dialect, "*")], from_=Table(dialect, "users"), @@ -139,7 +140,7 @@ Literal(dialect, "alice@example.com"), ), ) -explain_search = ExplainExpression( +explain_search = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(), @@ -152,7 +153,7 @@ print(f" {row}") # 3. EXPLAIN with FORMAT=JSON for detailed analysis -explain_json = ExplainExpression( +explain_json = ExplainStatement( dialect=dialect, statement=query2, options=ExplainOptions(format="JSON"), @@ -167,7 +168,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py index 8c05c361..7e1c7368 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/fulltext.py @@ -22,23 +22,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "articles"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "articles"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "articles"), columns=[ @@ -46,8 +47,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("title", "VARCHAR(200)"), @@ -58,7 +59,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "articles"), columns=["title", "content"], @@ -89,7 +90,7 @@ # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ -from rhosocial.activerecord.backend.expression import QueryExpression, Column # noqa: E402 +from rhosocial.activerecord.backend.expression import QueryStatement, Column # noqa: E402 from rhosocial.activerecord.backend.impl.mysql.expression import MySQLMatchAgainstExpression, MatchAgainstMode # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table @@ -118,7 +119,7 @@ ) # Build query using expression (supports alias via as_) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py index 09315f8d..a4341a14 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/join.py @@ -24,27 +24,28 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_customers = DropTableExpression(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) +drop_customers = DropTableStatement(dialect=dialect, table=Table(dialect, "customers"), if_exists=True) sql, params = drop_customers.to_sql() backend.execute(sql, params) -drop_orders = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_orders = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_orders.to_sql() backend.execute(sql, params) -create_customers = CreateTableExpression( +create_customers = CreateTableStatement( dialect=dialect, table=Table(dialect, "customers"), columns=[ @@ -52,14 +53,14 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("email", "VARCHAR(100)"), ], @@ -68,7 +69,7 @@ sql, params = create_customers.to_sql() backend.execute(sql, params) -create_orders = CreateTableExpression( +create_orders = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ @@ -76,8 +77,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("customer_id", "INT"), @@ -89,7 +90,7 @@ sql, params = create_orders.to_sql() backend.execute(sql, params) -insert_customers = InsertExpression( +insert_customers = InsertStatement( dialect=dialect, into=Table(dialect, "customers"), columns=["name", "email"], @@ -104,7 +105,7 @@ sql, params = insert_customers.to_sql() backend.execute(sql, params) -insert_orders = InsertExpression( +insert_orders = InsertStatement( dialect=dialect, into=Table(dialect, "orders"), columns=["customer_id", "total", "status"], @@ -124,7 +125,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, ) @@ -137,7 +138,7 @@ customers = NamedRelationRef(dialect, Table(dialect, "customers"), alias="c") orders = NamedRelationRef(dialect, Table(dialect, "orders"), alias="o") -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name", table="c"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py index 12949501..0cc4ccf1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/json_table.py @@ -1,7 +1,7 @@ """ MySQL JSON_TABLE - Convert JSON data to relational format (MySQL 8.0+). -Demonstrates using MySQLJSONTableExpression with QueryExpression to build +Demonstrates using MySQLJSONTableExpression with QueryStatement to build a SELECT query that flattens JSON array data into relational rows. """ @@ -25,23 +25,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "orders"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ @@ -49,8 +50,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("order_data", "JSON"), @@ -60,7 +61,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "orders"), columns=["order_data"], @@ -86,7 +87,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.impl.mysql.expression.json_table import ( # noqa: E402 @@ -111,11 +112,11 @@ json_table_sql, json_table_params = json_table.to_sql() print(f"JSON_TABLE SQL: {json_table_sql}") -# Build the query using QueryExpression with a comma-separated FROM clause +# Build the query using QueryStatement with a comma-separated FROM clause # (equivalent to an implicit CROSS JOIN). # Note: MySQLJSONTableExpression is not yet in the FromSourceType validation whitelist, # so we temporarily disable strict validation when generating SQL. -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id", table="o"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py index ad7ed5bb..6dda1d40 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/pagination.py @@ -28,10 +28,10 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 @@ -39,11 +39,11 @@ ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -56,7 +56,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -79,12 +79,12 @@ # SECTION: LIMIT (get first N rows) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, LimitOffsetClause, ) from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -101,7 +101,7 @@ # ============================================================ # SECTION: OFFSET (skip first N rows) # ============================================================ -query_offset = QueryExpression( +query_offset = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -115,7 +115,7 @@ # ============================================================ # SECTION: Teardown # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py index e2017892..0339d8d2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/predicate.py @@ -24,23 +24,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -48,14 +49,14 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(None, branch=ColumnNotNullConstraint(None))], ), ColumnDefinition("department", "VARCHAR(50)"), ColumnDefinition("salary", "DECIMAL(10,2)"), @@ -66,7 +67,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["name", "department", "salary", "manager_id"], @@ -94,10 +95,11 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, WhereClause, ) from rhosocial.activerecord.backend.expression.predicates import ( # noqa: E402 + LikeOperator, ComparisonPredicate, LikePredicate, InPredicate, @@ -109,7 +111,7 @@ # 1. LIKE predicate - pattern matching # Find employees whose name starts with 'A' -like_query = QueryExpression( +like_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -121,7 +123,7 @@ dialect, condition=LikePredicate( dialect, - "LIKE", + LikeOperator.LIKE, Column(dialect, "name"), Literal(dialect, "A%"), ), @@ -134,7 +136,7 @@ # 2. IN predicate - list membership # Find employees in Engineering or Sales departments -in_query = QueryExpression( +in_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -158,7 +160,7 @@ # 3. BETWEEN predicate - range check # Find employees with salary between 65000 and 75000 -between_query = QueryExpression( +between_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -183,7 +185,7 @@ # 4. IS NULL / IS NOT NULL predicates # Find employees without a manager (manager_id IS NULL) -is_null_query = QueryExpression( +is_null_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -205,7 +207,7 @@ print(f"IS NULL Params: {params}") # Find employees with a manager (manager_id IS NOT NULL) -is_not_null_query = QueryExpression( +is_not_null_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), @@ -229,7 +231,7 @@ # 5. Combining predicates with AND/OR logic # Engineering employees earning between 75000 and 100000 -combined_query = QueryExpression( +combined_query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py index 81971a5b..38370350 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/subquery.py @@ -24,27 +24,28 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_departments = DropTableExpression(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) +drop_departments = DropTableStatement(dialect=dialect, table=Table(dialect, "departments"), if_exists=True) sql, params = drop_departments.to_sql() backend.execute(sql, params) -drop_employees = DropTableExpression(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) +drop_employees = DropTableStatement(dialect=dialect, table=Table(dialect, "employees"), if_exists=True) sql, params = drop_employees.to_sql() backend.execute(sql, params) -create_departments = CreateTableExpression( +create_departments = CreateTableStatement( dialect=dialect, table=Table(dialect, "departments"), columns=[ @@ -52,8 +53,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -64,7 +65,7 @@ sql, params = create_departments.to_sql() backend.execute(sql, params) -create_employees = CreateTableExpression( +create_employees = CreateTableStatement( dialect=dialect, table=Table(dialect, "employees"), columns=[ @@ -72,8 +73,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -85,7 +86,7 @@ sql, params = create_employees.to_sql() backend.execute(sql, params) -insert_departments = InsertExpression( +insert_departments = InsertStatement( dialect=dialect, into=Table(dialect, "departments"), columns=["name", "budget"], @@ -100,7 +101,7 @@ sql, params = insert_departments.to_sql() backend.execute(sql, params) -insert_employees = InsertExpression( +insert_employees = InsertStatement( dialect=dialect, into=Table(dialect, "employees"), columns=["name", "salary", "department_id"], @@ -121,24 +122,24 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WhereClause, Subquery, ) -from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 +from rhosocial.activerecord.backend.expression import functions as F # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -subquery_query = QueryExpression( +subquery_query = QueryStatement( dialect=dialect, - select=[FunctionCall(dialect, "AVG", Column(dialect, "salary"))], + select=[F.avg(dialect, Column(dialect, "salary"))], from_=Table(dialect, "employees"), ) sql, params = subquery_query.to_sql() subquery = Subquery(dialect, sql, params) -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "name"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py index acfa0090..61c42924 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/union.py @@ -1,5 +1,5 @@ """ -UNION using SetOperationExpression - MySQL. +UNION using SetOperation - MySQL. This example demonstrates: 1. UNION (distinct) @@ -28,24 +28,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnPrimaryKeyConstraint, ) # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -53,7 +53,7 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None)), ], ), ColumnDefinition("name", "VARCHAR(100)"), @@ -65,7 +65,7 @@ backend.execute(sql, params) # Insert data into table_a -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["id", "name"], @@ -85,28 +85,29 @@ # SECTION: UNION (distinct) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, - SetOperationExpression, + QueryStatement, + SetOperation, ) from rhosocial.activerecord.backend.expression.objects import Table # First query -query1 = QueryExpression( +query1 = QueryStatement( dialect=dialect, select=[Column(dialect, "name")], from_=Table(dialect, "users"), ) -query2 = QueryExpression( +query2 = QueryStatement( dialect=dialect, select=[Literal(dialect, "Charlie")], ) -# Union -union_expr = SetOperationExpression( +# Union. The node states the operation as a word and the dialect writes the +# spelling, so a backend that gates UNION behind a probe refuses it there. +union_expr = SetOperation( dialect=dialect, + op="union", left=query1, - operation="UNION", right=query2, ) sql, params = union_expr.to_sql() @@ -120,11 +121,10 @@ # ============================================================ # SECTION: UNION ALL # ============================================================ -union_all = SetOperationExpression( +union_all = SetOperation( dialect=dialect, + op="union_all", left=query1, - operation="UNION", - all_=True, right=query2, ) sql, params = union_all.to_sql() @@ -135,7 +135,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -144,6 +144,8 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use SetOperationExpression with operation='UNION', all_=True for UNION ALL +# 1. Use SetOperation with op='union' / op='union_all'; the dialect writes the +# SQL spelling and gates it behind its own capability probes # 2. UNION removes duplicates, UNION ALL keeps all rows -# 3. Can add ORDER BY using query1.with_order_by() +# 3. ORDER BY is a clause on the statement, not a setter on the query: +# QueryStatement(..., order_by=OrderByClause(dialect, [...])) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py index 7fcf6f61..33b69b4b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window.py @@ -24,23 +24,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales_data"), columns=[ @@ -48,8 +49,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("salesperson", "VARCHAR(100)"), @@ -61,7 +62,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales_data"), columns=["salesperson", "region", "amount"], @@ -83,7 +84,7 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, WindowSpecification, OrderByClause, @@ -91,7 +92,7 @@ from rhosocial.activerecord.backend.expression.core import FunctionCall # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "salesperson"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py index e5343307..b0cdae66 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/query/window_mysql57.py @@ -34,24 +34,25 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "sales_data"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "sales_data"), columns=[ @@ -59,8 +60,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("salesperson", "VARCHAR(100)"), @@ -72,7 +73,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "sales_data"), columns=["salesperson", "region", "amount"], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py index e33575a0..418ec50e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py @@ -33,22 +33,23 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + DropTableStatement, CreateTableStatement, ColumnDefinition, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, ) -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ColumnDefinition(dialect, "email", VarCharType(dialect, 200)), @@ -67,7 +68,7 @@ MySQLSchemaDiffer, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 - AlterTableExpression, + AlterTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression import ( # noqa: E402 MySQLAddColumn, @@ -80,7 +81,7 @@ # Add `age` column between `name` and `email` — shifts email to position 4 add_col = MySQLAddColumn(dialect, ColumnDefinition(dialect, "age", IntegerType(dialect)), after="name") -alter_expr = AlterTableExpression(dialect, Table(dialect, "users"), [add_col]) +alter_expr = AlterTableStatement(dialect, Table(dialect, "users"), [add_col]) sql, params = alter_expr.to_sql() backend.execute(sql, params) @@ -105,7 +106,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py index 9152d6e2..2a0c7852 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql56_json.py @@ -33,8 +33,9 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + DropTableStatement, CreateTableStatement, ColumnDefinition, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, JsonType, @@ -43,20 +44,20 @@ MySQLLongTextType, ) -expr = DropTableExpression(dialect, Table(dialect, "documents56"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents56"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "documents57"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents57"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) # Simulate MySQL 5.6: store JSON as TEXT -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents56"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "data", MySQLLongTextType(dialect)), ] @@ -64,12 +65,12 @@ sql, params = expr.to_sql() backend.execute(sql, params) # Simulate MySQL 5.7+: native JSON column -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents57"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "data", JsonType(dialect)), ] @@ -103,10 +104,10 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "documents56"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents56"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "documents57"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "documents57"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py index 2dac763e..be11c90d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/mysql80_charset.py @@ -33,23 +33,24 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + DropTableStatement, CreateTableStatement, ColumnDefinition, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, ) -expr = DropTableExpression(dialect, Table(dialect, "demo"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "demo"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) # Create table without explicit charset to inherit database/server default -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "demo"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] @@ -80,7 +81,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "demo"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "demo"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py index 90928a24..f28b9c92 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/table_changes.py @@ -26,12 +26,12 @@ # Clean up any leftover tables from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, + DropTableStatement, ) -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "orders"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "orders"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) @@ -45,8 +45,9 @@ MySQLSchemaDiffer, ) from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, ColumnDefinition, - ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, + ColumnConstraint, + ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( # noqa: E402 IntegerType, VarCharType, DecimalType, @@ -57,24 +58,24 @@ snapshot_before = builder.build() # Create one table, drop another (if it existed) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] ) sql, params = expr.to_sql() backend.execute(sql, params) -expr = CreateTableExpression( +expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL), - ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), ]), ColumnDefinition(dialect, "user_id", IntegerType(dialect)), ColumnDefinition(dialect, "amount", DecimalType(dialect, 10, 2)), @@ -99,10 +100,10 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -expr = DropTableExpression(dialect, Table(dialect, "users"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "users"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) -expr = DropTableExpression(dialect, Table(dialect, "orders"), if_exists=True) +expr = DropTableStatement(dialect, Table(dialect, "orders"), if_exists=True) sql, params = expr.to_sql() backend.execute(sql, params) backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py index a82aef2f..2b60b8b8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/basic.py @@ -26,30 +26,32 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - UpdateExpression, - QueryExpression, + UpdateStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -57,22 +59,22 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "balance", "DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], @@ -81,7 +83,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -106,7 +108,7 @@ # Simple transaction - auto commits on success with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 50)}, @@ -116,7 +118,7 @@ backend.execute(sql, params, options=dml_options) # Verify -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -134,7 +136,7 @@ try: with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, -100)}, @@ -152,7 +154,7 @@ # MySQL supports savepoints for nested transactions with backend.transaction(): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 40)}, @@ -163,7 +165,7 @@ backend.transaction_manager.savepoint("sp1") try: - update_expr2 = UpdateExpression( + update_expr2 = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 20)}, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py index dcb05814..20f5d4d7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py @@ -13,20 +13,22 @@ from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - InsertExpression, + CreateTableStatement, + DropTableStatement, + InsertStatement, ValuesSource, - QueryExpression, - UpdateExpression, + QueryStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column from rhosocial.activerecord.backend.expression.statements.dql import OrderByClause from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -47,11 +49,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) dml_options = ExecutionOptions(stmt_type=StatementType.DML) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -59,22 +61,22 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "balance", "DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], @@ -84,7 +86,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -109,7 +111,7 @@ print("--- READ COMMITTED ---") with backend.transaction_manager.transaction(isolation_level=IsolationLevel.READ_COMMITTED): # Read Alice's balance - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -120,7 +122,7 @@ print(f"READ COMMITTED - Alice's balance: {result.data[0]['balance']}") # Update within the transaction - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 1100)}, @@ -130,7 +132,7 @@ backend.execute(sql, params, options=dml_options) # After commit, the change is visible to all subsequent transactions -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -146,7 +148,7 @@ print("\n--- REPEATABLE READ (MySQL default) ---") with backend.transaction_manager.transaction(isolation_level=IsolationLevel.REPEATABLE_READ): # First read - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -171,7 +173,7 @@ print("\n--- SERIALIZABLE ---") with backend.transaction_manager.transaction(isolation_level=IsolationLevel.SERIALIZABLE): # Read all accounts - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -182,7 +184,7 @@ print(f"SERIALIZABLE - All accounts: {result.data}") # Update Bob's balance - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 600)}, @@ -199,7 +201,7 @@ # Transaction 1: SERIALIZABLE with backend.transaction_manager.transaction(isolation_level=IsolationLevel.SERIALIZABLE): - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 1200)}, @@ -210,7 +212,7 @@ # Transaction 2: defaults to REPEATABLE READ (MySQL default) with backend.transaction(): - query = QueryExpression( + query = QueryStatement( dialect=dialect, select=[Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -223,7 +225,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py index fef7ab97..a66bdf50 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py @@ -28,20 +28,22 @@ from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - QueryExpression, - UpdateExpression, + DropTableStatement, + QueryStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.options import ExecutionOptions # noqa: E402 from rhosocial.activerecord.backend.schema import StatementType # noqa: E402 @@ -49,11 +51,11 @@ dql_options = ExecutionOptions(stmt_type=StatementType.DQL) dml_options = ExecutionOptions(stmt_type=StatementType.DML) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "accounts"), columns=[ @@ -61,22 +63,22 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "balance", "DECIMAL(10,2)", constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0), + ColumnConstraint(None, branch=ColumnDefaultConstraint(None, 0)), ], ), ], @@ -86,7 +88,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "accounts"), columns=["name", "balance"], @@ -109,7 +111,7 @@ # In a transaction, select with FOR UPDATE with backend.transaction(): # Lock Alice's row using ForUpdateClause - lock_query = QueryExpression( + lock_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -121,7 +123,7 @@ print(f"Locked row: {result.data}") # Update the balance - update_expr = UpdateExpression( + update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "accounts"), assignments={"balance": Literal(dialect, 900)}, @@ -138,7 +140,7 @@ # Lock specific rows based on conditions with backend.transaction(): - lock_query = QueryExpression( + lock_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -154,7 +156,7 @@ # ============================================================ # Skip locked rows instead of waiting -skip_query = QueryExpression( +skip_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -171,7 +173,7 @@ # Fail immediately if rows are locked try: - nowait_query = QueryExpression( + nowait_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -198,7 +200,7 @@ from rhosocial.activerecord.backend.expression.objects import Table with backend.transaction(): - share_query = QueryExpression( + share_query = QueryStatement( dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "balance")], from_=Table(dialect, "accounts"), @@ -211,7 +213,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "accounts"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -220,7 +222,7 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use ForUpdateClause with QueryExpression for SELECT ... FOR UPDATE +# 1. Use ForUpdateClause with QueryStatement for SELECT ... FOR UPDATE # 2. ForUpdateClause(dialect, skip_locked=True) for SKIP LOCKED (MySQL 8.0+) # 3. ForUpdateClause(dialect, nowait=True) for NOWAIT (MySQL 8.0+) # 4. Use ForUpdateClause with LockStrength.SHARE for FOR SHARE (MySQL 8.0+) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py index 6e51ff1f..91da0409 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_basic.py @@ -25,24 +25,25 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, + DropTableStatement, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) # Create table with JSON column (MySQL 5.7+) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents"), columns=[ @@ -50,8 +51,8 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("data", "JSON"), @@ -61,7 +62,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "documents"), columns=["data"], @@ -80,13 +81,13 @@ # SECTION: Business Logic (the pattern to learn) # ============================================================ from rhosocial.activerecord.backend.expression import ( # noqa: E402 - QueryExpression, + QueryStatement, Column, ) from rhosocial.activerecord.backend.impl.mysql.functions.json import json_extract, json_unquote # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -query = QueryExpression( +query = QueryStatement( dialect=dialect, select=[ Column(dialect, "id"), diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py index 3c185058..7178a62e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/types/json_mysql56.py @@ -28,25 +28,26 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - DropTableExpression, - InsertExpression, + DropTableStatement, + InsertStatement, ValuesSource, ) from rhosocial.activerecord.backend.expression.core import Literal # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "documents"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -from rhosocial.activerecord.backend.expression import CreateTableExpression # noqa: E402 +from rhosocial.activerecord.backend.expression import CreateTableStatement # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "documents"), columns=[ @@ -54,15 +55,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "data", "TEXT", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ], @@ -70,7 +71,7 @@ sql, params = create_table.to_sql() backend.execute(sql, params) -insert_expr = InsertExpression( +insert_expr = InsertStatement( dialect=dialect, into=Table(dialect, "documents"), columns=["data"], diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py index 7277c5dd..1fb74c2e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/update/basic.py @@ -1,5 +1,5 @@ """ -UPDATE using UpdateExpression - MySQL. +UPDATE using UpdateStatement - MySQL. This example demonstrates: 1. Update single row @@ -28,28 +28,29 @@ dialect = backend.dialect from rhosocial.activerecord.backend.expression import ( # noqa: E402 - CreateTableExpression, - InsertExpression, + CreateTableStatement, + InsertStatement, ValuesSource, - DropTableExpression, - UpdateExpression, + DropTableStatement, + UpdateStatement, ) from rhosocial.activerecord.backend.expression.core import Literal, Column # noqa: E402 from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression # noqa: E402 +from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation # noqa: E402 from rhosocial.activerecord.backend.expression.statements import ( # noqa: E402 - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.objects import Table # Drop table first for clean setup -drop_table = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_table = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) -create_table = CreateTableExpression( +create_table = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=[ @@ -57,15 +58,15 @@ "id", "INT", constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(None, branch=ColumnPrimaryKeyConstraint(None, is_auto_increment=True)), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition( "name", "VARCHAR(100)", constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(None, branch=ColumnNotNullConstraint(None)), ], ), ColumnDefinition("age", "INT"), @@ -76,7 +77,7 @@ print(f"Create table SQL: {sql}") backend.execute(sql, params) -insert = InsertExpression( +insert = InsertStatement( dialect=dialect, into=Table(dialect, "users"), columns=["name", "age"], @@ -95,7 +96,7 @@ # ============================================================ # SECTION: Update Single Row # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={"age": Literal(dialect, 26)}, @@ -112,11 +113,11 @@ # ============================================================ # SECTION: Update with Expression # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={ - "age": BinaryArithmeticExpression(dialect, "+", Column(dialect, "age"), Literal(dialect, 1)), + "age": ArithmeticOperation(dialect, "add", Column(dialect, "age"), Literal(dialect, 1)), }, where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), ) @@ -128,7 +129,7 @@ # ============================================================ # SECTION: Update All Rows # ============================================================ -update_expr = UpdateExpression( +update_expr = UpdateStatement( dialect=dialect, table=Table(dialect, "users"), assignments={"age": Literal(dialect, 99)}, @@ -141,7 +142,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table=Table(dialect, "users"), if_exists=True) +drop_expr = DropTableStatement(dialect=dialect, table=Table(dialect, "users"), if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() @@ -150,7 +151,8 @@ # SECTION: Summary # ============================================================ # Key points: -# 1. Use UpdateExpression with assignments dict for SET clause -# 2. Use BinaryArithmeticExpression for expressions like age + 1 +# 1. Use UpdateStatement with assignments dict for SET clause +# 2. Use ArithmeticOperation for expressions like age + 1; op names the +# operation as a word ("add"), and the dialect writes the character # 3. Omit where parameter to update all rows # 4. affected_rows shows number of updated rows diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py index cb21f641..c5614b84 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py @@ -28,7 +28,7 @@ MySQLColumnDefinition, MySQLColumnOptions, ) -from .load_data import MySQLLoadDataExpression, LoadDataOptions +from .load_data import MySQLLoadDataStatement, LoadDataOptions from .json_table import MySQLJSONTableExpression, JSONTableColumn, NestedPath from .json import ( MySQLJSONExtractExpression, @@ -51,7 +51,7 @@ MySQLSTGeomFromWKBExpression, MySQLSTAsTextExpression, MySQLSTAsGeoJSONExpression, - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, ) from .vector import ( MySQLVectorExpression, @@ -61,7 +61,7 @@ MySQLStringToVectorExpression, MySQLVectorToStringExpression, MySQLVectorDimExpression, - MySQLCreateVectorIndexExpression, + MySQLCreateVectorIndexStatement, ) from .set_type import ( MySQLSetLiteralExpression, @@ -72,8 +72,8 @@ from .table_expr import MySQLInlineIndexExpression, MySQLStorageOptionsExpression from .match_against import MySQLMatchAgainstExpression, MatchAgainstMode from .json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityNestedMapping, @@ -125,56 +125,56 @@ MySQLDropOldestPartitionHelper, MySQLReorganizePartitionHelper, ) -from .rename_table import MySQLRenameTableExpression +from .rename_table import MySQLRenameTableStatement from .table_statement import MySQLTableStatement, MySQLValuesExpression from .maintenance import ( - MySQLAnalyzeTableExpression, - MySQLCheckTableExpression, - MySQLChecksumTableExpression, - MySQLOptimizeTableExpression, - MySQLRepairTableExpression, + MySQLAnalyzeTableStatement, + MySQLCheckTableStatement, + MySQLChecksumTableStatement, + MySQLOptimizeTableStatement, + MySQLRepairTableStatement, CheckTableOption, ChecksumTableOption, RepairTableOption, NoWriteToBinlogOption, ) from .routine import ( - MySQLCallExpression, - MySQLCreateProcedureExpression, - MySQLDropProcedureExpression, - MySQLCreateFunctionExpression, - MySQLDropFunctionExpression, + MySQLCallStatement, + MySQLCreateProcedureStatement, + MySQLDropProcedureStatement, + MySQLCreateFunctionStatement, + MySQLDropFunctionStatement, ) -from .load_xml import MySQLLoadXMLEXpression, LoadXMLPriority, LoadXMLConflictMode +from .load_xml import MySQLLoadXMLStatement, LoadXMLPriority, LoadXMLConflictMode from .admin import ( - MySQLCacheIndexExpression, - MySQLLoadIndexIntoCacheExpression, - MySQLFlushExpression, + MySQLCacheIndexStatement, + MySQLLoadIndexIntoCacheStatement, + MySQLFlushStatement, FlushOption, - MySQLResetExpression, + MySQLResetStatement, ResetOption, - MySQLInstallComponentExpression, - MySQLUninstallComponentExpression, - MySQLInstallPluginExpression, - MySQLUninstallPluginExpression, - MySQLCloneExpression, - MySQLRestartExpression, - MySQLBinlogExpression, - MySQLHandlerOpenExpression, - MySQLHandlerReadExpression, - MySQLHandlerCloseExpression, + MySQLInstallComponentStatement, + MySQLUninstallComponentStatement, + MySQLInstallPluginStatement, + MySQLUninstallPluginStatement, + MySQLCloneStatement, + MySQLRestartStatement, + MySQLBinlogStatement, + MySQLHandlerOpenStatement, + MySQLHandlerReadStatement, + MySQLHandlerCloseStatement, HandlerReadMode, - MySQLDoExpression, - MySQLKillExpression, + MySQLDoStatement, + MySQLKillStatement, KillTarget, - MySQLShutdownExpression, - MySQLHelpExpression, + MySQLShutdownStatement, + MySQLHelpStatement, AccountSpec, GrantPrivilege, - MySQLCreateUserExpression, - MySQLDropUserExpression, - MySQLGrantExpression, - MySQLRevokeExpression, + MySQLCreateUserStatement, + MySQLDropUserStatement, + MySQLGrantStatement, + MySQLRevokeStatement, ) # DataType subclasses for DDL @@ -210,7 +210,7 @@ ) __all__ = [ - "MySQLLoadDataExpression", + "MySQLLoadDataStatement", "LoadDataOptions", "MySQLCreateTableOptions", "MySQLRowFormat", @@ -241,7 +241,7 @@ "MySQLSTGeomFromWKBExpression", "MySQLSTAsTextExpression", "MySQLSTAsGeoJSONExpression", - "MySQLCreateSpatialIndexExpression", + "MySQLCreateSpatialIndexStatement", "MySQLVectorExpression", "MySQLDistanceEuclideanExpression", "MySQLDistanceCosineExpression", @@ -249,7 +249,7 @@ "MySQLStringToVectorExpression", "MySQLVectorToStringExpression", "MySQLVectorDimExpression", - "MySQLCreateVectorIndexExpression", + "MySQLCreateVectorIndexStatement", "MySQLSetLiteralExpression", "MySQLFindInSetExpression", "MySQLSetContainsExpression", @@ -258,8 +258,8 @@ "MySQLStorageOptionsExpression", "MySQLMatchAgainstExpression", "MatchAgainstMode", - "CreateJsonDualityViewExpression", - "DropJsonDualityViewExpression", + "CreateJsonDualityViewStatement", + "DropJsonDualityViewStatement", "DualityObjectSpec", "DualityColumnMapping", "DualityNestedMapping", @@ -298,54 +298,54 @@ "MySQLSubpartitionStrategy", "MySQLSubpartitionDefinition", "MySQLSubpartitionClause", - "MySQLRenameTableExpression", + "MySQLRenameTableStatement", "MySQLTableStatement", "MySQLValuesExpression", - "MySQLAnalyzeTableExpression", - "MySQLCheckTableExpression", - "MySQLChecksumTableExpression", - "MySQLOptimizeTableExpression", - "MySQLRepairTableExpression", + "MySQLAnalyzeTableStatement", + "MySQLCheckTableStatement", + "MySQLChecksumTableStatement", + "MySQLOptimizeTableStatement", + "MySQLRepairTableStatement", "CheckTableOption", "ChecksumTableOption", "RepairTableOption", "NoWriteToBinlogOption", - "MySQLCallExpression", - "MySQLCreateProcedureExpression", - "MySQLDropProcedureExpression", - "MySQLCreateFunctionExpression", - "MySQLDropFunctionExpression", - "MySQLLoadXMLEXpression", + "MySQLCallStatement", + "MySQLCreateProcedureStatement", + "MySQLDropProcedureStatement", + "MySQLCreateFunctionStatement", + "MySQLDropFunctionStatement", + "MySQLLoadXMLStatement", "LoadXMLPriority", "LoadXMLConflictMode", - "MySQLCacheIndexExpression", - "MySQLLoadIndexIntoCacheExpression", - "MySQLFlushExpression", + "MySQLCacheIndexStatement", + "MySQLLoadIndexIntoCacheStatement", + "MySQLFlushStatement", "FlushOption", - "MySQLResetExpression", + "MySQLResetStatement", "ResetOption", - "MySQLInstallComponentExpression", - "MySQLUninstallComponentExpression", - "MySQLInstallPluginExpression", - "MySQLUninstallPluginExpression", - "MySQLCloneExpression", - "MySQLRestartExpression", - "MySQLBinlogExpression", - "MySQLHandlerOpenExpression", - "MySQLHandlerReadExpression", - "MySQLHandlerCloseExpression", + "MySQLInstallComponentStatement", + "MySQLUninstallComponentStatement", + "MySQLInstallPluginStatement", + "MySQLUninstallPluginStatement", + "MySQLCloneStatement", + "MySQLRestartStatement", + "MySQLBinlogStatement", + "MySQLHandlerOpenStatement", + "MySQLHandlerReadStatement", + "MySQLHandlerCloseStatement", "HandlerReadMode", - "MySQLDoExpression", - "MySQLKillExpression", + "MySQLDoStatement", + "MySQLKillStatement", "KillTarget", - "MySQLShutdownExpression", - "MySQLHelpExpression", + "MySQLShutdownStatement", + "MySQLHelpStatement", "AccountSpec", "GrantPrivilege", - "MySQLCreateUserExpression", - "MySQLDropUserExpression", - "MySQLGrantExpression", - "MySQLRevokeExpression", + "MySQLCreateUserStatement", + "MySQLDropUserStatement", + "MySQLGrantStatement", + "MySQLRevokeStatement", # DataType subclasses for DDL "MySQLBigIntType", "MySQLBinaryType", @@ -376,3 +376,32 @@ "MySQLVectorType", "MySQLYearType", ] + +# ``MySQL`` is one word. The general derivation -- a run of capitals counts as +# a single word -- disagrees with this backend's identifier and yields +# ``my_sql_flush_expression``, ``my_sqljson_object_expression``, +# ``my_sqlst_as_text_expression`` and ``my_sqluuid_type``. The kind is a wire +# contract (§3), so the identifier is declared whole and the kind is derived +# from that declaration: ``KIND_RESERVED`` is data, not behaviour, so no class +# overrides ``kind()`` and class name, kind and formatter entry keep one source. +# +# The loop walks this package's own modules rather than an exported name list, +# for the reason the sqlite package's note gives: a hand-maintained list can +# only ever be out of date, and a node that is defined but not re-exported here +# (the SHOW family) would silently keep the split spelling. +import importlib as _importlib +import pkgutil as _pkgutil + +from rhosocial.activerecord.backend.expression.core import BaseExpression as _BaseExpression + +for _module_info in _pkgutil.walk_packages(__path__, prefix=__name__ + ".", onerror=lambda _error: None): + _module = _importlib.import_module(_module_info.name) + for _value in vars(_module).values(): + if ( + isinstance(_value, type) + and issubclass(_value, _BaseExpression) + and _value.__name__.startswith("MySQL") + and _value.__module__ == _module_info.name + ): + _value.KIND_RESERVED = ("MySQL",) +del _module_info, _module, _value, _importlib, _pkgutil, _BaseExpression diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py index 268d633a..4210b8b2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py @@ -30,9 +30,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class FlushOption(Enum): +class FlushOption(str, Enum): """FLUSH statement options.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + TABLES = "TABLES" LOGS = "LOGS" PRIVILEGES = "PRIVILEGES" @@ -53,32 +56,41 @@ class FlushOption(Enum): SLAVE = "SLAVE" -class ResetOption(Enum): +class ResetOption(str, Enum): """RESET statement options.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + MASTER = "MASTER" REPLICA = "REPLICA" SLAVE = "SLAVE" PERSIST = "PERSIST" -class KillTarget(Enum): +class KillTarget(str, Enum): """KILL target selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + CONNECTION = "CONNECTION" QUERY = "QUERY" -class HandlerReadMode(Enum): +class HandlerReadMode(str, Enum): """HANDLER READ modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + FIRST = "FIRST" NEXT = "NEXT" PREV = "PREV" LAST = "LAST" -class MySQLFlushExpression(BaseExpression): +class MySQLFlushStatement(BaseExpression): """Represent ``FLUSH`` with one or more options.""" def __init__( @@ -89,7 +101,10 @@ def __init__( no_write_to_binlog: bool = False, ): super().__init__(dialect) - self.options: List[FlushOption] = list(options) + # Members or the words they spell (§10: wide entry, normalised + # storage) -- the formatter reads ``.value``, so a raw string would + # only render on the constructing side of a round-trip. + self.options: List[FlushOption] = [FlushOption(o) for o in options] self.no_write_to_binlog: bool = no_write_to_binlog def validate(self, strict: bool = True) -> None: @@ -101,11 +116,11 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_flush_statement" + return "format_mysql_flush_statement" -class MySQLResetExpression(BaseExpression): +class MySQLResetStatement(BaseExpression): """Represent ``RESET`` with one option.""" def __init__( @@ -114,16 +129,19 @@ def __init__( option: ResetOption, ): super().__init__(dialect) - self.option: ResetOption = option + # The member or the word it spells: the formatter reads ``.value``. + # A word this enum does not name is refused here, where the caller + # can still see which option was meant. + self.option: ResetOption = ResetOption(option) @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_reset_statement" + return "format_mysql_reset_statement" -class MySQLCacheIndexExpression(BaseExpression): +class MySQLCacheIndexStatement(BaseExpression): """Represent ``CACHE INDEX ... IN key_cache``.""" def __init__( @@ -149,11 +167,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_cache_index_statement" + return "format_mysql_cache_index_statement" -class MySQLLoadIndexIntoCacheExpression(BaseExpression): +class MySQLLoadIndexIntoCacheStatement(BaseExpression): """Represent ``LOAD INDEX INTO CACHE``.""" def __init__( @@ -176,11 +194,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_index_into_cache_statement" + return "format_mysql_load_index_into_cache_statement" -class MySQLInstallComponentExpression(BaseExpression): +class MySQLInstallComponentStatement(BaseExpression): """Represent ``INSTALL COMPONENT 'name' [, 'name' ...]``.""" def __init__( @@ -194,11 +212,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_install_component_statement" + return "format_mysql_install_component_statement" -class MySQLUninstallComponentExpression(BaseExpression): +class MySQLUninstallComponentStatement(BaseExpression): """Represent ``UNINSTALL COMPONENT 'name' [, 'name' ...]``.""" def __init__( @@ -212,11 +230,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_uninstall_component_statement" + return "format_mysql_uninstall_component_statement" -class MySQLInstallPluginExpression(BaseExpression): +class MySQLInstallPluginStatement(BaseExpression): """Represent ``INSTALL PLUGIN name SONAME 'library.so'``.""" def __init__( @@ -232,11 +250,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_install_plugin_statement" + return "format_mysql_install_plugin_statement" -class MySQLUninstallPluginExpression(BaseExpression): +class MySQLUninstallPluginStatement(BaseExpression): """Represent ``UNINSTALL PLUGIN name``.""" def __init__( @@ -250,11 +268,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_uninstall_plugin_statement" + return "format_mysql_uninstall_plugin_statement" -class MySQLCloneExpression(BaseExpression): +class MySQLCloneStatement(BaseExpression): """Represent ``CLONE INSTANCE FROM ...``. Attributes: @@ -286,11 +304,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_clone_statement" + return "format_mysql_clone_statement" -class MySQLRestartExpression(BaseExpression): +class MySQLRestartStatement(BaseExpression): """Represent the ``RESTART`` server statement.""" def __init__( @@ -302,11 +320,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_restart_statement" + return "format_mysql_restart_statement" -class MySQLBinlogExpression(BaseExpression): +class MySQLBinlogStatement(BaseExpression): """Represent ``BINLOG 'base64_encoded_event'``.""" def __init__( @@ -320,11 +338,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_binlog_statement" + return "format_mysql_binlog_statement" -class MySQLHandlerOpenExpression(BaseExpression): +class MySQLHandlerOpenStatement(BaseExpression): """Represent ``HANDLER table OPEN [AS alias]``. ``table`` is a @@ -345,11 +363,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_handler_open_statement" + return "format_mysql_handler_open_statement" -class MySQLHandlerReadExpression(BaseExpression): +class MySQLHandlerReadStatement(BaseExpression): """Represent ``HANDLER table READ {FIRST | NEXT}`` and variants. ``table`` is a @@ -378,11 +396,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_handler_read_statement" + return "format_mysql_handler_read_statement" -class MySQLHandlerCloseExpression(BaseExpression): +class MySQLHandlerCloseStatement(BaseExpression): """Represent ``HANDLER table CLOSE``. ``table`` is a @@ -400,11 +418,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_handler_close_statement" + return "format_mysql_handler_close_statement" -class MySQLDoExpression(BaseExpression): +class MySQLDoStatement(BaseExpression): """Represent ``DO expr [, expr ...]``.""" def __init__( @@ -418,11 +436,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_do_statement" + return "format_mysql_do_statement" -class MySQLKillExpression(BaseExpression): +class MySQLKillStatement(BaseExpression): """Represent ``KILL [CONNECTION | QUERY] processlist_id``.""" def __init__( @@ -432,17 +450,21 @@ def __init__( target: KillTarget = KillTarget.CONNECTION, ): super().__init__(dialect) + # The member or the word it spells: an artifact carries the word, so a + # read must not need the enum object to get back. The formatter reads + # ``.value``, which only the member has -- a stored raw string would + # render the pre-round-trip instance and refuse the restored one. self.processlist_id: int = processlist_id - self.target: KillTarget = target + self.target: KillTarget = KillTarget(target) @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_kill_statement" + return "format_mysql_kill_statement" -class MySQLShutdownExpression(BaseExpression): +class MySQLShutdownStatement(BaseExpression): """Represent the ``SHUTDOWN`` statement.""" def __init__( @@ -454,11 +476,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_shutdown_statement" + return "format_mysql_shutdown_statement" -class MySQLHelpExpression(BaseExpression): +class MySQLHelpStatement(BaseExpression): """Represent ``HELP 'topic'``.""" def __init__( @@ -472,7 +494,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_help_statement" + return "format_mysql_help_statement" @@ -487,7 +509,7 @@ def __repr__(self) -> str: # pragma: no cover return f"AccountSpec(user={self.user!r}, host={self.host!r})" -class MySQLCreateUserExpression(BaseExpression): +class MySQLCreateUserStatement(BaseExpression): """Represent ``CREATE USER [IF NOT EXISTS] 'user'@'host' [IDENTIFIED BY ...]``.""" def __init__( @@ -506,11 +528,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_user_statement" + return "format_mysql_create_user_statement" -class MySQLDropUserExpression(BaseExpression): +class MySQLDropUserStatement(BaseExpression): """Represent ``DROP USER [IF EXISTS] 'user'@'host' [, ...]``.""" def __init__( @@ -527,7 +549,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_user_statement" + return "format_mysql_drop_user_statement" @@ -547,7 +569,7 @@ def __repr__(self) -> str: # pragma: no cover return f"GrantPrivilege(privilege={self.privilege!r}, columns={self.columns!r})" -class MySQLGrantExpression(BaseExpression): +class MySQLGrantStatement(BaseExpression): """Represent ``GRANT priv_list ON object TO account_list``.""" def __init__( @@ -568,11 +590,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_grant_statement" + return "format_mysql_grant_statement" -class MySQLRevokeExpression(BaseExpression): +class MySQLRevokeStatement(BaseExpression): """Represent ``REVOKE priv_list ON object FROM account_list``.""" def __init__( @@ -591,4 +613,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_revoke_statement" + return "format_mysql_revoke_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py index 82073fd3..84a8b127 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py @@ -6,15 +6,15 @@ * ``AFTER `` — place the new column immediately after ``column``. -It lives on ``MySQLAddColumn`` (deriving the generic ``AddColumn``) and is -rendered by the MySQL ``format_add_column_action`` override. MySQL does -**not** share it with any other backend. +It lives on ``MySQLAddColumn`` (deriving the generic ``AddColumnAction``) +and is rendered by its own entry, ``format_mysql_add_column``. MySQL +does **not** share it with any other backend. """ from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.statements import ( - AddColumn, + AddColumnAction, ColumnDefinition, ) @@ -27,7 +27,7 @@ ] -class MySQLAddColumn(AddColumn): +class MySQLAddColumn(AddColumnAction): """A MySQL ``ADD COLUMN`` action extending the generic one. Adds the MySQL-only ``after`` positional clause. @@ -43,3 +43,8 @@ def __init__( ): super().__init__(dialect, column, if_not_exists=if_not_exists) self.after = after + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_add_column" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py index cbc0a901..ccc8e6b3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py @@ -34,16 +34,22 @@ ] -class MySQLColumnFormat(Enum): +class MySQLColumnFormat(str, Enum): """MySQL NDB ``COLUMN_FORMAT`` values.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + FIXED = "FIXED" DYNAMIC = "DYNAMIC" DEFAULT = "DEFAULT" -class MySQLColumnStorage(Enum): +class MySQLColumnStorage(str, Enum): """MySQL NDB ``STORAGE`` values.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + DISK = "DISK" MEMORY = "MEMORY" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py index cc6a1bf8..9a7a8b69 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py @@ -3,19 +3,25 @@ from typing import TYPE_CHECKING -from rhosocial.activerecord.backend.expression.statements import InsertExpression +from rhosocial.activerecord.backend.expression.statements import InsertStatement if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLInsertExpression(InsertExpression): +class MySQLInsertExpression(InsertStatement): """A MySQL INSERT statement extending the generic one. MySQL supports ``REPLACE INTO`` and ``INSERT IGNORE`` which have no direct generic equivalent and different semantics from other backends. The flags live here as typed fields and are rendered by the MySQL - ``format_insert_statement`` override. + ``format_insert_statement`` override, which reads them off this class -- + a plain :class:`InsertStatement` spells neither and renders a plain + ``INSERT``. + + The mutual-exclusion rules are enforced here, where the node is built, + rather than on every render: an invalid combination is a construction + error, not a render-time surprise. """ def __init__( @@ -38,6 +44,10 @@ def __init__( on_conflict=on_conflict, returning=returning, ) + if replace and ignore: + raise ValueError("Cannot use both 'replace' and 'ignore' options together") + if replace and self.on_conflict: + raise ValueError("REPLACE INTO does not support ON CONFLICT clause") self.replace = replace self.ignore = ignore diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py index e7ecb1d2..bfe8f68b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/fulltext.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLFulltextIndexOptionsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLFulltextIndexOptionsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL FULLTEXT index options expression for CREATE TABLE / ALTER TABLE. Args: @@ -45,7 +45,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_fulltext_index_options" + return "format_mysql_fulltext_index_options_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py new file mode 100644 index 00000000..a26b9761 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py @@ -0,0 +1,121 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/introspection.py +"""MySQL introspection queries the core catalog does not model. + +Core's catalog expressions cover the objects every engine shares: tables and +their columns, indexes, foreign keys, views, triggers. Two families are +MySQL's own and have no core object: + +* **storage options** -- engine, row format, collation, auto-increment, + comment and the size estimates, which live in ``information_schema.TABLES`` + beside the rows core already reads but are not part of core's projection; +* **partitions** -- which live in a separate ``information_schema.PARTITIONS`` + relation and belong to one table rather than to the database. + +Both are expressed here and rendered by the dialect, so a MySQL query is a +statement object with a ``format_method`` and the SQL comes out of the +expression system: no caller assembles text, and the dialect owns the +placeholder syntax and the quoting of ``information_schema``. + +The object reads one database at a time -- MySQL's only namespace level above +a table -- so every class takes the database and, where the question is about +one table, its name. + +Example: + >>> expr = MySQLTableOptionsListQueryStatement(dialect, schema="shop") + >>> sql, params = expr.to_sql() +""" + +from typing import Optional + +from rhosocial.activerecord.backend.expression.introspection import ( + IntrospectionExpression, +) + +__all__ = [ + "MySQLTableOptionsListQueryStatement", + "MySQLTableOptionsInfoQueryStatement", + "MySQLPartitionListQueryStatement", +] + + +class MySQLTableOptionsListQueryStatement(IntrospectionExpression): + """Storage options for every base table in one database. + + ``TABLE_TYPE = 'BASE TABLE'`` is part of the question rather than a filter + applied afterwards: the relation also carries views, and a view has no + engine to report. + """ + + def __init__(self, dialect: "SQLDialectBase", schema: Optional[str] = None): + """ + Args: + dialect: The SQL dialect to use for formatting. + schema: The database to read, or ``None`` to leave the resolution + to the formatter. + """ + super().__init__(dialect, schema=schema) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_options_list_query_statement" + + +class MySQLTableOptionsInfoQueryStatement(IntrospectionExpression): + """Storage options for one base table. + + Same projection as the list form, narrowed by name. An absent table then + yields no rows rather than a second query against a different relation. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + table_name: The table whose options are wanted. + schema: The database to read, or ``None`` to leave the resolution + to the formatter. + """ + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_table_options_info_query_statement" + + +class MySQLPartitionListQueryStatement(IntrospectionExpression): + """Partitions and subpartitions of one table. + + A non-partitioned table is reported by one row whose ``PARTITION_NAME`` is + NULL, so the predicate excludes it rather than filtering afterwards: an + empty result then means exactly "this table is not partitioned", and the + order is the engine's own ordinal pair. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + ): + """ + Args: + dialect: The SQL dialect to use for formatting. + table_name: The table whose partitions are wanted. + schema: The database to read, or ``None`` to leave the resolution + to the formatter. + """ + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_list_query_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py index d5089512..c661076d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -27,7 +27,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONExtractExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_EXTRACT expression. Extracts a value from a JSON document using a path. @@ -52,10 +52,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_extract" + return "format_mysql_json_extract_expression" -class MySQLJSONObjectExpression(AliasableMixin, SQLValueExpression): +class MySQLJSONObjectExpression(AliasableMixin, SQLValue): """MySQL JSON_OBJECT expression. Creates a JSON object from key-value pairs. @@ -63,7 +63,16 @@ class MySQLJSONObjectExpression(AliasableMixin, SQLValueExpression): Example: >>> expr = MySQLJSONObjectExpression(dialect, {'name': 'Alice', 'age': 30}) OR - >>> expr = MySQLJSONObjectExpression(dialect, ('name', 'Alice'), ('age', 30)) + >>> expr = MySQLJSONObjectExpression(dialect, [('name', 'Alice'), ('age', 30)]) + + ``data`` is the one parameter the pairs come from: a mapping, or an + iterable of ``(key, value)`` pairs. The class used to also accept the + pairs as ``**kwargs`` -- ``name="Alice"`` -- but a parameter whose + identity depends on how many extras arrived has no artifact spelling + (§4.11.4): the deserializer matches parameters by name, and a reader + would have to be told which names happened to arrive. The mapping and + the pair list are the two shapes that name every member, so they are + the two the constructor takes. """ def __init__( @@ -72,20 +81,10 @@ def __init__( data: Any = None, *, alias: Optional[str] = None, - **kwargs: Any, ): super().__init__(dialect) - self.data = data # keep raw for get_params() introspection - self.kwargs: Dict[str, Any] = dict(kwargs) - if data is not None and kwargs: - pairs = self._convert_to_pairs(data) + self._convert_to_pairs(kwargs) - elif data is not None: - pairs = self._convert_to_pairs(data) - elif kwargs: - pairs = self._convert_to_pairs(kwargs) - else: - pairs = [] - self.pairs = pairs + self.data = data + self.pairs = self._convert_to_pairs(data) if data is not None else [] self.alias = alias def _convert_to_pairs(self, data: Any) -> List[tuple]: @@ -97,10 +96,10 @@ def _convert_to_pairs(self, data: Any) -> List[tuple]: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_object" + return "format_mysql_json_object_expression" -class MySQLJSONArrayExpression(AliasableMixin, SQLValueExpression): +class MySQLJSONArrayExpression(AliasableMixin, SQLValue): """MySQL JSON_ARRAY expression. Creates a JSON array from values. @@ -143,10 +142,10 @@ def get_params(self) -> Dict[str, Any]: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_array" + return "format_mysql_json_array_expression" -class MySQLJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_CONTAINS expression. Checks if a JSON document contains a specific value. @@ -173,10 +172,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_contains" + return "format_mysql_json_contains_expression" -class MySQLJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONUnquoteExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_UNQUOTE expression. Removes quotes from a JSON-quoted string. @@ -201,10 +200,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_unquote" + return "format_mysql_json_unquote_expression" -class MySQLJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONSetExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_SET expression. Inserts or updates values in a JSON document. @@ -238,10 +237,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_set" + return "format_mysql_json_set_expression" -class MySQLJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONRemoveExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_REMOVE expression. Removes data from a JSON document. @@ -272,10 +271,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_remove" + return "format_mysql_json_remove_expression" -class MySQLJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONTypeExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_TYPE expression. Returns the type of a JSON value. @@ -300,10 +299,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_type" + return "format_mysql_json_type_expression" -class MySQLJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONValidExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_VALID expression. Checks whether a value is valid JSON. @@ -328,10 +327,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_valid" + return "format_mysql_json_valid_expression" -class MySQLJSONSearchExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLJSONSearchExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL JSON_SEARCH expression. Searches a JSON document for a string and returns the path. @@ -365,7 +364,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_search" + return "format_mysql_json_search_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py index 7dda6cf2..d5041416 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_duality_view.py @@ -12,7 +12,7 @@ from enum import Enum from typing import List, Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -22,8 +22,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class DualityViewDMLTag(Enum): +class DualityViewDMLTag(str, Enum): """DML permission tags for JSON_DUALITY_OBJECT.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + INSERT = "INSERT" UPDATE = "UPDATE" @@ -77,7 +80,7 @@ class DualityObjectSpec: join_condition: Optional[str] = None -class CreateJsonDualityViewExpression(BaseExpression): +class CreateJsonDualityViewStatement(BaseExpression): """CREATE JSON RELATIONAL DUALITY VIEW expression (MySQL 9.7+). Attributes: @@ -105,7 +108,7 @@ def format_method(self) -> str: -class DropJsonDualityViewExpression(BaseExpression): +class DropJsonDualityViewStatement(BaseExpression): """DROP VIEW expression for JSON Duality Views. Uses standard DROP VIEW syntax since MySQL has no special DROP for duality views. @@ -127,7 +130,7 @@ def format_method(self) -> str: return "format_drop_json_duality_view_statement" -class MySQLDualityObjectSelectExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDualityObjectSelectExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Duality Object SELECT expression. Wraps a DualityObjectSpec for SELECT JSON_DUALITY_OBJECT(...) FROM table. @@ -152,10 +155,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_duality_object_select" + return "format_mysql_duality_object_select_expression" -class MySQLDualityObjectBodyExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDualityObjectBodyExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Duality Object body expression. Wraps a DualityObjectSpec for JSON_DUALITY_OBJECT( ... ). @@ -180,10 +183,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_duality_object_body" + return "format_mysql_duality_object_body_expression" -class MySQLNestedDualityExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLNestedDualityExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL nested duality expression. Wraps a DualityNestedMapping for nested JSON_ARRAYAGG(JSON_DUALITY_OBJECT(...)). @@ -208,5 +211,5 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_nested_duality" + return "format_mysql_nested_duality_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py index b008ff6b..2fa5233c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/json_table.py @@ -7,12 +7,13 @@ """ from dataclasses import dataclass -from typing import Any, List, Optional, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING, Union from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.types import DataType @dataclass @@ -21,7 +22,8 @@ class JSONTableColumn: Attributes: name: Column name - type: SQL data type (e.g., 'VARCHAR(255)', 'INT') + type: The column's type -- a type node (``VarCharType(dialect, 255)``) + or a type name, which this backend's own rule decides path: JSON path expression for the column value ordinality: If True, use FOR ORDINALITY (row number) exists: If True, use EXISTS (boolean for path existence) @@ -30,7 +32,7 @@ class JSONTableColumn: """ name: str - type: Optional[str] = None + type: Optional[Union[str, "DataType"]] = None path: Optional[str] = None ordinality: bool = False exists: bool = False @@ -126,4 +128,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_json_table_expression" + return "format_mysql_json_table_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py index 6ac0a9aa..dd8573cf 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_data.py @@ -2,11 +2,10 @@ """ MySQL-specific LOAD DATA expression. -This module provides MySQLLoadDataExpression and LoadDataOptions for +This module provides MySQLLoadDataStatement and LoadDataOptions for MySQL's LOAD DATA INFILE functionality. """ -from dataclasses import dataclass from typing import Any, Dict, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -16,9 +15,17 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -@dataclass -class LoadDataOptions: - """Options for LOAD DATA INFILE statement. +class LoadDataOptions(BaseExpression): + """Options for LOAD DATA INFILE statement, as a node. + + A clause node rather than a plain dataclass: ``options`` is a + constructor parameter of :class:`MySQLLoadDataStatement`, and a + parameter is either a language-neutral scalar or an expression + instance (§10) -- those are the two forms an artifact can carry, and a + dataclass is neither. Being a node is also what makes the bundle + readable back: the artifact carries a nested ``load_data_options`` + node and the reader rebuilds it by name, exactly like any other + clause. Attributes: local: Use LOCAL keyword (client-side file) @@ -35,25 +42,59 @@ class LoadDataOptions: set_assignments: SET clause for column transformations """ - local: bool = True - replace: bool = False - ignore: bool = False - character_set: Optional[str] = None - fields_terminated_by: Optional[str] = None - fields_enclosed_by: Optional[str] = None - fields_escaped_by: Optional[str] = None - lines_starting_by: Optional[str] = None - lines_terminated_by: Optional[str] = None - ignore_lines: Optional[int] = None - column_list: Optional[List[str]] = None - set_assignments: Optional[Dict[str, Any]] = None - - -class MySQLLoadDataExpression(BaseExpression): + def __init__( + self, + dialect: "SQLDialectBase", + *, + local: bool = True, + replace: bool = False, + ignore: bool = False, + character_set: Optional[str] = None, + fields_terminated_by: Optional[str] = None, + fields_enclosed_by: Optional[str] = None, + fields_escaped_by: Optional[str] = None, + lines_starting_by: Optional[str] = None, + lines_terminated_by: Optional[str] = None, + ignore_lines: Optional[int] = None, + column_list: Optional[List[str]] = None, + set_assignments: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.local: bool = local + self.replace: bool = replace + self.ignore: bool = ignore + self.character_set: Optional[str] = character_set + self.fields_terminated_by: Optional[str] = fields_terminated_by + self.fields_enclosed_by: Optional[str] = fields_enclosed_by + self.fields_escaped_by: Optional[str] = fields_escaped_by + self.lines_starting_by: Optional[str] = lines_starting_by + self.lines_terminated_by: Optional[str] = lines_terminated_by + self.ignore_lines: Optional[int] = ignore_lines + self.column_list: Optional[List[str]] = ( + list(column_list) if column_list is not None else None + ) + self.set_assignments: Optional[Dict[str, Any]] = ( + dict(set_assignments) if set_assignments is not None else None + ) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression. + + No dialect defines a standalone entry for this clause: the LOAD + DATA statement's own formatter renders the options inline, reading + the fields declared here. The clause travels as a node so the + statement's parameters can carry it, not because it renders + standing alone. + """ + return "format_load_data_options" + + +class MySQLLoadDataStatement(BaseExpression): """MySQL LOAD DATA INFILE expression. Collects parameters for LOAD DATA [LOCAL] INFILE statement. - The actual SQL formatting is done by MySQLDialect.format_load_data_statement(). + The actual SQL formatting is done by MySQLDialect.format_mysql_load_data_statement(). Attributes: file_path: Path to the data file @@ -75,12 +116,12 @@ def __init__( Supplying an object rather than a bare string is what makes ``LOAD DATA ... INTO TABLE `other_db`.`users``` expressible; a string can only ever mean the session's current database. - options: Load options (default: LoadDataOptions()) + options: Load options (default: LoadDataOptions(dialect)) """ super().__init__(dialect) self.file_path = file_path self.table = table - self.options = options or LoadDataOptions() + self.options = options or LoadDataOptions(dialect) def validate(self, strict: bool = True) -> None: """Validate expression parameters. @@ -113,4 +154,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_data_statement" + return "format_mysql_load_data_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py index cd2c7cb7..d9feb3e1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py @@ -21,23 +21,29 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class LoadXMLPriority(Enum): +class LoadXMLPriority(str, Enum): """LOW_PRIORITY / CONCURRENT selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "" LOW_PRIORITY = "LOW_PRIORITY" CONCURRENT = "CONCURRENT" -class LoadXMLConflictMode(Enum): +class LoadXMLConflictMode(str, Enum): """REPLACE / IGNORE selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "" REPLACE = "REPLACE" IGNORE = "IGNORE" -class MySQLLoadXMLEXpression(BaseExpression): +class MySQLLoadXMLStatement(BaseExpression): """Represent the MySQL ``LOAD XML`` statement. Attributes: @@ -80,8 +86,11 @@ def __init__( self.file_path: str = file_path self.table: "Table" = table self.local: bool = local - self.priority: LoadXMLPriority = priority - self.conflict_mode: LoadXMLConflictMode = conflict_mode + # The member or the word it spells (§10: wide entry, normalised + # storage): an artifact carries the word, and the formatter reads + # ``.value``, which only the member has. + self.priority: LoadXMLPriority = LoadXMLPriority(priority) + self.conflict_mode: LoadXMLConflictMode = LoadXMLConflictMode(conflict_mode) self.character_set: Optional[str] = character_set self.rows_identified_by: Optional[str] = rows_identified_by self.ignore_count: Optional[int] = ignore_count @@ -113,4 +122,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_load_xml_statement" + return "format_mysql_load_xml_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py index b68adbb9..725ec077 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py @@ -21,17 +21,23 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class NoWriteToBinlogOption(Enum): +class NoWriteToBinlogOption(str, Enum): """NO_WRITE_TO_BINLOG / LOCAL synonym selector.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + NONE = "" NO_WRITE_TO_BINLOG = "NO_WRITE_TO_BINLOG" LOCAL = "LOCAL" -class CheckTableOption(Enum): +class CheckTableOption(str, Enum): """CHECK TABLE optional modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + FOR_UPGRADE = "FOR UPGRADE" QUICK = "QUICK" FAST = "FAST" @@ -40,16 +46,22 @@ class CheckTableOption(Enum): CHANGED = "CHANGED" -class ChecksumTableOption(Enum): +class ChecksumTableOption(str, Enum): """CHECKSUM TABLE optional modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + QUICK = "QUICK" EXTENDED = "EXTENDED" -class RepairTableOption(Enum): +class RepairTableOption(str, Enum): """REPAIR TABLE optional modes.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + QUICK = "QUICK" EXTENDED = "EXTENDED" USE_FRM = "USE_FRM" @@ -58,6 +70,10 @@ class RepairTableOption(Enum): class MySQLTableMaintenanceExpression(BaseExpression): """Base class for whole-table maintenance statements. + Not a node: it declares no ``format_method``, so it has no kind, cannot be + named in an artifact and never reaches the registry (§9: an abstract base + is not a node). The five subclasses below each name their own entry. + Attributes: operation: Statement keyword (ANALYZE / CHECK / CHECKSUM / OPTIMIZE / REPAIR). tables: List of target relations, as @@ -80,7 +96,12 @@ def __init__( ): super().__init__(dialect) self.tables: List[RelationObject] = list(tables) - self.no_write_to_binlog: NoWriteToBinlogOption = no_write_to_binlog + # The member or the word it spells (§10: wide entry, normalised + # storage): an artifact carries the word, and the formatter reads + # ``.value``, which only the member has. + self.no_write_to_binlog: NoWriteToBinlogOption = NoWriteToBinlogOption( + no_write_to_binlog + ) def validate(self, strict: bool = True) -> None: """Validate table list. @@ -106,20 +127,19 @@ def validate(self, strict: bool = True) -> None: f"Table(self.dialect, 'users', catalog_name='app') to qualify the name" ) - @property - def format_method(self) -> str: - """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" - - -class MySQLAnalyzeTableExpression(MySQLTableMaintenanceExpression): +class MySQLAnalyzeTableStatement(MySQLTableMaintenanceExpression): """Represent ``ANALYZE TABLE``.""" operation: str = "ANALYZE" + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_analyze_table_statement" + -class MySQLCheckTableExpression(MySQLTableMaintenanceExpression): +class MySQLCheckTableStatement(MySQLTableMaintenanceExpression): """Represent ``CHECK TABLE`` with optional modes.""" operation: str = "CHECK" @@ -136,16 +156,16 @@ def __init__( tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, ) - self.options: List[CheckTableOption] = list(options or []) + self.options: List[CheckTableOption] = [CheckTableOption(o) for o in (options or [])] @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" + return "format_mysql_check_table_statement" -class MySQLChecksumTableExpression(MySQLTableMaintenanceExpression): +class MySQLChecksumTableStatement(MySQLTableMaintenanceExpression): """Represent ``CHECKSUM TABLE`` with optional mode.""" operation: str = "CHECKSUM" @@ -162,22 +182,29 @@ def __init__( tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, ) - self.option: Optional[ChecksumTableOption] = option + self.option: Optional[ChecksumTableOption] = ( + ChecksumTableOption(option) if option is not None else None + ) @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" + return "format_mysql_checksum_table_statement" -class MySQLOptimizeTableExpression(MySQLTableMaintenanceExpression): +class MySQLOptimizeTableStatement(MySQLTableMaintenanceExpression): """Represent ``OPTIMIZE TABLE``.""" operation: str = "OPTIMIZE" + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_optimize_table_statement" + -class MySQLRepairTableExpression(MySQLTableMaintenanceExpression): +class MySQLRepairTableStatement(MySQLTableMaintenanceExpression): """Represent ``REPAIR TABLE`` with optional modes.""" operation: str = "REPAIR" @@ -195,9 +222,9 @@ def __init__( tables, no_write_to_binlog=no_write_to_binlog, ) - self.options: List[RepairTableOption] = list(options or []) + self.options: List[RepairTableOption] = [RepairTableOption(o) for o in (options or [])] @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_maintenance_statement" + return "format_mysql_repair_table_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py index 5ca1b48e..2c32331b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/match_against.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -28,7 +28,7 @@ class MatchAgainstMode: class MySQLMatchAgainstExpression( AliasableMixin, ComparisonMixin, - SQLValueExpression, + SQLValue, ): """MySQL MATCH...AGAINST expression. @@ -75,7 +75,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_match_against" + return "format_mysql_match_against_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py index 44a7f212..1b5d7ee0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/optimizer_hint.py @@ -18,8 +18,11 @@ from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -class OptimizerHintType(Enum): +class OptimizerHintType(str, Enum): """Types of MySQL optimizer hints.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + SET_VAR = "SET_VAR" @@ -50,5 +53,5 @@ def __init__(self, dialect: "SQLDialectBase", hints: List[SetVarHint]): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_optimizer_hint" + return "format_mysql_optimizer_hint_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py index a6926a0b..9a319944 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py @@ -18,9 +18,12 @@ from rhosocial.activerecord.backend.expression.objects import Table -class MySQLPartitionStrategy(Enum): +class MySQLPartitionStrategy(str, Enum): """MySQL table partitioning strategies supported by MySQLPartitionMixin.""" + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + RANGE = "RANGE" RANGE_COLUMNS = "RANGE COLUMNS" LIST = "LIST" @@ -31,13 +34,16 @@ class MySQLPartitionStrategy(Enum): LINEAR_KEY = "LINEAR KEY" -class MySQLSubpartitionStrategy(Enum): +class MySQLSubpartitionStrategy(str, Enum): """MySQL subpartitioning strategies. MySQL restricts subpartitioning to HASH and KEY (and their LINEAR variants) only. RANGE and LIST cannot be used for subpartitioning. """ + #: A member *is* the word it carries; see ``SortDirection.__str__``. + __str__ = str.__str__ + HASH = "HASH" KEY = "KEY" LINEAR_HASH = "LINEAR HASH" @@ -107,7 +113,7 @@ class MySQLSubpartitionClause(BaseExpression): override the template for each parent partition. Raises: - TypeError: if strategy is not a MySQLSubpartitionStrategy. + ValueError: if strategy is not a MySQLSubpartitionStrategy word. ValueError: if count is provided but is not a positive integer. """ @@ -121,14 +127,14 @@ def __init__( definitions: Optional[Sequence[MySQLSubpartitionDefinition]] = None, ): super().__init__(dialect) - if not isinstance(strategy, MySQLSubpartitionStrategy): - raise TypeError( - "strategy must be a MySQLSubpartitionStrategy value, " - f"got {type(strategy).__name__}" - ) + # The member or the word it spells (§10: wide entry, normalised + # storage): an artifact carries the word, and the formatter reads + # ``.value``, which only the member has. A word this enum does not + # name is refused here, where the caller can still see which + # strategy was meant. + self.strategy = MySQLSubpartitionStrategy(strategy) if count is not None and (not isinstance(count, int) or count <= 0): raise ValueError("count must be a positive integer when provided") - self.strategy = strategy self.expression = expression self.count = count self.definitions = list(definitions) if definitions else None @@ -136,7 +142,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_subpartition_by" + return "format_mysql_subpartition_clause" if TYPE_CHECKING: # pragma: no cover @@ -152,7 +158,7 @@ def __init__(self, dialect: "MySQLDialect"): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_value" + return "format_mysql_partition_max_value" @@ -176,7 +182,7 @@ def __init__(self, dialect: "MySQLDialect", value: Any): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_partition_value" + return "format_mysql_partition_value" @@ -255,6 +261,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_partition_by_range" + class MySQLPartitionByRangeColumns(MySQLPartitionClause): """MySQL PARTITION BY RANGE COLUMNS expression. @@ -279,6 +290,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_partition_by_range_columns" + class MySQLPartitionByList(MySQLPartitionClause): """MySQL PARTITION BY LIST expression. @@ -303,6 +319,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_partition_by_list" + class MySQLPartitionByListColumns(MySQLPartitionClause): """MySQL PARTITION BY LIST COLUMNS expression. @@ -327,6 +348,11 @@ def __init__( self.partitions = list(partitions or []) self.subpartition_by = subpartition_by + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_partition_by_list_columns" + class MySQLPartitionByHash(MySQLPartitionClause): """MySQL PARTITION BY HASH expression.""" @@ -344,6 +370,11 @@ def __init__( self.partitions_count = partitions_count self.linear = linear + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_partition_by_hash" + class MySQLPartitionByKey(MySQLPartitionClause): """MySQL PARTITION BY KEY expression. @@ -372,6 +403,11 @@ def __init__( self.partitions_count = partitions_count self.linear = linear + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_partition_by_key" + class MySQLPartitionTableExpression(BaseExpression): """Base for every ``ALTER TABLE ... `` statement. @@ -430,7 +466,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_add_partition_statement" + return "format_mysql_add_partition_expression" @@ -444,7 +480,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_partition_statement" + return "format_mysql_drop_partition_expression" @@ -458,7 +494,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_truncate_partition_statement" + return "format_mysql_truncate_partition_expression" @@ -479,7 +515,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_reorganize_partition_statement" + return "format_mysql_reorganize_partition_expression" @@ -523,7 +559,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_exchange_partition_statement" + return "format_mysql_exchange_partition_expression" @@ -536,7 +572,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_remove_partitioning_statement" + return "format_mysql_remove_partitioning_expression" @@ -552,7 +588,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, count: int): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_coalesce_partition_statement" + return "format_mysql_coalesce_partition_expression" @@ -566,7 +602,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_analyze_partition_statement" + return "format_mysql_analyze_partition_expression" @@ -580,7 +616,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_check_partition_statement" + return "format_mysql_check_partition_expression" @@ -594,7 +630,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_optimize_partition_statement" + return "format_mysql_optimize_partition_expression" @@ -608,7 +644,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_rebuild_partition_statement" + return "format_mysql_rebuild_partition_expression" @@ -622,7 +658,7 @@ def __init__(self, dialect: "MySQLDialect", table: Table, partitions: Sequence[s @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_repair_partition_statement" + return "format_mysql_repair_partition_expression" @@ -631,7 +667,7 @@ class MySQLGetPartitionsExpression(BaseExpression): Generates a SELECT statement retrieving partition name, method, expression, description, and storage statistics for the given table. - Delegates SQL generation to the dialect's ``format_get_partitions_expression``. + Delegates SQL generation to the dialect's ``format_mysql_get_partitions_expression``. ``table_name`` stays a plain string here, and is matched against the ``TABLE_NAME`` *column* rather than used to name a relation: this is a @@ -651,5 +687,5 @@ def __init__(self, dialect: "MySQLDialect", table_name: str): @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_get_partitions_expression" + return "format_mysql_get_partitions_expression" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py index 117f68e7..0ecab3a9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition_lifecycle.py @@ -87,7 +87,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_add_partition_helper" + return "format_mysql_add_partition_helper" class MySQLCoalescePartitionHelper(BaseExpression): @@ -118,7 +118,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_coalesce_partition_helper" + return "format_mysql_coalesce_partition_helper" class MySQLDropOldestPartitionHelper(BaseExpression): @@ -148,7 +148,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_oldest_partition_helper" + return "format_mysql_drop_oldest_partition_helper" class MySQLReorganizePartitionHelper(BaseExpression): @@ -177,7 +177,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_reorganize_partition_helper" + return "format_mysql_reorganize_partition_helper" class MySQLAddSubpartitionHelper(BaseExpression): @@ -215,4 +215,4 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_add_subpartition_helper" + return "format_mysql_add_subpartition_helper" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py index 97278f9c..eb98ad81 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py @@ -23,7 +23,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLRenameTableExpression(BaseExpression): +class MySQLRenameTableStatement(BaseExpression): """Represent a MySQL ``RENAME TABLE ...`` atomic multi-table statement. Attributes: @@ -78,4 +78,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_rename_table_statement" + return "format_mysql_rename_table_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py index 925a03d6..125f0a28 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py @@ -73,17 +73,17 @@ def validate(self, strict: bool = True) -> None: ) -class MySQLCreateProcedureExpression(MySQLRoutineExpression): +class MySQLCreateProcedureStatement(MySQLRoutineExpression): """Represent ``CREATE PROCEDURE``.""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_procedure_statement" + return "format_mysql_create_procedure_statement" -class MySQLDropProcedureExpression(MySQLRoutineExpression): +class MySQLDropProcedureStatement(MySQLRoutineExpression): """Represent ``DROP PROCEDURE [IF EXISTS]``.""" def __init__( @@ -99,11 +99,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_procedure_statement" + return "format_mysql_drop_procedure_statement" -class MySQLCreateFunctionExpression(MySQLRoutineExpression): +class MySQLCreateFunctionStatement(MySQLRoutineExpression): """Represent ``CREATE FUNCTION`` (stored function).""" def __init__( @@ -128,11 +128,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_function_statement" + return "format_mysql_create_function_statement" -class MySQLDropFunctionExpression(MySQLRoutineExpression): +class MySQLDropFunctionStatement(MySQLRoutineExpression): """Represent ``DROP FUNCTION [IF EXISTS]`` (stored function).""" def __init__( @@ -148,11 +148,11 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_drop_function_statement" + return "format_mysql_drop_function_statement" -class MySQLCallExpression(BaseExpression): +class MySQLCallStatement(BaseExpression): """Represent ``CALL procedure_name([args])``. Attributes: @@ -190,4 +190,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_call_statement" + return "format_mysql_call_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py index c8bed4be..c057def0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/set_type.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSetLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL SET literal value expression. Args: @@ -39,10 +39,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_set_literal" + return "format_mysql_set_literal_expression" -class MySQLFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLFindInSetExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL FIND_IN_SET expression. Args: @@ -68,10 +68,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_find_in_set" + return "format_mysql_find_in_set_expression" -class MySQLSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSetContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL SET contains check expression. Args: @@ -97,7 +97,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_set_contains" + return "format_mysql_set_contains_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py index 92a1856e..2dff1ce5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/show.py @@ -10,6 +10,19 @@ following the expression-dialect pattern used throughout the codebase. Key design: +- **Every parameter is a constructor parameter.** There is no fluent builder: + an instance is complete the moment ``get_params()`` reads it back, so the + artifact is the constructor call and nothing else. The ``with_like`` / + ``with_session`` / ``with_global_status`` setters that used to fill these + same fields in afterwards are gone -- a parameter that can be changed after + construction is a second, invisible constructor. +- Each parameter is stored under its **own name**, which is what + ``get_params()`` reads. A parameter the class did not store under its own + name is a defect, not a spelling. +- A subclass that supports a schema qualifier declares ``schema`` in its + own ``__init__``: ``get_params()`` reads the class's own signature, so a + parameter declared only on the base would be invisible to the formatter + -- and the qualifier the caller passed was silently dropped. - Expressions collect parameters (table_name, schema, options) - Expressions hold a dialect reference - to_sql() delegates to dialect.format_show_* methods @@ -27,59 +40,59 @@ class ShowExpression(BaseExpression): """Base class for MySQL SHOW command expressions. - All MySQL SHOW expressions inherit from this class and provide - fluent API for setting parameters. + All MySQL SHOW expressions inherit from this class. Parameters are + declared by each ``__init__`` -- that signature is the wire contract, so a + parameter is always passed where the node is built rather than filled in + afterwards. """ def __init__(self, dialect: "MySQLDialect", *, schema: Optional[str] = None): super().__init__(dialect) - self._schema: Optional[str] = schema - - def schema(self, name: str) -> "ShowExpression": - """Set the schema/database name. - - Args: - name: Schema or database name. - - Returns: - Self for method chaining. - """ - self._schema = name - return self + self.schema: Optional[str] = schema # Concrete subclasses declare their dialect formatting method by # overriding the `format_method` property (inherited from BaseExpression). -class ShowCreateTableExpression(ShowExpression): +class ShowCreateTableStatement(ShowExpression): """Expression for SHOW CREATE TABLE command.""" - def __init__(self, dialect: "MySQLDialect", table_name: str): - super().__init__(dialect) - self._table_name = table_name + def __init__( + self, + dialect: "MySQLDialect", + table_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table_name: str = table_name @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_table" - + return "format_show_create_table_statement" -class ShowCreateViewExpression(ShowExpression): +class ShowCreateViewStatement(ShowExpression): """Expression for SHOW CREATE VIEW command.""" - def __init__(self, dialect: "MySQLDialect", view_name: str): - super().__init__(dialect) - self._view_name = view_name + def __init__( + self, + dialect: "MySQLDialect", + view_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.view_name: str = view_name @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_view" - + return "format_show_create_view_statement" -class ShowColumnsExpression(ShowExpression): +class ShowColumnsStatement(ShowExpression): """Expression for SHOW [FULL] COLUMNS command.""" def __init__( @@ -87,77 +100,62 @@ def __init__( dialect: "MySQLDialect", table_name: str, *, + schema: Optional[str] = None, full: bool = False, like_pattern: Optional[str] = None, ): - super().__init__(dialect) - self._table_name = table_name - self._full = full - self._like_pattern = like_pattern - - def full(self, value: bool = True) -> "ShowColumnsExpression": - """Include additional column information (privileges, comments).""" - self._full = value - return self - - def like(self, pattern: str) -> "ShowColumnsExpression": - """Filter columns by name pattern.""" - self._like_pattern = pattern - return self + super().__init__(dialect, schema=schema) + self.table_name: str = table_name + self.full: bool = full + self.like_pattern: Optional[str] = like_pattern @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_columns" + return "format_show_columns_statement" - -class ShowIndexExpression(ShowExpression): +class ShowIndexStatement(ShowExpression): """Expression for SHOW INDEX command.""" - def __init__(self, dialect: "MySQLDialect", table_name: str): - super().__init__(dialect) - self._table_name = table_name + def __init__( + self, + dialect: "MySQLDialect", + table_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.table_name: str = table_name @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_index" + return "format_show_index_statement" - -class ShowTablesExpression(ShowExpression): +class ShowTablesStatement(ShowExpression): """Expression for SHOW [FULL] TABLES command.""" def __init__( self, dialect: "MySQLDialect", *, + schema: Optional[str] = None, full: bool = False, like_pattern: Optional[str] = None, ): - super().__init__(dialect) - self._full = full - self._like_pattern = like_pattern - - def full(self, value: bool = True) -> "ShowTablesExpression": - """Include table type (BASE TABLE or VIEW).""" - self._full = value - return self - - def like(self, pattern: str) -> "ShowTablesExpression": - """Filter tables by name pattern.""" - self._like_pattern = pattern - return self + super().__init__(dialect, schema=schema) + self.full: bool = full + self.like_pattern: Optional[str] = like_pattern @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_tables" - + return "format_show_tables_statement" -class ShowDatabasesExpression(ShowExpression): +class ShowDatabasesStatement(ShowExpression): """Expression for SHOW DATABASES command.""" def __init__( @@ -167,84 +165,73 @@ def __init__( like_pattern: Optional[str] = None, ): super().__init__(dialect) - self._like_pattern = like_pattern - - def like(self, pattern: str) -> "ShowDatabasesExpression": - """Filter databases by name pattern.""" - self._like_pattern = pattern - return self + self.like_pattern: Optional[str] = like_pattern @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_databases" + return "format_show_databases_statement" - -class ShowTableStatusExpression(ShowExpression): +class ShowTableStatusStatement(ShowExpression): """Expression for SHOW TABLE STATUS command.""" def __init__( self, dialect: "MySQLDialect", *, + schema: Optional[str] = None, like_pattern: Optional[str] = None, ): - super().__init__(dialect) - self._like_pattern = like_pattern - - def like(self, pattern: str) -> "ShowTableStatusExpression": - """Filter tables by name pattern.""" - self._like_pattern = pattern - return self + super().__init__(dialect, schema=schema) + self.like_pattern: Optional[str] = like_pattern @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_table_status" + return "format_show_table_status_statement" - -class ShowTriggersExpression(ShowExpression): +class ShowTriggersStatement(ShowExpression): """Expression for SHOW TRIGGERS command.""" def __init__( self, dialect: "MySQLDialect", *, + schema: Optional[str] = None, table_name: Optional[str] = None, ): - super().__init__(dialect) - self._table_name = table_name - - def for_table(self, table_name: str) -> "ShowTriggersExpression": - """Filter triggers for a specific table.""" - self._table_name = table_name - return self + super().__init__(dialect, schema=schema) + self.table_name: Optional[str] = table_name @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_triggers" + return "format_show_triggers_statement" - -class ShowCreateTriggerExpression(ShowExpression): +class ShowCreateTriggerStatement(ShowExpression): """Expression for SHOW CREATE TRIGGER command.""" - def __init__(self, dialect: "MySQLDialect", trigger_name: str): - super().__init__(dialect) - self._trigger_name = trigger_name + def __init__( + self, + dialect: "MySQLDialect", + trigger_name: str, + *, + schema: Optional[str] = None, + ): + super().__init__(dialect, schema=schema) + self.trigger_name: str = trigger_name @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_create_trigger" - + return "format_show_create_trigger_statement" -class ShowVariablesExpression(ShowExpression): - """Expression for SHOW VARIABLES command.""" +class ShowVariablesStatement(ShowExpression): + """Expression for SHOW [GLOBAL] VARIABLES command.""" def __init__( self, @@ -254,33 +241,17 @@ def __init__( session: bool = True, ): super().__init__(dialect) - self._like_pattern = like_pattern - self._session = session - - def like(self, pattern: str) -> "ShowVariablesExpression": - """Filter variables by name pattern.""" - self._like_pattern = pattern - return self - - def session(self, value: bool = True) -> "ShowVariablesExpression": - """Show session variables (default).""" - self._session = value - return self - - def global_vars(self) -> "ShowVariablesExpression": - """Show global variables.""" - self._session = False - return self + self.like_pattern: Optional[str] = like_pattern + self.session: bool = session @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_variables" + return "format_show_variables_statement" - -class ShowStatusExpression(ShowExpression): - """Expression for SHOW STATUS command.""" +class ShowStatusStatement(ShowExpression): + """Expression for SHOW [GLOBAL] STATUS command.""" def __init__( self, @@ -290,33 +261,17 @@ def __init__( session: bool = True, ): super().__init__(dialect) - self._like_pattern = like_pattern - self._session = session - - def like(self, pattern: str) -> "ShowStatusExpression": - """Filter status by name pattern.""" - self._like_pattern = pattern - return self - - def session(self, value: bool = True) -> "ShowStatusExpression": - """Show session status (default).""" - self._session = value - return self - - def global_status(self) -> "ShowStatusExpression": - """Show global status.""" - self._session = False - return self + self.like_pattern: Optional[str] = like_pattern + self.session: bool = session @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_status" - + return "format_show_status_statement" -class ShowProcessListExpression(ShowExpression): - """Expression for SHOW PROCESSLIST command.""" +class ShowProcessListStatement(ShowExpression): + """Expression for SHOW [FULL] PROCESSLIST command.""" def __init__( self, @@ -325,21 +280,15 @@ def __init__( full: bool = False, ): super().__init__(dialect) - self._full = full - - def full(self, value: bool = True) -> "ShowProcessListExpression": - """Include full query text.""" - self._full = value - return self + self.full: bool = full @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_processlist" + return "format_show_process_list_statement" - -class ShowWarningsExpression(ShowExpression): +class ShowWarningsStatement(ShowExpression): """Expression for SHOW WARNINGS command.""" def __init__( @@ -349,21 +298,15 @@ def __init__( limit: Optional[int] = None, ): super().__init__(dialect) - self._limit = limit - - def limit(self, count: int) -> "ShowWarningsExpression": - """Limit number of warnings returned.""" - self._limit = count - return self + self.limit: Optional[int] = limit @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_warnings" - + return "format_show_warnings_statement" -class ShowErrorsExpression(ShowExpression): +class ShowErrorsStatement(ShowExpression): """Expression for SHOW ERRORS command.""" def __init__( @@ -373,31 +316,24 @@ def __init__( limit: Optional[int] = None, ): super().__init__(dialect) - self._limit = limit - - def limit(self, count: int) -> "ShowErrorsExpression": - """Limit number of errors returned.""" - self._limit = count - return self + self.limit: Optional[int] = limit @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_errors" - + return "format_show_errors_statement" -class ShowEnginesExpression(ShowExpression): +class ShowEnginesStatement(ShowExpression): """Expression for SHOW ENGINES command.""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_engines" - + return "format_show_engines_statement" -class ShowCharsetExpression(ShowExpression): +class ShowCharsetStatement(ShowExpression): """Expression for SHOW CHARACTER SET command.""" def __init__( @@ -407,21 +343,15 @@ def __init__( like_pattern: Optional[str] = None, ): super().__init__(dialect) - self._like_pattern = like_pattern - - def like(self, pattern: str) -> "ShowCharsetExpression": - """Filter character sets by name pattern.""" - self._like_pattern = pattern - return self + self.like_pattern: Optional[str] = like_pattern @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_charset" - + return "format_show_charset_statement" -class ShowCollationExpression(ShowExpression): +class ShowCollationStatement(ShowExpression): """Expression for SHOW COLLATION command.""" def __init__( @@ -431,21 +361,15 @@ def __init__( like_pattern: Optional[str] = None, ): super().__init__(dialect) - self._like_pattern = like_pattern - - def like(self, pattern: str) -> "ShowCollationExpression": - """Filter collations by name pattern.""" - self._like_pattern = pattern - return self + self.like_pattern: Optional[str] = like_pattern @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_collation" + return "format_show_collation_statement" - -class ShowGrantsExpression(ShowExpression): +class ShowGrantsStatement(ShowExpression): """Expression for SHOW GRANTS command.""" def __init__( @@ -456,27 +380,63 @@ def __init__( host: Optional[str] = None, ): super().__init__(dialect) - self._user = user - self._host = host + self.user: Optional[str] = user + self.host: Optional[str] = host + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_show_grants_statement" - def for_user(self, user: str, host: Optional[str] = None) -> "ShowGrantsExpression": - """Show grants for a specific user.""" - self._user = user - self._host = host - return self + +class ShowPluginsStatement(ShowExpression): + """Expression for SHOW PLUGINS command.""" @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_grants" + return "format_show_plugins_statement" +class ShowBinaryLogsStatement(ShowExpression): + """Expression for SHOW BINARY LOGS command. -class ShowPluginsExpression(ShowExpression): - """Expression for SHOW PLUGINS command.""" + Its own node rather than a flag on another one: the command takes no + parameters at all, so what distinguishes it from its neighbours is the + command itself, which is exactly what a class names. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_show_binary_logs_statement" + + +class ShowMasterStatusStatement(ShowExpression): + """Expression for SHOW MASTER STATUS command. + + MySQL 8.4 removed this command in favour of + ``performance_schema.log_status``; the node still renders the command, + and the caller chooses by server version which of the two to execute. + """ @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_show_plugins" + return "format_show_master_status_statement" + + +class ShowSlaveStatusStatement(ShowExpression): + """Expression for SHOW SLAVE STATUS command. + MySQL 8.0.22 added ``SHOW REPLICA STATUS`` as the preferred spelling and + 9.0 removed ``SHOW SLAVE STATUS``; this node renders the older command, + because it is the one every server in this backend's supported range + accepts. A server that has dropped it is asked through + ``performance_schema`` instead. + """ + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_show_slave_status_statement" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py index 44075757..106aab6d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/spatial.py @@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -26,7 +26,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLSTGeomFromTextExpression(AliasableMixin, SQLValueExpression): +class MySQLSTGeomFromTextExpression(AliasableMixin, SQLValue): """MySQL ST_GeomFromText expression. Creates a geometry value from WKT. @@ -49,10 +49,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_geom_from_text" + return "format_mysql_st_geom_from_text_expression" -class MySQLSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTDistanceExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_Distance expression. Returns the distance between two geometries. @@ -77,10 +77,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_distance" + return "format_mysql_st_distance_expression" -class MySQLSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTWithinExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_Within expression. Returns whether one geometry is within another. @@ -105,10 +105,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_within" + return "format_mysql_st_within_expression" -class MySQLSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTContainsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_Contains expression. Returns whether one geometry contains another. @@ -133,10 +133,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_contains" + return "format_mysql_st_contains_expression" -class MySQLSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSpatialLiteralExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL spatial literal expression with optional SRID. Args: @@ -162,10 +162,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_spatial_literal" + return "format_mysql_spatial_literal_expression" -class MySQLSTGeomFromWKBExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTGeomFromWKBExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_GeomFromWKB expression. Creates a geometry value from WKB (Well-Known Binary). @@ -193,10 +193,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_geom_from_wkb" + return "format_mysql_st_geom_from_wkb_expression" -class MySQLSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTAsTextExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_AsText expression. Converts a geometry value to its WKT (Well-Known Text) representation. @@ -221,10 +221,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_as_text" + return "format_mysql_st_as_text_expression" -class MySQLSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLSTAsGeoJSONExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL ST_AsGeoJSON expression. Converts a geometry value to GeoJSON format (MySQL 5.7.5+). @@ -249,10 +249,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_st_as_geojson" + return "format_mysql_st_as_geo_json_expression" -class MySQLCreateSpatialIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLCreateSpatialIndexStatement(AliasableMixin, ComparisonMixin, SQLValue): """MySQL CREATE SPATIAL INDEX expression. Args: @@ -281,7 +281,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_spatial_index" + return "format_mysql_create_spatial_index_statement" __all__ = [ @@ -293,5 +293,5 @@ def format_method(self) -> str: "MySQLSTGeomFromWKBExpression", "MySQLSTAsTextExpression", "MySQLSTAsGeoJSONExpression", - "MySQLCreateSpatialIndexExpression", + "MySQLCreateSpatialIndexStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/system.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/system.py new file mode 100644 index 00000000..d6e2cc12 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/system.py @@ -0,0 +1,54 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/system.py +"""MySQL server queries that ask the engine a question, as expression nodes. + +These are the statements a MySQL backend runs to learn about *itself* rather +than about a user's schema: the server version, whether the connection still +answers, and the session's own account. They are built here, once, because both +the backend and the status introspector ask them -- and because a caller that +writes ``"SELECT VERSION()"`` as text has bypassed the expression layer, leaving +the command as a string the framework cannot inspect. + +Each builder returns a node; the caller renders it with ``to_sql()``. Nothing +here executes anything. +""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.core import FunctionCall, Literal +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + +__all__ = ["current_user_query", "ping_query", "version_query"] + + +def version_query(dialect: "SQLDialectBase") -> BaseExpression: + """``SELECT VERSION()`` -- the server's own version string. + + No alias: the column label MySQL returns for the bare call is what callers + read back, and naming it here would rename it for them. + """ + return QueryStatement(dialect, select=[FunctionCall(dialect, "VERSION")]) + + +def ping_query(dialect: "SQLDialectBase") -> BaseExpression: + """``SELECT 1`` -- the cheapest question a live connection can answer. + + The literal is inline so the statement carries no parameters at all: a ping + that needs the driver to bind something tests the driver as well as the + connection. + """ + return QueryStatement(dialect, select=[Literal(dialect, 1, inline=True)]) + + +def current_user_query(dialect: "SQLDialectBase") -> BaseExpression: + """``SELECT CURRENT_USER`` -- the account this session authenticated as. + + Core's node spells the niladic form, so the column label is + ``CURRENT_USER`` rather than ``CURRENT_USER()``. + """ + from rhosocial.activerecord.backend.expression.functions.system import CurrentUserFunction + + return QueryStatement(dialect, select=[CurrentUserFunction(dialect)]) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py index dae9928a..a6e0e6bb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_expr.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -13,7 +13,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLInlineIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLInlineIndexExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL inline INDEX definition within CREATE TABLE. Args: @@ -36,10 +36,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_inline_index" + return "format_mysql_inline_index_expression" -class MySQLStorageOptionsExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLStorageOptionsExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL table storage options expression (ENGINE, CHARSET, etc.). Args: @@ -62,7 +62,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_storage_options" + return "format_mysql_storage_options_expression" __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py index 3ac11b73..e7d37e6d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py @@ -28,8 +28,11 @@ ] -class MySQLRowFormat(Enum): +class MySQLRowFormat(str, Enum): """MySQL ``ROW_FORMAT`` table option values.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + DEFAULT = "DEFAULT" DYNAMIC = "DYNAMIC" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py index 66181099..197c703d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py @@ -119,7 +119,7 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_table_statement" + return "format_mysql_table_statement" class MySQLValuesExpression(MySQLBaseTableStatement): @@ -152,4 +152,4 @@ def validate(self, strict: bool = True) -> None: @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_values_statement" \ No newline at end of file + return "format_mysql_values_expression" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py index 16f35d70..f18bc0ef 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/vector.py @@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Optional -from rhosocial.activerecord.backend.expression.bases import SQLValueExpression +from rhosocial.activerecord.backend.expression.bases import SQLValue from rhosocial.activerecord.backend.expression.mixins import ( AliasableMixin, ComparisonMixin, @@ -27,7 +27,7 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -class MySQLVectorExpression(AliasableMixin, SQLValueExpression): +class MySQLVectorExpression(AliasableMixin, SQLValue): """MySQL vector literal expression. Creates a vector value from array string. @@ -50,10 +50,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_vector_literal" + return "format_mysql_vector_expression" -class MySQLDistanceEuclideanExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDistanceEuclideanExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Euclidean distance expression. Example: @@ -76,10 +76,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_distance_euclidean" + return "format_mysql_distance_euclidean_expression" -class MySQLDistanceCosineExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDistanceCosineExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Cosine distance expression. Example: @@ -102,10 +102,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_distance_cosine" + return "format_mysql_distance_cosine_expression" -class MySQLDistanceDotExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLDistanceDotExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL Dot product distance expression. Example: @@ -128,10 +128,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_distance_dot" + return "format_mysql_distance_dot_expression" -class MySQLStringToVectorExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLStringToVectorExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL STRING_TO_VECTOR expression. Converts a string representation to a VECTOR value. @@ -156,10 +156,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_string_to_vector" + return "format_mysql_string_to_vector_expression" -class MySQLVectorToStringExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLVectorToStringExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL VECTOR_TO_STRING expression. Converts a VECTOR value to its string representation. @@ -184,10 +184,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_vector_to_string" + return "format_mysql_vector_to_string_expression" -class MySQLVectorDimExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLVectorDimExpression(AliasableMixin, ComparisonMixin, SQLValue): """MySQL VECTOR_DIM expression. Returns the dimension of a VECTOR value. @@ -212,10 +212,10 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_vector_dim" + return "format_mysql_vector_dim_expression" -class MySQLCreateVectorIndexExpression(AliasableMixin, ComparisonMixin, SQLValueExpression): +class MySQLCreateVectorIndexStatement(AliasableMixin, ComparisonMixin, SQLValue): """MySQL CREATE VECTOR INDEX expression. Args: @@ -244,7 +244,7 @@ def __init__( @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" - return "format_create_vector_index" + return "format_mysql_create_vector_index_statement" __all__ = [ @@ -255,5 +255,5 @@ def format_method(self) -> str: "MySQLStringToVectorExpression", "MySQLVectorToStringExpression", "MySQLVectorDimExpression", - "MySQLCreateVectorIndexExpression", + "MySQLCreateVectorIndexStatement", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py index 15c5abd9..9ed6fb97 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py @@ -7,12 +7,18 @@ Note: MySQL 9.6 does not support BIT_GET_BIT, BIT_SHIFT_LEFT, BIT_SHIFT_RIGHT as functions. These are implemented using native bitwise operators. + +The bitwise symbols are **not** arithmetic: ``ArithmeticOperation`` admits a +closed set of operation words (``add`` / ``sub`` / ``mul`` / ``div`` / ``mod``) +and ``&`` / ``|`` / ``^`` / ``<<`` / ``>>`` are none of them. These are plain +binary operators, so they are written as :class:`BinaryExpression`, which takes +the character the backend spells. """ from typing import Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core, NumericColumn -from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.operators import BinaryExpression if TYPE_CHECKING: # pragma: no cover from ..dialect import MySQLDialect @@ -50,7 +56,7 @@ def bit_and( else core.Literal(dialect, v) if isinstance(v, (int, float)) else NumericColumn(dialect, v) ) - result = BinaryArithmeticExpression(dialect, "&", result, v_expr) + result = BinaryExpression(dialect, "&", result, v_expr) return result @@ -86,7 +92,7 @@ def bit_or( else core.Literal(dialect, v) if isinstance(v, (int, float)) else NumericColumn(dialect, v) ) - result = BinaryArithmeticExpression(dialect, "|", result, v_expr) + result = BinaryExpression(dialect, "|", result, v_expr) return result @@ -122,7 +128,7 @@ def bit_xor( else core.Literal(dialect, v) if isinstance(v, (int, float)) else NumericColumn(dialect, v) ) - result = BinaryArithmeticExpression(dialect, "^", result, v_expr) + result = BinaryExpression(dialect, "^", result, v_expr) return result @@ -182,8 +188,8 @@ def bit_get_bit( else NumericColumn(dialect, bit) ) # (value >> bit) & 1 - shifted = BinaryArithmeticExpression(dialect, ">>", value_expr, bit_expr) - return BinaryArithmeticExpression(dialect, "&", shifted, core.Literal(dialect, 1)) + shifted = BinaryExpression(dialect, ">>", value_expr, bit_expr) + return BinaryExpression(dialect, "&", shifted, core.Literal(dialect, 1)) def bit_shift_left( @@ -217,7 +223,7 @@ def bit_shift_left( else core.Literal(dialect, count) if isinstance(count, (int, float)) else NumericColumn(dialect, count) ) - return BinaryArithmeticExpression(dialect, "<<", value_expr, count_expr) + return BinaryExpression(dialect, "<<", value_expr, count_expr) def bit_shift_right( @@ -251,7 +257,7 @@ def bit_shift_right( else core.Literal(dialect, count) if isinstance(count, (int, float)) else NumericColumn(dialect, count) ) - return BinaryArithmeticExpression(dialect, ">>", value_expr, count_expr) + return BinaryExpression(dialect, ">>", value_expr, count_expr) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py index 60ae0a67..383a94b1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/math_enhanced.py @@ -18,27 +18,58 @@ quoted SQL text. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression.functions import aggregate +from rhosocial.activerecord.backend.expression.functions.math import ( + CeilFunction, + FloorFunction, + ModuloFunction, + PowerFunction, + RoundFunction, + SquareRootFunction, + TruncateFunction, +) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase +def _expression_arg(arg: Any, factory: str) -> "bases.BaseExpression": + """Refuse a bare value where an expression is required. + + A string is a column name in this backend and a value is a ``Literal``, + so the factories take expressions -- and a bare argument used to fail at + render time with an ``AttributeError`` nobody could attribute to the + call. The classed nodes lift bare numbers on their own, but accepting + that here would loosen the factories' documented contract, and the lift + would *parse* a numeric-looking string into the number it spells -- the + exact silent guessing this backend refuses to reintroduce. The refusal + is at construction now, and it names the factory. + """ + if not isinstance(arg, bases.BaseExpression): + raise TypeError( + f"{factory} takes an expression -- a Column (or the " + f"NumericColumn that says the argument is a number) to read a " + f"column, a Literal to write a value -- not {type(arg).__name__}" + ) + return arg + + def round_( dialect: "SQLDialectBase", expr: "bases.BaseExpression", precision: int = 0, -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a ROUND function call. Rounds a numeric value to the specified number of decimal places. Usage: - - round_(dialect, NumericColumn(dialect, "price")) -> ROUND(`price`, 0) - - round_(dialect, NumericColumn(dialect, "price"), 2) -> ROUND(`price`, 2) + - round_(dialect, NumericColumn(dialect, "price")) -> ROUND(`price`, ?) + - round_(dialect, NumericColumn(dialect, "price"), 2) -> ROUND(`price`, ?) Args: dialect: The SQL dialect instance @@ -46,27 +77,28 @@ def round_( precision: Number of decimal places (default 0) Returns: - A FunctionCall instance representing the ROUND function + A ``RoundFunction`` node -- ``RoundIntegerFunction`` when the operand + is an integer column, because ROUND keeps a whole number whole Version: All MySQL versions """ - precision_expr = core.Literal(dialect, precision) - return core.FunctionCall(dialect, "ROUND", expr, precision_expr) + args = [_expression_arg(expr, "round_"), core.Literal(dialect, precision)] + return RoundFunction.for_operand(dialect, type(expr), *args) def pow( dialect: "SQLDialectBase", base: "bases.BaseExpression", exponent: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> PowerFunction: """ - Creates a POW function call. + Creates a POWER function call. Returns the value of base raised to the power of exponent. Usage: - - pow(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POW(2, 3) - - pow(dialect, NumericColumn(dialect, "x"), Literal(dialect, 2)) -> POW(`x`, 2) + - pow(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POWER(?, ?) + - pow(dialect, NumericColumn(dialect, "x"), Literal(dialect, 2)) -> POWER(`x`, ?) Args: dialect: The SQL dialect instance @@ -74,25 +106,32 @@ def pow( exponent: The exponent expression Returns: - A FunctionCall instance representing the POW function + A ``PowerFunction`` node. The classed entry point spells ``POWER``, + which renders where ``POW`` used to; the two are synonyms on MySQL + (both answer the same value), and the classed node carries the + standard spelling. Version: All MySQL versions """ - return core.FunctionCall(dialect, "POW", base, exponent) + return PowerFunction( + dialect, + _expression_arg(base, "pow"), + _expression_arg(exponent, "pow"), + ) def power( dialect: "SQLDialectBase", base: "bases.BaseExpression", exponent: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> PowerFunction: """ Creates a POWER function call (alias for POW). Returns the value of base raised to the power of exponent. Usage: - - power(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POWER(2, 3) + - power(dialect, Literal(dialect, 2), Literal(dialect, 3)) -> POWER(?, ?) - power(dialect, NumericColumn(dialect, "x"), NumericColumn(dialect, "y")) -> POWER(`x`, `y`) @@ -102,24 +141,28 @@ def power( exponent: The exponent expression Returns: - A FunctionCall instance representing the POWER function + A ``PowerFunction`` node Version: All MySQL versions """ - return core.FunctionCall(dialect, "POWER", base, exponent) + return PowerFunction( + dialect, + _expression_arg(base, "power"), + _expression_arg(exponent, "power"), + ) def sqrt( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> SquareRootFunction: """ Creates a SQRT function call. Returns the square root of the argument. Usage: - - sqrt(dialect, Literal(dialect, 16)) -> SQRT(16) + - sqrt(dialect, Literal(dialect, 16)) -> SQRT(?) - sqrt(dialect, NumericColumn(dialect, "value")) -> SQRT(`value`) Args: @@ -127,27 +170,27 @@ def sqrt( expr: The numeric expression Returns: - A FunctionCall instance representing the SQRT function + A ``SquareRootFunction`` node Version: All MySQL versions """ - return core.FunctionCall(dialect, "SQRT", expr) + return SquareRootFunction(dialect, _expression_arg(expr, "sqrt")) def mod( dialect: "SQLDialectBase", dividend: "bases.BaseExpression", divisor: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> ModuloFunction: """ Creates a MOD function call. Returns the remainder of dividend divided by divisor. Usage: - - mod(dialect, Literal(dialect, 10), Literal(dialect, 3)) -> MOD(10, 3) + - mod(dialect, Literal(dialect, 10), Literal(dialect, 3)) -> MOD(?, ?) - mod(dialect, NumericColumn(dialect, "total"), Literal(dialect, 10)) - -> MOD(`total`, 10) + -> MOD(`total`, ?) Args: dialect: The SQL dialect instance @@ -155,24 +198,28 @@ def mod( divisor: The divisor expression Returns: - A FunctionCall instance representing the MOD function + A ``ModuloFunction`` node Version: All MySQL versions """ - return core.FunctionCall(dialect, "MOD", dividend, divisor) + return ModuloFunction( + dialect, + _expression_arg(dividend, "mod"), + _expression_arg(divisor, "mod"), + ) def ceil( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a CEIL function call. Returns the smallest integer value not less than the argument. Usage: - - ceil(dialect, Literal(dialect, 3.14)) -> CEIL(3.14) + - ceil(dialect, Literal(dialect, 3.14)) -> CEIL(?) - ceil(dialect, NumericColumn(dialect, "price")) -> CEIL(`price`) Args: @@ -180,24 +227,27 @@ def ceil( expr: The numeric expression Returns: - A FunctionCall instance representing the CEIL function + A ``CeilFunction`` node -- ``CeilIntegerFunction`` when the operand + is an integer column, because CEIL keeps a whole number whole Version: All MySQL versions (CEILING is also available as alias) """ - return core.FunctionCall(dialect, "CEIL", expr) + return CeilFunction.for_operand( + dialect, type(expr), _expression_arg(expr, "ceil") + ) def floor( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a FLOOR function call. Returns the largest integer value not greater than the argument. Usage: - - floor(dialect, Literal(dialect, 3.14)) -> FLOOR(3.14) + - floor(dialect, Literal(dialect, 3.14)) -> FLOOR(?) - floor(dialect, NumericColumn(dialect, "price")) -> FLOOR(`price`) Args: @@ -205,27 +255,30 @@ def floor( expr: The numeric expression Returns: - A FunctionCall instance representing the FLOOR function + A ``FloorFunction`` node -- ``FloorIntegerFunction`` when the operand + is an integer column, because FLOOR keeps a whole number whole Version: All MySQL versions """ - return core.FunctionCall(dialect, "FLOOR", expr) + return FloorFunction.for_operand( + dialect, type(expr), _expression_arg(expr, "floor") + ) def trunc( dialect: "SQLDialectBase", expr: "bases.BaseExpression", precision: int = 0, -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a TRUNCATE function call. Returns the value truncated to the specified number of decimal places. Usage: - - trunc(dialect, Literal(dialect, 3.14)) -> TRUNCATE(3.14, 0) - - trunc(dialect, Literal(dialect, 3.14), 2) -> TRUNCATE(3.14, 2) - - trunc(dialect, NumericColumn(dialect, "price"), 2) -> TRUNCATE(`price`, 2) + - trunc(dialect, Literal(dialect, 3.14)) -> TRUNCATE(?, ?) + - trunc(dialect, Literal(dialect, 3.14), 2) -> TRUNCATE(?, ?) + - trunc(dialect, NumericColumn(dialect, "price"), 2) -> TRUNCATE(`price`, ?) Note: MySQL uses TRUNCATE, not TRUNC (which is used by some other databases). @@ -236,18 +289,20 @@ def trunc( precision: Number of decimal places (default 0) Returns: - A FunctionCall instance representing the TRUNCATE function + A ``TruncateFunction`` node -- ``TruncateIntegerFunction`` when the + operand is an integer column, because TRUNCATE keeps a whole number + whole Version: All MySQL versions """ - precision_expr = core.Literal(dialect, precision) - return core.FunctionCall(dialect, "TRUNCATE", expr, precision_expr) + args = [_expression_arg(expr, "trunc"), core.Literal(dialect, precision)] + return TruncateFunction.for_operand(dialect, type(expr), *args) def max_( dialect: "SQLDialectBase", *args: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a GREATEST or MAX function call. @@ -262,24 +317,28 @@ def max_( When called with a single argument, uses MAX (aggregate). When called with multiple arguments, uses GREATEST (scalar). + Only the aggregate half is a classed node: MAX is a standard function, so it + is a ``MaxFunction``. GREATEST is a MySQL scalar with no standard node of + its own and stays on the generic call -- the escape hatch. + Args: dialect: The SQL dialect instance *args: The expressions to compare Returns: - A FunctionCall instance representing GREATEST or MAX + A MaxFunction for one argument, a FunctionCall for several Version: All MySQL versions """ if len(args) == 1: - return core.FunctionCall(dialect, "MAX", args[0]) + return aggregate.MaxFunction(dialect, args[0]) return core.FunctionCall(dialect, "GREATEST", *args) def min_( dialect: "SQLDialectBase", *args: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "bases.BaseExpression": """ Creates a LEAST or MIN function call. @@ -294,24 +353,27 @@ def min_( When called with a single argument, uses MIN (aggregate). When called with multiple arguments, uses LEAST (scalar). + The aggregate half is a ``MinFunction`` and the scalar half stays on the + generic call, for the reason given on :func:`max_`. + Args: dialect: The SQL dialect instance *args: The expressions to compare Returns: - A FunctionCall instance representing LEAST or MIN + A MinFunction for one argument, a FunctionCall for several Version: All MySQL versions """ if len(args) == 1: - return core.FunctionCall(dialect, "MIN", args[0]) + return aggregate.MinFunction(dialect, args[0]) return core.FunctionCall(dialect, "LEAST", *args) def avg( dialect: "SQLDialectBase", expr: "bases.BaseExpression", -) -> "core.FunctionCall": +) -> "aggregate.AvgFunction": """ Creates an AVG function call. @@ -325,11 +387,11 @@ def avg( expr: The numeric expression Returns: - A FunctionCall instance representing the AVG function + An AvgFunction node Version: All MySQL versions """ - return core.FunctionCall(dialect, "AVG", expr) + return aggregate.AvgFunction(dialect, expr) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py index e38c84f4..d61dfd84 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/__init__.py @@ -9,12 +9,15 @@ AsyncShowIntrospector — asynchronous MySQL-specific SHOW command sub-introspector SyncMySQLStatusIntrospector — synchronous MySQL status introspector AsyncMySQLStatusIntrospector -- asynchronous MySQL status introspector + MySQLTableOptions — storage options of one table + MySQLPartitionInfo — one partition (or subpartition) of one table """ from .introspector import ( SyncMySQLIntrospector, AsyncMySQLIntrospector, ) +from .types import MySQLPartitionInfo, MySQLTableOptions from .show_introspector import ( SyncShowIntrospector, AsyncShowIntrospector, @@ -31,4 +34,6 @@ "AsyncShowIntrospector", "SyncMySQLStatusIntrospector", "AsyncMySQLStatusIntrospector", + "MySQLTableOptions", + "MySQLPartitionInfo", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py index b6d2cef3..f9e6ff2a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/introspector.py @@ -12,12 +12,14 @@ - SQL generation: Delegated to MySQLIntrospectionMixin.format_*_query() methods in the Dialect layer via Expression.to_sql() - Query execution: Handled by IntrospectorExecutor - - Result parsing: _parse_* methods in this module (pure Python, no I/O) + - Result parsing: parse_* methods in this module (pure Python, no I/O) Key behaviours: - Queries information_schema.TABLES, COLUMNS, STATISTICS, KEY_COLUMN_USAGE, REFERENTIAL_CONSTRAINTS, VIEWS, TRIGGERS - - _parse_* methods are pure Python — shared by sync and async introspectors + - MySQL-only families: ``list_table_options`` / ``get_table_options`` + (storage options from TABLES) and ``list_partitions`` (from PARTITIONS) + - parse_* methods are pure Python — shared by sync and async introspectors Design principle: Sync and Async are separate and cannot coexist. - SyncMySQLIntrospector: for synchronous backends @@ -25,33 +27,42 @@ """ import copy -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +if TYPE_CHECKING: # pragma: no cover + from ..expression.introspection import ( + MySQLPartitionListQueryStatement, + MySQLTableOptionsInfoQueryStatement, + MySQLTableOptionsListQueryStatement, + ) from rhosocial.activerecord.backend.introspection.base import ( IntrospectorMixin, SyncAbstractIntrospector, AsyncAbstractIntrospector, ) +from rhosocial.activerecord.backend.introspection.cache import IntrospectionCache from rhosocial.activerecord.backend.introspection.executor import ( SyncIntrospectorExecutor, AsyncIntrospectorExecutor, ) from rhosocial.activerecord.backend.introspection.types import ( - DatabaseInfo, - TableInfo, + IntrospectionDatabaseInfo, + IntrospectionTableInfo, TableType, - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, IndexInfo, IndexColumnInfo, IndexType, - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, - ViewInfo, - TriggerInfo, + IntrospectionViewInfo, + IntrospectionTriggerInfo, IntrospectionScope, ) from rhosocial.activerecord.backend.expression.types import DataType +from .types import MySQLPartitionInfo, MySQLTableOptions from .show_introspector import ( SyncShowIntrospector, AsyncShowIntrospector, @@ -68,45 +79,54 @@ class MySQLIntrospectorMixin(IntrospectorMixin): Both SyncMySQLIntrospector and AsyncMySQLIntrospector inherit from this mixin to share: - Default schema handling - - MySQL version detection - - _parse_* implementations + - The version a database record reports (``server_version``) + - parse_* implementations SQL generation is delegated to the Dialect layer via Expression.to_sql() which calls MySQLIntrospectionMixin.format_*_query() methods. """ - def _get_default_schema(self) -> str: + @property + def default_schema(self) -> str: """Return the MySQL database name from the backend config.""" if hasattr(self._backend, "config") and hasattr(self._backend.config, "database"): return self._backend.config.database or "" return "" - def _get_version(self) -> tuple: - """Return the MySQL server version tuple from the backend.""" - return getattr(self._backend, "_version", (8, 0, 0)) - # ------------------------------------------------------------------ # # Parse methods — pure Python, no I/O # ------------------------------------------------------------------ # - def _parse_database_info(self, rows: List[Dict[str, Any]]) -> DatabaseInfo: - version = self._get_version() - version_str = ".".join(str(v) for v in version) - db_name = self._get_default_schema() - + def parse_database_info(self, rows: List[Dict[str, Any]]) -> IntrospectionDatabaseInfo: + """Turn the schema row into a database record. + + The version comes from :attr:`server_version`, not from the strict + ``dialect.version`` or a backend private: a parser has no connection + to ask, and refusing to describe a database until + ``introspect_and_adapt()`` has run would make the answer depend on a + step the caller did not need. An unresolved version is reported as + the literal ``"unknown"`` with ``(0, 0, 0)`` -- distinguishable from + a server that really is 0.0.0 -- rather than a fabricated ``8.0.0``. + + ``encoding`` and ``collation`` are the two columns the statement + selects. ``table_count`` and ``size_bytes`` are not among them: they + live in ``information_schema.TABLES``, so they stay ``None`` rather + than being guessed here. + """ + version = self.server_version db_row = rows[0] if rows else {} - return DatabaseInfo( - name=db_name, - version=version_str, - version_tuple=version, + return IntrospectionDatabaseInfo( + name=self.default_schema, + version=".".join(str(part) for part in version) if version else "unknown", + version_tuple=tuple(version) if version else (0, 0, 0), vendor="MySQL", encoding=db_row.get("DEFAULT_CHARACTER_SET_NAME"), collation=db_row.get("DEFAULT_COLLATION_NAME"), ) - def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[TableInfo]: - target_db = schema if schema is not None else self._get_default_schema() + def parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> List[IntrospectionTableInfo]: + target_db = schema if schema is not None else self.default_schema table_type_map = { "BASE TABLE": TableType.BASE_TABLE, "VIEW": TableType.VIEW, @@ -116,7 +136,7 @@ def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> Li for row in rows: t_type = table_type_map.get(row.get("TABLE_TYPE", "BASE TABLE"), TableType.BASE_TABLE) tables.append( - TableInfo( + IntrospectionTableInfo( name=row["TABLE_NAME"], schema=target_db, table_type=t_type, @@ -130,19 +150,19 @@ def _parse_tables(self, rows: List[Dict[str, Any]], schema: Optional[str]) -> Li ) return tables - def _parse_columns( + def parse_columns( self, rows: List[Dict[str, Any]], table_name: str, schema: str, - ) -> List[ColumnInfo]: + ) -> List[IntrospectionColumnInfo]: columns = [] for row in rows: nullable = ColumnNullable.NULLABLE if row.get("IS_NULLABLE") == "YES" else ColumnNullable.NOT_NULL col_type = row.get("COLUMN_TYPE") or row.get("DATA_TYPE") or "VARCHAR" parsed = DataType.parse_data_type_str(self._backend.dialect, col_type) columns.append( - ColumnInfo( + IntrospectionColumnInfo( name=row["COLUMN_NAME"], table_name=table_name, schema=schema, @@ -165,7 +185,7 @@ def _parse_columns( ) return columns - def _parse_indexes( + def parse_indexes( self, rows: List[Dict[str, Any]], table_name: str, @@ -201,12 +221,12 @@ def _parse_indexes( ) return list(index_map.values()) - def _parse_foreign_keys( + def parse_foreign_keys( self, rows: List[Dict[str, Any]], table_name: str, schema: str, - ) -> List[ForeignKeyInfo]: + ) -> List[IntrospectionForeignKeyInfo]: action_map = { "NO_ACTION": ReferentialAction.NO_ACTION, "NO ACTION": ReferentialAction.NO_ACTION, @@ -217,13 +237,13 @@ def _parse_foreign_keys( "SET_DEFAULT": ReferentialAction.SET_DEFAULT, "SET DEFAULT": ReferentialAction.SET_DEFAULT, } - fk_map: Dict[str, ForeignKeyInfo] = {} + fk_map: Dict[str, IntrospectionForeignKeyInfo] = {} for row in rows: fk_name = row.get("CONSTRAINT_NAME", "") if fk_name not in fk_map: on_update_raw = (row.get("UPDATE_RULE") or "NO ACTION").upper() on_delete_raw = (row.get("DELETE_RULE") or "NO ACTION").upper() - fk_map[fk_name] = ForeignKeyInfo( + fk_map[fk_name] = IntrospectionForeignKeyInfo( name=fk_name, table_name=table_name, schema=schema, @@ -237,9 +257,9 @@ def _parse_foreign_keys( fk_map[fk_name].referenced_columns.append(row.get("REFERENCED_COLUMN_NAME", "")) return list(fk_map.values()) - def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[ViewInfo]: + def parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionViewInfo]: return [ - ViewInfo( + IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", "")), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -249,16 +269,16 @@ def _parse_views(self, rows: List[Dict[str, Any]], schema: str) -> List[ViewInfo for row in rows ] - def _parse_view_info( + def parse_view_info( self, rows: List[Dict[str, Any]], view_name: str, schema: str, - ) -> Optional[ViewInfo]: + ) -> Optional[IntrospectionViewInfo]: if not rows: return None row = rows[0] - return ViewInfo( + return IntrospectionViewInfo( name=row.get("TABLE_NAME", row.get("VIEW_NAME", view_name)), schema=schema, definition=row.get("VIEW_DEFINITION"), @@ -266,9 +286,9 @@ def _parse_view_info( is_updatable=row.get("IS_UPDATABLE") == "YES", ) - def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[TriggerInfo]: + def parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[IntrospectionTriggerInfo]: return [ - TriggerInfo( + IntrospectionTriggerInfo( name=row.get("TRIGGER_NAME", ""), table_name=row.get("EVENT_OBJECT_TABLE", ""), schema=schema, @@ -279,6 +299,101 @@ def _parse_triggers(self, rows: List[Dict[str, Any]], schema: str) -> List[Trigg for row in rows ] + # ------------------------------------------------------------------ # + # Table storage options and partitions — statement factories + # + # Neither family has a scope of its own in IntrospectionScope, and + # neither is the same question as "what tables are there". Both are + # therefore asked in IntrospectionScope.TABLE and kept apart from the + # table list (and from each other) by a distinct ``extra`` namespace, + # which is exactly what ``extra`` exists for. + # + # Each factory returns a statement object and renders nothing: the + # dialect's ``format_*_query_statement`` decides the SQL, so nothing + # here spells it and no helper is private. + # ------------------------------------------------------------------ # + + def table_options_list_statement( + self, *, schema: Optional[str] + ) -> "MySQLTableOptionsListQueryStatement": + """Return the statement that lists every base table's storage options. + + ``DATA_LENGTH`` and ``INDEX_LENGTH`` are reported in bytes; see + :class:`MySQLTableOptions` for the caveat about InnoDB estimates. + """ + from ..expression.introspection import MySQLTableOptionsListQueryStatement + + target_db = schema if schema is not None else self.default_schema + return MySQLTableOptionsListQueryStatement(self.dialect, schema=target_db) + + def table_options_info_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "MySQLTableOptionsInfoQueryStatement": + """Return the statement that reads one base table's storage options.""" + from ..expression.introspection import MySQLTableOptionsInfoQueryStatement + + target_db = schema if schema is not None else self.default_schema + return MySQLTableOptionsInfoQueryStatement( + self.dialect, table_name=table_name, schema=target_db + ) + + def partition_list_statement( + self, *, table_name: str, schema: Optional[str] + ) -> "MySQLPartitionListQueryStatement": + """Return the statement that lists one table's partitions. + + Rows without a ``PARTITION_NAME`` describe a non-partitioned table + and are not partitions, so the predicate excludes them rather than + filtering after the fact: an empty result then means exactly "this + table is not partitioned". + """ + from ..expression.introspection import MySQLPartitionListQueryStatement + + target_db = schema if schema is not None else self.default_schema + return MySQLPartitionListQueryStatement( + self.dialect, table_name=table_name, schema=target_db + ) + + # ------------------------------------------------------------------ # + # Table storage options and partitions — parsing + # ------------------------------------------------------------------ # + + def parse_table_options(self, rows: List[Dict[str, Any]]) -> List[MySQLTableOptions]: + return [ + MySQLTableOptions( + schema=row.get("TABLE_SCHEMA", ""), + table_name=row["TABLE_NAME"], + engine=row.get("ENGINE"), + row_format=row.get("ROW_FORMAT"), + table_collation=row.get("TABLE_COLLATION"), + auto_increment=row.get("AUTO_INCREMENT"), + table_comment=row.get("TABLE_COMMENT"), + create_options=row.get("CREATE_OPTIONS"), + data_length=row.get("DATA_LENGTH"), + index_length=row.get("INDEX_LENGTH"), + ) + for row in rows + ] + + def parse_partitions(self, rows: List[Dict[str, Any]]) -> List[MySQLPartitionInfo]: + return [ + MySQLPartitionInfo( + schema=row.get("TABLE_SCHEMA", ""), + table_name=row.get("TABLE_NAME", ""), + partition_name=row.get("PARTITION_NAME"), + partition_method=row.get("PARTITION_METHOD"), + partition_expression=row.get("PARTITION_EXPRESSION"), + partition_description=row.get("PARTITION_DESCRIPTION"), + partition_ordinal_position=row.get("PARTITION_ORDINAL_POSITION"), + subpartition_name=row.get("SUBPARTITION_NAME"), + subpartition_ordinal_position=row.get("SUBPARTITION_ORDINAL_POSITION"), + table_rows=row.get("TABLE_ROWS"), + data_length=row.get("DATA_LENGTH"), + index_length=row.get("INDEX_LENGTH"), + ) + for row in rows + ] + class SyncMySQLIntrospector(MySQLIntrospectorMixin, SyncAbstractIntrospector): """Synchronous introspector for MySQL backends. @@ -294,8 +409,13 @@ class SyncMySQLIntrospector(MySQLIntrospectorMixin, SyncAbstractIntrospector): variables = backend.introspector.show.variables(like="max_%") """ - def __init__(self, backend: Any, executor: SyncIntrospectorExecutor) -> None: - super().__init__(backend, executor) + def __init__( + self, + backend: Any, + executor: SyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) self._show_instance: Optional[SyncShowIntrospector] = None self._status_instance: Optional[SyncMySQLStatusIntrospector] = None @@ -317,9 +437,9 @@ def status(self) -> SyncMySQLStatusIntrospector: # get_table_info override # ------------------------------------------------------------------ # - def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[TableInfo]: - key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self._get_cached(key) + def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -332,9 +452,73 @@ def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optio table.columns = self.list_columns(table_name, schema) table.indexes = self.list_indexes(table_name, schema) table.foreign_keys = self.list_foreign_keys(table_name, schema) - self._set_cached(key, table) + self._cache.set(key, table) return table + # ------------------------------------------------------------------ # + # MySQL-specific: table storage options and partitions + # ------------------------------------------------------------------ # + + def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTableOptions]: + """List storage options for every base table in *schema*. + + Answers from ``information_schema.TABLES``: engine, row format, + collation, auto-increment counter, comment, extra create options + and the server's data/index byte accounting. + """ + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.table_options_list_statement(schema=schema).to_sql() + result = self.parse_table_options(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + def get_table_options( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[MySQLTableOptions]: + """Return storage options for one base table, or ``None``. + + ``None`` covers both "no such table" and "that name is a view": + a view has no storage engine, and reporting a half-empty record + as if it were a table would be worse than saying nothing. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.table_options_info_statement(table_name=table_name, schema=schema).to_sql() + result = next(iter(self.parse_table_options(self._executor.execute(sql, params))), None) + if result is not None: + self._cache.set(key, result) + return result + + def list_partitions( + self, table_name: str, schema: Optional[str] = None + ) -> List[MySQLPartitionInfo]: + """List the partitions of one table, from ``information_schema.PARTITIONS``. + + Returns an empty list for a table that is not partitioned. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="partitions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.partition_list_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_partitions(self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + class AsyncMySQLIntrospector(MySQLIntrospectorMixin, AsyncAbstractIntrospector): """Asynchronous introspector for MySQL backends. @@ -350,8 +534,13 @@ class AsyncMySQLIntrospector(MySQLIntrospectorMixin, AsyncAbstractIntrospector): variables = await backend.introspector.show.variables(like="max_%") """ - def __init__(self, backend: Any, executor: AsyncIntrospectorExecutor) -> None: - super().__init__(backend, executor) + def __init__( + self, + backend: Any, + executor: AsyncIntrospectorExecutor, + cache: Optional[IntrospectionCache] = None, + ) -> None: + super().__init__(backend, executor, cache) self._show_instance: Optional[AsyncShowIntrospector] = None self._status_instance: Optional[AsyncMySQLStatusIntrospector] = None @@ -373,9 +562,9 @@ def status(self) -> AsyncMySQLStatusIntrospector: # get_table_info override # ------------------------------------------------------------------ # - async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[TableInfo]: - key = self._make_cache_key(IntrospectionScope.TABLE, table_name, schema=schema) - cached = self._get_cached(key) + async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> Optional[IntrospectionTableInfo]: + key = self._cache.key(IntrospectionScope.TABLE, table_name, schema=schema) + cached = self._cache.get(key) if cached is not None: return cached @@ -388,5 +577,70 @@ async def get_table_info(self, table_name: str, schema: Optional[str] = None) -> table.columns = await self.list_columns(table_name, schema) table.indexes = await self.list_indexes(table_name, schema) table.foreign_keys = await self.list_foreign_keys(table_name, schema) - self._set_cached(key, table) + self._cache.set(key, table) return table + + # ------------------------------------------------------------------ # + # MySQL-specific: table storage options and partitions + # ------------------------------------------------------------------ # + + async def list_table_options(self, schema: Optional[str] = None) -> List[MySQLTableOptions]: + """List storage options for every base table in *schema*. + + Answers from ``information_schema.TABLES``: engine, row format, + collation, auto-increment counter, comment, extra create options + and the server's data/index byte accounting. + """ + key = self._cache.key( + IntrospectionScope.TABLE, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.table_options_list_statement(schema=schema).to_sql() + result = self.parse_table_options(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result + + async def get_table_options( + self, table_name: str, schema: Optional[str] = None + ) -> Optional[MySQLTableOptions]: + """Return storage options for one base table, or ``None``. + + ``None`` covers both "no such table" and "that name is a view": + a view has no storage engine, and reporting a half-empty record + as if it were a table would be worse than saying nothing. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="table_options" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.table_options_info_statement(table_name=table_name, schema=schema).to_sql() + rows = await self._executor.execute(sql, params) + result = next(iter(self.parse_table_options(rows)), None) + if result is not None: + self._cache.set(key, result) + return result + + async def list_partitions( + self, table_name: str, schema: Optional[str] = None + ) -> List[MySQLPartitionInfo]: + """List the partitions of one table, from ``information_schema.PARTITIONS``. + + Returns an empty list for a table that is not partitioned. + """ + key = self._cache.key( + IntrospectionScope.TABLE, table_name, schema=schema, extra="partitions" + ) + cached = self._cache.get(key) + if cached is not None: + return cached + + sql, params = self.partition_list_statement(table_name=table_name, schema=schema).to_sql() + result = self.parse_partitions(await self._executor.execute(sql, params)) + self._cache.set(key, result) + return result diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py index ca35ca1e..4c1d70dd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/show_introspector.py @@ -18,25 +18,25 @@ AsyncIntrospectorExecutor, ) from ..expression.show import ( - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) @@ -47,7 +47,7 @@ class ShowMixin: from this mixin to share: - Dialect access - SQL generation helpers - - _parse_* static methods + - parse_* static methods """ @property @@ -60,7 +60,7 @@ def dialect(self): # ------------------------------------------------------------------ # @staticmethod - def _parse_create_table(rows: List[Dict], table_name: str): + def parse_create_table(rows: List[Dict], table_name: str): from ..show.types import ShowCreateTableResult if not rows: @@ -72,7 +72,7 @@ def _parse_create_table(rows: List[Dict], table_name: str): ) @staticmethod - def _parse_create_view(rows: List[Dict], view_name: str): + def parse_create_view(rows: List[Dict], view_name: str): from ..show.types import ShowCreateViewResult if not rows: @@ -86,7 +86,7 @@ def _parse_create_view(rows: List[Dict], view_name: str): ) @staticmethod - def _parse_columns(rows: List[Dict]): + def parse_columns(rows: List[Dict]): from ..show.types import ShowColumnResult columns = [] @@ -106,7 +106,7 @@ def _parse_columns(rows: List[Dict]): return columns @staticmethod - def _parse_indexes(rows: List[Dict]): + def parse_indexes(rows: List[Dict]): from ..show.types import ShowIndexResult return [ @@ -121,7 +121,7 @@ def _parse_indexes(rows: List[Dict]): sub_part=row.get("Sub_part", row.get("SUB_PART")), packed=row.get("Packed", row.get("PACKED")), null=row.get("Null", row.get("NULLABLE")), - index_type=row.get("Index_type", row.get("INDEX_TYPE"), "BTREE"), + index_type=row.get("Index_type") or row.get("INDEX_TYPE") or "BTREE", comment=row.get("Comment", row.get("INDEX_COMMENT")), index_comment=row.get("Index_comment", row.get("INDEX_COMMENT")), visible=row.get("Visible", row.get("IS_VISIBLE")), @@ -131,7 +131,7 @@ def _parse_indexes(rows: List[Dict]): ] @staticmethod - def _parse_tables(rows: List[Dict]): + def parse_tables(rows: List[Dict]): from ..show.types import ShowTableResult result = [] @@ -145,13 +145,13 @@ def _parse_tables(rows: List[Dict]): return result @staticmethod - def _parse_databases(rows: List[Dict]): + def parse_databases(rows: List[Dict]): from ..show.types import ShowDatabaseResult return [ShowDatabaseResult(name=row.get("Database")) for row in rows] @staticmethod - def _parse_table_status(rows: List[Dict]): + def parse_table_status(rows: List[Dict]): from ..show.types import ShowTableStatusResult return [ @@ -179,7 +179,7 @@ def _parse_table_status(rows: List[Dict]): ] @staticmethod - def _parse_triggers(rows: List[Dict]): + def parse_triggers(rows: List[Dict]): from ..show.types import ShowTriggerResult return [ @@ -200,7 +200,7 @@ def _parse_triggers(rows: List[Dict]): ] @staticmethod - def _parse_create_trigger(rows: List[Dict], trigger_name: str): + def parse_create_trigger(rows: List[Dict], trigger_name: str): from ..show.types import ShowCreateTriggerResult if not rows: @@ -215,19 +215,19 @@ def _parse_create_trigger(rows: List[Dict], trigger_name: str): ) @staticmethod - def _parse_variables(rows: List[Dict]): + def parse_variables(rows: List[Dict]): from ..show.types import ShowVariableResult return [ShowVariableResult(variable_name=row.get("Variable_name"), value=row.get("Value")) for row in rows] @staticmethod - def _parse_status(rows: List[Dict]): + def parse_status(rows: List[Dict]): from ..show.types import ShowStatusResult return [ShowStatusResult(variable_name=row.get("Variable_name"), value=row.get("Value")) for row in rows] @staticmethod - def _parse_processlist(rows: List[Dict]): + def parse_processlist(rows: List[Dict]): from ..show.types import ShowProcessListResult return [ @@ -245,7 +245,7 @@ def _parse_processlist(rows: List[Dict]): ] @staticmethod - def _parse_warnings(rows: List[Dict]): + def parse_warnings(rows: List[Dict]): from ..show.types import ShowWarningResult return [ @@ -253,7 +253,7 @@ def _parse_warnings(rows: List[Dict]): ] @staticmethod - def _parse_engines(rows: List[Dict]): + def parse_engines(rows: List[Dict]): from ..show.types import ShowEngineResult return [ @@ -268,7 +268,7 @@ def _parse_engines(rows: List[Dict]): ] @staticmethod - def _parse_charset(rows: List[Dict]): + def parse_charset(rows: List[Dict]): from ..show.types import ShowCharsetResult return [ @@ -282,7 +282,7 @@ def _parse_charset(rows: List[Dict]): ] @staticmethod - def _parse_collation(rows: List[Dict]): + def parse_collation(rows: List[Dict]): from ..show.types import ShowCollationResult return [ @@ -298,13 +298,13 @@ def _parse_collation(rows: List[Dict]): ] @staticmethod - def _parse_grants(rows: List[Dict]): + def parse_grants(rows: List[Dict]): from ..show.types import ShowGrantResult return [ShowGrantResult(grants=row.get("Grants for")) for row in rows] @staticmethod - def _parse_plugins(rows: List[Dict]): + def parse_plugins(rows: List[Dict]): from ..show.types import ShowPluginResult return [ @@ -345,19 +345,15 @@ def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_table(self._exec(sql, params), table_name) + return self.parse_create_table(self._exec(sql, params), table_name) def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_view(self._exec(sql, params), view_name) + return self.parse_create_view(self._exec(sql, params), view_name) def columns( self, @@ -367,23 +363,17 @@ def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) + expr = ShowColumnsStatement( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() - return self._parse_columns(self._exec(sql, params)) + return self.parse_columns(self._exec(sql, params)) def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_indexes(self._exec(sql, params)) + return self.parse_indexes(self._exec(sql, params)) def tables( self, @@ -392,131 +382,93 @@ def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() - return self._parse_tables(self._exec(sql, params)) + return self.parse_tables(self._exec(sql, params)) def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_databases(self._exec(sql, params)) + return self.parse_databases(self._exec(sql, params)) def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() - return self._parse_table_status(self._exec(sql, params)) + return self.parse_table_status(self._exec(sql, params)) def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.schema(schema) - if table_name: - expr.for_table(table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() - return self._parse_triggers(self._exec(sql, params)) + return self.parse_triggers(self._exec(sql, params)) def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.schema(schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_trigger(self._exec(sql, params), trigger_name) + return self.parse_create_trigger(self._exec(sql, params), trigger_name) def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_vars() + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_variables(self._exec(sql, params)) + return self.parse_variables(self._exec(sql, params)) def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_status() + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_status(self._exec(sql, params)) + return self.parse_status(self._exec(sql, params)) def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() - return self._parse_processlist(self._exec(sql, params)) + return self.parse_processlist(self._exec(sql, params)) def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(self._exec(sql, params)) + return self.parse_warnings(self._exec(sql, params)) def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(self._exec(sql, params)) + return self.parse_warnings(self._exec(sql, params)) def engines(self): """Show storage engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_engines(self._exec(sql, params)) + return self.parse_engines(self._exec(sql, params)) def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_charset(self._exec(sql, params)) + return self.parse_charset(self._exec(sql, params)) def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_collation(self._exec(sql, params)) + return self.parse_collation(self._exec(sql, params)) def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() - return self._parse_grants(self._exec(sql, params)) + return self.parse_grants(self._exec(sql, params)) def plugins(self): """Show plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_plugins(self._exec(sql, params)) + return self.parse_plugins(self._exec(sql, params)) class AsyncShowIntrospector(ShowMixin): @@ -546,19 +498,15 @@ async def _exec(self, sql: str, params: tuple) -> List[Dict[str, Any]]: async def create_table(self, table_name: str, schema: Optional[str] = None): """Get CREATE TABLE statement for a table.""" - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_table(await self._exec(sql, params), table_name) + return self.parse_create_table(await self._exec(sql, params), table_name) async def create_view(self, view_name: str, schema: Optional[str] = None): """Get CREATE VIEW statement for a view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_view(await self._exec(sql, params), view_name) + return self.parse_create_view(await self._exec(sql, params), view_name) async def columns( self, @@ -568,23 +516,17 @@ async def columns( like: Optional[str] = None, ): """Get column information for a table.""" - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) + expr = ShowColumnsStatement( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() - return self._parse_columns(await self._exec(sql, params)) + return self.parse_columns(await self._exec(sql, params)) async def indexes(self, table_name: str, schema: Optional[str] = None): """Get index information for a table.""" - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() - return self._parse_indexes(await self._exec(sql, params)) + return self.parse_indexes(await self._exec(sql, params)) async def tables( self, @@ -593,128 +535,90 @@ async def tables( full: bool = False, ): """List tables in the database.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() - return self._parse_tables(await self._exec(sql, params)) + return self.parse_tables(await self._exec(sql, params)) async def databases(self, like: Optional[str] = None): """List databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_databases(await self._exec(sql, params)) + return self.parse_databases(await self._exec(sql, params)) async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Get table status information.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() - return self._parse_table_status(await self._exec(sql, params)) + return self.parse_table_status(await self._exec(sql, params)) async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """List triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.schema(schema) - if table_name: - expr.for_table(table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() - return self._parse_triggers(await self._exec(sql, params)) + return self.parse_triggers(await self._exec(sql, params)) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Get CREATE TRIGGER statement.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.schema(schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() - return self._parse_create_trigger(await self._exec(sql, params), trigger_name) + return self.parse_create_trigger(await self._exec(sql, params), trigger_name) async def variables(self, like: Optional[str] = None, session: bool = True): """Show server variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_vars() + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_variables(await self._exec(sql, params)) + return self.parse_variables(await self._exec(sql, params)) async def status(self, like: Optional[str] = None, session: bool = True): """Show server status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_status() + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() - return self._parse_status(await self._exec(sql, params)) + return self.parse_status(await self._exec(sql, params)) async def processlist(self, full: bool = False): """Show process list.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() - return self._parse_processlist(await self._exec(sql, params)) + return self.parse_processlist(await self._exec(sql, params)) async def warnings(self, limit: Optional[int] = None): """Show warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(await self._exec(sql, params)) + return self.parse_warnings(await self._exec(sql, params)) async def errors(self, limit: Optional[int] = None): """Show errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() - return self._parse_warnings(await self._exec(sql, params)) + return self.parse_warnings(await self._exec(sql, params)) async def engines(self): """Show storage engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_engines(await self._exec(sql, params)) + return self.parse_engines(await self._exec(sql, params)) async def charset(self, like: Optional[str] = None): """Show character sets.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_charset(await self._exec(sql, params)) + return self.parse_charset(await self._exec(sql, params)) async def collation(self, like: Optional[str] = None): """Show collations.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() - return self._parse_collation(await self._exec(sql, params)) + return self.parse_collation(await self._exec(sql, params)) async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Show grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() - return self._parse_grants(await self._exec(sql, params)) + return self.parse_grants(await self._exec(sql, params)) async def plugins(self): """Show plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() - return self._parse_plugins(await self._exec(sql, params)) + return self.parse_plugins(await self._exec(sql, params)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py index e28ff00f..dd3a9ab5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/status_introspector.py @@ -10,7 +10,7 @@ - AsyncMySQLStatusIntrospector: for asynchronous backends """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from rhosocial.activerecord.backend.introspection.status import ( StatusItem, @@ -142,7 +142,242 @@ def _get_vendor_name(self) -> str: """Get MySQL vendor name.""" return "MySQL" - def _parse_variable_value(self, value: Any) -> Any: + # ------------------------------------------------------------------ # + # Statements as expression trees + # ------------------------------------------------------------------ # + # + # Every command this module runs is built here from a node, so the SQL is + # the dialect's to write and this module never spells a keyword. The SHOW + # family is this backend's own (``expression/show.py``); the SELECT family + # uses core's query and function nodes. + # + # It matters here rather than only in the formatters: a status introspector + # is a *caller*, and a caller that writes the SHOW command as an f-string + # has bypassed the expression layer entirely -- the command then exists only + # as text, uninspectable and one interpolation away from spelling something + # else. + + @property + def _dialect(self) -> Any: + """The dialect the command nodes are rendered with.""" + return self._backend.dialect + + def _show_variables(self, like: Optional[str] = None, session: bool = True) -> Tuple[str, tuple]: + """``SHOW [GLOBAL] VARIABLES [LIKE ...]`` as a node.""" + from ..expression.show import ShowVariablesStatement + + return ShowVariablesStatement(self._dialect, like_pattern=like, session=session).to_sql() + + def _show_status(self, like: Optional[str] = None, session: bool = True) -> Tuple[str, tuple]: + """``SHOW [GLOBAL] STATUS [LIKE ...]`` as a node.""" + from ..expression.show import ShowStatusStatement + + return ShowStatusStatement(self._dialect, like_pattern=like, session=session).to_sql() + + def _show_processlist(self, full: bool = False) -> Tuple[str, tuple]: + """``SHOW [FULL] PROCESSLIST`` as a node.""" + from ..expression.show import ShowProcessListStatement + + return ShowProcessListStatement(self._dialect, full=full).to_sql() + + def _show_binary_logs(self) -> Tuple[str, tuple]: + """``SHOW BINARY LOGS`` as a node.""" + from ..expression.show import ShowBinaryLogsStatement + + return ShowBinaryLogsStatement(self._dialect).to_sql() + + def _show_master_status(self) -> Tuple[str, tuple]: + """``SHOW MASTER STATUS`` as a node. + + Servers from 8.4 answer through ``performance_schema.log_status`` + instead; the caller picks by version, and this is the older branch. + """ + from ..expression.show import ShowMasterStatusStatement + + return ShowMasterStatusStatement(self._dialect).to_sql() + + def _show_slave_status(self) -> Tuple[str, tuple]: + """``SHOW SLAVE STATUS`` as a node (the branch for servers below 8.4).""" + from ..expression.show import ShowSlaveStatusStatement + + return ShowSlaveStatusStatement(self._dialect).to_sql() + + def _version_query(self) -> Tuple[str, tuple]: + """``SELECT VERSION()`` -- the server's own version string. + + Built by the shared node in ``expression/system.py``, which the backend + uses for the same question. No alias: the column label ``VERSION()`` is + what every consumer in this module reads back. + """ + from ..expression.system import version_query + + return version_query(self._dialect).to_sql() + + def _current_user_query(self) -> Tuple[str, tuple]: + """``SELECT CURRENT_USER`` -- the account this session authenticated as. + + Core's node renders the niladic spelling, without the parentheses the + concatenated form wrote, so the column label is ``CURRENT_USER`` -- + which is what the callers below read back. + """ + from ..expression.system import current_user_query + + return current_user_query(self._dialect).to_sql() + + def _log_status_query(self, with_gtid: bool = False) -> Tuple[str, tuple]: + """``SELECT ... FROM performance_schema.log_status``. + + The JSON paths stay literal-inline, exactly as the concatenated form + left them: the parameter tuple is empty either way. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + QueryStatement, + json_extract, + json_extract_text, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + local = Column(self._dialect, "LOCAL") + projection = [ + json_extract_text(self._dialect, local, "$.binary_log_file").as_("File"), + json_extract(self._dialect, local, "$.binary_log_position").as_("Position"), + ] + if with_gtid: + projection.append(json_extract_text(self._dialect, local, "$.gtid_executed").as_("Gtid")) + return QueryStatement( + self._dialect, + select=projection, + from_=Table(self._dialect, "log_status", catalog_name="performance_schema"), + ).to_sql() + + def _database_size_query(self) -> Tuple[str, tuple]: + """``SELECT SUM(DATA_LENGTH + INDEX_LENGTH) FROM information_schema.TABLES``.""" + from rhosocial.activerecord.backend.expression import ( + ArithmeticOperation, + Column, + ComparisonPredicate, + Literal, + QueryStatement, + SumFunction, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + total = SumFunction( + dialect, + ArithmeticOperation( + dialect, "add", Column(dialect, "DATA_LENGTH"), Column(dialect, "INDEX_LENGTH") + ), + ).as_("total_size") + return QueryStatement( + dialect, + select=[total], + from_=Table(dialect, "TABLES", catalog_name="information_schema"), + where=WhereClause( + dialect, + ComparisonPredicate( + dialect, + "eq", + Column(dialect, "TABLE_SCHEMA"), + Literal(dialect, self._backend.config.database), + ), + ), + ).to_sql() + + def _table_type_counts_query(self, db_names: List[str]) -> Tuple[str, tuple]: + """Table and view counts per database, grouped by type.""" + from rhosocial.activerecord.backend.expression import ( + Column, + CountFunction, + GroupByHavingClause, + InPredicate, + Literal, + QueryStatement, + RowCountWildcard, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + return QueryStatement( + dialect, + select=[ + Column(dialect, "TABLE_SCHEMA"), + Column(dialect, "TABLE_TYPE"), + CountFunction(dialect, RowCountWildcard(dialect)).as_("count"), + ], + from_=Table(dialect, "TABLES", catalog_name="information_schema"), + where=WhereClause( + dialect, + InPredicate( + dialect, Column(dialect, "TABLE_SCHEMA"), Literal(dialect, tuple(db_names)) + ), + ), + group_by_having=GroupByHavingClause( + dialect, group_by=[Column(dialect, "TABLE_SCHEMA"), Column(dialect, "TABLE_TYPE")] + ), + ).to_sql() + + def _users_query(self) -> Tuple[str, tuple]: + """``SELECT User, Host, Super_priv FROM mysql.user``.""" + from rhosocial.activerecord.backend.expression import Column, QueryStatement + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + return QueryStatement( + dialect, + select=[ + Column(dialect, "User"), + Column(dialect, "Host"), + Column(dialect, "Super_priv"), + ], + from_=Table(dialect, "user", catalog_name="mysql"), + ).to_sql() + + def _replication_connection_status_query(self, limit: Optional[int] = None) -> Tuple[str, tuple]: + """Channels whose replication connection is running (8.4+ servers).""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + LimitOffsetClause, + Literal, + QueryStatement, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + dialect = self._dialect + if limit is not None: + projection = [Column(dialect, "SERVICE_STATE")] + else: + projection = [ + Column(dialect, "CHANNEL_NAME"), + Column(dialect, "SOURCE_UUID"), + Column(dialect, "SERVICE_STATE"), + ] + kwargs: Dict[str, Any] = { + "select": projection, + "from_": Table( + dialect, "replication_connection_status", catalog_name="performance_schema" + ), + "where": WhereClause( + dialect, + ComparisonPredicate( + dialect, + "eq", + Column(dialect, "SERVICE_STATE"), + Literal(dialect, "ON", inline=True), + ), + ), + } + if limit is not None: + kwargs["limit_offset"] = LimitOffsetClause(dialect, limit=limit) + return QueryStatement(dialect, **kwargs).to_sql() + + + def parse_variable_value(self, value: Any) -> Any: """Parse variable value to appropriate Python type.""" if value is None: return None @@ -155,7 +390,7 @@ def _parse_variable_value(self, value: Any) -> Any: return value return value - def _parse_version_string(self, version_str: str) -> tuple: + def parse_version_string(self, version_str: str) -> tuple: """Parse MySQL version string to (major, minor, patch) tuple. Examples: @@ -187,7 +422,7 @@ def _is_mysql_version_at_least(self, version_str: str, major: int, minor: int = Returns: True if version >= major.minor """ - parsed = self._parse_version_string(version_str) + parsed = self.parse_version_string(version_str) return parsed >= (major, minor, 0) def _create_status_item( @@ -202,7 +437,7 @@ def _create_status_item( """Create a StatusItem with parsed value.""" return StatusItem( name=name, - value=self._parse_variable_value(value), + value=self.parse_variable_value(value), category=category, description=description, unit=unit, @@ -372,11 +607,11 @@ def get_connection_info(self) -> ConnectionInfo: var_dict[var.variable_name] = var.value return ConnectionInfo( - active_count=self._parse_variable_value(status_dict.get("Threads_connected")), - max_connections=self._parse_variable_value(var_dict.get("max_connections")), - idle_count=self._parse_variable_value(status_dict.get("Threads_cached")), + active_count=self.parse_variable_value(status_dict.get("Threads_connected")), + max_connections=self.parse_variable_value(var_dict.get("max_connections")), + idle_count=self.parse_variable_value(status_dict.get("Threads_cached")), extra={ - "threads_running": self._parse_variable_value(status_dict.get("Threads_running")), + "threads_running": self.parse_variable_value(status_dict.get("Threads_running")), }, ) @@ -390,22 +625,17 @@ def get_storage_info(self) -> StorageInfo: # Get database size from information_schema if available total_size = None try: - result = self._backend.execute( - "SELECT SUM(data_length + index_length) as total_size " - "FROM information_schema.TABLES " - f"WHERE table_schema = {self._backend.dialect.p()}", - (self._backend.config.database,), - ) + result = self._backend.execute(*self._database_size_query()) if result and result.data: total_size = result.data[0].get("total_size") except Exception: pass return StorageInfo( - total_size_bytes=self._parse_variable_value(total_size), + total_size_bytes=self.parse_variable_value(total_size), extra={ "datadir": var_dict.get("datadir"), - "innodb_buffer_pool_size": self._parse_variable_value(var_dict.get("innodb_buffer_pool_size")), + "innodb_buffer_pool_size": self.parse_variable_value(var_dict.get("innodb_buffer_pool_size")), }, ) @@ -421,15 +651,7 @@ def list_databases(self) -> List[DatabaseBriefInfo]: view_counts: Dict[str, int] = {} try: - p = self._backend.dialect.p() - placeholders = ", ".join([p] * len(db_names)) - result = self._backend.execute( - "SELECT table_schema, table_type, COUNT(*) as count " - "FROM information_schema.TABLES " - f"WHERE table_schema IN ({placeholders}) " - "GROUP BY table_schema, table_type", - tuple(db_names), - ) + result = self._backend.execute(*self._table_type_counts_query(db_names)) if result and result.data: for row in result.data: # MySQL returns column names in uppercase @@ -458,7 +680,7 @@ def list_users(self) -> List[UserInfo]: users = [] try: - result = self._backend.execute("SELECT User, Host, Super_priv FROM mysql.user", ()) + result = self._backend.execute(*self._users_query()) if result and result.data: for row in result.data: user = UserInfo( @@ -479,9 +701,9 @@ def get_session_info(self) -> SessionInfo: # Get current user try: - result = self._backend.execute("SELECT CURRENT_USER()", ()) + result = self._backend.execute(*self._current_user_query()) if result and result.data: - current_user = result.data[0].get("CURRENT_USER()") + current_user = result.data[0].get("CURRENT_USER") if current_user: # Parse user@host format parts = current_user.split("@") @@ -496,7 +718,7 @@ def get_session_info(self) -> SessionInfo: # Get SSL status try: - result = self._backend.execute("SHOW STATUS LIKE 'Ssl_version'", ()) + result = self._backend.execute(*self._show_status(like="Ssl_version")) if result and result.data: ssl_version = result.data[0].get("Value") if ssl_version: @@ -507,7 +729,7 @@ def get_session_info(self) -> SessionInfo: # Get SSL cipher try: - result = self._backend.execute("SHOW STATUS LIKE 'Ssl_cipher'", ()) + result = self._backend.execute(*self._show_status(like="Ssl_cipher")) if result and result.data: ssl_cipher = result.data[0].get("Value") if ssl_cipher: @@ -525,57 +747,57 @@ def get_innodb_info(self) -> InnoDBInfo: innodb = InnoDBInfo() # Get buffer pool info try: - result = self._backend.execute("SHOW STATUS LIKE 'Innodb_buffer_pool%'", ()) + result = self._backend.execute(*self._show_status(like="Innodb_buffer_pool%")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_buffer_pool_pages_total": - innodb.buffer_pool_pages_total = self._parse_variable_value(value) + innodb.buffer_pool_pages_total = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_data": - innodb.buffer_pool_pages_data = self._parse_variable_value(value) + innodb.buffer_pool_pages_data = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_dirty": - innodb.buffer_pool_pages_dirty = self._parse_variable_value(value) + innodb.buffer_pool_pages_dirty = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_free": - innodb.buffer_pool_pages_free = self._parse_variable_value(value) + innodb.buffer_pool_pages_free = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_read_requests": - innodb.buffer_pool_read_requests = self._parse_variable_value(value) + innodb.buffer_pool_read_requests = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_reads": - innodb.buffer_pool_reads = self._parse_variable_value(value) + innodb.buffer_pool_reads = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_wait_free": - innodb.buffer_pool_wait_free = self._parse_variable_value(value) + innodb.buffer_pool_wait_free = self.parse_variable_value(value) except Exception: pass # Get InnoDB variables try: - result = self._backend.execute("SHOW VARIABLES LIKE 'innodb%'", ()) + result = self._backend.execute(*self._show_variables(like="innodb%")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "innodb_buffer_pool_size": - innodb.buffer_pool_size = self._parse_variable_value(value) + innodb.buffer_pool_size = self.parse_variable_value(value) elif key == "innodb_buffer_pool_instances": - innodb.buffer_pool_instances = self._parse_variable_value(value) + innodb.buffer_pool_instances = self.parse_variable_value(value) elif key == "innodb_log_file_size": - innodb.log_file_size = self._parse_variable_value(value) + innodb.log_file_size = self.parse_variable_value(value) elif key == "innodb_log_buffer_size": - innodb.log_buffer_size = self._parse_variable_value(value) + innodb.log_buffer_size = self.parse_variable_value(value) elif key == "innodb_lock_wait_timeout": - innodb.lock_wait_timeout = self._parse_variable_value(value) + innodb.lock_wait_timeout = self.parse_variable_value(value) except Exception: pass # Get InnoDB row lock status try: - result = self._backend.execute("SHOW STATUS LIKE 'Innodb_row_lock%'", ()) + result = self._backend.execute(*self._show_status(like="Innodb_row_lock%")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_row_lock_time": - innodb.row_lock_time = self._parse_variable_value(value) + innodb.row_lock_time = self.parse_variable_value(value) elif key == "Innodb_row_lock_waits": - innodb.row_lock_waits = self._parse_variable_value(value) + innodb.row_lock_waits = self.parse_variable_value(value) elif key == "Innodb_row_lock_time_avg": # Calculate average total_lock_time = innodb.row_lock_time or 0 @@ -583,23 +805,23 @@ def get_innodb_info(self) -> InnoDBInfo: avg_time = total_lock_time / lock_waits if lock_waits > 0 else 0 innodb.row_lock_time_avg = round(avg_time, 2) elif key == "Innodb_rows_read": - innodb.rows_read = self._parse_variable_value(value) + innodb.rows_read = self.parse_variable_value(value) elif key == "Innodb_rows_inserted": - innodb.rows_inserted = self._parse_variable_value(value) + innodb.rows_inserted = self.parse_variable_value(value) elif key == "Innodb_rows_updated": - innodb.rows_updated = self._parse_variable_value(value) + innodb.rows_updated = self.parse_variable_value(value) elif key == "Innodb_rows_deleted": - innodb.rows_deleted = self._parse_variable_value(value) + innodb.rows_deleted = self.parse_variable_value(value) elif key == "Innodb_data_reads": - innodb.data_reads = self._parse_variable_value(value) + innodb.data_reads = self.parse_variable_value(value) elif key == "Innodb_data_writes": - innodb.data_writes = self._parse_variable_value(value) + innodb.data_writes = self.parse_variable_value(value) elif key == "Innodb_os_log_fsyncs": - innodb.os_fsyncs = self._parse_variable_value(value) + innodb.os_fsyncs = self.parse_variable_value(value) elif key == "Innodb_os_file_reads": - innodb.os_file_reads = self._parse_variable_value(value) + innodb.os_file_reads = self.parse_variable_value(value) elif key == "Innodb_os_file_writes": - innodb.os_file_writes = self._parse_variable_value(value) + innodb.os_file_writes = self.parse_variable_value(value) except Exception: pass return innodb @@ -609,7 +831,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: binary_log = BinaryLogInfo() # Check if binary logging is enabled try: - result = self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -623,7 +845,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: # Get binary log files (only if binary logging is enabled) if binary_log.log_enabled: try: - result = self._backend.execute("SHOW BINARY LOGS", ()) + result = self._backend.execute(*self._show_binary_logs()) if result and result.data: log_files = [] total_size = 0 @@ -634,7 +856,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: # Get file size from the File_size column if available file_size = row.get("File_size") if file_size: - total_size += self._parse_variable_value(file_size) + total_size += self.parse_variable_value(file_size) binary_log.log_files = log_files binary_log.log_size_bytes = total_size except Exception: @@ -645,37 +867,31 @@ def get_binary_log_info(self) -> BinaryLogInfo: # MySQL 9.0+ removed SHOW MASTER STATUS, use performance_schema.log_status instead try: # Get server version first - version_result = self._backend.execute("SELECT VERSION()", ()) + version_result = self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 8.4+: use performance_schema.log_status - result = self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position, " - "JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.gtid_executed')) as Gtid " - "FROM performance_schema.log_status", - (), - ) + result = self._backend.execute(*self._log_status_query(with_gtid=True)) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) binary_log.gtid_executed = row.get("Gtid") else: # MySQL < 8.4: use SHOW MASTER STATUS - result = self._backend.execute("SHOW MASTER STATUS", ()) + result = self._backend.execute(*self._show_master_status()) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) except Exception: pass # Get GTID info try: - result = self._backend.execute("SHOW VARIABLES LIKE 'gtid_mode'", ()) + result = self._backend.execute(*self._show_variables(like="gtid_mode")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -684,7 +900,7 @@ def get_binary_log_info(self) -> BinaryLogInfo: except Exception: pass try: - result = self._backend.execute("SHOW GLOBAL VARIABLES LIKE 'gtid_executed'", ()) + result = self._backend.execute(*self._show_variables(like="gtid_executed", session=False)) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -704,7 +920,7 @@ def list_processes(self) -> List[ProcessInfo]: """List current running processes/queries.""" processes = [] try: - result = self._backend.execute("SHOW FULL PROCESSLIST", ()) + result = self._backend.execute(*self._show_processlist(full=True)) if result and result.data: for row in result.data: proc = ProcessInfo( @@ -727,7 +943,7 @@ def get_slow_query_info(self) -> SlowQueryInfo: slow_query = SlowQueryInfo() # Get slow query log status try: - result = self._backend.execute("SHOW VARIABLES LIKE 'slow_query_log%'", ()) + result = self._backend.execute(*self._show_variables(like="slow_query_log%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -743,18 +959,18 @@ def get_slow_query_info(self) -> SlowQueryInfo: elif key == "log_slow_admin_statements": slow_query.log_slow_admin_statements = str(value).lower() in ("on", "1") elif key == "min_examined_row_limit": - slow_query.min_examined_row_limit = self._parse_variable_value(value) + slow_query.min_examined_row_limit = self.parse_variable_value(value) except Exception: pass # Get slow query count try: - result = self._backend.execute("SHOW STATUS LIKE 'Slow_queries'", ()) + result = self._backend.execute(*self._show_status(like="Slow_queries")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "Slow_queries": - slow_query.slow_queries_count = self._parse_variable_value(value) + slow_query.slow_queries_count = self.parse_variable_value(value) except Exception: pass return slow_query @@ -764,19 +980,19 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl_info = MySQLReplicationInfo() # Get server_id try: - result = self._backend.execute("SHOW VARIABLES LIKE 'server_id'", ()) + result = self._backend.execute(*self._show_variables(like="server_id")) if result and result.data: for row in result.data: key = row.get("Variable_name") if key == "server_id": - repl_info.server_id = self._parse_variable_value(row.get("Value")) + repl_info.server_id = self.parse_variable_value(row.get("Value")) except Exception: pass # Check if this is a master # First check if binary logging is enabled log_bin_enabled = False try: - result = self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: if row.get("Variable_name") == "log_bin": @@ -790,36 +1006,30 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: # MySQL 9.0+ removed SHOW MASTER STATUS, use performance_schema.log_status instead try: # Get server version first - version_result = self._backend.execute("SELECT VERSION()", ()) + version_result = self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 9.0+: use performance_schema.log_status - result = self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position, " - "JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.gtid_executed')) as Gtid " - "FROM performance_schema.log_status", - (), - ) + result = self._backend.execute(*self._log_status_query(with_gtid=True)) if result and result.data: repl_info.is_master = True master_info = ReplicationMasterInfo() row = result.data[0] master_info.binary_log_file = row.get("File") - master_info.binary_log_position = self._parse_variable_value(row.get("Position")) + master_info.binary_log_position = self.parse_variable_value(row.get("Position")) repl_info.master_info = master_info else: # MySQL < 9.0: use SHOW MASTER STATUS - result = self._backend.execute("SHOW MASTER STATUS", ()) + result = self._backend.execute(*self._show_master_status()) if result and result.data: repl_info.is_master = True master_info = ReplicationMasterInfo() row = result.data[0] master_info.binary_log_file = row.get("File") - master_info.binary_log_position = self._parse_variable_value(row.get("Position")) + master_info.binary_log_position = self.parse_variable_value(row.get("Position")) # Parse comma-separated list do_db = row.get("Binlog_Do_DB", "") if do_db: @@ -836,7 +1046,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: try: # Get server version first (reuse if already fetched above) if "version_str" not in dir() or not version_str: - version_result = self._backend.execute("SELECT VERSION()", ()) + version_result = self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) @@ -844,12 +1054,7 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 9.0+: use performance_schema replication tables # Check replication connection status - result = self._backend.execute( - "SELECT CHANNEL_NAME, SOURCE_UUID, SERVICE_STATE " - "FROM performance_schema.replication_connection_status " - "WHERE SERVICE_STATE = 'ON'", - (), - ) + result = self._backend.execute(*self._replication_connection_status_query()) if result and result.data: repl_info.is_slave = True slave_info = ReplicationSlaveInfo() @@ -859,28 +1064,28 @@ def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl_info.slave_info = slave_info else: # MySQL < 9.0: use SHOW SLAVE STATUS - result = self._backend.execute("SHOW SLAVE STATUS", ()) + result = self._backend.execute(*self._show_slave_status()) if result and result.data: repl_info.is_slave = True slave_info = ReplicationSlaveInfo() row = result.data[0] slave_info.master_host = row.get("Master_Host") - slave_info.master_port = self._parse_variable_value(row.get("Master_Port")) + slave_info.master_port = self.parse_variable_value(row.get("Master_Port")) slave_info.master_user = row.get("Master_User") slave_info.slave_io_running = row.get("Slave_IO_Running") slave_info.slave_sql_running = row.get("Slave_SQL_Running") - slave_info.seconds_behind_master = self._parse_variable_value(row.get("Seconds_Behind_Master")) + slave_info.seconds_behind_master = self.parse_variable_value(row.get("Seconds_Behind_Master")) slave_info.master_log_file = row.get("Master_Log_File") - slave_info.read_master_log_pos = self._parse_variable_value(row.get("Read_Master_Log_Pos")) + slave_info.read_master_log_pos = self.parse_variable_value(row.get("Read_Master_Log_Pos")) slave_info.relay_master_log_file = row.get("Relay_Master_Log_File") slave_info.slave_io_state = row.get("Slave_IO_State") - slave_info.last_io_errno = self._parse_variable_value(row.get("Last_IO_Errno")) + slave_info.last_io_errno = self.parse_variable_value(row.get("Last_IO_Errno")) slave_info.last_io_error = row.get("Last_IO_Error") - slave_info.last_sql_errno = self._parse_variable_value(row.get("Last_SQL_Errno")) + slave_info.last_sql_errno = self.parse_variable_value(row.get("Last_SQL_Errno")) slave_info.last_sql_error = row.get("Last_SQL_Error") slave_info.relay_log_file = row.get("Relay_Log_File") - slave_info.relay_log_pos = self._parse_variable_value(row.get("Relay_Log_Pos")) - slave_info.exec_master_log_pos = self._parse_variable_value(row.get("Exec_Master_Log_Pos")) + slave_info.relay_log_pos = self.parse_variable_value(row.get("Relay_Log_Pos")) + slave_info.exec_master_log_pos = self.parse_variable_value(row.get("Exec_Master_Log_Pos")) repl_info.slave_info = slave_info except Exception: pass @@ -1011,11 +1216,11 @@ async def get_connection_info(self) -> ConnectionInfo: var_dict[var.variable_name] = var.value return ConnectionInfo( - active_count=self._parse_variable_value(status_dict.get("Threads_connected")), - max_connections=self._parse_variable_value(var_dict.get("max_connections")), - idle_count=self._parse_variable_value(status_dict.get("Threads_cached")), + active_count=self.parse_variable_value(status_dict.get("Threads_connected")), + max_connections=self.parse_variable_value(var_dict.get("max_connections")), + idle_count=self.parse_variable_value(status_dict.get("Threads_cached")), extra={ - "threads_running": self._parse_variable_value(status_dict.get("Threads_running")), + "threads_running": self.parse_variable_value(status_dict.get("Threads_running")), }, ) @@ -1028,22 +1233,17 @@ async def get_storage_info(self) -> StorageInfo: total_size = None try: - result = await self._backend.execute( - "SELECT SUM(data_length + index_length) as total_size " - "FROM information_schema.TABLES " - f"WHERE table_schema = {self._backend.dialect.p()}", - (self._backend.config.database,), - ) + result = await self._backend.execute(*self._database_size_query()) if result and result.data: total_size = result.data[0].get("total_size") except Exception: pass return StorageInfo( - total_size_bytes=self._parse_variable_value(total_size), + total_size_bytes=self.parse_variable_value(total_size), extra={ "datadir": var_dict.get("datadir"), - "innodb_buffer_pool_size": self._parse_variable_value(var_dict.get("innodb_buffer_pool_size")), + "innodb_buffer_pool_size": self.parse_variable_value(var_dict.get("innodb_buffer_pool_size")), }, ) @@ -1059,15 +1259,7 @@ async def list_databases(self) -> List[DatabaseBriefInfo]: view_counts: Dict[str, int] = {} try: - p = self._backend.dialect.p() - placeholders = ", ".join([p] * len(db_names)) - result = await self._backend.execute( - "SELECT table_schema, table_type, COUNT(*) as count " - "FROM information_schema.TABLES " - f"WHERE table_schema IN ({placeholders}) " - "GROUP BY table_schema, table_type", - tuple(db_names), - ) + result = await self._backend.execute(*self._table_type_counts_query(db_names)) if result and result.data: for row in result.data: # MySQL returns column names in uppercase @@ -1096,7 +1288,7 @@ async def list_users(self) -> List[UserInfo]: users = [] try: - result = await self._backend.execute("SELECT User, Host, Super_priv FROM mysql.user", ()) + result = await self._backend.execute(*self._users_query()) if result and result.data: for row in result.data: user = UserInfo( @@ -1116,9 +1308,9 @@ async def get_session_info(self) -> SessionInfo: # Get current user try: - result = await self._backend.execute("SELECT CURRENT_USER()", ()) + result = await self._backend.execute(*self._current_user_query()) if result and result.data: - current_user = result.data[0].get("CURRENT_USER()") + current_user = result.data[0].get("CURRENT_USER") if current_user: parts = current_user.split("@") session.user = parts[0] if parts else current_user @@ -1132,7 +1324,7 @@ async def get_session_info(self) -> SessionInfo: # Get SSL status try: - result = await self._backend.execute("SHOW STATUS LIKE 'Ssl_version'", ()) + result = await self._backend.execute(*self._show_status(like="Ssl_version")) if result and result.data: ssl_version = result.data[0].get("Value") if ssl_version: @@ -1143,7 +1335,7 @@ async def get_session_info(self) -> SessionInfo: # Get SSL cipher try: - result = await self._backend.execute("SHOW STATUS LIKE 'Ssl_cipher'", ()) + result = await self._backend.execute(*self._show_status(like="Ssl_cipher")) if result and result.data: ssl_cipher = result.data[0].get("Value") if ssl_cipher: @@ -1161,80 +1353,80 @@ async def get_innodb_info(self) -> InnoDBInfo: innodb = InnoDBInfo() # Get buffer pool info try: - result = await self._backend.execute("SHOW STATUS LIKE 'Innodb_buffer_pool%'", ()) + result = await self._backend.execute(*self._show_status(like="Innodb_buffer_pool%")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_buffer_pool_pages_total": - innodb.buffer_pool_pages_total = self._parse_variable_value(value) + innodb.buffer_pool_pages_total = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_data": - innodb.buffer_pool_pages_data = self._parse_variable_value(value) + innodb.buffer_pool_pages_data = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_dirty": - innodb.buffer_pool_pages_dirty = self._parse_variable_value(value) + innodb.buffer_pool_pages_dirty = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_pages_free": - innodb.buffer_pool_pages_free = self._parse_variable_value(value) + innodb.buffer_pool_pages_free = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_read_requests": - innodb.buffer_pool_read_requests = self._parse_variable_value(value) + innodb.buffer_pool_read_requests = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_reads": - innodb.buffer_pool_reads = self._parse_variable_value(value) + innodb.buffer_pool_reads = self.parse_variable_value(value) elif key == "Innodb_buffer_pool_wait_free": - innodb.buffer_pool_wait_free = self._parse_variable_value(value) + innodb.buffer_pool_wait_free = self.parse_variable_value(value) except Exception: pass # Get InnoDB variables try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'innodb%'", ()) + result = await self._backend.execute(*self._show_variables(like="innodb%")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "innodb_buffer_pool_size": - innodb.buffer_pool_size = self._parse_variable_value(value) + innodb.buffer_pool_size = self.parse_variable_value(value) elif key == "innodb_buffer_pool_instances": - innodb.buffer_pool_instances = self._parse_variable_value(value) + innodb.buffer_pool_instances = self.parse_variable_value(value) elif key == "innodb_log_file_size": - innodb.log_file_size = self._parse_variable_value(value) + innodb.log_file_size = self.parse_variable_value(value) elif key == "innodb_log_buffer_size": - innodb.log_buffer_size = self._parse_variable_value(value) + innodb.log_buffer_size = self.parse_variable_value(value) elif key == "innodb_lock_wait_timeout": - innodb.lock_wait_timeout = self._parse_variable_value(value) + innodb.lock_wait_timeout = self.parse_variable_value(value) except Exception: pass # Get InnoDB row lock status try: - result = await self._backend.execute("SHOW STATUS LIKE 'Innodb_row_lock%'", ()) + result = await self._backend.execute(*self._show_status(like="Innodb_row_lock%")) if result and result.data: for row in result.data: key = row.get("Variable_name") value = row.get("Value") if key == "Innodb_row_lock_time": - innodb.row_lock_time = self._parse_variable_value(value) + innodb.row_lock_time = self.parse_variable_value(value) elif key == "Innodb_row_lock_waits": - innodb.row_lock_waits = self._parse_variable_value(value) + innodb.row_lock_waits = self.parse_variable_value(value) elif key == "Innodb_row_lock_time_avg": total_lock_time = innodb.row_lock_time or 0 lock_waits = innodb.row_lock_waits or 1 avg_time = total_lock_time / lock_waits if lock_waits > 0 else 0 innodb.row_lock_time_avg = round(avg_time, 2) elif key == "Innodb_rows_read": - innodb.rows_read = self._parse_variable_value(value) + innodb.rows_read = self.parse_variable_value(value) elif key == "Innodb_rows_inserted": - innodb.rows_inserted = self._parse_variable_value(value) + innodb.rows_inserted = self.parse_variable_value(value) elif key == "Innodb_rows_updated": - innodb.rows_updated = self._parse_variable_value(value) + innodb.rows_updated = self.parse_variable_value(value) elif key == "Innodb_rows_deleted": - innodb.rows_deleted = self._parse_variable_value(value) + innodb.rows_deleted = self.parse_variable_value(value) elif key == "Innodb_data_reads": - innodb.data_reads = self._parse_variable_value(value) + innodb.data_reads = self.parse_variable_value(value) elif key == "Innodb_data_writes": - innodb.data_writes = self._parse_variable_value(value) + innodb.data_writes = self.parse_variable_value(value) elif key == "Innodb_os_log_fsyncs": - innodb.os_fsyncs = self._parse_variable_value(value) + innodb.os_fsyncs = self.parse_variable_value(value) elif key == "Innodb_os_file_reads": - innodb.os_file_reads = self._parse_variable_value(value) + innodb.os_file_reads = self.parse_variable_value(value) elif key == "Innodb_os_file_writes": - innodb.os_file_writes = self._parse_variable_value(value) + innodb.os_file_writes = self.parse_variable_value(value) except Exception: pass return innodb @@ -1244,7 +1436,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: binary_log = BinaryLogInfo() # Check if binary logging is enabled try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = await self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1258,7 +1450,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: # Get binary log files (only if binary logging is enabled) if binary_log.log_enabled: try: - result = await self._backend.execute("SHOW BINARY LOGS", ()) + result = await self._backend.execute(*self._show_binary_logs()) if result and result.data: log_files = [] total_size = 0 @@ -1268,7 +1460,7 @@ async def get_binary_log_info(self) -> BinaryLogInfo: log_files.append(log_file) file_size = row.get("File_size") if file_size: - total_size += self._parse_variable_value(file_size) + total_size += self.parse_variable_value(file_size) binary_log.log_files = log_files binary_log.log_size_bytes = total_size except Exception: @@ -1279,37 +1471,31 @@ async def get_binary_log_info(self) -> BinaryLogInfo: # MySQL 9.0+ removed SHOW MASTER STATUS, use performance_schema.log_status instead try: # Get server version first - version_result = await self._backend.execute("SELECT VERSION()", ()) + version_result = await self._backend.execute(*self._version_query()) version_str = ( version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" ) if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 8.4+: use performance_schema.log_status - result = await self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position, " - "JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.gtid_executed')) as Gtid " - "FROM performance_schema.log_status", - (), - ) + result = await self._backend.execute(*self._log_status_query(with_gtid=True)) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) binary_log.gtid_executed = row.get("Gtid") else: # MySQL < 8.4: use SHOW MASTER STATUS - result = await self._backend.execute("SHOW MASTER STATUS", ()) + result = await self._backend.execute(*self._show_master_status()) if result and result.data: row = result.data[0] binary_log.current_log_file = row.get("File") - binary_log.current_log_position = self._parse_variable_value(row.get("Position")) + binary_log.current_log_position = self.parse_variable_value(row.get("Position")) except Exception: pass # Get GTID info try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'gtid_mode'", ()) + result = await self._backend.execute(*self._show_variables(like="gtid_mode")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1329,7 +1515,7 @@ async def list_processes(self) -> List[ProcessInfo]: """List current running processes/queries.""" processes = [] try: - result = await self._backend.execute("SHOW FULL PROCESSLIST", ()) + result = await self._backend.execute(*self._show_processlist(full=True)) if result and result.data: for row in result.data: proc = ProcessInfo( @@ -1351,7 +1537,7 @@ async def get_slow_query_info(self) -> SlowQueryInfo: slow_query = SlowQueryInfo() # Get slow query log settings try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'slow_query_log%'", ()) + result = await self._backend.execute(*self._show_variables(like="slow_query_log%")) if result and result.data: for row in result.data: key = row.get("Variable_name") @@ -1364,7 +1550,7 @@ async def get_slow_query_info(self) -> SlowQueryInfo: pass # Get long_query_time try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'long_query_time'", ()) + result = await self._backend.execute(*self._show_variables(like="long_query_time")) if result and result.data: for row in result.data: value = row.get("Value") @@ -1374,12 +1560,12 @@ async def get_slow_query_info(self) -> SlowQueryInfo: pass # Get slow query count try: - result = await self._backend.execute("SHOW STATUS LIKE 'Slow_queries'", ()) + result = await self._backend.execute(*self._show_status(like="Slow_queries")) if result and result.data: for row in result.data: value = row.get("Value") if value: - slow_query.slow_queries_count = self._parse_variable_value(value) + slow_query.slow_queries_count = self.parse_variable_value(value) except Exception: pass return slow_query @@ -1389,7 +1575,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl = MySQLReplicationInfo() # Get server version first try: - version_result = await self._backend.execute("SELECT VERSION()", ()) + version_result = await self._backend.execute(*self._version_query()) version_str = version_result.data[0].get("VERSION()", "") if version_result and version_result.data else "" except Exception: version_str = "" @@ -1397,7 +1583,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: # Check if binary logging is enabled first log_bin_enabled = False try: - result = await self._backend.execute("SHOW VARIABLES LIKE 'log_bin'", ()) + result = await self._backend.execute(*self._show_variables(like="log_bin")) if result and result.data: for row in result.data: if row.get("Variable_name") == "log_bin": @@ -1411,42 +1597,33 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: try: if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 8.4+: use performance_schema.log_status - result = await self._backend.execute( - "SELECT JSON_UNQUOTE(JSON_EXTRACT(LOCAL, '$.binary_log_file')) as File, " - "JSON_EXTRACT(LOCAL, '$.binary_log_position') as Position " - "FROM performance_schema.log_status", - (), - ) + result = await self._backend.execute(*self._log_status_query()) if result and result.data: repl.is_master = True row = result.data[0] repl.master_log_file = row.get("File") - repl.master_log_position = self._parse_variable_value(row.get("Position")) + repl.master_log_position = self.parse_variable_value(row.get("Position")) else: # MySQL < 8.4: use SHOW MASTER STATUS - result = await self._backend.execute("SHOW MASTER STATUS", ()) + result = await self._backend.execute(*self._show_master_status()) if result and result.data: repl.is_master = True row = result.data[0] repl.master_log_file = row.get("File") - repl.master_log_position = self._parse_variable_value(row.get("Position")) + repl.master_log_position = self.parse_variable_value(row.get("Position")) except Exception: pass # Check if server is configured as slave try: if self._is_mysql_version_at_least(version_str, 8, 4): # MySQL 9.0+: use performance_schema replication tables - result = await self._backend.execute( - "SELECT SERVICE_STATE FROM performance_schema.replication_connection_status " - "WHERE SERVICE_STATE = 'ON' LIMIT 1", - (), - ) + result = await self._backend.execute(*self._replication_connection_status_query(limit=1)) if result and result.data: repl.is_slave = True repl.slave_io_running = True else: # MySQL < 9.0: use SHOW SLAVE STATUS - result = await self._backend.execute("SHOW SLAVE STATUS", ()) + result = await self._backend.execute(*self._show_slave_status()) if result and result.data: repl.is_slave = True row = result.data[0] @@ -1455,7 +1632,7 @@ async def get_mysql_replication_info(self) -> MySQLReplicationInfo: repl.slave_master_host = row.get("Master_Host") repl.slave_master_port = row.get("Master_Port") repl.slave_relay_log_file = row.get("Relay_Log_File") - repl.slave_relay_log_pos = self._parse_variable_value(row.get("Relay_Log_Pos")) + repl.slave_relay_log_pos = self.parse_variable_value(row.get("Relay_Log_Pos")) repl.slave_last_error = row.get("Last_Error") except Exception: pass diff --git a/src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py b/src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py new file mode 100644 index 00000000..54377943 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py @@ -0,0 +1,116 @@ +# src/rhosocial/activerecord/backend/impl/mysql/introspection/types.py +"""MySQL-specific introspection result types. + +The core introspection types describe what every backend can answer about +tables, columns and indexes. MySQL can answer two further questions that +have no cross-backend shape: what a table's *storage* options are (engine, +row format, collation, comment, self-managed auto-increment counter), and +how it is *partitioned*. Both are reported here as frozen dataclasses so a +result cannot be edited in place after the cache handed it out. + +Units are stated on the fields rather than implied: MySQL reports +``DATA_LENGTH`` and ``INDEX_LENGTH`` in bytes, and those are the values +carried here unchanged. They are the server's own accounting, which for +InnoDB is an estimate from the tablespace rather than an exact byte count, +so they are reported as the server said them and not recomputed. +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class MySQLTableOptions: + """Storage options of one MySQL table. + + Mirrors the ``information_schema.TABLES`` columns of the same names. + Every field but the identity is optional: a view has no engine, a + table without a self-managed counter has no auto-increment value, and + the server may not report a field at all on an older release. + + Attributes: + schema: The database (MySQL's catalog/schema slot) the table lives in. + table_name: The table's name. + engine: Storage engine, e.g. ``"InnoDB"``. + row_format: Physical row format, e.g. ``"Dynamic"``. + table_collation: Default collation for the table's character columns. + auto_increment: Next value the self-managed counter will hand out; + ``None`` when the table has no AUTO_INCREMENT column or has not + been written to yet. + table_comment: The table's ``COMMENT``, empty string when unset. + create_options: Extra options as the server echoes them in + ``SHOW CREATE TABLE`` (e.g. ``"partitioned"``). + data_length: Bytes the server accounts to the table's rows. For + InnoDB this is an estimate from the tablespace, not an exact + sum of row sizes. + index_length: Bytes the server accounts to the table's indexes; + same caveat about being an estimate for InnoDB. + """ + + schema: str + table_name: str + engine: Optional[str] = None + row_format: Optional[str] = None + table_collation: Optional[str] = None + auto_increment: Optional[int] = None + table_comment: Optional[str] = None + create_options: Optional[str] = None + data_length: Optional[int] = None + index_length: Optional[int] = None + + @property + def total_length(self) -> Optional[int]: + """Data plus index bytes, or ``None`` if neither is reported. + + A convenience only: the two components stay available separately + because they answer different questions (row storage vs index + storage) and summing them here does not change that. + """ + if self.data_length is None and self.index_length is None: + return None + return (self.data_length or 0) + (self.index_length or 0) + + +@dataclass(frozen=True) +class MySQLPartitionInfo: + """One row of ``information_schema.PARTITIONS`` for a table. + + A subpartitioned table reports one row per (partition, subpartition) + pair, which is why the subpartition identity is carried too: without + it two rows of the same partition would be indistinguishable. + + Attributes: + schema: The database the table lives in. + table_name: The partitioned table's name. + partition_name: The partition's name, or ``None`` for a + non-partitioned table (those rows are filtered out before + parsing, so this is a plain string in practice). + partition_method: ``RANGE``, ``LIST``, ``HASH``, ``KEY`` or + ``LINEAR`` as reported by the server. + partition_expression: The expression the method was applied to, + e.g. ``"YEAR(`created_at`)"``; ``None`` for ``KEY`` partitions. + partition_description: The boundary value for RANGE/LIST + partitions; ``None`` for HASH/KEY. + partition_ordinal_position: 1-based position of the partition. + subpartition_name: The subpartition's name, ``None`` when the table + is not subpartitioned. + subpartition_ordinal_position: 1-based position within the + partition, ``None`` when not subpartitioned. + table_rows: Rows the server estimates the partition holds. + data_length: Bytes the server accounts to this partition's rows. + index_length: Bytes the server accounts to this partition's + indexes. + """ + + schema: str + table_name: str + partition_name: Optional[str] = None + partition_method: Optional[str] = None + partition_expression: Optional[str] = None + partition_description: Optional[str] = None + partition_ordinal_position: Optional[int] = None + subpartition_name: Optional[str] = None + subpartition_ordinal_position: Optional[int] = None + table_rows: Optional[int] = None + data_length: Optional[int] = None + index_length: Optional[int] = None diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py index ad1f8a6b..87d95d25 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py @@ -47,6 +47,7 @@ from .function import MySQLFunctionMixin from .namespace import MySQLNamespaceMixin from .column_type import MySQLColumnTypeMixin +from .literal import MySQLLiteralValueMixin # ``__all__`` is the mixin surface: the names ``dialect.py`` mixes into # MySQLDialect. ``require_kind`` from ``.object_kind`` is deliberately absent -- @@ -101,4 +102,5 @@ "MySQLFunctionMixin", "MySQLNamespaceMixin", "MySQLColumnTypeMixin", + "MySQLLiteralValueMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py index e084d9bb..2e2c81df 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py @@ -1,34 +1,35 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/admin.py from typing import TYPE_CHECKING, Tuple +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table from .object_kind import require_kind if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.admin import ( - MySQLCacheIndexExpression, - MySQLCloneExpression, - MySQLCreateUserExpression, - MySQLDropUserExpression, - MySQLFlushExpression, - MySQLGrantExpression, - MySQLHelpExpression, - MySQLInstallComponentExpression, - MySQLInstallPluginExpression, - MySQLKillExpression, - MySQLLoadIndexIntoCacheExpression, - MySQLResetExpression, - MySQLRestartExpression, - MySQLRevokeExpression, - MySQLShutdownExpression, - MySQLBinlogExpression, - MySQLUninstallComponentExpression, - MySQLUninstallPluginExpression, - MySQLDoExpression, - MySQLHandlerOpenExpression, - MySQLHandlerReadExpression, - MySQLHandlerCloseExpression, + MySQLCacheIndexStatement, + MySQLCloneStatement, + MySQLCreateUserStatement, + MySQLDropUserStatement, + MySQLFlushStatement, + MySQLGrantStatement, + MySQLHelpStatement, + MySQLInstallComponentStatement, + MySQLInstallPluginStatement, + MySQLKillStatement, + MySQLLoadIndexIntoCacheStatement, + MySQLResetStatement, + MySQLRestartStatement, + MySQLRevokeStatement, + MySQLShutdownStatement, + MySQLBinlogStatement, + MySQLUninstallComponentStatement, + MySQLUninstallPluginStatement, + MySQLDoStatement, + MySQLHandlerOpenStatement, + MySQLHandlerReadStatement, + MySQLHandlerCloseStatement, ) @@ -40,7 +41,7 @@ class MySQLAdminCommandMixin: def supports_flush(self) -> bool: return True - def format_flush_statement(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: + def format_mysql_flush_statement(self, expr: "MySQLFlushStatement") -> Tuple[str, tuple]: """Format ``FLUSH [NO_WRITE_TO_BINLOG|LOCAL] option [, option ...]``.""" expr.validate(strict=self.strict_validation) parts = ["FLUSH"] @@ -54,7 +55,7 @@ def format_flush_statement(self, expr: "MySQLFlushExpression") -> Tuple[str, tup def supports_reset(self) -> bool: return True - def format_reset_statement(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: + def format_mysql_reset_statement(self, expr: "MySQLResetStatement") -> Tuple[str, tuple]: """Format ``RESET option``.""" return f"RESET {expr.option.value}", () @@ -97,9 +98,9 @@ def _format_accounts(self, accounts) -> str: parts.append(f"'{acct.user}'@'{host}'") return ", ".join(parts) - def format_cache_index_statement( + def format_mysql_cache_index_statement( self, - expr: "MySQLCacheIndexExpression", + expr: "MySQLCacheIndexStatement", ) -> Tuple[str, tuple]: """Format ``CACHE INDEX table [INDEX (...)] IN key_cache``.""" expr.validate(strict=self.strict_validation) @@ -109,9 +110,9 @@ def format_cache_index_statement( def supports_load_index_into_cache(self) -> bool: return True - def format_load_index_into_cache_statement( + def format_mysql_load_index_into_cache_statement( self, - expr: "MySQLLoadIndexIntoCacheExpression", + expr: "MySQLLoadIndexIntoCacheStatement", ) -> Tuple[str, tuple]: """Format ``LOAD INDEX INTO CACHE table [INDEX (...)]``.""" expr.validate(strict=self.strict_validation) @@ -123,9 +124,9 @@ def format_load_index_into_cache_statement( def supports_install_component(self) -> bool: return True - def format_install_component_statement( + def format_mysql_install_component_statement( self, - expr: "MySQLInstallComponentExpression", + expr: "MySQLInstallComponentStatement", ) -> Tuple[str, tuple]: """Format ``INSTALL COMPONENT 'name' [, 'name' ...]``.""" expr.validate(strict=self.strict_validation) @@ -135,9 +136,9 @@ def format_install_component_statement( def supports_uninstall_component(self) -> bool: return True - def format_uninstall_component_statement( + def format_mysql_uninstall_component_statement( self, - expr: "MySQLUninstallComponentExpression", + expr: "MySQLUninstallComponentStatement", ) -> Tuple[str, tuple]: """Format ``UNINSTALL COMPONENT 'name' [, 'name' ...]``.""" expr.validate(strict=self.strict_validation) @@ -147,9 +148,9 @@ def format_uninstall_component_statement( def supports_install_plugin(self) -> bool: return True - def format_install_plugin_statement( + def format_mysql_install_plugin_statement( self, - expr: "MySQLInstallPluginExpression", + expr: "MySQLInstallPluginStatement", ) -> Tuple[str, tuple]: """Format ``INSTALL PLUGIN name SONAME 'library.so'``.""" expr.validate(strict=self.strict_validation) @@ -158,9 +159,9 @@ def format_install_plugin_statement( def supports_uninstall_plugin(self) -> bool: return True - def format_uninstall_plugin_statement( + def format_mysql_uninstall_plugin_statement( self, - expr: "MySQLUninstallPluginExpression", + expr: "MySQLUninstallPluginStatement", ) -> Tuple[str, tuple]: """Format ``UNINSTALL PLUGIN name``.""" expr.validate(strict=self.strict_validation) @@ -171,7 +172,7 @@ def format_uninstall_plugin_statement( def supports_clone(self) -> bool: return True - def format_clone_statement(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: + def format_mysql_clone_statement(self, expr: "MySQLCloneStatement") -> Tuple[str, tuple]: """Format ``CLONE INSTANCE FROM ...`` / ``CLONE LOCAL DATA DIRECTORY``.""" parts = ["CLONE"] if expr.from_data_directory: @@ -192,14 +193,14 @@ def format_clone_statement(self, expr: "MySQLCloneExpression") -> Tuple[str, tup def supports_restart(self) -> bool: return True - def format_restart_statement(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: + def format_mysql_restart_statement(self, expr: "MySQLRestartStatement") -> Tuple[str, tuple]: """Format the ``RESTART`` server statement.""" return "RESTART", () def supports_binlog(self) -> bool: return True - def format_binlog_statement(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: + def format_mysql_binlog_statement(self, expr: "MySQLBinlogStatement") -> Tuple[str, tuple]: """Format ``BINLOG 'base64_encoded_event'``.""" return f"BINLOG '{expr.encoded}'", () @@ -208,9 +209,9 @@ def format_binlog_statement(self, expr: "MySQLBinlogExpression") -> Tuple[str, t def supports_handler(self) -> bool: return True - def format_handler_open_statement( + def format_mysql_handler_open_statement( self, - expr: "MySQLHandlerOpenExpression", + expr: "MySQLHandlerOpenStatement", ) -> Tuple[str, tuple]: """Format ``HANDLER table OPEN [AS alias]``.""" parts = ["HANDLER", self._format_admin_table(expr.table), "OPEN"] @@ -219,9 +220,9 @@ def format_handler_open_statement( parts.append(self.format_identifier(expr.alias)) return " ".join(parts), () - def format_handler_read_statement( + def format_mysql_handler_read_statement( self, - expr: "MySQLHandlerReadExpression", + expr: "MySQLHandlerReadStatement", ) -> Tuple[str, tuple]: """Format ``HANDLER table READ ...``.""" params = [] @@ -230,7 +231,7 @@ def format_handler_read_statement( parts.append(self.format_identifier(expr.index)) parts.append(expr.mode.value) if expr.key_value is not None: - if hasattr(expr.key_value, "to_sql"): + if isinstance(expr.key_value, BaseExpression): sql, p = expr.key_value.to_sql() parts.append(f"({sql})") params.extend(p) @@ -245,9 +246,9 @@ def format_handler_read_statement( parts.append(f"LIMIT {int(expr.limit)}") return " ".join(parts), tuple(params) - def format_handler_close_statement( + def format_mysql_handler_close_statement( self, - expr: "MySQLHandlerCloseExpression", + expr: "MySQLHandlerCloseStatement", ) -> Tuple[str, tuple]: """Format ``HANDLER table CLOSE``.""" return f"HANDLER {self._format_admin_table(expr.table)} CLOSE", () @@ -257,12 +258,12 @@ def format_handler_close_statement( def supports_do(self) -> bool: return True - def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: + def format_mysql_do_statement(self, expr: "MySQLDoStatement") -> Tuple[str, tuple]: """Format ``DO expr [, expr ...]``.""" params = [] expr_parts = [] for e in expr.expressions: - if hasattr(e, "to_sql"): + if isinstance(e, BaseExpression): sql, p = e.to_sql() expr_parts.append(sql) params.extend(p) @@ -274,7 +275,7 @@ def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: def supports_kill(self) -> bool: return True - def format_kill_statement(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: + def format_mysql_kill_statement(self, expr: "MySQLKillStatement") -> Tuple[str, tuple]: """Format ``KILL [CONNECTION | QUERY] processlist_id``.""" parts = ["KILL"] if expr.target.value: @@ -285,14 +286,14 @@ def format_kill_statement(self, expr: "MySQLKillExpression") -> Tuple[str, tuple def supports_shutdown(self) -> bool: return True - def format_shutdown_statement(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: + def format_mysql_shutdown_statement(self, expr: "MySQLShutdownStatement") -> Tuple[str, tuple]: """Format the ``SHUTDOWN`` statement.""" return "SHUTDOWN", () def supports_help(self) -> bool: return True - def format_help_statement(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: + def format_mysql_help_statement(self, expr: "MySQLHelpStatement") -> Tuple[str, tuple]: """Format ``HELP 'topic'``.""" return f"HELP '{expr.topic}'", () @@ -301,9 +302,9 @@ def format_help_statement(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple def supports_create_user(self) -> bool: return True - def format_create_user_statement( + def format_mysql_create_user_statement( self, - expr: "MySQLCreateUserExpression", + expr: "MySQLCreateUserStatement", ) -> Tuple[str, tuple]: """Format ``CREATE USER [IF NOT EXISTS] acct [IDENTIFIED BY 'pwd']``.""" parts = ["CREATE USER"] @@ -317,7 +318,7 @@ def format_create_user_statement( def supports_drop_user(self) -> bool: return True - def format_drop_user_statement(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: + def format_mysql_drop_user_statement(self, expr: "MySQLDropUserStatement") -> Tuple[str, tuple]: """Format ``DROP USER [IF EXISTS] acct [, acct ...]``.""" parts = ["DROP USER"] if expr.if_exists: @@ -328,7 +329,7 @@ def format_drop_user_statement(self, expr: "MySQLDropUserExpression") -> Tuple[s def supports_grant(self) -> bool: return True - def format_grant_statement(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: + def format_mysql_grant_statement(self, expr: "MySQLGrantStatement") -> Tuple[str, tuple]: """Format ``GRANT priv ON obj TO acct [WITH GRANT OPTION]``.""" priv_parts = [] for p in expr.privileges: @@ -349,7 +350,7 @@ def format_grant_statement(self, expr: "MySQLGrantExpression") -> Tuple[str, tup def supports_revoke(self) -> bool: return True - def format_revoke_statement(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: + def format_mysql_revoke_statement(self, expr: "MySQLRevokeStatement") -> Tuple[str, tuple]: """Format ``REVOKE priv ON obj FROM acct``.""" priv_parts = [] for p in expr.privileges: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py index a2ad3825..d8eb1fc4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py @@ -14,7 +14,7 @@ from typing import FrozenSet, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from rhosocial.activerecord.backend.expression.collation import CollateExpression + from rhosocial.activerecord.backend.expression.collation import CollateFunction __all__ = [ "MySQLCharset", @@ -24,8 +24,11 @@ ] -class MySQLCharset(Enum): +class MySQLCharset(str, Enum): """MySQL character sets for table/column ``CHARACTER SET``.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + ARMSCII8 = "armscii8" ASCII = "ascii" @@ -71,8 +74,11 @@ class MySQLCharset(Enum): UTF32 = "utf32" -class MySQLStorageEngine(Enum): +class MySQLStorageEngine(str, Enum): """Built-in MySQL storage engines for ``ENGINE=``.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + INNODB = "InnoDB" MYISAM = "MyISAM" @@ -89,8 +95,11 @@ class MySQLStorageEngine(Enum): TEMPORARY = "TEMPORARY" -class MySQLCollation(Enum): +class MySQLCollation(str, Enum): """MySQL collations for expression-level COLLATE.""" + #: A member *is* the word it carries; see `SortDirection.__str__`. + __str__ = str.__str__ + # Built-in pseudo collation BINARY = "binary" @@ -407,13 +416,29 @@ def supports_collate_expression(self) -> bool: """MySQL supports expression-level COLLATE.""" return True - def validate_collation_name(self, expr: "CollateExpression") -> str: - """Validate a ``CollateExpression`` and return its collation SQL.""" + def validate_collation_name(self, expr: "CollateFunction") -> str: + """Validate a ``CollateFunction`` and return its collation SQL. + + The node's options bag is gone: core made schema qualification the + one named parameter, so there is no ``collation_options`` to read any + more (the old read raised ``AttributeError`` from inside the + renderer). MySQL's ``COLLATE`` grammar takes a single collation + identifier -- a schema-qualified request is not a spelling this + server parses, so it is refused by name here rather than silently + dropped. + """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - if expr.collation_options: - unsupported = ", ".join(sorted(expr.collation_options)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {unsupported}") + if expr.schema is not None: + raise UnsupportedFeatureError( + self.name, + "COLLATE schema qualification", + suggestion=( + "MySQL's COLLATE takes a single collation name, e.g. " + "COLLATE utf8mb4_bin; a schema-qualified collation is " + "not part of this server's grammar" + ), + ) return self.validate_collation_by_name(expr.collation_name) # --- charset --------------------------------------------------------- diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py index 0081584d..aa6da085 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/column.py @@ -3,8 +3,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - ModifyColumn, - ChangeColumn, + ChangeColumnAction, + ModifyColumnAction, ) @@ -17,7 +17,7 @@ def supports_modify_column(self) -> bool: def supports_change_column(self) -> bool: return True - def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, tuple]: + def format_modify_column_action(self, action: "ModifyColumnAction") -> Tuple[str, tuple]: """Format MODIFY COLUMN action for ALTER TABLE.""" col_sql, col_params = self.format_column_definition(action.column) sql = f"MODIFY COLUMN {col_sql}" @@ -27,7 +27,7 @@ def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, tupl sql += " FIRST" return sql, col_params - def format_change_column_action(self, action: "ChangeColumn") -> Tuple[str, tuple]: + def format_change_column_action(self, action: "ChangeColumnAction") -> Tuple[str, tuple]: """Format CHANGE COLUMN action for ALTER TABLE.""" col_sql, col_params = self.format_column_definition(action.column) sql = f"CHANGE COLUMN {self.format_identifier(action.old_name)} {col_sql}" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py index 75e9d78f..5cbba582 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/concurrency.py @@ -18,8 +18,14 @@ def connect(self): def _fetch_concurrency_hint(self) -> None: """Fetch max_connections from MySQL server and compute concurrency hint.""" try: + from ..expression.show import ShowVariablesStatement + cursor = self._connection.cursor(dictionary=True) - cursor.execute("SHOW VARIABLES LIKE 'max_connections'") + cursor.execute( + *ShowVariablesStatement( + self.dialect, like_pattern="max_connections" + ).to_sql() + ) row = cursor.fetchone() cursor.close() @@ -56,8 +62,14 @@ async def connect(self): async def _fetch_concurrency_hint(self) -> None: """Fetch max_connections from MySQL server and compute concurrency hint.""" try: + from ..expression.show import ShowVariablesStatement + cursor = await self._connection.cursor(dictionary=True) - await cursor.execute("SHOW VARIABLES LIKE 'max_connections'") + await cursor.execute( + *ShowVariablesStatement( + self.dialect, like_pattern="max_connections" + ).to_sql() + ) row = await cursor.fetchone() await cursor.close() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py index 65ca4d9d..3bf378c4 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py @@ -1,31 +1,732 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/constraint.py +from typing import TYPE_CHECKING, Tuple + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterConstraintAction, + ) + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraint, + TableConstraint, + ) + + +def _constraint_kind_sql(branch) -> str: + """The SQL spelling of a constraint branch's kind. + + A refusal names the constraint the way the caller spelled it, so the + message reads ``FOREIGN KEY NOT ENFORCED`` rather than naming an enum + member or a Python class. The kind used to be a tag on an enum and is a + branch class now, so the mapping is derived rather than carried on the + node; a branch outside the table falls back to its own name rather than + being silently reported as some other kind. + + The branches are imported here rather than at module scope because the + expression layer is behind the lazy-import boundary this package uses. + """ + from rhosocial.activerecord.backend.expression.statements import ( + ColumnCheckConstraint, + ColumnForeignKeyConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableCheckConstraint, + TableExcludeConstraint, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, + ) + + return { + ColumnPrimaryKeyConstraint: "PRIMARY KEY", + TablePrimaryKeyConstraint: "PRIMARY KEY", + ColumnUniqueConstraint: "UNIQUE", + TableUniqueConstraint: "UNIQUE", + ColumnForeignKeyConstraint: "FOREIGN KEY", + TableForeignKeyConstraint: "FOREIGN KEY", + ColumnCheckConstraint: "CHECK", + TableCheckConstraint: "CHECK", + TableExcludeConstraint: "EXCLUDE", + }.get(type(branch), type(branch).__name__) + + class MySQLConstraintMixin: - """MySQL constraint feature support.""" + """MySQL constraint capability probes. + + Every verdict in this mixin was measured against a live server, and the + docstring of a switch records what the server did with the exact spelling + in question. Two of those measurements explain why several answers are + ``False`` even though the statement is *accepted*: + + * ``MATCH`` parses and is then discarded, so rendering the clause would + tell the caller it is in effect when nothing enforces it. + * ``ENFORCED`` is accepted but renders identically to the default, so the + rendered DDL cannot show that it was written. + + The third is that ``ALTER CONSTRAINT`` exists in exactly one form, for + exactly one kind of constraint, and that a primary key is not in the + constraint-name space that ``DROP CONSTRAINT`` addresses. + + **Every constraint verdict this backend relies on is declared *here*.** + The values below agree with core's ``ConstraintMixin`` defaults, but the + agreement is the point rather than an accident: a switch this backend + depends on must resolve to a class in this package, so a core default that + moves cannot silently move MySQL's contract with it. ``test_constraint_ + switches_are_declared_locally`` in this backend's test suite fails if any + of them is ever dropped and falls back to inheriting. + + A note on MariaDB, which is **not** this backend and must never be folded + into it: MariaDB 13.1 rejects ``[NOT] ENFORCED`` outright (this MySQL + accepts it on CHECK), and MariaDB *does* accept ``DROP CONSTRAINT IF + EXISTS`` (MySQL answers ``1064``). The two disagree on exactly the two + clauses a caller would most expect to be portable, so each verdict below + is stated for MySQL only. + """ def supports_invisible_index(self) -> bool: """Whether INVISIBLE indexes are supported.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_descending_index(self) -> bool: """Whether descending indexes are supported.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_functional_index(self) -> bool: """Whether functional (expression) indexes are supported.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) + + # Basic constraint types (SQL-86/SQL-92) + + def supports_primary_key_constraint(self) -> bool: + """Whether PRIMARY KEY constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY (v)`` is accepted and + rendered back by ``SHOW CREATE TABLE`` as ``PRIMARY KEY (`v`)``. + + Two measured details belong to the *name space*, not to this switch, + and are recorded in :meth:`supports_drop_constraint` and + :meth:`supports_alter_constraint_enforced`: MySQL discards the + ``CONSTRAINT pk`` symbol for a primary key (the rendered DDL contains + no ``pk``), and a second one is refused with + ``1068 (42000): Multiple primary key defined``. + """ + return True + + def supports_unique_constraint(self) -> bool: + """Whether UNIQUE constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT uq UNIQUE (v)`` is accepted, the symbol + survives in ``SHOW CREATE TABLE`` as ``CONSTRAINT `uq` UNIQUE (`v`)``, + and ``ALTER TABLE t DROP CONSTRAINT uq`` removes it. + """ + return True + + def supports_not_null_constraint(self) -> bool: + """Whether NOT NULL column constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``CREATE TABLE t (x INT NOT NULL)`` is accepted and renders + ``x int NOT NULL``. Trivial for the server, but declared here so the + verdict is this backend's rather than an inherited default. + """ + return True + + def supports_foreign_key_constraint(self) -> bool: + """Whether FOREIGN KEY constraints are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p (id)`` + is accepted, rendered back, and enforced -- a dangling child row is + refused with + ``1452 (23000): Cannot add or update a child row: a foreign key + constraint fails``, and adding the constraint over such a row fails the + same way. + """ + return True + + def supports_fk_on_delete(self) -> bool: + """Whether ``ON DELETE`` referential actions are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): all five + ``reference_option`` values are accepted on ``ADD CONSTRAINT``, and + ``information_schema.REFERENTIAL_CONSTRAINTS.DELETE_RULE`` reads back + ``CASCADE`` for the action that was written. + + Independent of :meth:`supports_fk_match`, which is ``False``: that + clause is discarded, while these actions survive even when a ``MATCH`` + is written alongside them (measured -- see ``supports_fk_match``). + """ + return True + + def supports_fk_on_update(self) -> bool: + """Whether ``ON UPDATE`` referential actions are supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): ``ON + UPDATE CASCADE`` is accepted and + ``information_schema.REFERENTIAL_CONSTRAINTS.UPDATE_RULE`` reads back + ``CASCADE``. Measured even together with ``MATCH FULL``, where the + manual claims the referential actions are ignored (see + :meth:`supports_fk_match`). + """ + return True + + def supports_exclude_constraint(self) -> bool: + """Whether EXCLUDE table constraints are supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t ADD CONSTRAINT ex EXCLUDE (a WITH =)`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'E' + + This is a backend-owned ``False`` rather than the inherited default, so + that no core change can hand MySQL a constraint kind it cannot spell. + """ + return False def supports_check_constraint(self) -> bool: - """Whether CHECK constraints are enforced.""" - return self.version >= (8, 0, 16) + """Whether CHECK constraints are supported and enforced. + + Measured on Community Server 26.7.0 (192.168.1.3:14686): + ``ALTER TABLE t ADD CONSTRAINT ck CHECK (v > 0)`` is accepted and + rendered back by ``SHOW CREATE TABLE`` as + ``CONSTRAINT ck CHECK ((v > 0))``, and it is really enforced -- adding + it over a row that violates it is refused with + ``3819 (HY000): Check constraint 'ck3' is violated.`` and a violating + insert afterwards with ``3819 (HY000): Check constraint 'ck3' is + violated.`` (measured). The same addition with ``NOT ENFORCED`` is + accepted over the violating row and admits it. + + Below 8.0.16 the clause parses and is discarded, so the probe is + version-gated rather than a flat ``True``. + """ + return self.version_at_least((8, 0, 16)) + + def supports_add_constraint(self) -> bool: + """Whether ``ALTER TABLE ADD CONSTRAINT`` is supported. + + True. Measured on Community Server 26.7.0 (192.168.1.3:14686): all + four kinds are accepted by the ALTER path -- ``ADD CONSTRAINT ck + CHECK (v > 0)``, ``ADD CONSTRAINT pk PRIMARY KEY (id)`` (on a table + that has no primary key yet; a second one is refused with + ``1068 (42000): Multiple primary key defined``), ``ADD CONSTRAINT uq + UNIQUE (v)`` and ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p + (id) ON DELETE CASCADE`` each succeed and are rendered back by + ``SHOW CREATE TABLE``. + + Adding validates the rows already present: a CHECK over a violating + row fails with ``3819 (HY000)`` and a FOREIGN KEY over a dangling + row with ``1452 (23000): Cannot add or update a child row: a foreign + key constraint fails``. There is therefore no "add first, check + later" spelling here (see ``supports_constraint_novalidate``), and the + clause-level attributes are not additive (see + ``supports_deferrable_constraint``). + """ + return True + + def supports_drop_constraint(self) -> bool: + """Whether ``ALTER TABLE DROP CONSTRAINT`` is supported. + + True, but the name space is narrower than it looks. Measured on + Community Server 26.7.0 (192.168.1.3:14686): + + * ``DROP CONSTRAINT uq`` (UNIQUE) and ``DROP CONSTRAINT fk`` + (FOREIGN KEY) both work. + * ``DROP CONSTRAINT pk2`` answers + ``3940 (HY000): Constraint 'pk2' does not exist.`` even though the + table really does have that primary key -- ``SHOW CREATE TABLE`` + renders ``PRIMARY KEY (`v`)`` on a table where + ``ADD CONSTRAINT pk2 PRIMARY KEY (v)` had just succeeded. The + symbol ``pk2`` never appears anywhere in the rendered DDL: MySQL + drops the ``CONSTRAINT`` symbol for a PRIMARY KEY outright, so the + name space ``DROP CONSTRAINT`` addresses never contained it. The + working spelling is ``ALTER TABLE t DROP PRIMARY KEY`` (measured -- + it removes the key). This is a genuine per-kind gap: one boolean + cannot say "drop everything except the primary key", and a caller + that treats ``supports_drop_constraint() is True`` as "any named + constraint can be dropped" is wrong for that one kind. + * Dropping an FK leaves its auto-created helper index behind: + ``KEY fkm (d)`` survives ``DROP CONSTRAINT fkm``, with the + ``CONSTRAINT ... FOREIGN KEY`` line gone (measured). + * A missing name answers ``3940 (HY000): Constraint 'x' does not + exist.`` while ``DROP CHECK x`` answers ``3821 (HY000): Check + constraint 'x' is not found in the table.`` -- the same "not + there" outcome under **two different errnos**, so error mapping + keyed on a single errno silently misses half the cases. Neither can + be caught in a way that makes the drop re-runnable (see + ``supports_drop_constraint_if_exists``). + + MariaDB 13.1 diverges from this backend on the idempotent spelling -- + it *does* accept ``DROP CONSTRAINT IF EXISTS`` -- so a value copied + from the MariaDB backend into this one would be wrong. See + :meth:`supports_drop_constraint_if_exists`. + """ + return True + + def supports_drop_constraint_if_exists(self) -> bool: + """Whether ``ALTER TABLE DROP CONSTRAINT IF EXISTS`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t DROP CONSTRAINT IF EXISTS x`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'IF EXISTS x' at line 1 + + The failure is in the parser, before the name is looked up, so no + errno can be caught to make a drop idempotent: **there is no + idempotent drop spelling for a MySQL constraint at all.** + ``MySQLDDLColumnMixin.format_drop_table_constraint_action`` refuses + the ``if_exists=True`` request by name for this reason; pre-check + ``information_schema.TABLE_CONSTRAINTS`` instead. + + MariaDB 13.1 measured ``True`` for the same switch, so the two must not + be conflated: this is MySQL only. + """ + return False + + def supports_rename_constraint(self) -> bool: + """Whether ``ALTER TABLE ... RENAME CONSTRAINT ... TO ...`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t RENAME CONSTRAINT ck TO ck2`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'CONSTRAINT ck TO ck2' at line 1 + + The failure is at the parser, before either name is resolved, so + renaming has to be done as a drop-and-re-add. + + The name of this switch is provisional: core S3 owns the final + ``AlterTableConstraintSupport`` spelling and may rename it. + """ + return False + + def supports_enable_disable_constraint(self) -> bool: + """Whether ``ALTER TABLE ... {ENABLE|DISABLE} CONSTRAINT`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), both + halves are parser errors: + + 1064 (42000): ... near 'CONSTRAINT ck' at line 1 + + ``ENABLE`` and ``DISABLE`` are parseable keywords in MySQL (they belong + to ``ENABLE KEYS``, ``DISABLE KEYS`` and to the ``ALTER TABLE + ENABLE|DISABLE KEYS`` forms), so the failure lands on the trailing + ``CONSTRAINT ck`` rather than on the keyword -- worth knowing when + reading the error, which does not name the real problem. + + Only Oracle has this of the backends measured. The name of this switch + is provisional: core S3 owns the final spelling and may rename it. + """ + return False + + def supports_constraint_novalidate(self) -> bool: + """Whether ``ADD CONSTRAINT ... NOT VALID`` is accepted. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t ADD CONSTRAINT ck CHECK (v > 0) NOT VALID`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'VALID' at line 1 + + and the same for ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p + (id) NOT VALID``. There is nothing to defer to: an added CHECK is + validated against the rows already present (``3819``) and so is an + added FOREIGN KEY (``1452``). + """ + return False + + def supports_validate_constraint(self) -> bool: + """Whether ``ALTER TABLE ... VALIDATE CONSTRAINT`` is supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t VALIDATE CONSTRAINT ck`` answers + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'VALIDATE CONSTRAINT ck' at line 1 + + MySQL has no deferred-constraint state to validate -- see + ``supports_deferrable_constraint`` and + ``supports_constraint_novalidate``, both refused by the same 1064. + """ + return False + + def supports_alter_constraint_enforced(self) -> bool: + """Whether the ``ALTER CONSTRAINT ... [NOT] ENFORCED`` verb exists. + + Version-only, per the rule that a switch answers a family and the + kinds it can address are narrowed in the formatter: this backend's + :meth:`format_alter_constraint_action` refuses every kind but CHECK + by name before the shared rendering runs. + + The verb exists from 8.0.16 on. Measured on Community Server 26.7.0 + (192.168.1.3:14686): + + * ``ALTER TABLE t ALTER CONSTRAINT ck NOT ENFORCED`` is accepted and + ``SHOW CREATE TABLE`` then renders + ``CONSTRAINT `ck` CHECK ((`v` > 0)) /*!80016 NOT ENFORCED */``. + * ``ALTER TABLE t ALTER CONSTRAINT ck ENFORCED`` is accepted and the + marker is dropped again -- but note the asymmetry with + :meth:`supports_constraint_enforced`: here ``ENFORCED`` is at + least *observable* as the loss of the ``NOT ENFORCED`` marker, + whereas on the ``ADD`` path a freshly written ``ENFORCED`` leaves + DDL that is identical to a constraint where the clause was never + typed. Either way the rendered DDL cannot prove ``ENFORCED`` was + written. + * On a FOREIGN KEY the server answers, rather than the parser: + ``3941 (HY000): Altering constraint enforcement is not supported + for the constraint 'fkZ'. Enforcement state alter is not supported + for the PRIMARY, UNIQUE and FOREIGN KEY type constraints.`` (errno + ``3941``, measured). The server itself names the three kinds it + refuses, which is the evidence the formatter's CHECK-only + narrowing stands on. + + ``ALTER CONSTRAINT ck [NOT] ENFORCED`` is the **only** working + ``ALTER CONSTRAINT`` form MySQL has (see + :meth:`supports_alter_constraint`); ``ALTER CONSTRAINT ck + DEFERRABLE`` is a parser error (1064) and belongs to + :meth:`supports_deferrable_constraint`, not here. + + MariaDB 13.1 has no ``ALTER CONSTRAINT`` at all and rejects + ``[NOT] ENFORCED`` on every path, so this ``True`` is MySQL only. + """ + return self.version_at_least((8, 0, 16)) + + def format_alter_constraint_action( + self, action: "AlterConstraintAction" + ) -> Tuple[str, tuple]: + """``ALTER CONSTRAINT`` addresses CHECK only on this server. + + The server refuses the other kinds rather than the parser (errno + ``3941`` names PRIMARY, UNIQUE and FOREIGN KEY -- measured, see + :meth:`supports_alter_constraint_enforced`), so the dialect refuses + them by name instead of sending a statement the server rejects. + The action names its kind by the branch class it carries; the + constructor has already refused every branch but CHECK and + FOREIGN KEY, so the narrowing left here is the FOREIGN KEY half. + + Raises: + UnsupportedFeatureError: The branch is anything but a table + CHECK. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.expression.statements import ( + TableCheckConstraint, + ) + + branch = action.constraint_branch + if branch is not TableCheckConstraint: + feature = f"ALTER CONSTRAINT {_constraint_kind_sql(branch)} ENFORCED/NOT ENFORCED" + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}. MySQL alters the " + "enforcement state of CHECK constraints only; the server answers " + "errno 3941 on PRIMARY KEY, UNIQUE and FOREIGN KEY.", + ) + return super().format_alter_constraint_action(action) + + def supports_alter_constraint(self) -> bool: + """Whether ``ALTER TABLE ... ALTER CONSTRAINT`` is supported at all. + + True, for exactly one attribute. Measured on Community Server 26.7.0 + (192.168.1.3:14686) the statement exists and ``ALTER CONSTRAINT ck + [NOT] ENFORCED`` works on a CHECK; every other attribute -- ``DEFERRABLE``, + ``INITIALLY DEFERRED``, ``VALIDATE``, ``NOVALIDATE``, ``RELY`` -- is a + ``1064 (42000)`` parser error. + + So ``True`` here means "one form exists", not "constraints are + alterable": the attribute-level answer is + :meth:`supports_alter_constraint_enforced`, and the deferred/validate + answers are :meth:`supports_deferrable_constraint`, + :meth:`supports_constraint_novalidate` and + :meth:`supports_validate_constraint`, all ``False`` on the same 1064. + + The name of this switch is provisional: core S3 owns the final + ``AlterTableConstraintSupport`` spelling and may rename it. + """ + return True + + # Constraint enforcement control (SQL:2016) def supports_constraint_enforced(self) -> bool: - """Whether ENFORCED/NOT ENFORCED constraint control is supported.""" - return self.version >= (8, 0, 16) + """Whether ``[NOT] ENFORCED`` on a CHECK constraint is supported. + + True from 8.0.16 on. Measured on Community Server 26.7.0 + (192.168.1.3:14686) on the ``ADD CONSTRAINT`` / ``CREATE TABLE`` + path: + + * ``CHECK (v > 0) NOT ENFORCED`` is accepted and round-trips as + ``CHECK ((v > 0)) /*!80016 NOT ENFORCED */``. + * ``CHECK (v > 0) ENFORCED`` is accepted but renders *identically to + the default* -- no clause at all in ``SHOW CREATE TABLE``. Only + ``information_schema.TABLE_CONSTRAINTS.ENFORCED`` distinguishes + them ('YES' / 'NO', measured); the rendered DDL cannot. + + Check only: ``ADD CONSTRAINT fk FOREIGN KEY ... NOT ENFORCED`` and + ``ADD CONSTRAINT uq UNIQUE (v) NOT ENFORCED`` are both answered by + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'NOT ENFORCED' at line 1 + + The per-kind half of that lives in the formatter, which renders the + clause for CHECK and refuses the FOREIGN KEY request by name; the + ALTER-path half lives in ``supports_alter_constraint_enforced``. + + **The caveat on ``ENFORCED`` above applies to every caller of this + switch.** Saying "this backend supports ENFORCED" is true and still + not enough to make a round-trip assertion: a rendered ``ENFORCED`` and + a rendered default are the same string, so a caller cannot prove from + the DDL that the clause was accepted, only that nothing is missing. + + MariaDB 13.1 rejects ``[NOT] ENFORCED`` outright on every path, so this + ``True`` is MySQL only. + """ + return self.version_at_least((8, 0, 16)) + + def _require_constraint_enforcement(self, constraint) -> None: + """Narrow the ENFORCABLE kinds to CHECK for this server. + + The shared renderer accepts whatever the standard-derived whitelist + allows; MySQL's parser accepts the trait on CHECK only (measured on + Community Server 26.7.0: UNIQUE and FOREIGN KEY answer ``1064`` -- + see :meth:`supports_constraint_enforced`), so the branch is narrowed + here and everything else is refused by name. Both levels render the + trait through this gate -- the column path from + ``format_constraint_traits`` and the table path from the core's + ``format_table_constraint`` -- so one narrowing refuses alike for + the column clause and the table clause, and each refusal names the + kind and the spelling it refuses so the two halves of the trait + stay distinguishable. + + Raises: + UnsupportedFeatureError: The branch is not a CHECK, or the + version predates the clause. + """ + if not (constraint.enforced or constraint.not_enforced): + return + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + spelling = "ENFORCED" if constraint.enforced else "NOT ENFORCED" + kind = _constraint_kind_sql(constraint.branch) + if kind != "CHECK": + raise UnsupportedFeatureError( + self.name, + f"{kind} {spelling}", + f"{self.name} does not support {kind} {spelling}. MySQL carries " + "[NOT] ENFORCED on CHECK constraints only; the server answers " + "1064 on every other kind (measured on 26.7.0).", + ) + if not self.supports_constraint_enforced(): + feature = f"CHECK {spelling}" + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}. MySQL accepts " + f"{spelling} on CHECK constraints from 8.0.16 on; below that the " + "clause parses and is discarded (measured on 26.7.0: 1064).", + ) + + def format_constraint_enforcement(self, constraint: "ColumnConstraint") -> str: + """Render the enforcement trait behind this backend's narrowing. + + The shared private hook takes the production, so it is shared by the + column-level trait renderer and the table-level one: the kind + narrowing must therefore live in a helper both can reach, not in the + kind test itself. That is what :meth:`_require_constraint_enforcement` + is -- the table path calls it ahead of the branch body (see + :meth:`format_table_constraint`) so the refusal still names the + spelling when the branch has its own, earlier refusal to make. + """ + self._require_constraint_enforcement(constraint) + return super().format_constraint_enforcement(constraint) + + # Constraint deferral (the rendering half; the probe is above) + + def _require_no_constraint_deferral(self, constraint) -> None: + """Refuse a deferral trait by the exact spelling that was asked for. + + The probe (:meth:`supports_deferrable_constraint`) is a whole-family + ``False`` and the renderer must refuse *by name* -- one generic + message for ``DEFERRABLE`` and ``NOT DEFERRABLE`` would leave the two + halves of the trait indistinguishable, which is exactly what the + clause-pair guard refuses to accept. The refusal names the branch's + SQL kind and the spelling that was asked for, so a + ``FOREIGN KEY NOT DEFERRABLE`` and a ``FOREIGN KEY INITIALLY DEFERRED`` + do not collapse into one message. The spellings are the SQL ones + (measured on 26.7.0: every one answers ``1064`` -- see the probe). + + Raises: + UnsupportedFeatureError: Any deferral trait was requested. + """ + wants = [] + if constraint.deferrable: + wants.append("DEFERRABLE") + if constraint.not_deferrable: + wants.append("NOT DEFERRABLE") + if constraint.initially_deferred: + wants.append("INITIALLY DEFERRED") + if constraint.initially_immediate: + wants.append("INITIALLY IMMEDIATE") + if wants and not self.supports_deferrable_constraint(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + feature = f"{_constraint_kind_sql(constraint.branch)} " + " ".join(wants) + raise UnsupportedFeatureError( + self.name, + feature, + f"{self.name} does not support {feature}. No spelling of " + "constraint deferral exists in MySQL (measured on 26.7.0: " + "1064).", + ) + + def format_constraint_traits(self, constraint: "ColumnConstraint") -> Tuple[str, str]: + """Render the column-clause traits after this backend's deferral gate. + + Core's own renderer refuses the deferral family with one generic + message; this backend narrows through + :meth:`_require_no_constraint_deferral` first so the refusal names + the exact spelling, then defers everything else to core. + """ + self._require_no_constraint_deferral(constraint) + return super().format_constraint_traits(constraint) + + def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, Tuple]: + """Render a table-level constraint clause after this backend's gates. + + The production-level rendering is core's; what this override adds is + the two narrowings the column path runs, both ahead of the + delegation: + + * the deferral gate, so a ``DEFERRABLE`` request on a table clause is + refused by its exact spelling rather than by the generic family + message core's renderer would produce; + * the enforcement gate, **before** the branch body. Core renders the + branch first and the traits after it, but this server's own CHECK + body carries its own version refusal + (``format_table_check_constraint``, below 8.0.16) that says nothing + about the enforcement spelling. Running the gate first keeps a + ``CHECK ... NOT ENFORCED`` at 8.0.0 refused by the spelling it + asked for, which is what keeps the two refusals distinguishable -- + the ordering this mixin had before the branch nodes split the + production apart. + """ + self._require_no_constraint_deferral(expr) + self._require_constraint_enforcement(expr) + return super().format_table_constraint(expr) + + # FK match modes (SQL:1999) def supports_fk_match(self) -> bool: - """Whether MATCH {SIMPLE|PARTIAL|FULL} is supported.""" + """Whether ``MATCH {SIMPLE|PARTIAL|FULL}`` is supported. + + False -- and not because the spelling is rejected. Measured on + Community Server 26.7.0 (192.168.1.3:14686), + ``ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p (id) + MATCH FULL`` is *accepted*, and the MATCH clause is then parsed away: + ``SHOW CREATE TABLE`` renders + + CONSTRAINT `fk` FOREIGN KEY (`d`) REFERENCES `p` (`id`) + + with no MATCH in it. Three FKs added to one table by ``MATCH FULL``, + ``MATCH PARTIAL`` and no clause at all all render the *same* three + lines, so ``MATCH`` is absent from the DDL entirely. + + **``MATCH_OPTION`` does retain the requested mode.** Measured on 26.7.0, + ``information_schema.REFERENTIAL_CONSTRAINTS.MATCH_OPTION`` reads + ``FULL`` for the ``MATCH FULL`` FK, ``PARTIAL`` for the ``MATCH + PARTIAL`` one, and ``NONE`` for the FK written with no MATCH clause + (measured on the same server, 192.168.1.3:14686). So the server + records the mode in its catalog even though it renders and enforces + nothing from it -- reading it back proves what was *written*, never + what is in *force*. (Earlier notes on this switch said ``MATCH_OPTION`` + came back ``NONE``; that is the value for a FK with no MATCH clause, + and the full-vs-none distinction is the measurement above.) + + The referential actions survive, which is narrower than the manual + claims. MySQL 8.0 manual 15.1.20.5 says an explicit MATCH "also causes + ON DELETE and ON UPDATE clauses to be ignored"; measured on 26.7.0, + ``ADD CONSTRAINT fk FOREIGN KEY (d) REFERENCES p (id) MATCH FULL ON + DELETE CASCADE ON UPDATE CASCADE`` renders + ``... ON DELETE CASCADE ON UPDATE CASCADE``, and the catalog reads + ``DELETE_RULE='CASCADE'``, ``UPDATE_RULE='CASCADE'`` (measured). The + manual's MATCH half holds; its side-effect half does not, on this + version. + + So this is **parsed and discarded, not "unsupported"** -- the clause + is accepted by the grammar and then dropped, and the distinction + matters to callers: an unsupported clause raises a ``1064`` the caller + can catch and report, whereas a discarded clause is accepted silently, + so a caller who wrote it believes MATCH is in force when no engine + recognizes it. That is why the refusal this switch drives is by name + rather than by letting the server take it. + + Official, MySQL 8.0 manual 15.1.20.5 ("FOREIGN KEY Constraints"): + + no storage engine, including InnoDB, recognizes or enforces the + MATCH clause ... specifying MATCH should be avoided. + + MariaDB 13.1 was measured to discard ``MATCH`` the same way, so the + ``False`` is not what separates the two servers here. + """ return False + # Constraint deferral (SQL:1999) + def supports_deferrable_constraint(self) -> bool: - """Whether DEFERRABLE constraints are supported.""" + """Whether ``DEFERRABLE`` / ``INITIALLY DEFERRED|IMMEDIATE`` is + supported. + + False. Measured on Community Server 26.7.0 (192.168.1.3:14686), every + spelling is a parser error: ``ADD CONSTRAINT ck CHECK (v > 0) + DEFERRABLE``, ``ADD CONSTRAINT ck CHECK (v > 0) NOT DEFERRABLE + INITIALLY DEFERRED`` and ``ADD CONSTRAINT fk FOREIGN KEY (d) + REFERENCES p (id) DEFERRABLE`` all answer + + 1064 (42000): You have an error in your SQL syntax; check the + manual that corresponds to your MySQL server version for the + right syntax to use near 'DEFERRABLE' at line 1 + + ``ALTER CONSTRAINT ck DEFERRABLE`` answers the same 1064, which is + what distinguishes it from the one ``ALTER CONSTRAINT`` form MySQL + does accept (``supports_alter_constraint_enforced``). + + This is a whole-family ``False``, and the reason is uniform: **no + spelling of constraint deferral exists in MySQL.** Measured on 26.7.0, + every one of these is ``1064 (42000)``: + + ============================== ================================== + ``ADD CONSTRAINT ... DEFERRABLE`` ``near 'DEFERRABLE'`` + ``ADD CONSTRAINT ... NOT VALID`` ``near 'VALID'`` + ``VALIDATE CONSTRAINT ck`` ``near 'VALIDATE CONSTRAINT ck'`` + ``ALTER CONSTRAINT ck DEFERRABLE`` ``near 'DEFERRABLE'`` + ``RENAME CONSTRAINT ck TO ck2`` ``near 'CONSTRAINT ck TO ck2'`` + ``ENABLE CONSTRAINT ck`` ``near 'CONSTRAINT ck'`` + ``DISABLE CONSTRAINT ck`` ``near 'CONSTRAINT ck'`` + ``NOCHECK CONSTRAINT ck`` ``near 'NOCHECK CONSTRAINT ck'`` + ``CHECK CONSTRAINT ck`` ``near 'CONSTRAINT ck'`` + ``DROP CONSTRAINT IF EXISTS ck`` ``near 'IF EXISTS ck'`` + ============================== ================================== + + Note the last one is this backend's lack of an idempotent drop spelling + rather than part of the deferral family; it is listed because it is + the same errno and the same fix (no such grammar). The remaining + switches in the family each record their own measurement: + ``supports_constraint_novalidate``, ``supports_validate_constraint``, + ``supports_rename_constraint``, ``supports_enable_disable_constraint`` + and ``supports_drop_constraint_if_exists``. + """ return False diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py index 6efcf6c7..207bd836 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/cte.py @@ -4,11 +4,11 @@ class MySQLCTEMixin: def supports_basic_cte(self) -> bool: """Basic CTEs are supported since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_recursive_cte(self) -> bool: """Recursive CTEs are supported since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_materialized_cte(self) -> bool: """Whether the CTE ``MATERIALIZED`` / ``NOT MATERIALIZED`` hint exists. @@ -23,4 +23,4 @@ def supports_materialized_cte(self) -> bool: def supports_unconditional_cte_order_by(self) -> bool: """ORDER BY inside a CTE definition requires CTE support (8.0.0+).""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py index c5da1e39..7dec5a82 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py @@ -1,7 +1,12 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py -from typing import Tuple +from enum import Enum +from typing import TYPE_CHECKING, Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.impl.mysql.expression.alter_column import MySQLAddColumn class MySQLDDLColumnMixin: @@ -30,20 +35,44 @@ def format_column(self, expr) -> Tuple[str, Tuple]: return col_sql, () def format_add_column_action(self, action) -> Tuple[str, tuple]: - if getattr(action, "if_not_exists", None) is True: + """Format core's ``ADD COLUMN`` action. + + ``AddColumnAction`` declares ``if_not_exists`` and ``column``, so both + are read directly: the contract states which fields exist, and a + formatter that probes for them cannot tell a missing field from a + missing feature. The MySQL-only positional clause lives on + :class:`MySQLAddColumn` and has its own entry. + """ + if action.if_not_exists is True: + raise UnsupportedFeatureError( + self.name, "ALTER TABLE ADD COLUMN IF NOT EXISTS", + suggestion="MySQL does not support IF NOT EXISTS on ADD COLUMN. " + "Pre-check information_schema.COLUMNS before ALTER." + ) + column_sql, column_params = self.format_column_definition(action.column) + return f"ADD COLUMN {column_sql}", column_params + + def format_mysql_add_column(self, action: "MySQLAddColumn") -> Tuple[str, tuple]: + """Format MySQL's ``ADD COLUMN``, with its optional ``AFTER`` clause. + + Its own entry because it is its own node: ``AFTER `` is a field + core's action does not declare, and reading it off the core class meant + probing for a field that is not in the contract. + """ + if action.if_not_exists is True: raise UnsupportedFeatureError( self.name, "ALTER TABLE ADD COLUMN IF NOT EXISTS", suggestion="MySQL does not support IF NOT EXISTS on ADD COLUMN. " "Pre-check information_schema.COLUMNS before ALTER." ) column_sql, column_params = self.format_column_definition(action.column) - after = getattr(action, "after", None) - if after: - return f"ADD COLUMN {column_sql} AFTER {self.format_identifier(after)}", column_params + if action.after: + return f"ADD COLUMN {column_sql} AFTER {self.format_identifier(action.after)}", column_params return f"ADD COLUMN {column_sql}", column_params def format_drop_column_action(self, action) -> Tuple[str, tuple]: - if getattr(action, "if_exists", None) is True: + """Format ``DROP COLUMN``.""" + if action.if_exists is True: raise UnsupportedFeatureError( self.name, "DROP COLUMN IF EXISTS", suggestion="MySQL does not support IF EXISTS on DROP COLUMN. " @@ -52,7 +81,8 @@ def format_drop_column_action(self, action) -> Tuple[str, tuple]: return super().format_drop_column_action(action) def format_drop_table_constraint_action(self, action) -> Tuple[str, tuple]: - if getattr(action, "if_exists", None) is True: + """Format ``DROP CONSTRAINT``.""" + if action.if_exists is True: raise UnsupportedFeatureError( self.name, "DROP CONSTRAINT IF EXISTS", suggestion="MySQL does not support IF EXISTS on DROP CONSTRAINT. " @@ -92,20 +122,25 @@ def format_references_clause(self, expr) -> Tuple[str, Tuple]: def format_alter_column_action(self, action) -> Tuple[str, tuple]: """Format ALTER TABLE ... ALTER COLUMN {SET DEFAULT | DROP DEFAULT}.""" - operation = getattr(action.operation, "value", None) or str(action.operation) + # The contract types the operation as the enum member or the word it + # spells, so the entry asks which one it is instead of asking the + # object whether it happens to carry a ``value``. + operation = ( + action.operation.value if isinstance(action.operation, Enum) else action.operation + ) col_name = self.format_identifier(action.column_name) if operation == "DROP DEFAULT": return f"ALTER COLUMN {col_name} DROP DEFAULT", () if operation == "SET DEFAULT": - new_value = getattr(action, "new_value", None) + new_value = action.new_value if isinstance(new_value, str): - escaped = self._escape_sql_string(new_value) + escaped = self.escape_sql_string(new_value) return f"ALTER COLUMN {col_name} SET DEFAULT '{escaped}'", () if new_value is None: raise ValueError("SET DEFAULT requires a default value") - if hasattr(new_value, "to_sql"): + if isinstance(new_value, BaseExpression): value_sql, value_params = new_value.to_sql() if value_params: from rhosocial.activerecord.backend.expression.core import Literal diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py index 61e0397f..15ef797c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_database.py @@ -11,9 +11,9 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - AlterDatabaseExpression, - CreateDatabaseExpression, - DropDatabaseExpression, + AlterDatabaseStatement, + CreateDatabaseStatement, + DropDatabaseStatement, ) @@ -50,7 +50,7 @@ def supports_database_collation(self) -> bool: return True def format_create_database_statement( - self, expr: CreateDatabaseExpression + self, expr: CreateDatabaseStatement ) -> Tuple[str, tuple]: """Format ``CREATE DATABASE``. @@ -61,7 +61,7 @@ def format_create_database_statement( database's -- ``CREATE DATABASE `users``` is a well-formed statement addressing something else. """ - require_kind(expr.database, Database, "CreateDatabaseExpression.database") + require_kind(expr.database, Database, "CreateDatabaseStatement.database") parts = ["CREATE DATABASE"] if expr.if_not_exists: parts.append("IF NOT EXISTS") @@ -73,7 +73,7 @@ def format_create_database_statement( return " ".join(parts), () def format_drop_database_statement( - self, expr: DropDatabaseExpression + self, expr: DropDatabaseStatement ) -> Tuple[str, tuple]: """Format ``DROP DATABASE``. @@ -81,7 +81,7 @@ def format_drop_database_statement( TypeError: ``expr.database`` is not a Database; another object kind would be named as the database's. """ - require_kind(expr.database, Database, "DropDatabaseExpression.database") + require_kind(expr.database, Database, "DropDatabaseStatement.database") parts = ["DROP DATABASE"] if expr.if_exists: parts.append("IF EXISTS") @@ -89,7 +89,7 @@ def format_drop_database_statement( return " ".join(parts), () def format_alter_database_statement( - self, expr: AlterDatabaseExpression + self, expr: AlterDatabaseStatement ) -> Tuple[str, tuple]: """Format ``ALTER DATABASE``. @@ -99,7 +99,7 @@ def format_alter_database_statement( """ from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction - require_kind(expr.database, Database, "AlterDatabaseExpression.database") + require_kind(expr.database, Database, "AlterDatabaseStatement.database") parts = ["ALTER DATABASE"] parts.append(expr.database.to_sql()[0]) if expr.action == AlterDatabaseAction.CHARACTER_SET: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py index 1ccec98c..3430a312 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_rename_table.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.rename_table import ( - MySQLRenameTableExpression, + MySQLRenameTableStatement, ) @@ -35,9 +35,9 @@ def supports_cross_database_rename(self) -> bool: """ return True - def format_rename_table_statement( + def format_mysql_rename_table_statement( self, - expr: "MySQLRenameTableExpression", + expr: "MySQLRenameTableStatement", ) -> Tuple[str, tuple]: """Format a MySQL ``RENAME TABLE ...`` statement. @@ -61,10 +61,10 @@ def format_rename_table_statement( pairs = [] for position, (old_table, new_table) in enumerate(expr.renames): require_kind( - old_table, Table, f"MySQLRenameTableExpression.renames old side at position {position}" + old_table, Table, f"MySQLRenameTableStatement.renames old side at position {position}" ) require_kind( - new_table, Table, f"MySQLRenameTableExpression.renames new side at position {position}" + new_table, Table, f"MySQLRenameTableStatement.renames new side at position {position}" ) old_sql = old_table.to_sql()[0] new_sql = new_table.to_sql()[0] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 270ca451..3242f1e5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -1,6 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py from typing import Any, List, TYPE_CHECKING, Tuple -import re from rhosocial.activerecord.backend.expression.objects import Table @@ -9,21 +8,20 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnDefinition, - CreateTableLikeExpression, + CreateTableLikeStatement, IndexDefinition, StorageOptionsExpression, - TableConstraint, + TableCheckConstraint, + TableForeignKeyConstraint, + ) + from rhosocial.activerecord.backend.impl.mysql.expression.table_expr import ( + MySQLStorageOptionsExpression, ) class MySQLTableMixin: """MySQL table DDL implementation.""" - @staticmethod - def _validate_data_type(data_type: str) -> bool: - """Validate data type string, allowing single quotes for MySQL ENUM types.""" - return bool(re.fullmatch(r"[A-Za-z0-9\s\(\),\']+", data_type)) - def supports_if_not_exists_table(self) -> bool: """Whether CREATE TABLE IF NOT EXISTS is supported.""" return True @@ -32,6 +30,15 @@ def supports_if_exists_table(self) -> bool: """Whether DROP TABLE IF EXISTS is supported.""" return True + + def supports_create_table_as_bind_parameters(self) -> bool: + """MySQL accepts bind parameters in a CTAS body. + + Measured on 9.7: ``CREATE TABLE t AS SELECT %s FROM s`` is accepted + and the table is created. MySQL's "parameters" are interpolated + client-side, so the value is baked into the statement's text. + """ + return True def supports_create_table_like(self) -> bool: return True @@ -77,7 +84,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - require_kind(expr.table, Table, "CreateTableExpression.table") + require_kind(expr.table, Table, "CreateTableStatement.table") if expr.tablespace: raise UnsupportedFeatureError( self.name, "TABLESPACE", @@ -91,7 +98,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: all_params: List[Any] = [] options_part = "" - table_options = getattr(expr, "table_options", None) + table_options = expr.table_options if table_options is not None: options_sql, options_params = table_options.to_sql() if options_sql: @@ -129,8 +136,12 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: if expr.storage_options: from rhosocial.activerecord.backend.expression.statements.ddl_table import StorageOptionsExpression - storage_expr = StorageOptionsExpression(self, expr.storage_options) if isinstance(expr.storage_options, dict) else expr.storage_options - storage_sql, storage_params = self.format_storage_options(storage_expr) + storage_expr = ( + StorageOptionsExpression(self, expr.storage_options) + if isinstance(expr.storage_options, dict) + else expr.storage_options + ) + storage_sql, storage_params = self.format_storage_options_expression(storage_expr) if storage_sql: parts.append(storage_sql) all_params.extend(storage_params) @@ -138,8 +149,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.impl.mysql.expression.table_options import ( MySQLCreateTableOptions, ) - table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None) is not None: + if table_options is not None and table_options.comment is not None: comment_sql, _ = self.format_table_comment_clause(table_options.comment) parts.append(comment_sql.strip()) @@ -163,7 +173,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: return " ".join(parts), tuple(all_params) - def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") -> Tuple[str, tuple]: + def format_create_table_like_statement(self, expr: "CreateTableLikeStatement") -> Tuple[str, tuple]: """Render ``CREATE TABLE ... LIKE`` via the generic TableMixin formatter.""" return super().format_create_table_like_statement(expr) @@ -192,7 +202,14 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu if constraint_text: parts.append(constraint_text) params.extend(list(cp)) - if constraint.is_auto_increment: + from rhosocial.activerecord.backend.expression.statements import ( + ColumnPrimaryKeyConstraint, + ) + + if ( + isinstance(constraint.branch, ColumnPrimaryKeyConstraint) + and constraint.branch.is_auto_increment + ): # The marker is rendered by the dialect's # format_auto_increment_clause, not spelled here: the formatter # consults supports_auto_increment_column(), so the capability @@ -228,118 +245,76 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu return " ".join(parts), params - def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tuple]: - """Format a table-level constraint. + def format_table_check_constraint( + self, constraint: "TableCheckConstraint" + ) -> Tuple[str, tuple]: + """Format a table-level ``CHECK`` body behind this server's version gate. - Raises: - TypeError: ``t_const.foreign_key_table`` is set and is not a Table. - The rendered statement would reference whatever object's name - was in that slot. + The core's table-level CHECK body renders without consulting a + probe; MySQL's server accepted and *enforced* CHECK only from + 8.0.16 on (below that the clause parses and is discarded -- see + ``supports_check_constraint``), so the version gate stays on the + branch this backend overrides. """ - from rhosocial.activerecord.backend.expression.statements import ( - ForeignKeyConstraint, - ReferentialAction, - TableConstraintType, + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, ) - if t_const.foreign_key_table is not None: - require_kind( - t_const.foreign_key_table, - Table, - "TableConstraint.foreign_key_table", + + if not self.supports_check_constraint(): + raise UnsupportedFeatureError( + self.name, + "CHECK constraint", + f"{self.name} does not support CHECK constraints.", ) - parts = [] - params: List[Any] = [] - - if t_const.name: - parts.append(f"CONSTRAINT {self.format_identifier(t_const.name)}") - - if t_const.constraint_type == TableConstraintType.PRIMARY_KEY: - if t_const.columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"PRIMARY KEY ({cols_str})") - elif t_const.constraint_type == TableConstraintType.UNIQUE: - if t_const.columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) - parts.append(f"UNIQUE ({cols_str})") - elif t_const.constraint_type == TableConstraintType.CHECK: - if t_const.check_condition is not None: - enforcement = "" - if t_const.enforced or t_const.not_enforced: - if not self.supports_constraint_enforced(): - feature = ( - "CHECK constraint ENFORCED" - if t_const.enforced - else "CHECK constraint NOT ENFORCED" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - enforcement = " ENFORCED" if t_const.enforced else " NOT ENFORCED" - if not self.supports_check_constraint(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, "CHECK constraint", - f"{self.name} does not support CHECK constraints." - ) - check_sql, check_params = t_const.check_condition.to_sql() - parts.append(f"CHECK ({check_sql}){enforcement}") - params.extend(check_params) - elif t_const.constraint_type == TableConstraintType.FOREIGN_KEY: - if t_const.deferrable or t_const.not_deferrable: - feature = ( - "FOREIGN KEY DEFERRABLE" - if t_const.deferrable - else "FOREIGN KEY NOT DEFERRABLE" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - if t_const.initially_deferred or t_const.initially_immediate: - feature = ( - "FOREIGN KEY INITIALLY DEFERRED" - if t_const.initially_deferred - else "FOREIGN KEY INITIALLY IMMEDIATE" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - if t_const.enforced or t_const.not_enforced: - feature = ( - "FOREIGN KEY ENFORCED" - if t_const.enforced - else "FOREIGN KEY NOT ENFORCED" - ) - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, feature, - f"{self.name} does not support {feature}." - ) - if t_const.columns and t_const.foreign_key_table and t_const.foreign_key_columns: - cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) - ref_cols_str = ", ".join(self.format_identifier(c) for c in t_const.foreign_key_columns) - ref_table = t_const.foreign_key_table.to_sql()[0] - parts.append(f"FOREIGN KEY ({cols_str}) REFERENCES {ref_table} ({ref_cols_str})") - if isinstance(t_const, ForeignKeyConstraint): - if t_const.on_delete and t_const.on_delete != ReferentialAction.NO_ACTION: - parts.append(f"ON DELETE {t_const.on_delete.value}") - if t_const.on_update and t_const.on_update != ReferentialAction.NO_ACTION: - parts.append(f"ON UPDATE {t_const.on_update.value}") - if t_const.match_type is not None: - if not self.supports_fk_match(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, "FOREIGN KEY MATCH", - f"{self.name} does not support MATCH for foreign keys." - ) - parts.append(f"MATCH {t_const.match_type}") + return super().format_table_check_constraint(constraint) + + def format_table_foreign_key_constraint( + self, constraint: "TableForeignKeyConstraint" + ) -> Tuple[str, tuple]: + """Format a table-level ``FOREIGN KEY`` with MySQL's spaced column list. + + MySQL's own DDL (``SHOW CREATE TABLE``) sets the referenced column + list off with a space -- ``FOREIGN KEY (`d`) REFERENCES `p` (`id`)`` + -- and this backend's tests and DDL fixtures pin that spelling, so + the reference body is re-spaced here rather than through the + core's ``REFERENCES t(c)``. The referential actions and ``MATCH`` + still ride the shared ``ReferencesClause`` machinery, so the + capability probes stay consulted (``supports_fk_match`` refuses + MATCH on this server). - return " ".join(parts), params + Raises: + ValueError: The local columns, the referenced columns or the + referenced table are missing. + """ + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ReferencesClause, + ) + + if not constraint.columns: + raise ValueError( + "FOREIGN KEY constraint must have at least one local column specified." + ) + referenced_table, referenced_columns = constraint.references + if not referenced_columns: + raise ValueError( + "FOREIGN KEY constraint must have at least one foreign key column specified." + ) + if not referenced_table: + raise ValueError("FOREIGN KEY constraint must have a foreign key table specified.") + cols_str = ", ".join(self.format_identifier(c) for c in constraint.columns) + references = ReferencesClause( + self, + referenced_table, + list(referenced_columns), + on_delete=constraint.on_delete, + on_update=constraint.on_update, + match_type=constraint.match_type, + ) + ref_sql, ref_params = self.format_references_clause(references) + # The shared clause renders "REFERENCES ()..."; MySQL + # spells the column list with a space: "REFERENCES
()...". + head, rest = ref_sql.split("(", 1) + return f"FOREIGN KEY ({cols_str}) {head} ({rest}", tuple(ref_params) def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: """Format an inline INDEX definition within CREATE TABLE (MySQL-specific).""" @@ -354,11 +329,27 @@ def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tupl parts.append(f"USING {idx_def.type}") return " ".join(parts), () - def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: - """Format MySQL table storage options (ENGINE, CHARSET, etc.).""" + def format_storage_options_expression(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: + """Format core's table storage options (ENGINE, CHARSET, etc.).""" + return self._render_storage_options(expr.options), () + + def format_mysql_storage_options_expression( + self, + expr: "MySQLStorageOptionsExpression", + ) -> Tuple[str, tuple]: + """Format :class:`MySQLStorageOptionsExpression`. + + Its own entry because it is its own node: the constructor parameter is + ``storage_options`` where core's node calls the same mapping + ``options``, and one entry reading the other field raised + ``AttributeError`` on half of the pair. + """ + return self._render_storage_options(expr.storage_options), () + + def _render_storage_options(self, options) -> str: parts = [] - for key, value in expr.options.items(): + for key, value in options.items(): rendered = self.inline_sql_literal(value) parts.append(f"{key}={rendered}") - return " ".join(parts), () + return " ".join(parts) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py index 43c38982..638cc671 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table_statement.py from typing import List, TYPE_CHECKING, Tuple +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table from .object_kind import require_kind @@ -21,13 +22,13 @@ class MySQLTableStatementMixin: def supports_table_statement(self) -> bool: """MySQL 8.0.19+ supports the TABLE statement.""" - return getattr(self, "version", None) is not None and self.version >= (8, 0, 19) + return self.version_at_least((8, 0, 19)) def supports_values_table_constructor(self) -> bool: """MySQL 8.0.19+ supports VALUES as a table value constructor.""" - return getattr(self, "version", None) is not None and self.version >= (8, 0, 19) + return self.version_at_least((8, 0, 19)) - def format_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: + def format_mysql_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: """Format ``TABLE
[ORDER BY ...] [LIMIT ...]``. The table is a schema object, so it renders itself and @@ -43,7 +44,7 @@ def format_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tupl parts.extend(self._format_statement_clauses(expr)) return " ".join(part for part in parts if part), () - def format_values_statement(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: + def format_mysql_values_expression(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: """Format ``VALUES ROW(...), ... [ORDER BY ...] [LIMIT ...]``.""" expr.validate(strict=self.strict_validation) params = [] @@ -51,7 +52,11 @@ def format_values_statement(self, expr: "MySQLValuesExpression") -> Tuple[str, t for row in expr.rows: cell_parts = [] for value in row: - if hasattr(value, "to_sql"): + # A cell is a host value or an expression instance (§10, and + # the rule that binds: can the driver bind it?). Asking the + # object whether it happens to answer to ``to_sql`` cannot tell + # a value that renders from a value that does not. + if isinstance(value, BaseExpression): sql, p = value.to_sql() cell_parts.append(sql) params.extend(p) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py index 46e9cc6a..b47364f7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py @@ -25,6 +25,15 @@ def supports_temporary_view(self) -> bool: """Whether TEMPORARY views are supported.""" return True + + def supports_create_view_bind_parameters(self) -> bool: + """MySQL accepts bind parameters in a view body. + + Measured on 9.7: a view whose definition binds is accepted, because + MySQL's "parameters" are interpolated client-side and the value is + baked into the stored definition. + """ + return True def supports_if_exists_view(self) -> bool: """Whether DROP VIEW IF EXISTS is supported.""" return True @@ -50,7 +59,7 @@ def supports_with_data_clause(self) -> bool: """ return False - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: + def format_create_view_statement(self, expr: "CreateViewStatement") -> Tuple[str, tuple]: """Format CREATE VIEW statement for MySQL. Raises: @@ -59,7 +68,7 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st which is a well-formed statement creating a view over something the caller never named. """ - require_kind(expr.view, View, "CreateViewExpression.view") + require_kind(expr.view, View, "CreateViewStatement.view") parts = ["CREATE"] if expr.temporary: @@ -76,6 +85,8 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st parts.append(f"({cols})") query_sql, query_params = expr.query.to_sql() + if query_params and not self.supports_create_view_bind_parameters(): + self.refuse_ddl_bind_parameters("CREATE VIEW with bound parameters") parts.append(f"AS {query_sql}") if expr.options and expr.options.check_option: @@ -90,7 +101,7 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st return " ".join(parts), query_params - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: + def format_drop_view_statement(self, expr: "DropViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW statement for MySQL. The CASCADE / RESTRICT pair is consumed here: MySQL does not provide @@ -104,7 +115,7 @@ def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, t UnsupportedFeatureError: If the dialect does not accept the requested behavior keyword. """ - require_kind(expr.view, View, "DropViewExpression.view") + require_kind(expr.view, View, "DropViewStatement.view") if expr.cascade and not self.supports_cascade_view(): from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py index cb9c1bf7..6e431f6f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/dml.py from typing import TYPE_CHECKING, Any, List, Tuple +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.expression.objects import Table @@ -9,7 +10,7 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import OnConflictClause from rhosocial.activerecord.backend.impl.mysql.expression.load_data import ( - MySQLLoadDataExpression, + MySQLLoadDataStatement, ) @@ -25,7 +26,7 @@ def supports_replace_into(self) -> bool: def supports_load_data(self) -> bool: return True - def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: + def format_mysql_load_data_statement(self, expr: "MySQLLoadDataStatement") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Raises: @@ -33,7 +34,7 @@ def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[s wrong kind there would render a statement loading into whatever object's name was supplied. """ - require_kind(expr.table, Table, "MySQLLoadDataExpression.table") + require_kind(expr.table, Table, "MySQLLoadDataStatement.table") expr.validate(strict=self.strict_validation) all_params: List[Any] = [] @@ -61,20 +62,20 @@ def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[s field_parts = [] if expr.options.fields_terminated_by is not None: - field_parts.append(f"TERMINATED BY '{self._escape_sql_string(expr.options.fields_terminated_by)}'") + field_parts.append(f"TERMINATED BY '{self.escape_sql_string(expr.options.fields_terminated_by)}'") if expr.options.fields_enclosed_by is not None: - field_parts.append(f"ENCLOSED BY '{self._escape_sql_string(expr.options.fields_enclosed_by)}'") + field_parts.append(f"ENCLOSED BY '{self.escape_sql_string(expr.options.fields_enclosed_by)}'") if expr.options.fields_escaped_by is not None: - field_parts.append(f"ESCAPED BY '{self._escape_sql_string(expr.options.fields_escaped_by)}'") + field_parts.append(f"ESCAPED BY '{self.escape_sql_string(expr.options.fields_escaped_by)}'") if field_parts: parts.append("FIELDS") parts.append(" ".join(field_parts)) line_parts = [] if expr.options.lines_starting_by is not None: - line_parts.append(f"STARTING BY '{self._escape_sql_string(expr.options.lines_starting_by)}'") + line_parts.append(f"STARTING BY '{self.escape_sql_string(expr.options.lines_starting_by)}'") if expr.options.lines_terminated_by is not None: - line_parts.append(f"TERMINATED BY '{self._escape_sql_string(expr.options.lines_terminated_by)}'") + line_parts.append(f"TERMINATED BY '{self.escape_sql_string(expr.options.lines_terminated_by)}'") if line_parts: parts.append("LINES") parts.append(" ".join(line_parts)) @@ -90,7 +91,7 @@ def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[s set_parts = [] for col, val in expr.options.set_assignments.items(): col_sql = self.format_identifier(col) - if hasattr(val, "to_sql"): + if isinstance(val, BaseExpression): val_sql, val_params = val.to_sql() all_params.extend(val_params) else: @@ -110,7 +111,7 @@ def format_on_conflict_clause(self, expr: "OnConflictClause") -> Tuple[str, tupl if expr.update_assignments: for col_name, value_expr in expr.update_assignments.items(): col_sql = self.format_identifier(col_name) - if hasattr(value_expr, "to_sql"): + if isinstance(value_expr, BaseExpression): val_sql, val_params = value_expr.to_sql() else: val_sql, val_params = Literal(self, value_expr).to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py index 67fc23b3..5b262399 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py @@ -1,4 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/explain.py +from enum import Enum from typing import Tuple @@ -7,7 +8,7 @@ class MySQLExplainMixin: def supports_explain_analyze(self) -> bool: """Whether EXPLAIN ANALYZE is supported.""" - return self.version >= (8, 0, 18) + return self.version_at_least((8, 0, 18)) def supports_explain_format(self, format_type: str) -> bool: """Check if specific EXPLAIN format is supported.""" @@ -15,13 +16,13 @@ def supports_explain_format(self, format_type: str) -> bool: if format_type_upper == "TEXT": return True elif format_type_upper == "JSON": - return self.version >= (5, 6, 5) + return self.version_at_least((5, 6, 5)) elif format_type_upper == "TREE": - return self.version >= (8, 0, 16) + return self.version_at_least((8, 0, 16)) else: return False - def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: + def format_explain_statement(self, explain_expr: "ExplainStatement") -> tuple: """Build the MySQL EXPLAIN SQL string and return (sql, params).""" from rhosocial.activerecord.backend.expression.statements import ExplainType @@ -37,7 +38,11 @@ def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: parts.append("ANALYZE") if options.format is not None: - fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) + # ``format`` is the enum member or the word it spells; asking + # the value whether it carries a ``name`` cannot tell the two + # apart from anything else. + fmt = options.format + fmt_name = fmt.value if isinstance(fmt, Enum) else str(fmt) parts.append(f"FORMAT={fmt_name.upper()}") elif options.type is not None and options.type == ExplainType.QUERY_PLAN: pass diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py index ef5c45e0..a5b28d36 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/fulltext.py @@ -7,7 +7,7 @@ class MySQLFullTextSearchMixin: def supports_fulltext_index(self) -> bool: """Whether FULLTEXT index is supported (MySQL 5.6+ InnoDB).""" - return self.version >= (5, 6, 0) + return self.version_at_least((5, 6, 0)) def supports_fulltext_search(self) -> bool: """Whether full-text search (MATCH ... AGAINST) is supported. @@ -20,18 +20,18 @@ def supports_fulltext_search(self) -> bool: return self.supports_fulltext_index() def supports_fulltext_parser(self) -> bool: - return self.version >= (5, 1, 0) + return self.version_at_least((5, 1, 0)) def supports_fulltext_query_expansion(self) -> bool: return True - def format_fulltext_index_options(self, expr) -> Tuple[str, tuple]: + def format_mysql_fulltext_index_options_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLFulltextIndexOptionsExpression` node.""" from ..expression.fulltext import MySQLFulltextIndexOptionsExpression if not isinstance(expr, MySQLFulltextIndexOptionsExpression): raise TypeError( - f"format_fulltext_index_options expects MySQLFulltextIndexOptionsExpression, " + f"format_mysql_fulltext_index_options_expression expects MySQLFulltextIndexOptionsExpression, " f"got {type(expr).__name__}" ) @@ -41,7 +41,7 @@ def format_fulltext_index_options(self, expr) -> Tuple[str, tuple]: sql += f" WITH PARSER {self.format_identifier(expr.parser_name)}" return sql, () - def format_match_against(self, expr) -> Tuple[str, tuple]: + def format_mysql_match_against_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLMatchAgainstExpression` node.""" cols_sql = ", ".join(self.format_identifier(c) for c in expr.columns) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py index 75f2a4d7..9d88c4ca 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py @@ -1,5 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/function.py -from typing import Dict +from typing import Dict, Tuple + +from rhosocial.activerecord.backend.expression import bases # MySQL function version support: function_name -> (min_version, max_version) # min_version: minimum supported version (inclusive), None = all versions @@ -98,17 +100,48 @@ def supports_functions(self) -> Dict[str, bool]: return result def _is_mysql_function_supported(self, func_name: str) -> bool: - """Check if a MySQL-specific function is supported based on version.""" + """Check if a MySQL-specific function is supported based on version. + + This is a capability check, so it answers before adaptation rather + than raising: a name with no version bound is available on every + server, and a version-bounded one is not claimed until a version is + known. ``supports_functions()`` reports the result as-is. + """ version_range = self._MYSQL_FUNCTION_VERSIONS.get(func_name) if version_range is None: return True + version = self.version_or_none + if version is None: + return False + min_version, max_version = version_range - if min_version is not None and self.version < min_version: + if min_version is not None and version < min_version: return False - if max_version is not None and self.version > max_version: + if max_version is not None and version > max_version: return False return True + + def format_char_length_function(self, expr: "bases.BaseExpression") -> Tuple[str, tuple]: + """MySQL's character-length spelling: ``CHAR_LENGTH(operand)``. + + The base spells ``LENGTH``, and on MySQL that answers a different + question: measured on 8.0.46 (192.168.1.3:14682), + ``LENGTH('aé')`` is 3 -- the byte count, é being two bytes -- while + ``CHAR_LENGTH('aé')`` is 2, the characters, so the default would + answer a different question than this node asks. + + The node's ``unit`` records the semantics the caller asked for and + never enters the SQL, exactly as the base's contract says; the + octet and bit semantics have their own dedicated nodes, whose + defaults (``OCTET_LENGTH`` / ``BIT_LENGTH``) are spellings MySQL + accepts as-is. + """ + operand_sql, operand_params = expr.operand.to_sql() + sql = f"CHAR_LENGTH({operand_sql})" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, tuple(operand_params) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py index 1f735359..6b402b11 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/generated_column.py @@ -4,7 +4,7 @@ class MySQLGeneratedColumnMixin: def supports_generated_column(self) -> bool: """Whether generated (computed) columns are supported.""" - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_auto_increment_column(self) -> bool: """Whether the parameterless ``AUTO_INCREMENT`` column marker is supported. @@ -33,4 +33,4 @@ def supports_virtual_generated_columns(self) -> bool: def supports_default_column_value_expression(self) -> bool: """Whether DEFAULT column values can use expressions.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py index 3f459d73..da41e36f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py @@ -1,18 +1,61 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/introspection.py -from typing import List, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.introspection.types import IntrospectionScope from rhosocial.activerecord.backend.expression.introspection import ( - DatabaseInfoExpression, - TableListExpression, - ColumnInfoExpression, - IndexInfoExpression, - ForeignKeyExpression, - ViewListExpression, - ViewInfoExpression, - TriggerListExpression, + DatabaseInfoQueryStatement, + TableListQueryStatement, + ColumnInfoQueryStatement, + IndexInfoQueryStatement, + ForeignKeyQueryStatement, + ViewListQueryStatement, + ViewInfoQueryStatement, + TriggerListQueryStatement, ) + from ...expression.introspection import ( + MySQLTableOptionsListQueryStatement, + MySQLTableOptionsInfoQueryStatement, + MySQLPartitionListQueryStatement, + ) + + +#: MySQL's own metadata databases. A caller asking for "user tables" does not +#: mean these four, so they are excluded unless system objects were requested. +SYSTEM_SCHEMAS = ("information_schema", "performance_schema", "mysql", "sys") + +#: The storage-options projection, shared by the list and the single-table +#: form: one engine's facts about a table, in the order the catalogue reports +#: them. ``DATA_LENGTH`` and ``INDEX_LENGTH`` are byte estimates for InnoDB. +TABLE_OPTION_COLUMNS = ( + "TABLE_SCHEMA", + "TABLE_NAME", + "ENGINE", + "ROW_FORMAT", + "TABLE_COLLATION", + "AUTO_INCREMENT", + "TABLE_COMMENT", + "CREATE_OPTIONS", + "DATA_LENGTH", + "INDEX_LENGTH", +) + +#: The partition projection: one row per partition or subpartition, in the +#: engine's ordinal order. +PARTITION_COLUMNS = ( + "TABLE_SCHEMA", + "TABLE_NAME", + "PARTITION_NAME", + "SUBPARTITION_NAME", + "PARTITION_METHOD", + "PARTITION_EXPRESSION", + "PARTITION_DESCRIPTION", + "PARTITION_ORDINAL_POSITION", + "SUBPARTITION_ORDINAL_POSITION", + "TABLE_ROWS", + "DATA_LENGTH", + "INDEX_LENGTH", +) class MySQLIntrospectionMixin: @@ -31,9 +74,22 @@ class MySQLIntrospectionMixin: → Dialect.format_*_query() [this mixin] → Returns SQL and parameters + Every query here is an expression tree, not assembled text: a projection of + :class:`~...expression.core.Column` nodes, a relation, a conjunction of + predicates and an ordering. What a query *asks* is therefore a structure the + rest of the framework can inspect, and this module writes no clause at all -- + the dialect renders each node. + MySQL supports all introspection features via information_schema. """ + def supports_table_options_introspection(self) -> bool: + """MySQL answers for a table's storage options from ``information_schema.TABLES``. + + The capability for the ``introspect table-options`` family: engine, row + format, collation and the byte estimates the same view already reports.""" + return True + # ========== Capability Detection ========== def supports_introspection(self) -> bool: @@ -84,143 +140,471 @@ def get_supported_introspection_scopes(self) -> List["IntrospectionScope"]: # ========== Query Formatting ========== - def format_database_info_query(self, expr: "DatabaseInfoExpression") -> Tuple[str, tuple]: + def _information_schema_query( + self, + relation: str, + projections: "list", + conditions: "list", + order_by: "Optional[list]" = None, + source: "Any" = None, + ) -> Tuple[str, tuple]: + """Build a SELECT over one ``information_schema`` relation. + + Each formatter below supplies only what varies -- the relation, the + projection, the conjunction and the ordering -- and the shape lives + here. The relation is a :class:`Table` carrying its database in the + catalog slot, because MySQL's only namespace level is the database and + putting ``information_schema`` in ``schema_name`` would be refused + rather than silently dropped. + + Args: + relation: The ``information_schema`` relation being read. + projections: The select list, in order. + conditions: Conditions to AND together. + order_by: The ordering expressions, or ``None`` for no ORDER BY. + source: A pre-built FROM source (a join, for the foreign-key + query), or ``None`` to read *relation* directly. + + Returns: + Tuple of (SQL string, parameters tuple). + """ + from rhosocial.activerecord.backend.expression import ( + LogicalPredicate, + OrderByClause, + QueryStatement, + WhereClause, + ) + from rhosocial.activerecord.backend.expression.objects import Table + + kwargs: "dict" = { + "select": projections, + "from_": ( + source + if source is not None + else Table(self, relation, catalog_name="information_schema") + ), + "where": WhereClause(self, LogicalPredicate(self, "AND", *conditions)), + } + if order_by is not None: + kwargs["order_by"] = OrderByClause(self, order_by) + return QueryStatement(self, **kwargs).to_sql() + + def format_database_info_query_statement(self, expr: "DatabaseInfoQueryStatement") -> Tuple[str, tuple]: """Format database information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() schema = params.get("schema", "") - sql = ( - "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME " - "FROM information_schema.SCHEMATA " - f"WHERE SCHEMA_NAME = {self.p()}" + + return self._information_schema_query( + "SCHEMATA", + [ + Column(self, "DEFAULT_CHARACTER_SET_NAME"), + Column(self, "DEFAULT_COLLATION_NAME"), + ], + [ComparisonPredicate(self, "eq", Column(self, "SCHEMA_NAME"), Literal(self, schema))], ) - return (sql, (schema,)) - def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tuple]: + def format_table_list_query_statement(self, expr: "TableListQueryStatement") -> Tuple[str, tuple]: """Format table list query.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + Literal, + LogicalPredicate, + ) + params = expr.get_params() schema = params.get("schema", "") include_views = params.get("include_views", True) include_system = params.get("include_system", False) table_type = params.get("table_type") - conditions = [f"TABLE_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + # The bound schema value comes first, so the parameter tuple keeps the + # order the concatenated form produced. + conditions = [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)) + ] if not include_system: - conditions.append("TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')") + # The exclusion list is literal-inline: it is a fact about the + # engine, not caller data, so it never becomes a bind parameter. + conditions.append( + LogicalPredicate( + self, + "NOT", + InPredicate( + self, + Column(self, "TABLE_SCHEMA"), + Literal(self, SYSTEM_SCHEMAS, inline=True), + ), + ) + ) if not include_views: - conditions.append("TABLE_TYPE = 'BASE TABLE'") + conditions.append( + ComparisonPredicate( + self, + "eq", + Column(self, "TABLE_TYPE"), + Literal(self, "BASE TABLE", inline=True), + ) + ) if table_type: - conditions.append(f"TABLE_TYPE = {self.p()}") - sql_params.append(table_type) - - where = " AND ".join(conditions) - sql = ( - "SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT, TABLE_ROWS, " - "DATA_LENGTH, AUTO_INCREMENT, CREATE_TIME, UPDATE_TIME " - f"FROM information_schema.TABLES WHERE {where}" + conditions.append( + ComparisonPredicate( + self, "eq", Column(self, "TABLE_TYPE"), Literal(self, table_type) + ) + ) + + return self._information_schema_query( + "TABLES", + [ + Column(self, "TABLE_NAME"), + Column(self, "TABLE_TYPE"), + Column(self, "TABLE_COMMENT"), + Column(self, "TABLE_ROWS"), + Column(self, "DATA_LENGTH"), + Column(self, "AUTO_INCREMENT"), + Column(self, "CREATE_TIME"), + Column(self, "UPDATE_TIME"), + ], + conditions, ) - return (sql, tuple(sql_params)) - def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, tuple]: + def format_column_info_query_statement(self, expr: "ColumnInfoQueryStatement") -> Tuple[str, tuple]: """Format column information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema", "") - sql = ( - "SELECT COLUMN_NAME, ORDINAL_POSITION, COLUMN_DEFAULT, IS_NULLABLE, " - "DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE, " - "COLUMN_TYPE, COLUMN_KEY, EXTRA, COLUMN_COMMENT, " - "CHARACTER_SET_NAME, COLLATION_NAME " - "FROM information_schema.COLUMNS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()} " - "ORDER BY ORDINAL_POSITION" - ) - return (sql, (schema, table_name)) - - def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tuple]: + + return self._information_schema_query( + "COLUMNS", + [ + Column(self, "COLUMN_NAME"), + Column(self, "ORDINAL_POSITION"), + Column(self, "COLUMN_DEFAULT"), + Column(self, "IS_NULLABLE"), + Column(self, "DATA_TYPE"), + Column(self, "CHARACTER_MAXIMUM_LENGTH"), + Column(self, "NUMERIC_PRECISION"), + Column(self, "NUMERIC_SCALE"), + Column(self, "COLUMN_TYPE"), + Column(self, "COLUMN_KEY"), + Column(self, "EXTRA"), + Column(self, "COLUMN_COMMENT"), + Column(self, "CHARACTER_SET_NAME"), + Column(self, "COLLATION_NAME"), + ], + [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)), + ComparisonPredicate(self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name)), + ], + [Column(self, "ORDINAL_POSITION")], + ) + + def format_index_info_query_statement(self, expr: "IndexInfoQueryStatement") -> Tuple[str, tuple]: """Format index information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema", "") - sql = ( - "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME, " - "INDEX_TYPE, SUB_PART, NULLABLE " - "FROM information_schema.STATISTICS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()} " - "ORDER BY INDEX_NAME, SEQ_IN_INDEX" + + return self._information_schema_query( + "STATISTICS", + [ + Column(self, "INDEX_NAME"), + Column(self, "NON_UNIQUE"), + Column(self, "SEQ_IN_INDEX"), + Column(self, "COLUMN_NAME"), + Column(self, "INDEX_TYPE"), + Column(self, "SUB_PART"), + Column(self, "NULLABLE"), + ], + [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)), + ComparisonPredicate(self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name)), + ], + [Column(self, "INDEX_NAME"), Column(self, "SEQ_IN_INDEX")], ) - return (sql, (schema, table_name)) - def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, tuple]: + def format_foreign_key_query_statement(self, expr: "ForeignKeyQueryStatement") -> Tuple[str, tuple]: """Format foreign key information query.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + IsNullPredicate, + JoinClause, + Literal, + LogicalPredicate, + ) + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources.relation import NamedRelationRef + params = expr.get_params() table_name = params.get("table_name", "") schema = params.get("schema", "") - sql = ( - "SELECT kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION, " - "kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME, " - "rc.UPDATE_RULE, rc.DELETE_RULE " - "FROM information_schema.KEY_COLUMN_USAGE kcu " - "JOIN information_schema.REFERENTIAL_CONSTRAINTS rc " - " ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME " - " AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA " - f"WHERE kcu.TABLE_SCHEMA = {self.p()} AND kcu.TABLE_NAME = {self.p()} " - " AND kcu.REFERENCED_TABLE_NAME IS NOT NULL " - "ORDER BY kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION" - ) - return (sql, (schema, table_name)) - - def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple]: + + usage = NamedRelationRef( + self, + Table(self, "KEY_COLUMN_USAGE", catalog_name="information_schema"), + alias="kcu", + ) + constraints = NamedRelationRef( + self, + Table(self, "REFERENTIAL_CONSTRAINTS", catalog_name="information_schema"), + alias="rc", + ) + join = JoinClause( + self, + left_table=usage, + right_table=constraints, + join_type="JOIN", + condition=LogicalPredicate( + self, + "AND", + ComparisonPredicate( + self, + "eq", + Column(self, "CONSTRAINT_NAME", table="kcu"), + Column(self, "CONSTRAINT_NAME", table="rc"), + ), + ComparisonPredicate( + self, + "eq", + Column(self, "CONSTRAINT_SCHEMA", table="kcu"), + Column(self, "CONSTRAINT_SCHEMA", table="rc"), + ), + ), + ) + + return self._information_schema_query( + "KEY_COLUMN_USAGE", + [ + Column(self, "CONSTRAINT_NAME", table="kcu"), + Column(self, "COLUMN_NAME", table="kcu"), + Column(self, "ORDINAL_POSITION", table="kcu"), + Column(self, "REFERENCED_TABLE_NAME", table="kcu"), + Column(self, "REFERENCED_COLUMN_NAME", table="kcu"), + Column(self, "UPDATE_RULE", table="rc"), + Column(self, "DELETE_RULE", table="rc"), + ], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA", table="kcu"), Literal(self, schema) + ), + ComparisonPredicate( + self, "eq", Column(self, "TABLE_NAME", table="kcu"), Literal(self, table_name) + ), + # A usage row without a referenced table is a plain index + # column, not a foreign key: the predicate says so in the tree. + IsNullPredicate(self, Column(self, "REFERENCED_TABLE_NAME", table="kcu"), is_not=True), + ], + [ + Column(self, "CONSTRAINT_NAME", table="kcu"), + Column(self, "ORDINAL_POSITION", table="kcu"), + ], + source=join, + ) + + def format_view_list_query_statement(self, expr: "ViewListQueryStatement") -> Tuple[str, tuple]: """Format view list query.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + InPredicate, + Literal, + LogicalPredicate, + ) + params = expr.get_params() schema = params.get("schema", "") include_system = params.get("include_system", False) - conditions = [f"TABLE_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + conditions = [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)) + ] if not include_system: - conditions.append("TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')") - - where = " AND ".join(conditions) - sql = ( - "SELECT TABLE_NAME, VIEW_DEFINITION, CHECK_OPTION, IS_UPDATABLE " - f"FROM information_schema.VIEWS WHERE {where} " - "ORDER BY TABLE_NAME" + conditions.append( + LogicalPredicate( + self, + "NOT", + InPredicate( + self, + Column(self, "TABLE_SCHEMA"), + Literal(self, SYSTEM_SCHEMAS, inline=True), + ), + ) + ) + + return self._information_schema_query( + "VIEWS", + [ + Column(self, "TABLE_NAME"), + Column(self, "VIEW_DEFINITION"), + Column(self, "CHECK_OPTION"), + Column(self, "IS_UPDATABLE"), + ], + conditions, + [Column(self, "TABLE_NAME")], ) - return (sql, tuple(sql_params)) - def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple]: + def format_view_info_query_statement(self, expr: "ViewInfoQueryStatement") -> Tuple[str, tuple]: """Format single view information query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() view_name = params.get("view_name", "") schema = params.get("schema", "") - sql = ( - "SELECT TABLE_NAME, VIEW_DEFINITION, CHECK_OPTION, IS_UPDATABLE " - "FROM information_schema.VIEWS " - f"WHERE TABLE_SCHEMA = {self.p()} AND TABLE_NAME = {self.p()}" + + return self._information_schema_query( + "VIEWS", + [ + Column(self, "TABLE_NAME"), + Column(self, "VIEW_DEFINITION"), + Column(self, "CHECK_OPTION"), + Column(self, "IS_UPDATABLE"), + ], + [ + ComparisonPredicate(self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema)), + ComparisonPredicate(self, "eq", Column(self, "TABLE_NAME"), Literal(self, view_name)), + ], ) - return (sql, (schema, view_name)) - def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, tuple]: + def format_trigger_list_query_statement(self, expr: "TriggerListQueryStatement") -> Tuple[str, tuple]: """Format trigger list query.""" + from rhosocial.activerecord.backend.expression import Column, ComparisonPredicate, Literal + params = expr.get_params() table_name = params.get("table_name") schema = params.get("schema", "") - conditions = [f"TRIGGER_SCHEMA = {self.p()}"] - sql_params: list = [schema] - + conditions = [ + ComparisonPredicate(self, "eq", Column(self, "TRIGGER_SCHEMA"), Literal(self, schema)) + ] if table_name: - conditions.append(f"EVENT_OBJECT_TABLE = {self.p()}") - sql_params.append(table_name) + conditions.append( + ComparisonPredicate( + self, "eq", Column(self, "EVENT_OBJECT_TABLE"), Literal(self, table_name) + ) + ) + + return self._information_schema_query( + "TRIGGERS", + [ + Column(self, "TRIGGER_NAME"), + Column(self, "EVENT_MANIPULATION"), + Column(self, "EVENT_OBJECT_TABLE"), + Column(self, "ACTION_TIMING"), + Column(self, "ACTION_STATEMENT"), + Column(self, "CREATED"), + ], + conditions, + [Column(self, "TRIGGER_NAME")], + ) + + def format_table_options_list_query_statement( + self, expr: "MySQLTableOptionsListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the storage-options query for every base table. + + ``DATA_LENGTH`` and ``INDEX_LENGTH`` are bytes for InnoDB estimates of + the clustered index, not exact sizes; the parser repeats the caveat + next to the numbers. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + schema = expr.get_params().get("schema") or "" + + return self._information_schema_query( + "TABLES", + [Column(self, name) for name in TABLE_OPTION_COLUMNS], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema) + ), + ComparisonPredicate( + self, + "eq", + Column(self, "TABLE_TYPE"), + Literal(self, "BASE TABLE", inline=True), + ), + ], + [Column(self, "TABLE_NAME")], + ) + + def format_table_options_info_query_statement( + self, expr: "MySQLTableOptionsInfoQueryStatement" + ) -> Tuple[str, tuple]: + """Format the storage-options query for one table.""" + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + params = expr.get_params() + schema = params.get("schema") or "" + table_name = params.get("table_name", "") + + return self._information_schema_query( + "TABLES", + [Column(self, name) for name in TABLE_OPTION_COLUMNS], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema) + ), + ComparisonPredicate( + self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name) + ), + ComparisonPredicate( + self, + "eq", + Column(self, "TABLE_TYPE"), + Literal(self, "BASE TABLE", inline=True), + ), + ], + ) + + def format_partition_list_query_statement( + self, expr: "MySQLPartitionListQueryStatement" + ) -> Tuple[str, tuple]: + """Format the partition query for one table. + + ``PARTITION_NAME IS NOT NULL`` drops the row a non-partitioned table + always has, so an empty answer means "not partitioned" and nothing + weaker than that. + """ + from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, + ) + + params = expr.get_params() + schema = params.get("schema") or "" + table_name = params.get("table_name", "") - where = " AND ".join(conditions) - sql = ( - "SELECT TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, " - "ACTION_TIMING, ACTION_STATEMENT, CREATED " - f"FROM information_schema.TRIGGERS WHERE {where} " - "ORDER BY TRIGGER_NAME" + return self._information_schema_query( + "PARTITIONS", + [Column(self, name) for name in PARTITION_COLUMNS], + [ + ComparisonPredicate( + self, "eq", Column(self, "TABLE_SCHEMA"), Literal(self, schema) + ), + ComparisonPredicate( + self, "eq", Column(self, "TABLE_NAME"), Literal(self, table_name) + ), + Column(self, "PARTITION_NAME").is_not_null(), + ], + [ + Column(self, "PARTITION_ORDINAL_POSITION"), + Column(self, "SUBPARTITION_ORDINAL_POSITION"), + ], ) - return (sql, tuple(sql_params)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py index d0f23e35..bc30887e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/join.py @@ -4,7 +4,7 @@ class MySQLJoinMixin: def supports_lateral_join(self) -> bool: """Whether LATERAL joins are supported.""" - return self.version >= (8, 0, 14) + return self.version_at_least((8, 0, 14)) def supports_right_join(self) -> bool: """RIGHT JOIN is supported.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py index fb3261fa..d38e9e21 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json.py @@ -5,8 +5,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, - JSONTextExpression, + JSONDocumentValue, + JSONTextValue, ) #: What the two formatters below accept. Core used to have one class for @@ -14,8 +14,8 @@ #: ``->`` and ``->>`` from these same two methods, so the parameter is a #: union rather than either class alone. #: - #: The union is spelled out even though ``JSONTextExpression`` subclasses - #: ``JSONDocumentExpression`` and a checker would collapse it. That + #: The union is spelled out even though ``JSONTextValue`` subclasses + #: ``JSONDocumentValue`` and a checker would collapse it. That #: inheritance exists so a text access keeps the accessors that let a path #: chain continue; it is not a claim that ``->>`` yields a document. #: @@ -26,7 +26,7 @@ #: ``->`` and ``->>`` had a result type worth naming a class after. A #: non-arrow operation says nothing about what comes back, so no class #: asserts anything for it and the ``operation`` field alone decides. - JSONPathNode = Union["JSONDocumentExpression", "JSONTextExpression"] + JSONPathNode = Union["JSONDocumentValue", "JSONTextValue"] class MySQLJSONFunctionMixin: @@ -40,7 +40,7 @@ class MySQLJSONFunctionMixin: } def supports_json_type(self) -> bool: - return self.version >= (5, 7, 8) + return self.version_at_least((5, 7, 8)) def supports_json_path(self) -> bool: """Whether a JSON path can be read on this server. @@ -50,20 +50,20 @@ def supports_json_path(self) -> bool: perfectly well and has no JSON column type, so gating on the type would refuse queries it answers. """ - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_json_merge_patch(self) -> bool: - return self.version >= (8, 0, 3) + return self.version_at_least((8, 0, 3)) def supports_json_table(self) -> bool: - return self.version >= (8, 0, 4) + return self.version_at_least((8, 0, 4)) def supports_json_function(self, function_name: str) -> bool: if function_name in self._JSON_FUNCTION_VERSIONS: - return self.version >= self._JSON_FUNCTION_VERSIONS[function_name] - return self.version >= (5, 7, 8) + return self.version_at_least(self._JSON_FUNCTION_VERSIONS[function_name]) + return self.version_at_least((5, 7, 8)) - def format_json_extract(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_extract_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONExtractExpression` node.""" sql, params = self._format_json_extract_parts(expr.json_column, expr.path) if expr.alias: @@ -80,7 +80,7 @@ def _format_json_extract_parts( path_placeholders = ", ".join([self.p() for _ in all_paths]) return f"JSON_EXTRACT({json_doc}, {path_placeholders})", tuple(all_paths) - def format_json_unquote(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_unquote_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLJSONUnquoteExpression or a raw JSON value string.""" from ..expression.json import MySQLJSONUnquoteExpression @@ -96,7 +96,7 @@ def format_json_unquote(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_json_object(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_object_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONObjectExpression` node.""" sql, params = self._format_json_object_parts(expr.pairs) if expr.alias: @@ -119,7 +119,7 @@ def _format_json_object_parts(self, key_value_pairs: List[Tuple[str, Any]]) -> T return f"JSON_OBJECT({', '.join(parts)})", tuple(params) - def format_json_array(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_array_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONArrayExpression` node.""" sql, params = self._format_json_array_parts(expr.values) if expr.alias: @@ -133,7 +133,7 @@ def _format_json_array_parts(self, values: List[Any]) -> Tuple[str, tuple]: placeholders = ", ".join([self.p() for _ in values]) return f"JSON_ARRAY({placeholders})", tuple(values) - def format_json_contains(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_contains_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONContainsExpression` node.""" sql, params = self._format_json_contains_parts(expr.json_column, expr.value, expr.path) if expr.alias: @@ -146,13 +146,13 @@ def _format_json_contains_parts(self, target: str, candidate: str, path: Optiona return f"JSON_CONTAINS({target}, {self.p()}, {self.p()})", (candidate, path) return f"JSON_CONTAINS({target}, {self.p()})", (candidate,) - def format_json_set(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_set_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONSetExpression` node.""" from ..expression.json import MySQLJSONSetExpression if not isinstance(expr, MySQLJSONSetExpression): raise TypeError( - f"format_json_set expects MySQLJSONSetExpression, got {type(expr).__name__}" + f"format_mysql_json_set_expression expects MySQLJSONSetExpression, got {type(expr).__name__}" ) all_pairs = [(expr.path, expr.value)] + (expr.path_value_pairs or []) @@ -167,13 +167,13 @@ def format_json_set(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(params) - def format_json_remove(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_remove_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONRemoveExpression` node.""" from ..expression.json import MySQLJSONRemoveExpression if not isinstance(expr, MySQLJSONRemoveExpression): raise TypeError( - f"format_json_remove expects MySQLJSONRemoveExpression, got {type(expr).__name__}" + f"format_mysql_json_remove_expression expects MySQLJSONRemoveExpression, got {type(expr).__name__}" ) all_paths = [expr.path] + (expr.paths or []) @@ -183,7 +183,7 @@ def format_json_remove(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, tuple(all_paths) - def format_json_type(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_type_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLJSONTypeExpression or a raw JSON value string.""" from ..expression.json import MySQLJSONTypeExpression @@ -199,7 +199,7 @@ def format_json_type(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_json_valid(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_valid_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLJSONValidExpression or a raw JSON value string.""" from ..expression.json import MySQLJSONValidExpression @@ -215,13 +215,13 @@ def format_json_valid(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_json_search(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_search_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLJSONSearchExpression` node.""" from ..expression.json import MySQLJSONSearchExpression if not isinstance(expr, MySQLJSONSearchExpression): raise TypeError( - f"format_json_search expects MySQLJSONSearchExpression, got {type(expr).__name__}" + f"format_mysql_json_search_expression expects MySQLJSONSearchExpression, got {type(expr).__name__}" ) one_or_all = "'all'" if expr.all else "'one'" @@ -245,8 +245,8 @@ def format_json_arrow_expression(self, expr: "JSONPathNode") -> Tuple[str, Tuple 1. The JSON path as a string literal, not a parameter placeholder 2. No parentheses around the expression - Takes either JSON path node -- :class:`JSONDocumentExpression` for - ``->``, :class:`JSONTextExpression` for ``->>`` -- and tells them apart + Takes either JSON path node -- :class:`JSONDocumentValue` for + ``->``, :class:`JSONTextValue` for ``->>`` -- and tells them apart by ``expr.operation``, not by class, so which one the caller built does not change the SQL. Any other operation is emitted as an infix comparison with the path bound; see :class:`JSONPathNode` for why that @@ -258,7 +258,7 @@ def format_json_arrow_expression(self, expr: "JSONPathNode") -> Tuple[str, Tuple col_sql, col_params = self.format_identifier(str(expr.column)), () if expr.operation in ("->", "->>"): - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) sql = f"{col_sql}{expr.operation}'{escaped_path}'" params = col_params else: @@ -287,7 +287,7 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, Tu else: col_sql, col_params = self.format_identifier(str(expr.column)), () - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) if expr.operation == "->": sql = f"JSON_EXTRACT({col_sql}, '{escaped_path}')" @@ -304,9 +304,17 @@ def format_json_function_expression(self, expr: "JSONPathNode") -> Tuple[str, Tu return sql, params - def format_json_table_expression(self, expr) -> Tuple[str, tuple]: - """Format JSON_TABLE expression with safe literal escaping.""" + def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: + """Format :class:`MySQLJSONTableExpression` with safe literal escaping. + + The entry the MySQL node names. It is not an override of core's + ``format_json_table_expression``: core's ``JSONTableExpression`` + carries a different shape, and one entry reading the other raised + ``AttributeError`` for half of the pair. Core's entry renders core's + node again. + """ from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol + from rhosocial.activerecord.backend.expression.types import DataType expr.validate(strict=self.strict_validation) @@ -316,7 +324,7 @@ def format_json_table_expression(self, expr) -> Tuple[str, tuple]: # if it's a ToSQLProtocol expression, use to_sql() for parameterized # queries; otherwise raise to prevent SQL injection. if isinstance(expr.json_doc, str): - parts.append(f"'{self._escape_sql_string(expr.json_doc)}'") + parts.append(f"'{self.escape_sql_string(expr.json_doc)}'") elif isinstance(expr.json_doc, ToSQLProtocol): json_sql, _ = expr.json_doc.to_sql() parts.append(json_sql) @@ -326,7 +334,7 @@ def format_json_table_expression(self, expr) -> Tuple[str, tuple]: ) parts.append(",") - escaped_path = self._escape_sql_string(expr.path) + escaped_path = self.escape_sql_string(expr.path) parts.append(f"'{escaped_path}'") parts.append(" COLUMNS (") @@ -335,14 +343,17 @@ def format_json_table_expression(self, expr) -> Tuple[str, tuple]: if col.ordinality: column_parts.append(f"{self.format_identifier(col.name)} FOR ORDINALITY") elif col.exists: - escaped_col_path = self._escape_sql_string(col.path) if col.path else "" - column_parts.append(f"{self.format_identifier(col.name)} {col.type} EXISTS PATH '{escaped_col_path}'") + escaped_col_path = self.escape_sql_string(col.path) if col.path else "" + column_type, _ = DataType.as_type(self, col.type).to_sql() + column_parts.append( + f"{self.format_identifier(col.name)} {column_type} " + f"EXISTS PATH '{escaped_col_path}'" + ) else: - if not self._validate_data_type(col.type): - raise ValueError(f"Invalid data type: {col.type}") - col_def = f"{self.format_identifier(col.name)} {col.type}" + column_type, _ = DataType.as_type(self, col.type).to_sql() + col_def = f"{self.format_identifier(col.name)} {column_type}" if col.path: - escaped_col_path = self._escape_sql_string(col.path) + escaped_col_path = self.escape_sql_string(col.path) col_def += f" PATH '{escaped_col_path}'" if col.error_handling: valid_error_handling = {"NULL", "ERROR", "DEFAULT"} @@ -352,23 +363,24 @@ def format_json_table_expression(self, expr) -> Tuple[str, tuple]: f"Invalid error_handling: {col.error_handling}. Must be one of {valid_error_handling}" ) if error_handling_upper == "DEFAULT": - escaped_default = self._escape_sql_string(str(col.default_value)) + escaped_default = self.escape_sql_string(str(col.default_value)) col_def += f" DEFAULT '{escaped_default}' ON ERROR" else: col_def += f" {error_handling_upper} ON ERROR" column_parts.append(col_def) for nested in expr.nested_paths: - escaped_nested_path = self._escape_sql_string(nested.path) + escaped_nested_path = self.escape_sql_string(nested.path) nested_def = f"NESTED PATH '{escaped_nested_path}' COLUMNS (" nested_cols = [] for col in nested.columns: if col.ordinality: nested_cols.append(f"{self.format_identifier(col.name)} FOR ORDINALITY") else: - escaped_nested_col_path = self._escape_sql_string(col.path) if col.path else "" + escaped_nested_col_path = self.escape_sql_string(col.path) if col.path else "" + nested_type, _ = DataType.as_type(self, col.type).to_sql() nested_cols.append( - f"{self.format_identifier(col.name)} {col.type} PATH '{escaped_nested_col_path}'" + f"{self.format_identifier(col.name)} {nested_type} PATH '{escaped_nested_col_path}'" ) nested_def += ", ".join(nested_cols) + ")" if nested.alias: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py index 53118596..be0d5dc8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/json_duality_view.py @@ -3,8 +3,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, ) @@ -12,12 +12,12 @@ class MySQLJsonDualityViewMixin: """MySQL JSON Duality View implementation (MySQL 9.7+).""" def supports_json_duality_view(self) -> bool: - return self.version >= (9, 7, 0) + return self.version_at_least((9, 7, 0)) def supports_json_duality_view_dml(self) -> bool: - return self.version >= (9, 7, 0) + return self.version_at_least((9, 7, 0)) - def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewStatement") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement.""" parts = [] if expr.replace: @@ -27,26 +27,26 @@ def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityView parts.append(f"`{expr.view_name}`") parts.append("AS") - select_clause = self.format_duality_object_select(expr.root_spec) + select_clause = self.format_mysql_duality_object_select_expression(expr.root_spec) parts.append(select_clause) return " ".join(parts), () - def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW for a JSON Duality View.""" if expr.if_exists: return f"DROP VIEW IF EXISTS `{expr.view_name}`", () return f"DROP VIEW `{expr.view_name}`", () - def format_duality_object_select(self, spec) -> str: + def format_mysql_duality_object_select_expression(self, spec) -> str: """Format the SELECT JSON_DUALITY_OBJECT(...) FROM table clause.""" - obj_body = self.format_duality_object_body(spec) + obj_body = self.format_mysql_duality_object_body_expression(spec) table_ref = f"`{spec.from_table}`" if spec.from_alias: table_ref += f" `{spec.from_alias}`" return f"SELECT {obj_body} FROM {table_ref}" - def format_duality_object_body(self, spec) -> str: + def format_mysql_duality_object_body_expression(self, spec) -> str: """Format JSON_DUALITY_OBJECT( WITH(...) 'key': col, ... ).""" inner_parts = [] @@ -58,7 +58,7 @@ def format_duality_object_body(self, spec) -> str: inner_parts.append(f"'{col.json_key}': {col.column_expr}") for nested in spec.nested: - nested_subquery = self.format_nested_duality(nested) + nested_subquery = self.format_mysql_nested_duality_expression(nested) inner_parts.append(f"'{nested.json_key}': ({nested_subquery})") if spec.tags: @@ -70,10 +70,10 @@ def format_duality_object_body(self, spec) -> str: fields_str = ", ".join(inner_parts) return f"JSON_DUALITY_OBJECT({fields_str})" - def format_nested_duality(self, nested) -> str: + def format_mysql_nested_duality_expression(self, nested) -> str: """Format a nested JSON_ARRAYAGG(JSON_DUALITY_OBJECT(...)) subquery.""" sub_spec = nested.subquery - obj_body = self.format_duality_object_body(sub_spec) + obj_body = self.format_mysql_duality_object_body_expression(sub_spec) table_ref = f"`{sub_spec.from_table}`" if sub_spec.from_alias: table_ref += f" `{sub_spec.from_alias}`" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py new file mode 100644 index 00000000..911f5a46 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py @@ -0,0 +1,75 @@ +# src/rhosocial/activerecord/backend/impl/mysql/mixins/literal.py +"""MySQL's tier-2 value literals. + +``SQLDialectBase.format_literal`` is the single entry point for writing a +value as SQL text, and it resolves in three tiers: the portable scalars it +knows, the types a backend spells for themselves, and the +literal-construction protocol. This module is MySQL's second tier, and it +carries the two spellings measured on the live servers (8.0.46 at +192.168.1.3:14682, and the server at 192.168.1.3:14686 which answers +``SELECT VERSION()`` with ``26.7.0``): + +* ``bytes`` becomes ``X'0102FF'``, the hex form from MySQL's literal + grammar. ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'`` and a + ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those bytes on + insert (likewise ``TINYBLOB DEFAULT (X'0102FF')`` through the + expression-default syntax). Lower-case ``x'0102ff'`` is accepted too, and + upper-case is what this dialect renders. Quoting the bytes as text would + read back as text -- the wrong value in a binary column and the wrong + length besides. +* ``UUID`` becomes the bare quoted canonical text. MySQL has no UUID + column type (it numbers rows with ``AUTO_INCREMENT`` and spells UUIDs as + ``CHAR(36)``/``BINARY(16)`` storage conventions) and no literal form other + than the text: measured, ``CHAR(36) DEFAULT '550e8400-e29b-41d4-a716-446655440000'`` + reads the hyphenated text back verbatim. + +**INTERVAL stays unimplemented on purpose.** ``timedelta`` has no branch +here, so the base's explicit refusal is the answer: measured on both +servers, ``SELECT INTERVAL 1 DAY`` is a syntax error (errno 1064) and so is +a ``DEFAULT INTERVAL 1 DAY`` in every column shape tried. ``INTERVAL`` +exists only as the keyword parameter of date arithmetic +(``DATE_ADD('2026-10-17', INTERVAL 1 DAY)`` is accepted), never as a value +with a literal form of its own, and this module adds no spelling rather +than inventing one. + +The tier-1 forms (NULL / booleans / numbers / strings / dates / times / +datetimes) are the base's, reached through this dialect's backslash-doubling +``escape_sql_string``: measured on both servers as ``DEFAULT`` values +accepted by ``CREATE TABLE`` (``TRUE`` / ``FALSE`` / ``42`` / ``1.5`` / +``1.25`` / ``'x''y'`` / ``'2026-10-17'`` / ``'10:30:00'`` / +``'2026-10-17 10:00:00'``), each reading back unchanged -- so none of them +is overridden here. +""" + +import uuid +from typing import Any + + +class MySQLLiteralValueMixin: + """MySQL's tier-2 branches of :meth:`format_literal`. + + Stands alone rather than inheriting the base, and the dialect composes + it ahead of :class:`SQLDialectBase` -- the one base that defines + ``format_literal`` -- because a mixin placed after the class it + overrides never wins. + """ + + def format_literal(self, value: Any) -> str: + """Render *value* as inline SQL text, MySQL's spelling where it has one. + + Args: + value: The host value to write into the statement. + + Returns: + The escaped literal text, without a bind parameter. + + Raises: + TypeError: The base implementation refuses a type MySQL has no + literal form for -- ``timedelta`` among them, which is the + measured answer rather than a gap. + """ + if isinstance(value, (bytes, bytearray)): + return f"X'{bytes(value).hex().upper()}'" + if isinstance(value, uuid.UUID): + return self.format_literal(str(value)) + return super().format_literal(value) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py index 5cd6d236..6bc1d90e 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/load_xml.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.load_xml import ( - MySQLLoadXMLEXpression, + MySQLLoadXMLStatement, ) @@ -17,7 +17,7 @@ class MySQLLoadXMLLMixin: def supports_load_xml(self) -> bool: return True - def format_load_xml_statement(self, expr: "MySQLLoadXMLEXpression") -> Tuple[str, tuple]: + def format_mysql_load_xml_statement(self, expr: "MySQLLoadXMLStatement") -> Tuple[str, tuple]: """Format ``LOAD XML ... INFILE ... INTO TABLE ...``. Raises: @@ -25,7 +25,7 @@ def format_load_xml_statement(self, expr: "MySQLLoadXMLEXpression") -> Tuple[str wrong kind there would render a statement loading into whatever object's name was supplied. """ - require_kind(expr.table, Table, "MySQLLoadXMLEXpression.table") + require_kind(expr.table, Table, "MySQLLoadXMLStatement.table") expr.validate(strict=self.strict_validation) parts = ["LOAD XML"] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py index 076c1009..08c17e79 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/locking.py @@ -6,16 +6,23 @@ class MySQLLockingMixin: """MySQL row-level locking mixin.""" def supports_for_share(self) -> bool: - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_for_update_nowait(self) -> bool: - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_for_update_skip_locked(self) -> bool: - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def format_for_update_clause(self, clause) -> Tuple[str, tuple]: - """Format MySQL-specific FOR UPDATE / FOR SHARE clause.""" + """Format MySQL-specific FOR UPDATE / FOR SHARE clause. + + The node normalises ``strength`` to a :class:`LockStrength` member, + so the clause text is written from the member's ``phrase``: the wire + value stays the language-neutral strength word (``update`` / ``share``) + and the SQL spelling (``FOR UPDATE`` / ``FOR SHARE``) belongs to the + dialect, not to the artifact. + """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import LockStrength @@ -31,11 +38,14 @@ def format_for_update_clause(self, clause) -> Tuple[str, tuple]: self.name, f"{strength.value} (unsupported lock strength)" ) - sql_parts = [strength.value] + sql_parts = [strength.phrase] if clause.of_columns: of_parts = [] for col in clause.of_columns: + if isinstance(col, str): + of_parts.append(self.format_identifier(col)) + continue col_sql, col_params = col.to_sql() of_parts.append(col_sql) all_params.extend(col_params) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py index 5e10968d..03977008 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/maintenance.py @@ -5,6 +5,13 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.objects import RelationObject + from rhosocial.activerecord.backend.impl.mysql.expression.maintenance import ( + MySQLAnalyzeTableStatement, + MySQLCheckTableStatement, + MySQLChecksumTableStatement, + MySQLOptimizeTableStatement, + MySQLRepairTableStatement, + ) class MySQLMaintenanceMixin: @@ -34,8 +41,14 @@ def supports_optimize_table(self) -> bool: def supports_repair_table(self) -> bool: return True - def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: - """Format a whole-table maintenance statement (analyze/check/...).""" + def _render_mysql_table_maintenance(self, expr) -> Tuple[str, tuple]: + """Render the whole-table maintenance shape the five entries share. + + The public entries are one per kind; this is the shared assembly, + reached only from them. The keyword comes from the node's own + ``operation`` -- a class attribute each subclass declares -- so no + entry has to be told which word it renders. + """ expr.validate(strict=self.strict_validation) operation = expr.operation support_method = f"supports_{operation.lower()}_table" @@ -45,21 +58,56 @@ def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: parts = [f"{operation} TABLE"] - if hasattr(expr, "no_write_to_binlog") and expr.no_write_to_binlog.value: + if expr.no_write_to_binlog.value: parts.append(expr.no_write_to_binlog.value) table_parts = [self._format_maintenance_table(t) for t in expr.tables] parts.append(", ".join(table_parts)) - if operation == "CHECK" and getattr(expr, "options", None): + if operation == "CHECK" and expr.options: parts.append(" ".join(option.value for option in expr.options)) - elif operation == "CHECKSUM" and getattr(expr, "option", None): + elif operation == "CHECKSUM" and expr.option: parts.append(expr.option.value) - elif operation == "REPAIR" and getattr(expr, "options", None): + elif operation == "REPAIR" and expr.options: parts.append(" ".join(option.value for option in expr.options)) return " ".join(parts), () + def format_mysql_analyze_table_statement( + self, + expr: "MySQLAnalyzeTableStatement", + ) -> Tuple[str, tuple]: + """Format ``ANALYZE TABLE``.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_optimize_table_statement( + self, + expr: "MySQLOptimizeTableStatement", + ) -> Tuple[str, tuple]: + """Format ``OPTIMIZE TABLE``.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_check_table_statement( + self, + expr: "MySQLCheckTableStatement", + ) -> Tuple[str, tuple]: + """Format ``CHECK TABLE`` with its optional modes.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_checksum_table_statement( + self, + expr: "MySQLChecksumTableStatement", + ) -> Tuple[str, tuple]: + """Format ``CHECKSUM TABLE`` with its optional mode.""" + return self._render_mysql_table_maintenance(expr) + + def format_mysql_repair_table_statement( + self, + expr: "MySQLRepairTableStatement", + ) -> Tuple[str, tuple]: + """Format ``REPAIR TABLE`` with its optional modes.""" + return self._render_mysql_table_maintenance(expr) + def _format_maintenance_table(self, table: "RelationObject") -> str: """Render one maintenance target through its own formatter. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py index f130cbb8..ff38a1b7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/namespace.py @@ -136,7 +136,7 @@ def validate_catalog_name(self, expr) -> None: MySQL's 64-character identifier limit, or contains one of the characters MySQL reserves for name qualification. """ - name = getattr(expr, "catalog_name", None) + name = expr.catalog_name if not name: return if len(name) > MAX_IDENTIFIER_LENGTH: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py index d32f2b0c..b0654fde 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/object_kind.py @@ -7,7 +7,7 @@ name work without this dialect assembling a prefix, and it is also why a wrong kind used to render quietly. -``CreateDatabaseExpression(dialect, Table(dialect, "users"))`` reached +``CreateDatabaseStatement(dialect, Table(dialect, "users"))`` reached ``format_database_object`` -- the ``Table``'s own method -- and produced ``CREATE DATABASE `users```: well-formed SQL naming something other than what the caller asked for. No error, no warning, and a statement the server would @@ -23,7 +23,7 @@ The message is deliberately identical in shape to core's, which writes the same check out inline: - CreateDatabaseExpression.database must be a Database, got Table + CreateDatabaseStatement.database must be a Database, got Table Matching the wording means a caller who reads one dialect's error can read every dialect's, and a test may assert on it without knowing which dialect diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py index 9c2bdfef..7ddc937a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/optimizer_hint.py @@ -11,12 +11,12 @@ class MySQLOptimizerHintMixin: """MySQL optimizer hint implementation.""" def supports_optimizer_hint(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_hypergraph_optimizer(self) -> bool: - return self.version >= (9, 7, 0) + return self.version_at_least((9, 7, 0)) - def format_optimizer_hint(self, expr: "MySQLOptimizerHintExpression") -> "Tuple[str, tuple]": + def format_mysql_optimizer_hint_expression(self, expr: "MySQLOptimizerHintExpression") -> "Tuple[str, tuple]": """Format /*+ SET_VAR(...) */ hint clause.""" parts = [] for hint in expr.hints: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index 6e4071c3..8d2b5af2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -1,10 +1,14 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py -from typing import Any, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( + MySQLPartitionStrategy, +) + from .object_kind import require_kind if TYPE_CHECKING: # pragma: no cover @@ -70,6 +74,14 @@ def supports_subpartitioning(self) -> bool: """MySQL supports ``SUBPARTITION BY {HASH|KEY}`` since 5.1.""" return True + def supports_partition_introspection(self) -> bool: + """MySQL answers for a table's partitions from ``information_schema.PARTITIONS``. + + The capability for the ``introspect partitions`` family. An empty answer + means the table is not partitioned, which is a different statement from + "this engine has no partitions" -- hence the switch.""" + return True + def supports_range_columns_partitioning(self) -> bool: return True @@ -126,7 +138,7 @@ def supports_exchange_partition_with_validation(self) -> bool: 5.6) reject it as a syntax error. See MySQL 5.7 release notes for "InnoDB online DDL" support of EXCHANGE PARTITION WITH VALIDATION. """ - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_exchange_partition_without_validation(self) -> bool: """Whether ``EXCHANGE PARTITION ... WITHOUT VALIDATION`` is accepted. @@ -154,28 +166,37 @@ def supports_repair_partition(self) -> bool: return True def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: - """Format MySQL PARTITION BY clause from a PartitionClause expression.""" + """Format the clause shape ``PARTITION BY ()``. + + This is the contract :class:`~...statements.ddl_partition. + PartitionClause` declares -- method and keys, nothing else -- so it + is all that is read here. Named partitions, ``VALUES`` boundaries + and subpartitioning are fields the MySQL ``MySQLPartitionBy*`` + subclasses declare, and each of those classes names its own entry + (``format_mysql_partition_by_range`` and siblings); routing a base clause + into one of those entries used to read ``subpartition_by`` off a + node that does not declare it. + """ if not self.supports_table_partitioning(): raise UnsupportedFeatureError(self.name, "table partitioning") if not self.supports_partitioned_table_creation(): raise UnsupportedFeatureError(self.name, "partitioned table creation") method = expr.method.upper() - if method == "RANGE": - return self.format_partition_by_range(expr) - if method == "RANGE COLUMNS": - return self.format_partition_by_range_columns(expr) - if method == "LIST": - return self.format_partition_by_list(expr) - if method == "LIST COLUMNS": - return self.format_partition_by_list_columns(expr) - if method in {"HASH", "LINEAR HASH"}: - return self.format_partition_by_hash(expr) - if method in {"KEY", "LINEAR KEY"}: - return self.format_partition_by_key(expr) - raise ValueError("Invalid MySQL partition method") - - def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: + if not any( + method == strategy.value for strategy in MySQLPartitionStrategy + ): + raise ValueError("Invalid MySQL partition method") + + key_sql_parts = [] + params: List[Any] = [] + for key in expr.keys: + key_sql, key_params = key.to_sql() + key_sql_parts.append(key_sql) + params.extend(key_params) + return f" PARTITION BY {method} ({', '.join(key_sql_parts)})", tuple(params) + + def format_mysql_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: """Format PARTITION BY RANGE with optional subpartitioning.""" if not self.supports_range_table_partitioning(): raise UnsupportedFeatureError(self.name, "RANGE partitioning") @@ -188,13 +209,13 @@ def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, params.extend(key_params) sql = f" PARTITION BY RANGE ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -204,7 +225,7 @@ def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: """Format PARTITION BY RANGE COLUMNS with optional subpartitioning.""" if not self.supports_range_columns_partitioning(): raise UnsupportedFeatureError(self.name, "RANGE COLUMNS partitioning") @@ -217,13 +238,13 @@ def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns" params.extend(key_params) sql = f" PARTITION BY RANGE COLUMNS ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -233,7 +254,7 @@ def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns" sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: + def format_mysql_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: """Format PARTITION BY LIST with optional subpartitioning.""" if not self.supports_list_table_partitioning(): raise UnsupportedFeatureError(self.name, "LIST partitioning") @@ -246,13 +267,13 @@ def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, t params.extend(key_params) sql = f" PARTITION BY LIST ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -262,7 +283,7 @@ def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, t sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: """Format PARTITION BY LIST COLUMNS with optional subpartitioning.""" if not self.supports_list_columns_partitioning(): raise UnsupportedFeatureError(self.name, "LIST COLUMNS partitioning") @@ -275,13 +296,13 @@ def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") params.extend(key_params) sql = f" PARTITION BY LIST COLUMNS ({', '.join(key_sql_parts)})" - subpartition_by = getattr(expr, "subpartition_by", None) + subpartition_by = expr.subpartition_by if subpartition_by is not None: - sub_sql, sub_params = self.format_subpartition_by(subpartition_by) + sub_sql, sub_params = self.format_mysql_subpartition_clause(subpartition_by) sql = f"{sql}{sub_sql}" params.extend(sub_params) - partitions = getattr(expr, "partitions", []) + partitions = expr.partitions if partitions: partition_sql_parts = [] for partition in partitions: @@ -291,7 +312,7 @@ def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") sql = f"{sql} ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: + def format_mysql_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: """Format PARTITION BY HASH or LINEAR HASH.""" if not self.supports_hash_table_partitioning(): raise UnsupportedFeatureError(self.name, "HASH partitioning") @@ -308,14 +329,14 @@ def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, t params.extend(key_params) sql = f" PARTITION BY {keyword} ({', '.join(key_sql_parts)})" - partitions_count = getattr(expr, "partitions_count", None) + partitions_count = expr.partitions_count if partitions_count is not None: if not isinstance(partitions_count, int) or partitions_count <= 0: raise ValueError("partitions_count must be a positive integer") sql = f"{sql} PARTITIONS {partitions_count}" return sql, tuple(params) - def format_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: + def format_mysql_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: """Format PARTITION BY KEY or LINEAR KEY.""" if not self.supports_key_table_partitioning(): raise UnsupportedFeatureError(self.name, "KEY partitioning") @@ -332,7 +353,7 @@ def format_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tup params.extend(key_params) sql = f" PARTITION BY {keyword} ({', '.join(key_sql_parts)})" - partitions_count = getattr(expr, "partitions_count", None) + partitions_count = expr.partitions_count if partitions_count is not None: if not isinstance(partitions_count, int) or partitions_count <= 0: raise ValueError("partitions_count must be a positive integer") @@ -379,7 +400,7 @@ def format_partition_definition(self, definition: "MySQLPartitionDefinition") -> if options_sql: parts.append(options_sql) params.extend(options_params) - subpartition_defs = getattr(definition, "subpartition_definitions", None) + subpartition_defs = definition.subpartition_definitions if subpartition_defs: if not self.supports_subpartitioning(): raise UnsupportedFeatureError(self.name, "subpartition definitions") @@ -430,7 +451,7 @@ def format_partition_definition_options( elif name in {"comment", "data_directory", "index_directory"}: if not isinstance(value, str): raise TypeError(f"{name} option must be a string") - escaped = self._escape_sql_string(value) + escaped = self.escape_sql_string(value) parts.append(f"{keyword} '{escaped}'") else: if not isinstance(value, int) or value < 0: @@ -438,7 +459,7 @@ def format_partition_definition_options( parts.append(f"{keyword} {value}") return " ".join(parts), () - def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: + def format_mysql_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: """Format a ``SELECT ... FROM information_schema.PARTITIONS`` query. Args: @@ -454,14 +475,14 @@ def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") Literal, LogicalPredicate, OrderByClause, - QueryExpression, + QueryStatement, ) # MySQL's database is the catalog and there is no inner schema, so # ``information_schema`` is the outer slot. Putting it in ``schema_name`` # would be refused rather than silently dropped. partitions = Table(expr.dialect, "PARTITIONS", catalog_name="information_schema") - query = QueryExpression( + query = QueryStatement( expr.dialect, select=[ Column(expr.dialect, "PARTITION_NAME", alias="name"), @@ -494,23 +515,25 @@ def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") ) return query.to_sql() - def format_partition_value( - self, - expr: Union["MySQLPartitionValue", "MySQLPartitionMaxValue"], - ) -> Tuple[str, tuple]: + def format_mysql_partition_max_value(self, expr: "MySQLPartitionMaxValue") -> Tuple[str, tuple]: + """Format the ``MAXVALUE`` partition boundary token. + + Its own entry, not a branch of the value entry: ``MAXVALUE`` is a + token the boundary grammar names, while a value is a scalar the + entry below converts by type. One function branching on the node's + class is the "one entry, several contracts" shape the architecture + rules refuse. + """ + if not self.supports_partition_value_maxvalue(): + raise UnsupportedFeatureError(self.name, "MAXVALUE partition boundary") + return "MAXVALUE", () + + def format_mysql_partition_value(self, expr: "MySQLPartitionValue") -> Tuple[str, tuple]: """Format a MySQL partition boundary value.""" from datetime import date, datetime from decimal import Decimal from math import isfinite - from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( - MySQLPartitionMaxValue, - ) - - if isinstance(expr, MySQLPartitionMaxValue): - if not self.supports_partition_value_maxvalue(): - raise UnsupportedFeatureError(self.name, "MAXVALUE partition boundary") - return "MAXVALUE", () value = expr.value if value is None: return "NULL", () @@ -527,20 +550,20 @@ def format_partition_value( raise ValueError("partition value Decimal must be finite") return str(value), () if isinstance(value, datetime): - escaped = self._escape_sql_string(value.isoformat(sep=" ")) + escaped = self.escape_sql_string(value.isoformat(sep=" ")) return f"'{escaped}'", () if isinstance(value, date): - escaped = self._escape_sql_string(value.isoformat()) + escaped = self.escape_sql_string(value.isoformat()) return f"'{escaped}'", () if isinstance(value, str): - escaped = self._escape_sql_string(value) + escaped = self.escape_sql_string(value) return f"'{escaped}'", () raise TypeError( "partition value must be str, int, float, Decimal, " f"date, datetime, or None, got {type(value).__name__}" ) - def format_subpartition_by(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: + def format_mysql_subpartition_clause(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: """Format ``SUBPARTITION BY {HASH|KEY}(...) SUBPARTITIONS N``. Args: @@ -600,7 +623,7 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio params.extend(options_params) return " ".join(parts), tuple(params) - def format_add_partition_statement(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_add_partition_expression(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ADD PARTITION. Each object is checked before it is rendered because a partition @@ -624,7 +647,7 @@ def format_add_partition_statement(self, expr: "MySQLAddPartitionExpression") -> sql = f"ALTER TABLE {table_sql} ADD PARTITION ({', '.join(partition_sql_parts)})" return sql, tuple(params) - def format_drop_partition_statement(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_partition_expression(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... DROP PARTITION. Each object is checked before it is rendered because a partition @@ -642,7 +665,7 @@ def format_drop_partition_statement(self, expr: "MySQLDropPartitionExpression") partitions = ", ".join(self.format_identifier(partition) for partition in expr.partitions) return f"ALTER TABLE {table_sql} DROP PARTITION {partitions}", tuple(table_params) - def format_truncate_partition_statement(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: + def format_mysql_truncate_partition_expression(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... TRUNCATE PARTITION. Each object is checked before it is rendered because a partition @@ -660,7 +683,7 @@ def format_truncate_partition_statement(self, expr: "MySQLTruncatePartitionExpre partitions = ", ".join(self.format_identifier(partition) for partition in expr.partitions) return f"ALTER TABLE {table_sql} TRUNCATE PARTITION {partitions}", tuple(table_params) - def format_reorganize_partition_statement( + def format_mysql_reorganize_partition_expression( self, expr: "MySQLReorganizePartitionExpression", ) -> Tuple[str, tuple]: @@ -692,7 +715,7 @@ def format_reorganize_partition_statement( ) return sql, tuple(params) - def format_exchange_partition_statement( + def format_mysql_exchange_partition_expression( self, expr: "MySQLExchangePartitionExpression", ) -> Tuple[str, tuple]: @@ -746,7 +769,7 @@ def format_partition_name_list(self, partitions: Sequence[str]) -> str: raise ValueError("partitions must not be empty") return ", ".join(self.format_identifier(partition) for partition in partitions) - def format_remove_partitioning_statement( + def format_mysql_remove_partitioning_expression( self, expr: "MySQLRemovePartitioningExpression", ) -> Tuple[str, tuple]: @@ -768,7 +791,7 @@ def format_remove_partitioning_statement( table_sql, table_params = expr.table.to_sql() return f"ALTER TABLE {table_sql} REMOVE PARTITIONING", tuple(table_params) - def format_coalesce_partition_statement( + def format_mysql_coalesce_partition_expression( self, expr: "MySQLCoalescePartitionExpression", ) -> Tuple[str, tuple]: @@ -790,7 +813,7 @@ def format_coalesce_partition_statement( table_sql, table_params = expr.table.to_sql() return f"ALTER TABLE {table_sql} COALESCE PARTITION {expr.count}", tuple(table_params) - def format_analyze_partition_statement( + def format_mysql_analyze_partition_expression( self, expr: "MySQLAnalyzePartitionExpression", ) -> Tuple[str, tuple]: @@ -811,7 +834,7 @@ def format_analyze_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} ANALYZE PARTITION {partitions}", tuple(table_params) - def format_check_partition_statement( + def format_mysql_check_partition_expression( self, expr: "MySQLCheckPartitionExpression", ) -> Tuple[str, tuple]: @@ -832,7 +855,7 @@ def format_check_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} CHECK PARTITION {partitions}", tuple(table_params) - def format_optimize_partition_statement( + def format_mysql_optimize_partition_expression( self, expr: "MySQLOptimizePartitionExpression", ) -> Tuple[str, tuple]: @@ -853,7 +876,7 @@ def format_optimize_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} OPTIMIZE PARTITION {partitions}", tuple(table_params) - def format_rebuild_partition_statement( + def format_mysql_rebuild_partition_expression( self, expr: "MySQLRebuildPartitionExpression", ) -> Tuple[str, tuple]: @@ -874,7 +897,7 @@ def format_rebuild_partition_statement( partitions = self.format_partition_name_list(expr.partitions) return f"ALTER TABLE {table_sql} REBUILD PARTITION {partitions}", tuple(table_params) - def format_repair_partition_statement( + def format_mysql_repair_partition_expression( self, expr: "MySQLRepairPartitionExpression", ) -> Tuple[str, tuple]: @@ -899,7 +922,7 @@ def format_repair_partition_statement( # Partition lifecycle helpers (composing low-level DDL expressions) # ------------------------------------------------------------------ - def format_add_partition_helper(self, expr: "MySQLAddPartitionHelper") -> Tuple[str, tuple]: + def format_mysql_add_partition_helper(self, expr: "MySQLAddPartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLAddPartitionHelper` (generated partition names).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLAddPartitionExpression, @@ -919,7 +942,7 @@ def format_add_partition_helper(self, expr: "MySQLAddPartitionHelper") -> Tuple[ add_expr = MySQLAddPartitionExpression(self, expr.table, partitions) return add_expr.to_sql() - def format_coalesce_partition_helper(self, expr: "MySQLCoalescePartitionHelper") -> Tuple[str, tuple]: + def format_mysql_coalesce_partition_helper(self, expr: "MySQLCoalescePartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLCoalescePartitionHelper` (with validation).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLCoalescePartitionExpression, @@ -934,7 +957,7 @@ def format_coalesce_partition_helper(self, expr: "MySQLCoalescePartitionHelper") coalesce_expr = MySQLCoalescePartitionExpression(self, expr.table, count) return coalesce_expr.to_sql() - def format_drop_oldest_partition_helper(self, expr: "MySQLDropOldestPartitionHelper") -> Tuple[str, tuple]: + def format_mysql_drop_oldest_partition_helper(self, expr: "MySQLDropOldestPartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLDropOldestPartitionHelper` (lexicographically oldest).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLDropPartitionExpression, @@ -944,7 +967,7 @@ def format_drop_oldest_partition_helper(self, expr: "MySQLDropOldestPartitionHel drop_expr = MySQLDropPartitionExpression(self, expr.table, [sorted_names[0]]) return drop_expr.to_sql() - def format_reorganize_partition_helper(self, expr: "MySQLReorganizePartitionHelper") -> Tuple[str, tuple]: + def format_mysql_reorganize_partition_helper(self, expr: "MySQLReorganizePartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLReorganizePartitionHelper`.""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLReorganizePartitionExpression, @@ -953,7 +976,7 @@ def format_reorganize_partition_helper(self, expr: "MySQLReorganizePartitionHelp reorg_expr = MySQLReorganizePartitionExpression(self, expr.table, expr.partition, expr.into) return reorg_expr.to_sql() - def format_add_subpartition_helper(self, expr: "MySQLAddSubpartitionHelper") -> Tuple[str, tuple]: + def format_mysql_add_subpartition_helper(self, expr: "MySQLAddSubpartitionHelper") -> Tuple[str, tuple]: """Format a :class:`MySQLAddSubpartitionHelper` (partition + subpartitions).""" from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLAddPartitionExpression, diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py index c8235521..e807ef9f 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py @@ -7,11 +7,11 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.impl.mysql.expression.routine import ( - MySQLCallExpression, - MySQLCreateProcedureExpression, - MySQLDropProcedureExpression, - MySQLCreateFunctionExpression, - MySQLDropFunctionExpression, + MySQLCallStatement, + MySQLCreateProcedureStatement, + MySQLDropProcedureStatement, + MySQLCreateFunctionStatement, + MySQLDropFunctionStatement, ) @@ -43,9 +43,9 @@ def supports_stored_function(self) -> bool: def supports_call(self) -> bool: return True - def format_create_procedure_statement( + def format_mysql_create_procedure_statement( self, - expr: "MySQLCreateProcedureExpression", + expr: "MySQLCreateProcedureStatement", ) -> Tuple[str, tuple]: """Format ``CREATE PROCEDURE name(params) body``. @@ -55,7 +55,7 @@ def format_create_procedure_statement( ``strict_validation`` and can be switched off; a name of the wrong kind must not depend on a rendering strictness flag. """ - require_kind(expr.name, RoutineObject, "MySQLCreateProcedureExpression.name") + require_kind(expr.name, RoutineObject, "MySQLCreateProcedureStatement.name") expr.validate(strict=self.strict_validation) parts = ["CREATE PROCEDURE", expr.name.to_sql()[0]] params = ", ".join(self._format_param(p) for p in expr.params) @@ -64,16 +64,16 @@ def format_create_procedure_statement( parts.append(expr.body) return " ".join(parts), () - def format_drop_procedure_statement( + def format_mysql_drop_procedure_statement( self, - expr: "MySQLDropProcedureExpression", + expr: "MySQLDropProcedureStatement", ) -> Tuple[str, tuple]: """Format ``DROP PROCEDURE [IF EXISTS] name``. Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLDropProcedureExpression.name") + require_kind(expr.name, RoutineObject, "MySQLDropProcedureStatement.name") expr.validate(strict=self.strict_validation) parts = ["DROP PROCEDURE"] if expr.if_exists: @@ -81,16 +81,24 @@ def format_drop_procedure_statement( parts.append(expr.name.to_sql()[0]) return " ".join(parts), () - def format_create_function_statement( + def format_mysql_create_function_statement( self, - expr: "MySQLCreateFunctionExpression", + expr: "MySQLCreateFunctionStatement", ) -> Tuple[str, tuple]: """Format ``CREATE FUNCTION name(params) RETURNS type body``. + This is the entry :class:`MySQLCreateFunctionStatement` names. It is + deliberately not an override of core's + ``format_create_function_statement``: that node carries + ``function``/``parameters``/``language`` where this one carries + ``name``/``params``/``returns``, so one entry could read only one of + the two shapes and raised ``AttributeError`` on the other. Core's + entry renders core's node again. + Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLCreateFunctionExpression.name") + require_kind(expr.name, RoutineObject, "MySQLCreateFunctionStatement.name") expr.validate(strict=self.strict_validation) parts = ["CREATE FUNCTION", expr.name.to_sql()[0]] params = ", ".join(self._format_param(p) for p in expr.params) @@ -102,19 +110,19 @@ def format_create_function_statement( parts.append(expr.body) return " ".join(parts), () - def format_drop_function_statement( + def format_mysql_drop_function_statement( self, - expr: "MySQLDropFunctionExpression", + expr: "MySQLDropFunctionStatement", ) -> Tuple[str, tuple]: """Format ``DROP FUNCTION [IF EXISTS] name`` (stored function). - The loadable UDF form ``DROP FUNCTION name`` is syntactically - identical and shares this method. + The entry :class:`MySQLDropFunctionStatement` names, for the reason + given on :meth:`format_mysql_create_function_statement`. Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLDropFunctionExpression.name") + require_kind(expr.name, RoutineObject, "MySQLDropFunctionStatement.name") expr.validate(strict=self.strict_validation) parts = ["DROP FUNCTION"] if expr.if_exists: @@ -122,13 +130,13 @@ def format_drop_function_statement( parts.append(expr.name.to_sql()[0]) return " ".join(parts), () - def format_call_statement(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: + def format_mysql_call_statement(self, expr: "MySQLCallStatement") -> Tuple[str, tuple]: """Format ``CALL name([args])``. Raises: TypeError: ``expr.name`` is not a routine object. """ - require_kind(expr.name, RoutineObject, "MySQLCallExpression.name") + require_kind(expr.name, RoutineObject, "MySQLCallStatement.name") expr.validate(strict=self.strict_validation) params = [] arg_parts = [] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py index d72b9e41..f9970187 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_operation.py @@ -12,11 +12,11 @@ def supports_union_all(self) -> bool: def supports_intersect(self) -> bool: """INTERSECT is supported since MySQL 8.0.31.""" - return self.version >= (8, 0, 31) + return self.version_at_least((8, 0, 31)) def supports_except(self) -> bool: """EXCEPT is supported since MySQL 8.0.31.""" - return self.version >= (8, 0, 31) + return self.version_at_least((8, 0, 31)) def supports_set_operation_order_by(self) -> bool: """Set operations support ORDER BY.""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py index 2fed0b2c..852f3feb 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/set_type.py @@ -8,13 +8,13 @@ class MySQLSetTypeMixin: def supports_set_type(self) -> bool: return True - def format_set_literal(self, expr) -> Tuple[str, tuple]: + def format_mysql_set_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSetLiteralExpression` node.""" from ..expression.set_type import MySQLSetLiteralExpression if not isinstance(expr, MySQLSetLiteralExpression): raise TypeError( - f"format_set_literal expects MySQLSetLiteralExpression, got {type(expr).__name__}" + f"format_mysql_set_literal_expression expects MySQLSetLiteralExpression, got {type(expr).__name__}" ) values = expr.values @@ -37,13 +37,13 @@ def format_set_literal(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, (literal,) - def format_find_in_set(self, expr) -> Tuple[str, tuple]: + def format_mysql_find_in_set_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLFindInSetExpression` node.""" from ..expression.set_type import MySQLFindInSetExpression if not isinstance(expr, MySQLFindInSetExpression): raise TypeError( - f"format_find_in_set expects MySQLFindInSetExpression, got {type(expr).__name__}" + f"format_mysql_find_in_set_expression expects MySQLFindInSetExpression, got {type(expr).__name__}" ) sql = f"FIND_IN_SET({self.p()}, {self.format_identifier(expr.set_column)}) > 0" @@ -51,13 +51,13 @@ def format_find_in_set(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, (expr.value,) - def format_set_contains(self, expr) -> Tuple[str, tuple]: + def format_mysql_set_contains_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSetContainsExpression` node.""" from ..expression.set_type import MySQLSetContainsExpression if not isinstance(expr, MySQLSetContainsExpression): raise TypeError( - f"format_set_contains expects MySQLSetContainsExpression, got {type(expr).__name__}" + f"format_mysql_set_contains_expression expects MySQLSetContainsExpression, got {type(expr).__name__}" ) conditions = [] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py index 2b1ae274..122a1085 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/spatial.py @@ -12,36 +12,37 @@ def supports_spatial_type(self, type_name: str) -> bool: } if type_name.upper() not in valid_types: return False - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_spatial_index(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_geojson(self) -> bool: - return self.version >= (5, 7, 5) + return self.version_at_least((5, 7, 5)) def supports_geometry_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_point_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_curve_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_surface_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) def supports_geometry_collection_type(self) -> bool: - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) - def format_spatial_literal(self, expr) -> Tuple[str, tuple]: + def format_mysql_spatial_literal_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSpatialLiteralExpression` node.""" from ..expression.spatial import MySQLSpatialLiteralExpression if not isinstance(expr, MySQLSpatialLiteralExpression): raise TypeError( - f"format_spatial_literal expects MySQLSpatialLiteralExpression, got {type(expr).__name__}" + "format_mysql_spatial_literal_expression expects " + f"MySQLSpatialLiteralExpression, got {type(expr).__name__}" ) sql, params = self._format_spatial_literal_parts(expr.wkt, expr.srid) @@ -55,7 +56,7 @@ def _format_spatial_literal_parts(self, wkt: str, srid: Optional[int] = None) -> return f"ST_GeomFromText({self.p()}, {self.p()})", (wkt, srid) return f"ST_GeomFromText({self.p()})", (wkt,) - def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_text_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLSTGeomFromTextExpression or a raw WKT string.""" from ..expression.spatial import MySQLSTGeomFromTextExpression @@ -70,13 +71,14 @@ def format_st_geom_from_text(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_st_geom_from_wkb(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_wkb_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLSTGeomFromWKBExpression` node.""" from ..expression.spatial import MySQLSTGeomFromWKBExpression if not isinstance(expr, MySQLSTGeomFromWKBExpression): raise TypeError( - f"format_st_geom_from_wkb expects MySQLSTGeomFromWKBExpression, got {type(expr).__name__}" + "format_mysql_st_geom_from_wkb_expression expects " + f"MySQLSTGeomFromWKBExpression, got {type(expr).__name__}" ) if expr.srid is not None: @@ -89,7 +91,7 @@ def format_st_geom_from_wkb(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params - def format_st_as_text(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_as_text_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLSTAsTextExpression or a raw geometry reference.""" from ..expression.spatial import MySQLSTAsTextExpression @@ -105,7 +107,7 @@ def format_st_as_text(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_as_geo_json_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLSTAsGeoJSONExpression or a raw geometry reference.""" from ..expression.spatial import MySQLSTAsGeoJSONExpression @@ -124,13 +126,13 @@ def format_st_as_geojson(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: - """Format a :class:`MySQLCreateSpatialIndexExpression` node.""" - from ..expression.spatial import MySQLCreateSpatialIndexExpression + def format_mysql_create_spatial_index_statement(self, expr) -> Tuple[str, tuple]: + """Format a :class:`MySQLCreateSpatialIndexStatement` node.""" + from ..expression.spatial import MySQLCreateSpatialIndexStatement - if not isinstance(expr, MySQLCreateSpatialIndexExpression): + if not isinstance(expr, MySQLCreateSpatialIndexStatement): raise TypeError( - f"format_create_spatial_index expects MySQLCreateSpatialIndexExpression, " + f"format_mysql_create_spatial_index_statement expects MySQLCreateSpatialIndexStatement, " f"got {type(expr).__name__}" ) @@ -144,19 +146,19 @@ def format_create_spatial_index(self, expr) -> Tuple[str, tuple]: (), ) - def format_st_distance(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_distance_expression(self, expr) -> Tuple[str, tuple]: sql = f"ST_Distance({expr.geom1}, {expr.geom2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_st_within(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_within_expression(self, expr) -> Tuple[str, tuple]: sql = f"ST_Within({expr.geom1}, {expr.geom2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_st_contains(self, expr) -> Tuple[str, tuple]: + def format_mysql_st_contains_expression(self, expr) -> Tuple[str, tuple]: sql = f"ST_Contains({expr.geom1}, {expr.geom2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py index b899be0c..dab2f981 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/transaction.py @@ -51,7 +51,7 @@ def _build_set_isolation_sql(self, level: IsolationLevel) -> Tuple[str, tuple]: def supports_transaction_mode(self) -> bool: """MySQL supports READ ONLY transactions (5.6.5+).""" - return self.version >= (5, 6, 5) + return self.version_at_least((5, 6, 5)) def supports_isolation_level_in_begin(self) -> bool: """MySQL does not support isolation level in START TRANSACTION.""" @@ -59,7 +59,7 @@ def supports_isolation_level_in_begin(self) -> bool: def supports_read_only_transaction(self) -> bool: """MySQL supports READ ONLY transactions (5.6.5+).""" - return self.version >= (5, 6, 5) + return self.version_at_least((5, 6, 5)) def supports_deferrable_transaction(self) -> bool: """MySQL does not support DEFERRABLE mode.""" @@ -114,7 +114,7 @@ def supports_savepoint(self) -> bool: """MySQL supports savepoints.""" return True - def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, tuple]: + def format_set_transaction_statement(self, expr: "SetTransactionStatement") -> Tuple[str, tuple]: """Format SET TRANSACTION statement for MySQL.""" from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode @@ -153,7 +153,7 @@ def format_set_transaction(self, expr: "SetTransactionExpression") -> Tuple[str, return f"SET TRANSACTION {' '.join(parts)}", () - def format_begin_transaction(self, expr: "BeginTransactionExpression") -> Tuple[str, tuple]: + def format_begin_transaction_statement(self, expr: "BeginTransactionStatement") -> Tuple[str, tuple]: """Format START TRANSACTION statement for MySQL.""" from rhosocial.activerecord.backend.transaction import TransactionMode diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py index 3215c55c..3d4d8c12 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/trigger.py @@ -7,8 +7,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) @@ -17,15 +17,15 @@ class MySQLTriggerMixin: def supports_trigger(self) -> bool: """MySQL supports triggers since 5.0.2.""" - return self.version >= (5, 0, 2) + return self.version_at_least((5, 0, 2)) def supports_create_trigger(self) -> bool: """MySQL supports CREATE TRIGGER since 5.0.2.""" - return self.version >= (5, 0, 2) + return self.version_at_least((5, 0, 2)) def supports_drop_trigger(self) -> bool: """MySQL supports DROP TRIGGER since 5.0.2.""" - return self.version >= (5, 0, 2) + return self.version_at_least((5, 0, 2)) def supports_instead_of_trigger(self) -> bool: """MySQL does NOT support INSTEAD OF triggers.""" @@ -45,9 +45,9 @@ def supports_trigger_when(self) -> bool: def supports_trigger_if_not_exists(self) -> bool: """MySQL 5.7+ supports IF NOT EXISTS.""" - return self.version >= (5, 7, 0) + return self.version_at_least((5, 7, 0)) - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format CREATE TRIGGER statement (MySQL syntax). Raises: @@ -59,11 +59,11 @@ def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tu """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - require_kind(expr.trigger, Trigger, "CreateTriggerExpression.trigger") - require_kind(expr.table, Table, "CreateTriggerExpression.table") + require_kind(expr.trigger, Trigger, "CreateTriggerStatement.trigger") + require_kind(expr.table, Table, "CreateTriggerStatement.table") if expr.function is not None: require_kind( - expr.function, Function, "CreateTriggerExpression.function" + expr.function, Function, "CreateTriggerStatement.function" ) if not self.supports_trigger(): raise UnsupportedFeatureError(self.name, "triggers") @@ -109,7 +109,7 @@ def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tu return " ".join(parts), () - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: """Format DROP TRIGGER statement (MySQL syntax). Raises: @@ -117,7 +117,7 @@ def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[ """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - require_kind(expr.trigger, Trigger, "DropTriggerExpression.trigger") + require_kind(expr.trigger, Trigger, "DropTriggerStatement.trigger") if not self.supports_trigger(): raise UnsupportedFeatureError(self.name, "triggers") diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py index 727f87d3..b9e81399 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/truncate.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) @@ -32,7 +32,7 @@ def supports_truncate_restart_identity(self) -> bool: def supports_truncate_cascade(self) -> bool: return False - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: + def format_truncate_statement(self, expr: "TruncateStatement") -> Tuple[str, tuple]: """Format MySQL ``TRUNCATE [TABLE] tbl_name``. Raises: @@ -40,7 +40,7 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu so a View or a Sequence in that slot would render a statement emptying something else. """ - require_kind(expr.table, Table, "TruncateExpression.table") + require_kind(expr.table, Table, "TruncateStatement.table") if expr.restart_identity or expr.continue_identity: if expr.restart_identity: feature = "TRUNCATE ... RESTART IDENTITY" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py index 84fc464d..6289e911 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py @@ -106,6 +106,36 @@ def format_data_type_mysql_unsigned(self, data_type: MySQLUnsignedType) -> Tuple """ return "UNSIGNED", () + def _check_unsigned(self, data_type: DataType) -> None: + """Raise ``TypeError`` unless ``unsigned`` is a real ``bool``. + + ``unsigned`` is a *field* on every numeric concept here and every reader + below turns it into the ``UNSIGNED`` word of the rendered DDL. The + expressions collect it without judging it — a value is not a wrong value + until a backend has to honour it, and this is that backend — so the + judgement belongs at this end, which is why each reader calls this before + it reads. + + Without it a truthy non-bool renders silently: ``unsigned="yes"`` writes + ``INT UNSIGNED`` and reports success for a column nobody declared. That is + the same silent loss the dummy dialect's ``_refuse_unsigned`` names when + it refuses the flag outright, arriving from the opposite direction. + + The attribute is read directly rather than through ``getattr(..., False)``: + the field is part of every concept routed to a reader here, and probing + for it would turn "caller passed the wrong class" into the same silent + render this guard exists to prevent. + + Raises: + TypeError: naming the type actually supplied. + """ + unsigned = data_type.unsigned + if not isinstance(unsigned, bool): + raise TypeError( + f"unsigned must be a bool, got {type(unsigned).__name__}; it " + f"becomes a type modifier in the rendered DDL." + ) + def _format_mysql_integer(self, sql: str, data_type: DataType) -> Tuple[str, tuple]: """Append the ``UNSIGNED`` / ``ZEROFILL`` attributes in MySQL's order. @@ -132,6 +162,7 @@ def _format_mysql_integer(self, sql: str, data_type: DataType) -> Tuple[str, tup one the caller declared. Emitting both is what makes ``parse_type(t.to_sql()[0]) == t`` hold for every combination. """ + self._check_unsigned(data_type) if data_type.unsigned: sql = f"{sql} UNSIGNED" if data_type.zerofill: @@ -146,7 +177,7 @@ def format_data_type_mysql_tinyint(self, data_type: MySQLTinyIntType) -> Tuple[s rendered word is MySQL's own canonical one, since ``TINYINT`` is what the server reports back from ``SHOW CREATE TABLE``. """ - self._check_spelling(data_type, MySQLTinyIntType) + self.check_spelling(data_type, MySQLTinyIntType) return self._format_mysql_integer("TINYINT", data_type) def format_data_type_mysql_smallint(self, data_type: MySQLSmallIntType) -> Tuple[str, tuple]: @@ -155,7 +186,7 @@ def format_data_type_mysql_smallint(self, data_type: MySQLSmallIntType) -> Tuple ``SMALLINT`` and ``INT2`` are both MySQL words for the 2-byte type, so both are accepted and MySQL's own word is rendered. """ - self._check_spelling(data_type, MySQLSmallIntType) + self.check_spelling(data_type, MySQLSmallIntType) return self._format_mysql_integer("SMALLINT", data_type) def format_data_type_mysql_mediumint(self, data_type: MySQLMediumIntType) -> Tuple[str, tuple]: @@ -172,7 +203,7 @@ def format_data_type_mysql_mediumint(self, data_type: MySQLMediumIntType) -> Tup no core 3-byte concept to render or inherit, which is why this is a ``mysql_``-namespaced class rather than a spelling of an existing one. """ - self._check_spelling(data_type, MySQLMediumIntType) + self.check_spelling(data_type, MySQLMediumIntType) return self._format_mysql_integer("MEDIUMINT", data_type) def format_data_type_mysql_int(self, data_type: MySQLIntType) -> Tuple[str, tuple]: @@ -184,7 +215,7 @@ def format_data_type_mysql_int(self, data_type: MySQLIntType) -> Tuple[str, tupl column carrying ``UNSIGNED`` / ``ZEROFILL`` stays distinguishable from a plain ``integer`` in a schema diff — the word ``INT`` is the same. """ - self._check_spelling(data_type, MySQLIntType) + self.check_spelling(data_type, MySQLIntType) return self._format_mysql_integer("INT", data_type) def format_data_type_mysql_bigint(self, data_type: MySQLBigIntType) -> Tuple[str, tuple]: @@ -194,7 +225,7 @@ def format_data_type_mysql_bigint(self, data_type: MySQLBigIntType) -> Tuple[str manual's own table lists ``INT8``), so both are accepted and MySQL's canonical word is rendered. """ - self._check_spelling(data_type, MySQLBigIntType) + self.check_spelling(data_type, MySQLBigIntType) return self._format_mysql_integer("BIGINT", data_type) def format_data_type_mysql_tinyblob(self, data_type: MySQLTinyBlobType) -> Tuple[str, tuple]: @@ -204,22 +235,22 @@ def format_data_type_mysql_tinyblob(self, data_type: MySQLTinyBlobType) -> Tuple size variants inherit the core ``BlobType`` spelling list, and MySQL writes ``BLOB``, not ``BYTEA``, in every one of them. """ - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "TINYBLOB", () def format_data_type_mysql_blob(self, data_type: MySQLBlobType) -> Tuple[str, tuple]: """``BLOB`` — max 65,535 bytes. ``BYTEA`` refused; see ``mysql_tinyblob``.""" - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "BLOB", () def format_data_type_mysql_mediumblob(self, data_type: MySQLMediumBlobType) -> Tuple[str, tuple]: """``MEDIUMBLOB`` — max 16,777,215 bytes. ``BYTEA`` refused.""" - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "MEDIUMBLOB", () def format_data_type_mysql_longblob(self, data_type: MySQLLongBlobType) -> Tuple[str, tuple]: """``LONGBLOB`` — max 4,294,967,295 bytes. ``BYTEA`` refused.""" - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "LONGBLOB", () def format_data_type_mysql_tinytext(self, data_type: MySQLTinyTextType) -> Tuple[str, tuple]: @@ -229,22 +260,22 @@ def format_data_type_mysql_tinytext(self, data_type: MySQLTinyTextType) -> Tuple MySQL has no CLOB in any of them, so ``clob`` is refused here exactly as it is for the unbounded ``text``. """ - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "TINYTEXT", () def format_data_type_mysql_text(self, data_type: MySQLTextType) -> Tuple[str, tuple]: """``TEXT`` — max 65,535 bytes. ``CLOB`` refused; see ``mysql_tinytext``.""" - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "TEXT", () def format_data_type_mysql_mediumtext(self, data_type: MySQLMediumTextType) -> Tuple[str, tuple]: """``MEDIUMTEXT`` — max 16,777,215 bytes. ``CLOB`` refused.""" - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "MEDIUMTEXT", () def format_data_type_mysql_longtext(self, data_type: MySQLLongTextType) -> Tuple[str, tuple]: """``LONGTEXT`` — max 4,294,967,295 bytes. ``CLOB`` refused.""" - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "LONGTEXT", () def format_data_type_mysql_bit(self, data_type: MySQLBitType) -> Tuple[str, tuple]: @@ -404,7 +435,8 @@ def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, IntegerType) + self.check_spelling(data_type, IntegerType) + self._check_unsigned(data_type) return (f"INT UNSIGNED" if data_type.unsigned else "INT"), () def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: @@ -420,7 +452,8 @@ def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, BigIntType) + self.check_spelling(data_type, BigIntType) + self._check_unsigned(data_type) return ("BIGINT UNSIGNED" if data_type.unsigned else "BIGINT"), () def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple]: @@ -436,7 +469,8 @@ def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, SmallIntType) + self.check_spelling(data_type, SmallIntType) + self._check_unsigned(data_type) return ("SMALLINT UNSIGNED" if data_type.unsigned else "SMALLINT"), () def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: @@ -452,7 +486,8 @@ def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: here. https://dev.mysql.com/doc/refman/8.4/en/numeric-type-syntax.html """ - self._check_spelling(data_type, TinyIntType) + self.check_spelling(data_type, TinyIntType) + self._check_unsigned(data_type) return ("TINYINT UNSIGNED" if data_type.unsigned else "TINYINT"), () def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: @@ -473,7 +508,7 @@ def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: where the caller has no way to tell that from success. https://dev.mysql.com/doc/refman/8.4/en/string-type-syntax.html """ - self._check_spelling(data_type, ("varchar",)) + self.check_spelling(data_type, ("varchar",)) if data_type.length is None: raise ValueError( "MySQL's grammar is VARCHAR(M) with M mandatory, so a VarCharType " @@ -492,7 +527,7 @@ def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: spelling list too: this framework treats a character set as a *field*, not as a type.) """ - self._check_spelling(data_type, ("char",)) + self.check_spelling(data_type, ("char",)) return (f"CHAR({data_type.length})" if data_type.length is not None else "CHAR"), () def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: @@ -502,7 +537,7 @@ def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: still renders — a backend may write the concept under its own name but must not refuse the spelling every plain ``TextType(d)`` carries. """ - self._check_spelling(data_type, ("text",)) + self.check_spelling(data_type, ("text",)) return "TEXT", () def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: @@ -514,7 +549,7 @@ def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: why :class:`MySQLBitType` is a separate concept: a BIT string is not a boolean even at width 1. """ - self._check_spelling(data_type, BooleanType) + self.check_spelling(data_type, BooleanType) return "TINYINT(1)", () def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: @@ -592,6 +627,7 @@ def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: f"storage — declare DoubleType(dialect). " f"Got precision={data_type.precision}." ) + self._check_unsigned(data_type) return ("FLOAT UNSIGNED" if data_type.unsigned else "FLOAT"), () def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: @@ -629,7 +665,8 @@ def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: honoured rather than routed around. https://dev.mysql.com/doc/refman/9.7/en/numeric-type-syntax.html """ - self._check_spelling(data_type, DoubleType) + self.check_spelling(data_type, DoubleType) + self._check_unsigned(data_type) return ("DOUBLE UNSIGNED" if data_type.unsigned else "DOUBLE"), () def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: @@ -666,7 +703,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: out of range is still a ``ValueError`` and is still refused before any attribute is appended. """ - self._check_spelling(data_type, DecimalType) + self.check_spelling(data_type, DecimalType) if data_type.precision is not None and not 1 <= data_type.precision <= 65: raise ValueError( f"MySQL DECIMAL precision must be between 1 and 65, " @@ -689,6 +726,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: base = f"DECIMAL({data_type.precision})" else: base = "DECIMAL" + self._check_unsigned(data_type) return (f"{base} UNSIGNED" if data_type.unsigned else base), () def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: @@ -704,7 +742,7 @@ def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: at all, so it is refused here. ``BLOB`` — the concept's default spelling, and the word MySQL writes — always renders. """ - self._check_spelling(data_type, ("blob",)) + self.check_spelling(data_type, ("blob",)) return "BLOB", () def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: @@ -1175,7 +1213,7 @@ def parse_type(self, raw: str) -> DataType: # guessed at, so a hand-written string gets the same answer a # constructor call would. if length is None: - return CustomType(self, stripped) + return CustomType(self, raw=stripped) from ..expression.types import MySQLVarBinaryType return MySQLVarBinaryType(self, length) # CHAR / VARCHAR @@ -1281,7 +1319,7 @@ def parse_type(self, raw: str) -> DataType: return MySQLVectorType(self, dim) # Fallback: an honest "I do not model this type" rather than a guess. - return CustomType(self, stripped) + return CustomType(self, raw=stripped) # ------------------------------------------------------------------ # DataTypeSupport — cross-backend type suggestions diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py index 7993e99c..d0f5dc82 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/vector.py @@ -8,15 +8,15 @@ class MySQLVectorMixin: MAX_VECTOR_DIMENSION = 16384 def supports_vector_type(self) -> bool: - return self.version >= (9, 0, 0) + return self.version_at_least((9, 0, 0)) def supports_vector_index(self) -> bool: - return self.version >= (9, 0, 1) + return self.version_at_least((9, 0, 1)) def get_max_vector_dimension(self) -> int: return self.MAX_VECTOR_DIMENSION - def format_vector_literal(self, expr) -> Tuple[str, tuple]: + def format_mysql_vector_expression(self, expr) -> Tuple[str, tuple]: """Format a :class:`MySQLVectorExpression` node (VECTOR literal).""" sql, params = self._format_vector_literal_parts(expr.vector) if expr.alias: @@ -32,7 +32,7 @@ def _format_vector_literal_parts(self, values: List[float]) -> Tuple[str, tuple] vector_str = "[" + ",".join(str(v) for v in values) + "]" return f"STRING_TO_VECTOR({self.p()})", (vector_str,) - def format_string_to_vector(self, expr) -> Tuple[str, tuple]: + def format_mysql_string_to_vector_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLStringToVectorExpression or a raw vector string.""" from ..expression.vector import MySQLStringToVectorExpression @@ -47,7 +47,7 @@ def format_string_to_vector(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, (vector_str,) - def format_vector_to_string(self, expr) -> Tuple[str, tuple]: + def format_mysql_vector_to_string_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLVectorToStringExpression or a raw vector reference.""" from ..expression.vector import MySQLVectorToStringExpression @@ -63,7 +63,7 @@ def format_vector_to_string(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_vector_dim(self, expr) -> Tuple[str, tuple]: + def format_mysql_vector_dim_expression(self, expr) -> Tuple[str, tuple]: """Format MySQLVectorDimExpression or a raw vector reference.""" from ..expression.vector import MySQLVectorDimExpression @@ -79,31 +79,31 @@ def format_vector_dim(self, expr) -> Tuple[str, tuple]: sql = f"{sql} AS {self.format_identifier(alias)}" return sql, params - def format_distance_euclidean(self, expr) -> Tuple[str, tuple]: + def format_mysql_distance_euclidean_expression(self, expr) -> Tuple[str, tuple]: sql = f"DISTANCE_EUCLIDEAN({expr.vec1}, {expr.vec2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_distance_cosine(self, expr) -> Tuple[str, tuple]: + def format_mysql_distance_cosine_expression(self, expr) -> Tuple[str, tuple]: sql = f"DISTANCE_COSINE({expr.vec1}, {expr.vec2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_distance_dot(self, expr) -> Tuple[str, tuple]: + def format_mysql_distance_dot_expression(self, expr) -> Tuple[str, tuple]: sql = f"DISTANCE_DOT({expr.vec1}, {expr.vec2})" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, () - def format_create_vector_index(self, expr) -> Tuple[str, tuple]: - """Format a :class:`MySQLCreateVectorIndexExpression` node.""" - from ..expression.vector import MySQLCreateVectorIndexExpression + def format_mysql_create_vector_index_statement(self, expr) -> Tuple[str, tuple]: + """Format a :class:`MySQLCreateVectorIndexStatement` node.""" + from ..expression.vector import MySQLCreateVectorIndexStatement - if not isinstance(expr, MySQLCreateVectorIndexExpression): + if not isinstance(expr, MySQLCreateVectorIndexStatement): raise TypeError( - f"format_create_vector_index expects MySQLCreateVectorIndexExpression, " + f"format_mysql_create_vector_index_statement expects MySQLCreateVectorIndexStatement, " f"got {type(expr).__name__}" ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py index d9299594..3788e0dc 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/window.py @@ -4,8 +4,8 @@ class MySQLWindowMixin: def supports_window_functions(self) -> bool: """Window functions are supported since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) def supports_window_frame_clause(self) -> bool: """Whether window frame clauses (ROWS/RANGE) are supported, since MySQL 8.0.0.""" - return self.version >= (8, 0, 0) + return self.version_at_least((8, 0, 0)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index da9cc1f8..88154dc8 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -15,49 +15,51 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import OnConflictClause from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - ModifyColumn, - ChangeColumn, + ChangeColumnAction, + ModifyColumnAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( - CreateTableLikeExpression, + CreateTableLikeStatement, + TableCheckConstraint, + TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.admin import ( - MySQLCacheIndexExpression, - MySQLCloneExpression, - MySQLCreateUserExpression, - MySQLDoExpression, - MySQLDropUserExpression, - MySQLFlushExpression, - MySQLGrantExpression, - MySQLHelpExpression, - MySQLHandlerCloseExpression, - MySQLHandlerOpenExpression, - MySQLHandlerReadExpression, - MySQLInstallComponentExpression, - MySQLInstallPluginExpression, - MySQLKillExpression, - MySQLLoadIndexIntoCacheExpression, - MySQLResetExpression, - MySQLRestartExpression, - MySQLRevokeExpression, - MySQLShutdownExpression, - MySQLBinlogExpression, - MySQLUninstallComponentExpression, - MySQLUninstallPluginExpression, + MySQLCacheIndexStatement, + MySQLCloneStatement, + MySQLCreateUserStatement, + MySQLDoStatement, + MySQLDropUserStatement, + MySQLFlushStatement, + MySQLGrantStatement, + MySQLHelpStatement, + MySQLHandlerCloseStatement, + MySQLHandlerOpenStatement, + MySQLHandlerReadStatement, + MySQLInstallComponentStatement, + MySQLInstallPluginStatement, + MySQLKillStatement, + MySQLLoadIndexIntoCacheStatement, + MySQLResetStatement, + MySQLRestartStatement, + MySQLRevokeStatement, + MySQLShutdownStatement, + MySQLBinlogStatement, + MySQLUninstallComponentStatement, + MySQLUninstallPluginStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.load_data import ( - MySQLLoadDataExpression, + MySQLLoadDataStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.load_xml import ( - MySQLLoadXMLEXpression, + MySQLLoadXMLStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.optimizer_hint import ( MySQLOptimizerHintExpression, @@ -90,14 +92,14 @@ MySQLTruncatePartitionExpression, ) from rhosocial.activerecord.backend.impl.mysql.expression.rename_table import ( - MySQLRenameTableExpression, + MySQLRenameTableStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.routine import ( - MySQLCallExpression, - MySQLCreateFunctionExpression, - MySQLCreateProcedureExpression, - MySQLDropFunctionExpression, - MySQLDropProcedureExpression, + MySQLCallStatement, + MySQLCreateFunctionStatement, + MySQLCreateProcedureStatement, + MySQLDropFunctionStatement, + MySQLDropProcedureStatement, ) from rhosocial.activerecord.backend.impl.mysql.expression.table_statement import ( MySQLTableStatement, @@ -171,11 +173,11 @@ def supports_load_data(self) -> bool: """ ... - def format_load_data_statement(self, expr: "MySQLLoadDataExpression") -> Tuple[str, tuple]: + def format_mysql_load_data_statement(self, expr: "MySQLLoadDataStatement") -> Tuple[str, tuple]: """Format LOAD DATA INFILE statement. Args: - expr: MySQLLoadDataExpression instance + expr: MySQLLoadDataStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -189,7 +191,7 @@ def format_on_conflict_clause(self, expr: "OnConflictClause") -> Tuple[str, tupl ON CONFLICT clause for upsert operations. Args: - expr: OnConflictExpression or equivalent instance + expr: OnConflictClause or equivalent instance Returns: Tuple of (SQL string, parameters tuple) @@ -264,22 +266,22 @@ def supports_trigger_when(self) -> bool: """ ... - def format_create_trigger_statement(self, expr: "CreateTriggerExpression") -> Tuple[str, tuple]: + def format_create_trigger_statement(self, expr: "CreateTriggerStatement") -> Tuple[str, tuple]: """Format CREATE TRIGGER statement. Args: - expr: CreateTriggerExpression instance + expr: CreateTriggerStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_trigger_statement(self, expr: "DropTriggerExpression") -> Tuple[str, tuple]: + def format_drop_trigger_statement(self, expr: "DropTriggerStatement") -> Tuple[str, tuple]: """Format DROP TRIGGER statement. Args: - expr: DropTriggerExpression instance + expr: DropTriggerStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -311,6 +313,16 @@ class MySQLTableSupport(TableObjectSupport, Protocol): - Various storage engines: MySQL 5.5+ """ + def supports_create_table_as_bind_parameters(self) -> bool: + """Whether this statement's body may carry bind parameters. + + Defaults to ``False``: a DDL statement carries no bind parameters + unless the dialect says it can, so a dialect that does not answer + refuses rather than emitting a statement the engine may reject. A + dialect whose engine accepts them returns ``True``. + """ + ... # pragma: no cover + def supports_create_table_like(self) -> bool: """Whether CREATE TABLE ... LIKE is supported. @@ -353,7 +365,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: ... def format_create_table_like_statement( - self, expr: "CreateTableLikeExpression" + self, expr: "CreateTableLikeStatement" ) -> Tuple[str, tuple]: """Format CREATE TABLE ... LIKE statement.""" ... @@ -366,18 +378,30 @@ def supports_column_comment(self) -> bool: """Whether an inline column COMMENT is supported (MySQL: yes).""" ... - def format_table_constraint(self, t_const: Any) -> Tuple[str, tuple]: - """Format a table-level constraint.""" + def format_table_check_constraint( + self, constraint: "TableCheckConstraint" + ) -> Tuple[str, tuple]: + """Format the table-level ``CHECK`` body, refusing it below 8.0.16.""" + ... + + def format_table_foreign_key_constraint( + self, constraint: "TableForeignKeyConstraint" + ) -> Tuple[str, tuple]: + """Format the table-level ``FOREIGN KEY`` with MySQL's spaced column list.""" ... def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: """Format an inline INDEX definition inside CREATE TABLE.""" ... - def format_storage_options(self, expr: Any) -> Tuple[str, tuple]: + def format_storage_options_expression(self, expr: Any) -> Tuple[str, tuple]: """Format MySQL table storage options (ENGINE, CHARSET, etc.).""" ... + def format_mysql_storage_options_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format the MySQL storage-options node.""" + ... + @runtime_checkable class MySQLPartitionSupport(PartitionSupport, Protocol): @@ -389,6 +413,17 @@ class MySQLPartitionSupport(PartitionSupport, Protocol): expressions and formatted by the methods declared here. """ + def supports_partition_introspection(self) -> bool: + """Whether the partitions of a table can be read back. + + The ``introspect partitions`` family, under the name the family uses + wherever it exists. An empty answer means the table is not + partitioned, which is a different statement from "this engine has no + partitions" -- hence a switch of its own, beside core's + ``supports_partition_metadata_introspection``. + """ + ... + def supports_range_columns_partitioning(self) -> bool: """Whether RANGE COLUMNS partitioning is supported.""" ... @@ -501,7 +536,7 @@ def format_partition_definition_options( """Format MySQL PARTITION definition options.""" ... - def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: + def format_mysql_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: """Format a ``SELECT ... FROM information_schema.PARTITIONS`` query. Args: @@ -512,14 +547,15 @@ def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") """ ... - def format_partition_value( - self, - expr: "MySQLPartitionValue | MySQLPartitionMaxValue", - ) -> Tuple[str, tuple]: + def format_mysql_partition_max_value(self, expr: "MySQLPartitionMaxValue") -> Tuple[str, tuple]: + """Format the ``MAXVALUE`` partition boundary token.""" + ... + + def format_mysql_partition_value(self, expr: "MySQLPartitionValue") -> Tuple[str, tuple]: """Format a MySQL partition boundary value.""" ... - def format_subpartition_by(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: + def format_mysql_subpartition_clause(self, expr: "MySQLSubpartitionClause") -> Tuple[str, tuple]: """Format ``SUBPARTITION BY {HASH|KEY}(...) SUBPARTITIONS N``. Args: @@ -549,119 +585,119 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio """ ... - def format_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: + def format_mysql_partition_by_range(self, expr: "MySQLPartitionByRange") -> Tuple[str, tuple]: """Format PARTITION BY RANGE.""" ... - def format_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_range_columns(self, expr: "MySQLPartitionByRangeColumns") -> Tuple[str, tuple]: """Format PARTITION BY RANGE COLUMNS.""" ... - def format_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: + def format_mysql_partition_by_list(self, expr: "MySQLPartitionByList") -> Tuple[str, tuple]: """Format PARTITION BY LIST.""" ... - def format_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: + def format_mysql_partition_by_list_columns(self, expr: "MySQLPartitionByListColumns") -> Tuple[str, tuple]: """Format PARTITION BY LIST COLUMNS.""" ... - def format_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: + def format_mysql_partition_by_hash(self, expr: "MySQLPartitionByHash") -> Tuple[str, tuple]: """Format PARTITION BY HASH or LINEAR HASH.""" ... - def format_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: + def format_mysql_partition_by_key(self, expr: "MySQLPartitionByKey") -> Tuple[str, tuple]: """Format PARTITION BY KEY or LINEAR KEY.""" ... - def format_add_partition_statement(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_add_partition_expression(self, expr: "MySQLAddPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... ADD PARTITION.""" ... - def format_drop_partition_statement(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_partition_expression(self, expr: "MySQLDropPartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... DROP PARTITION.""" ... - def format_truncate_partition_statement(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: + def format_mysql_truncate_partition_expression(self, expr: "MySQLTruncatePartitionExpression") -> Tuple[str, tuple]: """Format ALTER TABLE ... TRUNCATE PARTITION.""" ... - def format_reorganize_partition_statement( + def format_mysql_reorganize_partition_expression( self, expr: "MySQLReorganizePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... REORGANIZE PARTITION.""" ... - def format_exchange_partition_statement( + def format_mysql_exchange_partition_expression( self, expr: "MySQLExchangePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... EXCHANGE PARTITION.""" ... - def format_remove_partitioning_statement( + def format_mysql_remove_partitioning_expression( self, expr: "MySQLRemovePartitioningExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... REMOVE PARTITIONING.""" ... - def format_coalesce_partition_statement( + def format_mysql_coalesce_partition_expression( self, expr: "MySQLCoalescePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... COALESCE PARTITION.""" ... - def format_add_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_add_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLAddPartitionHelper (generated partition names).""" ... - def format_coalesce_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_coalesce_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLCoalescePartitionHelper (with count validation).""" ... - def format_drop_oldest_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_drop_oldest_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLDropOldestPartitionHelper (lexicographically oldest).""" ... - def format_reorganize_partition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_reorganize_partition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLReorganizePartitionHelper.""" ... - def format_add_subpartition_helper(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_add_subpartition_helper(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLAddSubpartitionHelper (partition + subpartitions).""" ... - def format_analyze_partition_statement( + def format_mysql_analyze_partition_expression( self, expr: "MySQLAnalyzePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... ANALYZE PARTITION.""" ... - def format_check_partition_statement( + def format_mysql_check_partition_expression( self, expr: "MySQLCheckPartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... CHECK PARTITION.""" ... - def format_optimize_partition_statement( + def format_mysql_optimize_partition_expression( self, expr: "MySQLOptimizePartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... OPTIMIZE PARTITION.""" ... - def format_rebuild_partition_statement( + def format_mysql_rebuild_partition_expression( self, expr: "MySQLRebuildPartitionExpression", ) -> Tuple[str, tuple]: """Format ALTER TABLE ... REBUILD PARTITION.""" ... - def format_repair_partition_statement( + def format_mysql_repair_partition_expression( self, expr: "MySQLRepairPartitionExpression", ) -> Tuple[str, tuple]: @@ -697,7 +733,7 @@ def supports_set_type(self) -> bool: """Whether SET type is supported.""" ... - def format_set_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_set_literal_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSetLiteralExpression node. Args: @@ -708,7 +744,7 @@ def format_set_literal(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_find_in_set_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLFindInSetExpression node. Args: @@ -719,7 +755,7 @@ def format_find_in_set(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_set_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_set_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSetContainsExpression node. Args: @@ -1033,7 +1069,7 @@ def format_json_arrow_expression(self, expr) -> Tuple[str, tuple]: """Render a JSON path with the -> and ->> operators.""" ... - def format_json_table_expression(self, expr) -> Tuple[str, tuple]: + def format_mysql_json_table_expression(self, expr) -> Tuple[str, tuple]: """Render a JSON_TABLE query.""" ... @@ -1056,11 +1092,11 @@ def supports_json_function(self, function_name: str) -> bool: """ ... - def format_json_extract(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_extract_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONExtractExpression node.""" ... - def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_unquote_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONUnquoteExpression node. Args: @@ -1071,15 +1107,15 @@ def format_json_unquote(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_object(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_object_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONObjectExpression node.""" ... - def format_json_array(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_array_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONArrayExpression node.""" ... - def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONContainsExpression node. Args: @@ -1092,7 +1128,7 @@ def format_json_contains(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_set(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_set_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONSetExpression node. Args: @@ -1103,7 +1139,7 @@ def format_json_set(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_remove_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONRemoveExpression node. Args: @@ -1114,7 +1150,7 @@ def format_json_remove(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_type(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_type_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONTypeExpression node. Args: @@ -1125,7 +1161,7 @@ def format_json_type(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_valid(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_valid_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONValidExpression node. Args: @@ -1136,7 +1172,7 @@ def format_json_valid(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_json_search(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_json_search_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLJSONSearchExpression node. Args: @@ -1206,7 +1242,7 @@ def supports_geometry_collection_type(self) -> bool: """Whether GEOMETRYCOLLECTION is supported.""" ... - def format_spatial_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_spatial_literal_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSpatialLiteralExpression node. Args: @@ -1217,7 +1253,7 @@ def format_spatial_literal(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_text_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTGeomFromTextExpression node. Args: @@ -1228,7 +1264,7 @@ def format_st_geom_from_text(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_geom_from_wkb(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_geom_from_wkb_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTGeomFromWKBExpression node. Args: @@ -1239,7 +1275,7 @@ def format_st_geom_from_wkb(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_as_text(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_as_text_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTAsTextExpression node. Args: @@ -1250,7 +1286,7 @@ def format_st_as_text(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_as_geojson(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_as_geo_json_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLSTAsGeoJSONExpression node. Args: @@ -1261,7 +1297,7 @@ def format_st_as_geojson(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_distance(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_distance_expression(self, expr: Any) -> Tuple[str, tuple]: """Format ST_Distance function call. Args: @@ -1273,7 +1309,7 @@ def format_st_distance(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_within(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_within_expression(self, expr: Any) -> Tuple[str, tuple]: """Format ST_Within function call. Args: @@ -1285,7 +1321,7 @@ def format_st_within(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_st_contains(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_st_contains_expression(self, expr: Any) -> Tuple[str, tuple]: """Format ST_Contains function call. Args: @@ -1297,11 +1333,11 @@ def format_st_contains(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_create_spatial_index(self, expr: Any) -> Tuple[str, tuple]: - """Format a MySQLCreateSpatialIndexExpression node. + def format_mysql_create_spatial_index_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format a MySQLCreateSpatialIndexStatement node. Args: - expr: MySQLCreateSpatialIndexExpression instance + expr: MySQLCreateSpatialIndexStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -1342,7 +1378,7 @@ def get_max_vector_dimension(self) -> int: """ ... - def format_vector_literal(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_vector_expression(self, expr: Any) -> Tuple[str, tuple]: """Format vector literal from a list of float values. Args: @@ -1353,7 +1389,7 @@ def format_vector_literal(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_string_to_vector(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_string_to_vector_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLStringToVectorExpression node. Args: @@ -1364,7 +1400,7 @@ def format_string_to_vector(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_vector_to_string(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_vector_to_string_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLVectorToStringExpression node. Args: @@ -1375,7 +1411,7 @@ def format_vector_to_string(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_vector_dim(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_vector_dim_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLVectorDimExpression node. Args: @@ -1386,7 +1422,7 @@ def format_vector_dim(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_distance_euclidean(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_distance_euclidean_expression(self, expr: Any) -> Tuple[str, tuple]: """Format EUCLIDEAN_DISTANCE function call. Args: @@ -1398,7 +1434,7 @@ def format_distance_euclidean(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_distance_cosine(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_distance_cosine_expression(self, expr: Any) -> Tuple[str, tuple]: """Format COSINE_DISTANCE function call. Args: @@ -1410,7 +1446,7 @@ def format_distance_cosine(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_distance_dot(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_distance_dot_expression(self, expr: Any) -> Tuple[str, tuple]: """Format DOT_PRODUCT function call. Args: @@ -1422,11 +1458,11 @@ def format_distance_dot(self, expr: Any) -> Tuple[str, tuple]: """ ... - def format_create_vector_index(self, expr: Any) -> Tuple[str, tuple]: - """Format a MySQLCreateVectorIndexExpression node. + def format_mysql_create_vector_index_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format a MySQLCreateVectorIndexStatement node. Args: - expr: MySQLCreateVectorIndexExpression instance + expr: MySQLCreateVectorIndexStatement instance Returns: Tuple of (SQL string, parameters tuple) @@ -1481,11 +1517,11 @@ def supports_fulltext_query_expansion(self) -> bool: """Whether query expansion mode is supported (MySQL 5.6.7+).""" ... - def format_match_against(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_match_against_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLMatchAgainstExpression node.""" ... - def format_fulltext_index_options(self, expr: Any) -> Tuple[str, tuple]: + def format_mysql_fulltext_index_options_expression(self, expr: Any) -> Tuple[str, tuple]: """Format a MySQLFulltextIndexOptionsExpression node. Args: @@ -1573,22 +1609,22 @@ def supports_change_column(self) -> bool: """Whether CHANGE COLUMN is supported.""" ... - def format_modify_column_action(self, action: "ModifyColumn") -> Tuple[str, tuple]: + def format_modify_column_action(self, action: "ModifyColumnAction") -> Tuple[str, tuple]: """Format MODIFY COLUMN action for ALTER TABLE. Args: - action: ModifyColumn action instance + action: ModifyColumnAction action instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_change_column_action(self, action: "ChangeColumn") -> Tuple[str, tuple]: + def format_change_column_action(self, action: "ChangeColumnAction") -> Tuple[str, tuple]: """Format CHANGE COLUMN action for ALTER TABLE. Args: - action: ChangeColumn action instance + action: ChangeColumnAction action instance Returns: Tuple of (SQL string, parameters tuple) @@ -1623,30 +1659,30 @@ def supports_json_duality_view_dml(self) -> bool: """Whether DML on JSON Duality Views is supported (MySQL 9.7+).""" ... - def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_create_json_duality_view_statement(self, expr: "CreateJsonDualityViewStatement") -> Tuple[str, tuple]: """Format CREATE JSON RELATIONAL DUALITY VIEW statement. Args: - expr: CreateJsonDualityViewExpression instance + expr: CreateJsonDualityViewStatement instance Returns: Tuple of (SQL string, parameters tuple) """ ... - def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewExpression") -> Tuple[str, tuple]: + def format_drop_json_duality_view_statement(self, expr: "DropJsonDualityViewStatement") -> Tuple[str, tuple]: """Format DROP VIEW statement for a JSON Duality View.""" ... - def format_duality_object_select(self, spec: Any) -> str: + def format_mysql_duality_object_select_expression(self, spec: Any) -> str: """Format SELECT JSON_DUALITY_OBJECT(...) FROM table clause.""" ... - def format_duality_object_body(self, spec: Any) -> str: + def format_mysql_duality_object_body_expression(self, spec: Any) -> str: """Format JSON_DUALITY_OBJECT(...) body.""" ... - def format_nested_duality(self, nested: Any) -> str: + def format_mysql_nested_duality_expression(self, nested: Any) -> str: """Format nested JSON_ARRAYAGG(JSON_DUALITY_OBJECT(...)) subquery.""" ... @@ -1678,7 +1714,7 @@ def supports_hypergraph_optimizer(self) -> bool: """Whether the hypergraph optimizer is available (MySQL 9.7+).""" ... - def format_optimizer_hint(self, expr: "MySQLOptimizerHintExpression") -> Tuple[str, tuple]: + def format_mysql_optimizer_hint_expression(self, expr: "MySQLOptimizerHintExpression") -> Tuple[str, tuple]: """Format optimizer hint expression. Args: @@ -1723,7 +1759,7 @@ def supports_cross_database_rename(self) -> bool: """ ... - def format_rename_table_statement(self, expr: "MySQLRenameTableExpression") -> Tuple[str, tuple]: + def format_mysql_rename_table_statement(self, expr: "MySQLRenameTableStatement") -> Tuple[str, tuple]: """Format a MySQL RENAME TABLE statement.""" ... @@ -1747,11 +1783,11 @@ def supports_values_table_constructor(self) -> bool: """Whether VALUES as a table value constructor is supported.""" ... - def format_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: + def format_mysql_table_statement(self, expr: "MySQLTableStatement") -> Tuple[str, tuple]: """Format a TABLE statement.""" ... - def format_values_statement(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: + def format_mysql_values_expression(self, expr: "MySQLValuesExpression") -> Tuple[str, tuple]: """Format a VALUES table value constructor.""" ... @@ -1789,8 +1825,24 @@ def supports_repair_table(self) -> bool: """Whether REPAIR TABLE is supported.""" ... - def format_table_maintenance_statement(self, expr) -> Tuple[str, tuple]: - """Format a whole-table maintenance statement.""" + def format_mysql_analyze_table_statement(self, expr) -> Tuple[str, tuple]: + """Format ``ANALYZE TABLE``.""" + ... + + def format_mysql_optimize_table_statement(self, expr) -> Tuple[str, tuple]: + """Format ``OPTIMIZE TABLE``.""" + ... + + def format_mysql_check_table_statement(self, expr) -> Tuple[str, tuple]: + """Format ``CHECK TABLE`` with its optional modes.""" + ... + + def format_mysql_checksum_table_statement(self, expr) -> Tuple[str, tuple]: + """Format ``CHECKSUM TABLE`` with its optional mode.""" + ... + + def format_mysql_repair_table_statement(self, expr) -> Tuple[str, tuple]: + """Format ``REPAIR TABLE`` with its optional modes.""" ... @@ -1819,23 +1871,23 @@ def supports_call(self) -> bool: """Whether CALL is supported.""" ... - def format_create_procedure_statement(self, expr: "MySQLCreateProcedureExpression") -> Tuple[str, tuple]: + def format_mysql_create_procedure_statement(self, expr: "MySQLCreateProcedureStatement") -> Tuple[str, tuple]: """Format CREATE PROCEDURE.""" ... - def format_drop_procedure_statement(self, expr: "MySQLDropProcedureExpression") -> Tuple[str, tuple]: + def format_mysql_drop_procedure_statement(self, expr: "MySQLDropProcedureStatement") -> Tuple[str, tuple]: """Format DROP PROCEDURE.""" ... - def format_create_function_statement(self, expr: "MySQLCreateFunctionExpression") -> Tuple[str, tuple]: + def format_mysql_create_function_statement(self, expr: "MySQLCreateFunctionStatement") -> Tuple[str, tuple]: """Format CREATE FUNCTION (stored function).""" ... - def format_drop_function_statement(self, expr: "MySQLDropFunctionExpression") -> Tuple[str, tuple]: + def format_mysql_drop_function_statement(self, expr: "MySQLDropFunctionStatement") -> Tuple[str, tuple]: """Format DROP FUNCTION.""" ... - def format_call_statement(self, expr: "MySQLCallExpression") -> Tuple[str, tuple]: + def format_mysql_call_statement(self, expr: "MySQLCallStatement") -> Tuple[str, tuple]: """Format CALL.""" ... @@ -1854,7 +1906,7 @@ def supports_load_xml(self) -> bool: """Whether LOAD XML is supported.""" ... - def format_load_xml_statement(self, expr: "MySQLLoadXMLEXpression") -> Tuple[str, tuple]: + def format_mysql_load_xml_statement(self, expr: "MySQLLoadXMLStatement") -> Tuple[str, tuple]: """Format a LOAD XML statement.""" ... @@ -1877,131 +1929,131 @@ class MySQLAdminCommandSupport(Protocol): def supports_flush(self) -> bool: ... - def format_flush_statement(self, expr: "MySQLFlushExpression") -> Tuple[str, tuple]: + def format_mysql_flush_statement(self, expr: "MySQLFlushStatement") -> Tuple[str, tuple]: ... def supports_reset(self) -> bool: ... - def format_reset_statement(self, expr: "MySQLResetExpression") -> Tuple[str, tuple]: + def format_mysql_reset_statement(self, expr: "MySQLResetStatement") -> Tuple[str, tuple]: ... def supports_cache_index(self) -> bool: ... - def format_cache_index_statement(self, expr: "MySQLCacheIndexExpression") -> Tuple[str, tuple]: + def format_mysql_cache_index_statement(self, expr: "MySQLCacheIndexStatement") -> Tuple[str, tuple]: ... def supports_load_index_into_cache(self) -> bool: ... - def format_load_index_into_cache_statement( - self, expr: "MySQLLoadIndexIntoCacheExpression" + def format_mysql_load_index_into_cache_statement( + self, expr: "MySQLLoadIndexIntoCacheStatement" ) -> Tuple[str, tuple]: ... def supports_install_component(self) -> bool: ... - def format_install_component_statement(self, expr: "MySQLInstallComponentExpression") -> Tuple[str, tuple]: + def format_mysql_install_component_statement(self, expr: "MySQLInstallComponentStatement") -> Tuple[str, tuple]: ... def supports_uninstall_component(self) -> bool: ... - def format_uninstall_component_statement( - self, expr: "MySQLUninstallComponentExpression" + def format_mysql_uninstall_component_statement( + self, expr: "MySQLUninstallComponentStatement" ) -> Tuple[str, tuple]: ... def supports_install_plugin(self) -> bool: ... - def format_install_plugin_statement(self, expr: "MySQLInstallPluginExpression") -> Tuple[str, tuple]: + def format_mysql_install_plugin_statement(self, expr: "MySQLInstallPluginStatement") -> Tuple[str, tuple]: ... def supports_uninstall_plugin(self) -> bool: ... - def format_uninstall_plugin_statement(self, expr: "MySQLUninstallPluginExpression") -> Tuple[str, tuple]: + def format_mysql_uninstall_plugin_statement(self, expr: "MySQLUninstallPluginStatement") -> Tuple[str, tuple]: ... def supports_clone(self) -> bool: ... - def format_clone_statement(self, expr: "MySQLCloneExpression") -> Tuple[str, tuple]: + def format_mysql_clone_statement(self, expr: "MySQLCloneStatement") -> Tuple[str, tuple]: ... def supports_restart(self) -> bool: ... - def format_restart_statement(self, expr: "MySQLRestartExpression") -> Tuple[str, tuple]: + def format_mysql_restart_statement(self, expr: "MySQLRestartStatement") -> Tuple[str, tuple]: ... def supports_binlog(self) -> bool: ... - def format_binlog_statement(self, expr: "MySQLBinlogExpression") -> Tuple[str, tuple]: + def format_mysql_binlog_statement(self, expr: "MySQLBinlogStatement") -> Tuple[str, tuple]: ... def supports_handler(self) -> bool: ... - def format_handler_open_statement(self, expr: "MySQLHandlerOpenExpression") -> Tuple[str, tuple]: + def format_mysql_handler_open_statement(self, expr: "MySQLHandlerOpenStatement") -> Tuple[str, tuple]: ... - def format_handler_read_statement(self, expr: "MySQLHandlerReadExpression") -> Tuple[str, tuple]: + def format_mysql_handler_read_statement(self, expr: "MySQLHandlerReadStatement") -> Tuple[str, tuple]: ... - def format_handler_close_statement(self, expr: "MySQLHandlerCloseExpression") -> Tuple[str, tuple]: + def format_mysql_handler_close_statement(self, expr: "MySQLHandlerCloseStatement") -> Tuple[str, tuple]: ... def supports_do(self) -> bool: ... - def format_do_statement(self, expr: "MySQLDoExpression") -> Tuple[str, tuple]: + def format_mysql_do_statement(self, expr: "MySQLDoStatement") -> Tuple[str, tuple]: ... def supports_kill(self) -> bool: ... - def format_kill_statement(self, expr: "MySQLKillExpression") -> Tuple[str, tuple]: + def format_mysql_kill_statement(self, expr: "MySQLKillStatement") -> Tuple[str, tuple]: ... def supports_shutdown(self) -> bool: ... - def format_shutdown_statement(self, expr: "MySQLShutdownExpression") -> Tuple[str, tuple]: + def format_mysql_shutdown_statement(self, expr: "MySQLShutdownStatement") -> Tuple[str, tuple]: ... def supports_help(self) -> bool: ... - def format_help_statement(self, expr: "MySQLHelpExpression") -> Tuple[str, tuple]: + def format_mysql_help_statement(self, expr: "MySQLHelpStatement") -> Tuple[str, tuple]: ... def supports_create_user(self) -> bool: ... - def format_create_user_statement(self, expr: "MySQLCreateUserExpression") -> Tuple[str, tuple]: + def format_mysql_create_user_statement(self, expr: "MySQLCreateUserStatement") -> Tuple[str, tuple]: ... def supports_drop_user(self) -> bool: ... - def format_drop_user_statement(self, expr: "MySQLDropUserExpression") -> Tuple[str, tuple]: + def format_mysql_drop_user_statement(self, expr: "MySQLDropUserStatement") -> Tuple[str, tuple]: ... def supports_grant(self) -> bool: ... - def format_grant_statement(self, expr: "MySQLGrantExpression") -> Tuple[str, tuple]: + def format_mysql_grant_statement(self, expr: "MySQLGrantStatement") -> Tuple[str, tuple]: ... def supports_revoke(self) -> bool: ... - def format_revoke_statement(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: + def format_mysql_revoke_statement(self, expr: "MySQLRevokeStatement") -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py index 7ce40a50..de9a4cdd 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/__init__.py @@ -19,25 +19,28 @@ from ..expression.show import ( ShowExpression, - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, + ShowBinaryLogsStatement, + ShowMasterStatusStatement, + ShowSlaveStatusStatement, ) from .dialect import MySQLShowDialectMixin from .functionality import MySQLShowFunctionality, AsyncMySQLShowFunctionality @@ -46,25 +49,28 @@ __all__ = [ # Expression classes "ShowExpression", - "ShowCreateTableExpression", - "ShowCreateViewExpression", - "ShowColumnsExpression", - "ShowIndexExpression", - "ShowTablesExpression", - "ShowDatabasesExpression", - "ShowTableStatusExpression", - "ShowTriggersExpression", - "ShowCreateTriggerExpression", - "ShowVariablesExpression", - "ShowStatusExpression", - "ShowProcessListExpression", - "ShowWarningsExpression", - "ShowErrorsExpression", - "ShowEnginesExpression", - "ShowCharsetExpression", - "ShowCollationExpression", - "ShowGrantsExpression", - "ShowPluginsExpression", + "ShowCreateTableStatement", + "ShowCreateViewStatement", + "ShowColumnsStatement", + "ShowIndexStatement", + "ShowTablesStatement", + "ShowDatabasesStatement", + "ShowTableStatusStatement", + "ShowTriggersStatement", + "ShowCreateTriggerStatement", + "ShowVariablesStatement", + "ShowStatusStatement", + "ShowProcessListStatement", + "ShowWarningsStatement", + "ShowErrorsStatement", + "ShowEnginesStatement", + "ShowCharsetStatement", + "ShowCollationStatement", + "ShowGrantsStatement", + "ShowPluginsStatement", + "ShowBinaryLogsStatement", + "ShowMasterStatusStatement", + "ShowSlaveStatusStatement", # Dialect mixin "MySQLShowDialectMixin", # Functionality classes diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py index a2f370ff..38ab0099 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/dialect.py @@ -24,25 +24,28 @@ if TYPE_CHECKING: from ..expression.show import ( - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, + ShowBinaryLogsStatement, + ShowMasterStatusStatement, + ShowSlaveStatusStatement, ) @@ -79,7 +82,7 @@ def _format_show_target(self, target: Any, schema: Any, kind: type) -> str: # ========== SHOW CREATE Statements ========== - def format_show_create_table(self, expr: "ShowCreateTableExpression") -> Tuple[str, tuple]: + def format_show_create_table_statement(self, expr: "ShowCreateTableStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TABLE statement.""" params = expr.get_params() table_name = params["table_name"] @@ -87,7 +90,7 @@ def format_show_create_table(self, expr: "ShowCreateTableExpression") -> Tuple[s return f"SHOW CREATE TABLE {self._format_show_target(table_name, schema, Table)}", () - def format_show_create_view(self, expr: "ShowCreateViewExpression") -> Tuple[str, tuple]: + def format_show_create_view_statement(self, expr: "ShowCreateViewStatement") -> Tuple[str, tuple]: """Format SHOW CREATE VIEW statement.""" params = expr.get_params() view_name = params["view_name"] @@ -95,7 +98,7 @@ def format_show_create_view(self, expr: "ShowCreateViewExpression") -> Tuple[str return f"SHOW CREATE VIEW {self._format_show_target(view_name, schema, View)}", () - def format_show_create_trigger(self, expr: "ShowCreateTriggerExpression") -> Tuple[str, tuple]: + def format_show_create_trigger_statement(self, expr: "ShowCreateTriggerStatement") -> Tuple[str, tuple]: """Format SHOW CREATE TRIGGER statement.""" params = expr.get_params() trigger_name = params["trigger_name"] @@ -105,7 +108,7 @@ def format_show_create_trigger(self, expr: "ShowCreateTriggerExpression") -> Tup # ========== SHOW COLUMNS/INDEX ========== - def format_show_columns(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple]: + def format_show_columns_statement(self, expr: "ShowColumnsStatement") -> Tuple[str, tuple]: """Format SHOW [FULL] COLUMNS statement.""" params = expr.get_params() table_name = params["table_name"] @@ -126,7 +129,7 @@ def format_show_columns(self, expr: "ShowColumnsExpression") -> Tuple[str, tuple return " ".join(parts), sql_params - def format_show_index(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: + def format_show_index_statement(self, expr: "ShowIndexStatement") -> Tuple[str, tuple]: """Format SHOW INDEX statement.""" params = expr.get_params() table_name = params["table_name"] @@ -136,7 +139,7 @@ def format_show_index(self, expr: "ShowIndexExpression") -> Tuple[str, tuple]: # ========== SHOW TABLES/DATABASES ========== - def format_show_tables(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: + def format_show_tables_statement(self, expr: "ShowTablesStatement") -> Tuple[str, tuple]: """Format SHOW [FULL] TABLES statement.""" params = expr.get_params() schema = params.get("schema") @@ -157,7 +160,7 @@ def format_show_tables(self, expr: "ShowTablesExpression") -> Tuple[str, tuple]: return " ".join(parts), sql_params - def format_show_databases(self, expr: "ShowDatabasesExpression") -> Tuple[str, tuple]: + def format_show_databases_statement(self, expr: "ShowDatabasesStatement") -> Tuple[str, tuple]: """Format SHOW DATABASES statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -166,7 +169,7 @@ def format_show_databases(self, expr: "ShowDatabasesExpression") -> Tuple[str, t return f"SHOW DATABASES LIKE {self.p()}", (like_pattern,) return "SHOW DATABASES", () - def format_show_table_status(self, expr: "ShowTableStatusExpression") -> Tuple[str, tuple]: + def format_show_table_status_statement(self, expr: "ShowTableStatusStatement") -> Tuple[str, tuple]: """Format SHOW TABLE STATUS statement.""" params = expr.get_params() schema = params.get("schema") @@ -185,7 +188,7 @@ def format_show_table_status(self, expr: "ShowTableStatusExpression") -> Tuple[s # ========== SHOW TRIGGERS ========== - def format_show_triggers(self, expr: "ShowTriggersExpression") -> Tuple[str, tuple]: + def format_show_triggers_statement(self, expr: "ShowTriggersStatement") -> Tuple[str, tuple]: """Format SHOW TRIGGERS statement.""" params = expr.get_params() schema = params.get("schema") @@ -204,7 +207,7 @@ def format_show_triggers(self, expr: "ShowTriggersExpression") -> Tuple[str, tup # ========== SHOW VARIABLES/STATUS ========== - def format_show_variables(self, expr: "ShowVariablesExpression") -> Tuple[str, tuple]: + def format_show_variables_statement(self, expr: "ShowVariablesStatement") -> Tuple[str, tuple]: """Format SHOW VARIABLES statement.""" params = expr.get_params() session = params.get("session", True) @@ -222,7 +225,7 @@ def format_show_variables(self, expr: "ShowVariablesExpression") -> Tuple[str, t return " ".join(parts), sql_params - def format_show_status(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: + def format_show_status_statement(self, expr: "ShowStatusStatement") -> Tuple[str, tuple]: """Format SHOW STATUS statement.""" params = expr.get_params() session = params.get("session", True) @@ -242,7 +245,7 @@ def format_show_status(self, expr: "ShowStatusExpression") -> Tuple[str, tuple]: # ========== SHOW PROCESSLIST/WARNINGS/ERRORS ========== - def format_show_processlist(self, expr: "ShowProcessListExpression") -> Tuple[str, tuple]: + def format_show_process_list_statement(self, expr: "ShowProcessListStatement") -> Tuple[str, tuple]: """Format SHOW PROCESSLIST statement.""" params = expr.get_params() full = params.get("full", False) @@ -251,7 +254,7 @@ def format_show_processlist(self, expr: "ShowProcessListExpression") -> Tuple[st return "SHOW FULL PROCESSLIST", () return "SHOW PROCESSLIST", () - def format_show_warnings(self, expr: "ShowWarningsExpression") -> Tuple[str, tuple]: + def format_show_warnings_statement(self, expr: "ShowWarningsStatement") -> Tuple[str, tuple]: """Format SHOW WARNINGS statement.""" params = expr.get_params() limit = params.get("limit") @@ -260,7 +263,7 @@ def format_show_warnings(self, expr: "ShowWarningsExpression") -> Tuple[str, tup return f"SHOW WARNINGS LIMIT {limit}", () return "SHOW WARNINGS", () - def format_show_errors(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: + def format_show_errors_statement(self, expr: "ShowErrorsStatement") -> Tuple[str, tuple]: """Format SHOW ERRORS statement.""" params = expr.get_params() limit = params.get("limit") @@ -271,11 +274,11 @@ def format_show_errors(self, expr: "ShowErrorsExpression") -> Tuple[str, tuple]: # ========== SHOW ENGINES/CHARSET/COLLATION ========== - def format_show_engines(self, expr: "ShowEnginesExpression") -> Tuple[str, tuple]: + def format_show_engines_statement(self, expr: "ShowEnginesStatement") -> Tuple[str, tuple]: """Format SHOW ENGINES statement.""" return "SHOW ENGINES", () - def format_show_charset(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple]: + def format_show_charset_statement(self, expr: "ShowCharsetStatement") -> Tuple[str, tuple]: """Format SHOW CHARACTER SET statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -284,7 +287,7 @@ def format_show_charset(self, expr: "ShowCharsetExpression") -> Tuple[str, tuple return f"SHOW CHARACTER SET LIKE {self.p()}", (like_pattern,) return "SHOW CHARACTER SET", () - def format_show_collation(self, expr: "ShowCollationExpression") -> Tuple[str, tuple]: + def format_show_collation_statement(self, expr: "ShowCollationStatement") -> Tuple[str, tuple]: """Format SHOW COLLATION statement.""" params = expr.get_params() like_pattern = params.get("like_pattern") @@ -295,7 +298,7 @@ def format_show_collation(self, expr: "ShowCollationExpression") -> Tuple[str, t # ========== SHOW GRANTS/PLUGINS ========== - def format_show_grants(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: + def format_show_grants_statement(self, expr: "ShowGrantsStatement") -> Tuple[str, tuple]: """Format SHOW GRANTS statement.""" params = expr.get_params() user = params.get("user") @@ -307,6 +310,29 @@ def format_show_grants(self, expr: "ShowGrantsExpression") -> Tuple[str, tuple]: return f"SHOW GRANTS FOR {self.p()}", (user,) return "SHOW GRANTS", () - def format_show_plugins(self, expr: "ShowPluginsExpression") -> Tuple[str, tuple]: + def format_show_plugins_statement(self, expr: "ShowPluginsStatement") -> Tuple[str, tuple]: """Format SHOW PLUGINS statement.""" return "SHOW PLUGINS", () + + # ========== SHOW BINARY LOGS / REPLICATION STATUS ========== + # + # Each of these is a whole command with no parameters, so each has its own + # node and its own entry: a boolean on a neighbouring node would say "this + # is a different command" with something other than the class, which is what + # the class is for. + + def format_show_binary_logs_statement(self, expr: "ShowBinaryLogsStatement") -> Tuple[str, tuple]: + """Format SHOW BINARY LOGS statement.""" + return "SHOW BINARY LOGS", () + + def format_show_master_status_statement( + self, expr: "ShowMasterStatusStatement" + ) -> Tuple[str, tuple]: + """Format SHOW MASTER STATUS statement.""" + return "SHOW MASTER STATUS", () + + def format_show_slave_status_statement( + self, expr: "ShowSlaveStatusStatement" + ) -> Tuple[str, tuple]: + """Format SHOW SLAVE STATUS statement.""" + return "SHOW SLAVE STATUS", () diff --git a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py index ecb4a84d..b1519351 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/functionality.py @@ -7,7 +7,7 @@ for all SQL execution. The implementation: -- Creates expression objects with the dialect +- Creates expression objects with every parameter at construction - Calls expression.to_sql() to get SQL - Executes SQL via backend.execute() - Parses results into typed dataclasses @@ -16,25 +16,25 @@ from typing import Optional, Tuple, TYPE_CHECKING from ..expression.show import ( - ShowCreateTableExpression, - ShowCreateViewExpression, - ShowColumnsExpression, - ShowIndexExpression, - ShowTablesExpression, - ShowDatabasesExpression, - ShowTableStatusExpression, - ShowTriggersExpression, - ShowCreateTriggerExpression, - ShowVariablesExpression, - ShowStatusExpression, - ShowProcessListExpression, - ShowWarningsExpression, - ShowErrorsExpression, - ShowEnginesExpression, - ShowCharsetExpression, - ShowCollationExpression, - ShowGrantsExpression, - ShowPluginsExpression, + ShowCreateTableStatement, + ShowCreateViewStatement, + ShowColumnsStatement, + ShowIndexStatement, + ShowTablesStatement, + ShowDatabasesStatement, + ShowTableStatusStatement, + ShowTriggersStatement, + ShowCreateTriggerStatement, + ShowVariablesStatement, + ShowStatusStatement, + ShowProcessListStatement, + ShowWarningsStatement, + ShowErrorsStatement, + ShowEnginesStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowGrantsStatement, + ShowPluginsStatement, ) if TYPE_CHECKING: @@ -382,9 +382,7 @@ def create_table(self, table_name: str, schema: Optional[str] = None): ShowCreateTableResult with table name and CREATE statement, or None if table doesn't exist. """ - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_table_result(result, table_name) @@ -401,9 +399,7 @@ def create_view(self, view_name: str, schema: Optional[str] = None): Returns: ShowCreateViewResult with view details, or None if view doesn't exist. """ - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_view_result(result, view_name) @@ -428,14 +424,9 @@ def columns( Returns: List of ShowColumnResult objects. """ - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) - + expr = ShowColumnsStatement( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_columns_result(result) @@ -452,9 +443,7 @@ def indexes(self, table_name: str, schema: Optional[str] = None): Returns: List of ShowIndexResult objects. """ - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_indexes_result(result) @@ -477,14 +466,7 @@ def tables( Returns: List of ShowTableResult objects. """ - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() - + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_tables_result(result) @@ -500,10 +482,7 @@ def databases(self, like: Optional[str] = None): Returns: List of ShowDatabaseResult objects. """ - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) - + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_databases_result(result) @@ -520,12 +499,7 @@ def table_status(self, schema: Optional[str] = None, like: Optional[str] = None) Returns: List of ShowTableStatusResult objects. """ - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_table_status_result(result) @@ -542,12 +516,7 @@ def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = Non Returns: List of ShowTriggerResult objects. """ - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.schema(schema) - if table_name: - expr.for_table(table_name) - + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_triggers_result(result) @@ -562,10 +531,7 @@ def create_trigger(self, trigger_name: str, schema: Optional[str] = None): Returns: ShowCreateTriggerResult or None if not found. """ - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.schema(schema) - + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_create_trigger_result(result, trigger_name) @@ -582,12 +548,7 @@ def variables(self, like: Optional[str] = None, session: bool = True): Returns: List of ShowVariableResult objects. """ - expr = ShowVariablesExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_vars() - + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_variables_result(result) @@ -604,12 +565,7 @@ def status(self, like: Optional[str] = None, session: bool = True): Returns: List of ShowStatusResult objects. """ - expr = ShowStatusExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_status() - + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_status_result(result) @@ -625,10 +581,7 @@ def processlist(self, full: bool = False): Returns: List of ShowProcessListResult objects. """ - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() - + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_processlist_result(result) @@ -644,10 +597,7 @@ def warnings(self, limit: Optional[int] = None): Returns: List of ShowWarningResult objects. """ - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.limit(limit) - + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_warnings_result(result) @@ -661,10 +611,7 @@ def errors(self, limit: Optional[int] = None): Returns: List of ShowWarningResult objects. """ - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.limit(limit) - + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_errors_result(result) @@ -677,7 +624,7 @@ def engines(self): Returns: List of ShowEngineResult objects. """ - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_engines_result(result) @@ -693,10 +640,7 @@ def charset(self, like: Optional[str] = None): Returns: List of ShowCharsetResult objects. """ - expr = ShowCharsetExpression(self.dialect) - if like: - expr.like(like) - + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_charset_result(result) @@ -712,10 +656,7 @@ def collation(self, like: Optional[str] = None): Returns: List of ShowCollationResult objects. """ - expr = ShowCollationExpression(self.dialect) - if like: - expr.like(like) - + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_collation_result(result) @@ -732,10 +673,7 @@ def grants(self, user: Optional[str] = None, host: Optional[str] = None): Returns: List of ShowGrantResult objects. """ - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) - + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_grants_result(result) @@ -748,7 +686,7 @@ def plugins(self): Returns: List of ShowPluginResult objects. """ - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() result = self._backend.execute(sql, params) return self._parse_plugins_result(result) @@ -778,9 +716,7 @@ def __init__(self, backend: "AsyncMySQLBackend", version: Optional[Tuple[int, .. async def create_table(self, table_name: str, schema: Optional[str] = None): """Async version of create_table.""" - expr = ShowCreateTableExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowCreateTableStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_table_result(result, table_name) @@ -789,9 +725,7 @@ async def create_table(self, table_name: str, schema: Optional[str] = None): async def create_view(self, view_name: str, schema: Optional[str] = None): """Async version of create_view.""" - expr = ShowCreateViewExpression(self.dialect, view_name) - if schema: - expr.schema(schema) + expr = ShowCreateViewStatement(self.dialect, view_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_view_result(result, view_name) @@ -806,13 +740,9 @@ async def columns( like: Optional[str] = None, ): """Async version of columns.""" - expr = ShowColumnsExpression(self.dialect, table_name) - if schema: - expr.schema(schema) - if full: - expr.full() - if like: - expr.like(like) + expr = ShowColumnsStatement( + self.dialect, table_name, schema=schema, full=full, like_pattern=like + ) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_columns_result(result) @@ -821,9 +751,7 @@ async def columns( async def indexes(self, table_name: str, schema: Optional[str] = None): """Async version of indexes.""" - expr = ShowIndexExpression(self.dialect, table_name) - if schema: - expr.schema(schema) + expr = ShowIndexStatement(self.dialect, table_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_indexes_result(result) @@ -837,13 +765,7 @@ async def tables( full: bool = False, ): """Async version of tables.""" - expr = ShowTablesExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) - if full: - expr.full() + expr = ShowTablesStatement(self.dialect, schema=schema, full=full, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_tables_result(result) @@ -852,9 +774,7 @@ async def tables( async def databases(self, like: Optional[str] = None): """Async version of databases.""" - expr = ShowDatabasesExpression(self.dialect) - if like: - expr.like(like) + expr = ShowDatabasesStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_databases_result(result) @@ -863,11 +783,7 @@ async def databases(self, like: Optional[str] = None): async def table_status(self, schema: Optional[str] = None, like: Optional[str] = None): """Async version of table_status.""" - expr = ShowTableStatusExpression(self.dialect) - if schema: - expr.schema(schema) - if like: - expr.like(like) + expr = ShowTableStatusStatement(self.dialect, schema=schema, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_table_status_result(result) @@ -876,20 +792,14 @@ async def table_status(self, schema: Optional[str] = None, like: Optional[str] = async def triggers(self, schema: Optional[str] = None, table_name: Optional[str] = None): """Async version of triggers.""" - expr = ShowTriggersExpression(self.dialect) - if schema: - expr.schema(schema) - if table_name: - expr.for_table(table_name) + expr = ShowTriggersStatement(self.dialect, schema=schema, table_name=table_name) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_triggers_result(result) async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): """Async version of create_trigger.""" - expr = ShowCreateTriggerExpression(self.dialect, trigger_name) - if schema: - expr.schema(schema) + expr = ShowCreateTriggerStatement(self.dialect, trigger_name, schema=schema) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_create_trigger_result(result, trigger_name) @@ -898,11 +808,7 @@ async def create_trigger(self, trigger_name: str, schema: Optional[str] = None): async def variables(self, like: Optional[str] = None, session: bool = True): """Async version of variables.""" - expr = ShowVariablesExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_vars() + expr = ShowVariablesStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_variables_result(result) @@ -911,11 +817,7 @@ async def variables(self, like: Optional[str] = None, session: bool = True): async def status(self, like: Optional[str] = None, session: bool = True): """Async version of status.""" - expr = ShowStatusExpression(self.dialect) - if like: - expr.like(like) - if not session: - expr.global_status() + expr = ShowStatusStatement(self.dialect, like_pattern=like, session=session) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_status_result(result) @@ -924,9 +826,7 @@ async def status(self, like: Optional[str] = None, session: bool = True): async def processlist(self, full: bool = False): """Async version of processlist.""" - expr = ShowProcessListExpression(self.dialect) - if full: - expr.full() + expr = ShowProcessListStatement(self.dialect, full=full) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_processlist_result(result) @@ -935,18 +835,14 @@ async def processlist(self, full: bool = False): async def warnings(self, limit: Optional[int] = None): """Async version of warnings.""" - expr = ShowWarningsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowWarningsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_warnings_result(result) async def errors(self, limit: Optional[int] = None): """Async version of errors.""" - expr = ShowErrorsExpression(self.dialect) - if limit is not None: - expr.limit(limit) + expr = ShowErrorsStatement(self.dialect, limit=limit) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_errors_result(result) @@ -955,7 +851,7 @@ async def errors(self, limit: Optional[int] = None): async def engines(self): """Async version of engines.""" - expr = ShowEnginesExpression(self.dialect) + expr = ShowEnginesStatement(self.dialect) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_engines_result(result) @@ -964,9 +860,7 @@ async def engines(self): async def charset(self, like: Optional[str] = None): """Async version of charset.""" - expr = ShowCharsetExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCharsetStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_charset_result(result) @@ -975,9 +869,7 @@ async def charset(self, like: Optional[str] = None): async def collation(self, like: Optional[str] = None): """Async version of collation.""" - expr = ShowCollationExpression(self.dialect) - if like: - expr.like(like) + expr = ShowCollationStatement(self.dialect, like_pattern=like) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_collation_result(result) @@ -986,9 +878,7 @@ async def collation(self, like: Optional[str] = None): async def grants(self, user: Optional[str] = None, host: Optional[str] = None): """Async version of grants.""" - expr = ShowGrantsExpression(self.dialect) - if user: - expr.for_user(user, host) + expr = ShowGrantsStatement(self.dialect, user=user, host=host) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_grants_result(result) @@ -997,7 +887,7 @@ async def grants(self, user: Optional[str] = None, host: Optional[str] = None): async def plugins(self): """Async version of plugins.""" - expr = ShowPluginsExpression(self.dialect) + expr = ShowPluginsStatement(self.dialect) sql, params = expr.to_sql() result = await self._backend.execute(sql, params) return self._sync_impl._parse_plugins_result(result) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/transaction.py index 805e9c35..e3aaf4ca 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/transaction.py @@ -25,7 +25,7 @@ class MySQLTransactionManager(MySQLTransactionMixin, TransactionManager): START TRANSACTION when a specific isolation level is needed. This class overrides _do_begin() to handle this sequencing. - The format_begin_transaction() in MySQLDialect returns only "START TRANSACTION", + The format_begin_transaction_statement() in MySQLDialect returns only "START TRANSACTION", while this class handles the SET TRANSACTION step separately. Non-I/O methods (isolation_level, _build_set_isolation_sql, _ISOLATION_LEVELS) diff --git a/tests/mysql_live_server.py b/tests/mysql_live_server.py new file mode 100644 index 00000000..70f6a134 --- /dev/null +++ b/tests/mysql_live_server.py @@ -0,0 +1,66 @@ +# tests/mysql_live_server.py +"""Collection-time check that a MySQL server is reachable. + +The backend-feature suite connects from its fixtures, so a missing server +surfaces as a fixture error rather than a skip. Tests whose whole point is +a live server use :func:`mysql_server_available` in a module-level +``skipif`` marker instead, which is decided at collection time and before +any fixture tries to connect. + +The scenarios are read from the same YAML the test conftest reads, with +the same ``MYSQL_SCENARIOS_CONFIG_PATH`` override, so "reachable" means +"one of the servers this run is configured to use". +""" + +import os +import socket +from pathlib import Path +from typing import Iterator, Optional + +import yaml + +DEFAULT_SCENARIO_FILE = ( + Path(__file__).resolve().parent / "config" / "mysql_scenarios.yaml" +) + + +def scenario_config_path() -> Optional[Path]: + """Return the scenario file this run will use, if any.""" + explicit = os.getenv("MYSQL_SCENARIOS_CONFIG_PATH") + if explicit: + path = Path(explicit) + return path if path.is_file() else None + return DEFAULT_SCENARIO_FILE if DEFAULT_SCENARIO_FILE.is_file() else None + + +def _scenarios() -> Iterator[dict]: + path = scenario_config_path() + if path is None: + return + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return + for config in (data.get("scenarios") or {}).values(): + if isinstance(config, dict): + yield config + + +def mysql_server_available(timeout: float = 1.0) -> bool: + """Return True if any configured MySQL server accepts a TCP connection. + + Only the transport is checked: a server that accepts and then refuses + the credentials still means "a server is there", and the fixture that + actually connects reports the refusal in the usual way. + """ + for config in _scenarios(): + host = config.get("host") + port = config.get("port") + if not host or not port: + continue + try: + with socket.create_connection((str(host), int(port)), timeout=timeout): + return True + except OSError: + continue + return False diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 283b48d8..b8b800bf 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -385,7 +385,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -397,7 +397,7 @@ def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> try: backend.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend.dialect, table=Table(backend.dialect, table_name), if_exists=True, @@ -603,7 +603,7 @@ async def _setup_async_model( async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -612,7 +612,7 @@ async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: try: await backend.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend.dialect, table=Table(backend.dialect, table_name), if_exists=True, diff --git a/tests/providers/events.py b/tests/providers/events.py index a2b9bf75..ebd02091 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -117,7 +117,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -130,7 +130,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -196,7 +196,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.events import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -209,7 +209,7 @@ async def _setup_async_model( try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/__init__.py b/tests/providers/fixtures/__init__.py index 92297c44..9e365a6f 100644 --- a/tests/providers/fixtures/__init__.py +++ b/tests/providers/fixtures/__init__.py @@ -2,16 +2,16 @@ """MySQL-specific DDL expression fixtures for the testsuite providers. Each module in this package exposes a ``TABLE_EXPRESSIONS`` mapping of -table name -> factory callable ``Callable[[DialectLike, str], CreateTableExpression]``. +table name -> factory callable ``Callable[[DialectLike, str], CreateTableStatement]``. -The factory functions build :class:`CreateTableExpression` instances that emit +The factory functions build :class:`CreateTableStatement` instances that emit MySQL-compatible DDL (ENGINE / CHARSET / COLLATE / inline indexes / FK with ON DELETE CASCADE / TINYINT(1) / DATETIME(6) / ENUM / JSON). The pre-existing ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` remain as the authoritative reference for what the expressions here must produce; they are simply no longer read at runtime by the providers. -Because the MySQL backend's ``format_storage_options`` emits quoted values +Because the MySQL backend's ``format_storage_options_expression`` emits quoted values (e.g. ``ENGINE='InnoDB'``) while the reference SQL files use the unquoted form, each factory routes its expression through :func:`to_mysql_ddl_sql` which post-processes the storage option clause to drop the quotes, producing diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py index 15a24271..3e7b895e 100644 --- a/tests/providers/fixtures/_common.py +++ b/tests/providers/fixtures/_common.py @@ -1,22 +1,19 @@ # tests/providers/fixtures/_common.py """Shared helpers for the MySQL DDL expression fixtures. -The MySQL backend's :meth:`format_storage_options` quotes string option -values (``ENGINE='InnoDB'``) and :meth:`format_table_constraint` does not -emit ``ON DELETE`` / ``ON UPDATE`` referential actions for -:class:`ForeignKeyConstraint` instances. The authoritative ``.sql`` schema -files under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` -use the unquoted storage form and inline ``FOREIGN KEY ... ON DELETE CASCADE`` -clauses. - -Rather than modifying the MySQL backend library source (which is out of -scope for this refactor), :func:`to_mysql_ddl_sql` post-processes the SQL -produced by ``CreateTableExpression.to_sql()`` so that the generated DDL -matches the reference files byte-for-byte for the bits that matter: - -* ``KEY='value'`` storage options become ``KEY=value``. -* ``FOREIGN KEY ... REFERENCES ...`` clauses have their declared - ``on_delete`` / ``on_update`` referential actions appended. +The MySQL backend's :meth:`format_storage_options_expression` quotes string option +values (``ENGINE='InnoDB'``), while the authoritative ``.sql`` schema files +under ``tests/rhosocial/activerecord_mysql_test/feature//schema/`` +use the unquoted storage form. :func:`to_mysql_ddl_sql` post-processes the +SQL produced by ``CreateTableStatement.to_sql()`` so the generated DDL +matches the reference files for that bit. + +The former second post-processing step -- re-injecting ``ON DELETE`` / +``ON UPDATE`` referential actions that ``format_table_constraint`` used to +drop for plain table constraints -- is gone: the two-level constraint nodes +render every ``TableForeignKeyConstraint`` through the shared +``ReferencesClause`` machinery, so the actions the fixtures declare are on +the wire the dialect produced. Providers call :func:`create_table_sql` instead of ``expr.to_sql()`` directly to obtain the canonical ``(sql, params)`` tuple. @@ -28,18 +25,13 @@ from typing import Tuple from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, + CreateTableStatement, + DropTableStatement, ) -from rhosocial.activerecord.backend.expression.statements import ReferentialAction from rhosocial.activerecord.backend.expression.objects import Table _STORAGE_OPTION_RE = re.compile(r"([A-Z_ ]+=)'([^']*)'") -_FK_RE = re.compile( - r"(FOREIGN KEY \([`A-Za-z0-9_ ,]+\) REFERENCES [`A-Za-z0-9_.]+ \([`A-Za-z0-9_ ,]+\))" - r"(?=[,)])" -) def _strip_storage_option_quotes(sql: str) -> str: @@ -47,70 +39,30 @@ def _strip_storage_option_quotes(sql: str) -> str: return _STORAGE_OPTION_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}", sql) -def _append_fk_actions(expr: CreateTableExpression, sql: str) -> str: - """Append ``ON DELETE`` / ``ON UPDATE`` clauses to inline FK constraints. +def to_mysql_ddl_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: + """Generate canonical MySQL DDL for a :class:`CreateTableStatement`. - The MySQL dialect's ``format_table_constraint`` does not currently emit - referential actions even though :class:`ForeignKeyConstraint` carries them. - This helper re-injects them so the generated DDL matches the reference SQL - files that use ``ON DELETE CASCADE``. - """ - # Build a lookup of "FOREIGN KEY (cols) REFERENCES ref_table (ref_cols)" - # -> suffix to append for each matching constraint. - fk_suffix_by_clause: dict = {} - for t_const in expr.table_constraints: - from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, - ForeignKeyConstraint, - ) - if t_const.constraint_type != TableConstraintType.FOREIGN_KEY: - continue - if not isinstance(t_const, ForeignKeyConstraint): - continue - cols = ", ".join(f"`{c}`" for c in (t_const.columns or [])) - ref_cols = ", ".join(f"`{c}`" for c in (t_const.foreign_key_columns or [])) - ref_table = t_const.foreign_key_table.to_sql()[0] - clause = f"FOREIGN KEY ({cols}) REFERENCES {ref_table} ({ref_cols})" - suffix_parts = [] - on_delete = getattr(t_const, "on_delete", None) - on_update = getattr(t_const, "on_update", None) - if on_delete and on_delete != ReferentialAction.NO_ACTION: - suffix_parts.append(f"ON DELETE {on_delete.value}") - if on_update and on_update != ReferentialAction.NO_ACTION: - suffix_parts.append(f"ON UPDATE {on_update.value}") - if suffix_parts: - fk_suffix_by_clause[clause] = " " + " ".join(suffix_parts) - - def repl(match: re.Match) -> str: - return match.group(1) + fk_suffix_by_clause.get(match.group(1), "") - - return _FK_RE.sub(repl, sql) - - -def to_mysql_ddl_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: - """Generate canonical MySQL DDL for a :class:`CreateTableExpression`. - - Post-processes the dialect output to: - 1. Drop quotes around storage option values. - 2. Re-insert ``ON DELETE`` / ``ON UPDATE`` referential actions for - inline ``FOREIGN KEY`` table constraints. - - Returns the ``(sql, params)`` tuple suitable for ``backend.execute``. + Post-processes the dialect output to drop quotes around storage option + values. Referential actions need no help any more: the table-level + foreign-key branch renders them through the shared ``ReferencesClause`` + path, so what the fixtures declare is what the dialect emits. + + Returns: + The ``(sql, params)`` tuple suitable for ``backend.execute``. """ sql, params = expr.to_sql() sql = _strip_storage_option_quotes(sql) - sql = _append_fk_actions(expr, sql) return sql, params -def create_table_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: +def create_table_sql(expr: CreateTableStatement) -> Tuple[str, tuple]: """Public alias for :func:`to_mysql_ddl_sql`.""" return to_mysql_ddl_sql(expr) -def drop_table(dialect, table_name: str) -> DropTableExpression: +def drop_table(dialect, table_name: str) -> DropTableStatement: """Build a canonical ``DROP TABLE IF EXISTS`` expression.""" - return DropTableExpression( + return DropTableStatement( dialect=dialect, table=Table(dialect, table_name), if_exists=True, diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index 2421d831..ea33ebd0 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/basic.py """DDL expressions for the ``feature/basic`` table group (MySQL). -Each factory builds a :class:`CreateTableExpression` whose generated MySQL +Each factory builds a :class:`CreateTableStatement` whose generated MySQL DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature/basic/schema/``. Those ``.sql`` files are kept as the authoritative reference and are no longer @@ -16,17 +16,20 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, - TableConstraint, - TableConstraintType, ReferentialAction, + TableConstraint, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, @@ -59,8 +62,8 @@ _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): - """Route a CreateTableExpression through the canonical MySQL DDL post-processor.""" +def to_sql(expr: CreateTableStatement): + """Route a CreateTableStatement through the canonical MySQL DDL post-processor.""" return _common.to_mysql_ddl_sql(expr) @@ -68,27 +71,27 @@ def to_sql(expr: CreateTableExpression): # basic/users.sql # --------------------------------------------------------------------------- -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", FloatType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -100,19 +103,19 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress # basic/type_cases.sql # --------------------------------------------------------------------------- -def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableExpression: - return CreateTableExpression( +def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, 36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "username", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tiny_int", TextType(dialect)), ColumnDefinition(dialect, "small_int", TextType(dialect)), ColumnDefinition(dialect, "big_int", TextType(dialect)), @@ -138,32 +141,32 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa # basic/type_tests.sql # --------------------------------------------------------------------------- -def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", CharType(dialect, 36), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "string_field", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="test string")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "test string"))]), ColumnDefinition(dialect, "int_field", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=42)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 42))]), ColumnDefinition(dialect, "float_field", FloatType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=3.14)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 3.14))]), ColumnDefinition(dialect, "decimal_field", DoubleType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=10.99)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 10.99))]), ColumnDefinition(dialect, "bool_field", TinyIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "datetime_field", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "json_field", JsonType(dialect)), ColumnDefinition(dialect, "nullable_field", VarCharType(dialect, 255)), ], @@ -175,30 +178,30 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa # basic/validated_field_users.sql # --------------------------------------------------------------------------- -def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: +def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableStatement: # MySQL ENUM column for status. status_enum = MySQLEnumType(dialect, ['active', 'inactive', 'banned', 'pending', 'suspended']) - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DecimalType(precision=10, scale=2, dialect=dialect)), ColumnDefinition(dialect, "credit_score", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", status_enum, - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active"))]), ColumnDefinition(dialect, "is_active", TinyIntType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -208,18 +211,18 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie # basic/validated_users.sql # --------------------------------------------------------------------------- -def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableExpression: - return CreateTableExpression( +def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), @@ -230,14 +233,14 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - # basic/pydantic_validated_models.sql # --------------------------------------------------------------------------- -def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableExpression: - return CreateTableExpression( +def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "code", VarCharType(dialect, 32)), ColumnDefinition(dialect, "quantity", IntegerType(dialect)), ColumnDefinition(dialect, "step_count", IntegerType(dialect)), @@ -256,20 +259,20 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ # basic/bulk_users.sql # --------------------------------------------------------------------------- -def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableExpression: - return CreateTableExpression( +def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, ""))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -279,22 +282,22 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa # basic/posts.sql # --------------------------------------------------------------------------- -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "published_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "published", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -311,25 +314,25 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress # basic/comments.sql # --------------------------------------------------------------------------- -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "post_ref", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "author", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "text", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "approved", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], indexes=[ IndexDefinition(dialect, name="idx_post_ref", columns=["post_ref"]), @@ -349,18 +352,18 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE # basic/column_mapping_items.sql # --------------------------------------------------------------------------- -def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableExpression: - return CreateTableExpression( +def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "item_total", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "remarks", IntegerType(dialect)), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), @@ -371,16 +374,16 @@ def create_column_mapping_items_table(dialect, table_name: str = "column_mapping # basic/mixed_annotation_items.sql # --------------------------------------------------------------------------- -def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableExpression: - return CreateTableExpression( +def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "meta", TextType(dialect)), ColumnDefinition(dialect, "description", TextType(dialect)), @@ -394,16 +397,16 @@ def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annota # basic/type_adapter_tests.sql (no ENGINE/CHARSET in reference file) # --------------------------------------------------------------------------- -def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "optional_name", VarCharType(dialect, 255)), ColumnDefinition(dialect, "optional_age", IntegerType(dialect)), ColumnDefinition(dialect, "last_login", TextType(dialect)), @@ -419,25 +422,27 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes # basic/order_items.sql (composite PK, FK -> orders) # --------------------------------------------------------------------------- -def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["order_id", "product_id"]), + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["order_id", "product_id"]) +), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -447,25 +452,27 @@ def create_composite_pk_order_items_table(dialect, table_name: str = "order_item # basic/store_inventory.sql (composite PK, no FK) # --------------------------------------------------------------------------- -def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableExpression: - return CreateTableExpression( +def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "store_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "batch_id", VarCharType(dialect, 64), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "stock", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ], table_constraints=[ - TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, - columns=["store_id", "product_id", "batch_id"]), + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["store_id", "product_id", "batch_id"]) +), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -475,16 +482,16 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - # basic/orders.sql (single PK, no FK in composite-PK scenario) # --------------------------------------------------------------------------- -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "total", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", TextType(dialect)), ColumnDefinition(dialect, "updated_at", TextType(dialect)), ], @@ -496,20 +503,20 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre # basic/product.sql (single PK) # --------------------------------------------------------------------------- -def create_product_table(dialect, table_name: str = "product") -> CreateTableExpression: - return CreateTableExpression( +def create_product_table(dialect, table_name: str = "product") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=True, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", FloatType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index fb59c269..ff35d4c0 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -7,12 +7,15 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( DateTimeType, @@ -31,7 +34,7 @@ } -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_mysql_ddl_sql(expr) @@ -39,22 +42,22 @@ def to_sql(expr: CreateTableExpression): # events/event_tests.sql # --------------------------------------------------------------------------- -def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "revision", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), @@ -67,23 +70,23 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create # events/event_tracking_models.sql # --------------------------------------------------------------------------- -def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableExpression: - return CreateTableExpression( +def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "view_count", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "last_viewed_at", DateTimeType(precision=6, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index dea55cb8..1bd92bbe 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -7,12 +7,15 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, @@ -34,7 +37,7 @@ } -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_mysql_ddl_sql(expr) @@ -42,28 +45,28 @@ def to_sql(expr: CreateTableExpression): # mixins/combined_articles.sql # --------------------------------------------------------------------------- -def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableExpression: - return CreateTableExpression( +def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "draft"))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "deleted_at", DateTimeType(precision=6, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -73,21 +76,21 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles # mixins/tasks.sql # --------------------------------------------------------------------------- -def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpression: - return CreateTableExpression( +def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "is_completed", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "deleted_at", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) @@ -97,18 +100,18 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress # mixins/timestamped_posts.sql # --------------------------------------------------------------------------- -def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableExpression: - return CreateTableExpression( +def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -120,22 +123,22 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts # mixins/versioned_products.sql # --------------------------------------------------------------------------- -def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableExpression: - return CreateTableExpression( +def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "version", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ], storage_options=dict(_DEFAULT_STORAGE_OPTIONS), ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index ef147b71..29d1eea3 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -1,7 +1,7 @@ # tests/providers/fixtures/query.py """DDL expressions for the ``feature/query`` table group (MySQL). -Each factory builds a :class:`CreateTableExpression` whose generated MySQL DDL +Each factory builds a :class:`CreateTableStatement` whose generated MySQL DDL is semantically equivalent to the reference ``.sql`` schema files under ``tests/rhosocial/activerecord_mysql_test/feature/query/schema/``. Those ``.sql`` files are kept as the authoritative reference and are no longer @@ -11,12 +11,16 @@ from typing import Callable, Dict from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, ReferentialAction, @@ -44,7 +48,7 @@ _CASCADE = ReferentialAction.CASCADE -def to_sql(expr: CreateTableExpression): +def to_sql(expr: CreateTableStatement): return _common.to_mysql_ddl_sql(expr) @@ -52,27 +56,27 @@ def to_sql(expr: CreateTableExpression): # query/users.sql # --------------------------------------------------------------------------- -def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: - return CreateTableExpression( +def create_users_table(dialect, table_name: str = "users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 191), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "balance", DoubleType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "is_active", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -84,22 +88,22 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress # query/posts.sql # --------------------------------------------------------------------------- -def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: - return CreateTableExpression( +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "title", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "published"))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -119,21 +123,21 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress # query/comments.sql # --------------------------------------------------------------------------- -def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: - return CreateTableExpression( +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "post_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "content", TextType(dialect)), ColumnDefinition(dialect, "is_hidden", BooleanType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -155,24 +159,24 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE # query/orders.sql # --------------------------------------------------------------------------- -def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: - return CreateTableExpression( +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -188,26 +192,26 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre # query/order_items.sql # --------------------------------------------------------------------------- -def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "unit_price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "subtotal", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -224,16 +228,16 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create # query/profiles.sql # --------------------------------------------------------------------------- -def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableExpression: - return CreateTableExpression( +def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "bio", TextType(dialect)), ColumnDefinition(dialect, "avatar_url", VarCharType(dialect, 512)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), @@ -250,18 +254,18 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE # query/json_users.sql # --------------------------------------------------------------------------- -def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableExpression: - return CreateTableExpression( +def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "username", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "age", IntegerType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), @@ -281,21 +285,21 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa # query/nodes.sql (self-referential FK) # --------------------------------------------------------------------------- -def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpression: - return CreateTableExpression( +def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "parent_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))]), ColumnDefinition(dialect, "value", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), ColumnDefinition(dialect, "updated_at", DateTimeType(precision=6, dialect=dialect)), ], @@ -312,14 +316,14 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress # query/searchable_items.sql (no CHARSET/COLLATE in reference file) # --------------------------------------------------------------------------- -def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableExpression: - return CreateTableExpression( +def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 255)), ColumnDefinition(dialect, "tags", TextType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), @@ -333,30 +337,30 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") # query/extended_orders.sql # --------------------------------------------------------------------------- -def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "order_number", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "total_amount", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.0)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0.0))]), ColumnDefinition(dialect, "status", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "pending"))]), ColumnDefinition(dialect, "priority", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "medium"))]), ColumnDefinition(dialect, "region", VarCharType(dialect, 50), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "default"))]), ColumnDefinition(dialect, "category", VarCharType(dialect, 255)), ColumnDefinition(dialect, "product", VarCharType(dialect, 255)), ColumnDefinition(dialect, "department", VarCharType(dialect, 255)), @@ -383,23 +387,23 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - # query/extended_order_items.sql # --------------------------------------------------------------------------- -def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableExpression: - return CreateTableExpression( +def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableStatement: + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), if_not_exists=False, columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))]), ColumnDefinition(dialect, "order_id", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "product_name", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "quantity", IntegerType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 1))]), ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "category", VarCharType(dialect, 255)), ColumnDefinition(dialect, "region", VarCharType(dialect, 50)), ColumnDefinition(dialect, "created_at", DateTimeType(precision=6, dialect=dialect)), diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index bfb72715..2d88f5f4 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -137,7 +137,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -150,7 +150,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -222,7 +222,7 @@ async def _setup_async_model( from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.mixins import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -235,7 +235,7 @@ async def _setup_async_model( try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, diff --git a/tests/providers/query.py b/tests/providers/query.py index edb93c0c..ef16bf8c 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -160,7 +160,7 @@ def __init__(self): def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -173,7 +173,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -221,7 +221,7 @@ def _setup_multiple_models( def _reset_schema(self, backend_instance, table_name: str) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -231,7 +231,7 @@ def _reset_schema(self, backend_instance, table_name: str) -> None: try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -319,7 +319,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: """Set up the composite-PK OrderItem model for the query feature tests.""" from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_mysql_ddl_sql @@ -332,7 +332,7 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: try: backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, "order_items"), if_exists=True, @@ -461,7 +461,7 @@ async def _setup_model_async( from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from providers.fixtures.query import TABLE_EXPRESSIONS from providers.fixtures._common import to_mysql_ddl_sql @@ -479,7 +479,7 @@ async def _setup_model_async( try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, table_name), if_exists=True, @@ -630,7 +630,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe """Set up the composite-PK AsyncOrderItem model for the query feature tests.""" from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType - from rhosocial.activerecord.backend.expression import DropTableExpression + from rhosocial.activerecord.backend.expression import DropTableStatement from rhosocial.activerecord.backend.impl.mysql.backend.async_backend import AsyncMySQLBackend from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_mysql_ddl_sql @@ -644,7 +644,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe try: await backend_instance.execute("SET FOREIGN_KEY_CHECKS = 0") try: - drop_expr = DropTableExpression( + drop_expr = DropTableStatement( dialect=backend_instance.dialect, table=Table(backend_instance.dialect, "order_items"), if_exists=True, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py index 6a46dc72..089a9885 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py @@ -8,13 +8,13 @@ from rhosocial.activerecord.backend.expression.core import Column, Literal from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, - CreateTableExpression, - GeneratedColumnExpression, + ColumnGeneratedConstraint, + CreateTableStatement, GeneratedColumnType, IndexDefinition, StorageOptionsExpression, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -42,7 +42,14 @@ UseIndex, UseSqlType, ) -from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefaultConstraint, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, + TableForeignKeyConstraint, + TableUniqueConstraint, +) from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.backend.expression.objects import Table @@ -51,14 +58,16 @@ SQL_TYPE = MySQLIntType(unsigned=True) FALLBACK_SQL_TYPE = MySQLLongTextType() SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) -DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) +# A DEFAULT takes no bind parameter: the value clause marks the literal +# inline itself, so a plain Literal is the value here. +DEFAULT_VALUE = Literal(DIALECT, "new") DEFAULT_CONSTRAINT = UseConstraint( - ColumnConstraintType.DEFAULT, + ColumnDefaultConstraint, name="df_status_default", - default_value=DEFAULT_VALUE, + value=DEFAULT_VALUE, ) UNIQUE_CONSTRAINT = UseConstraint( - ColumnConstraintType.UNIQUE, + ColumnUniqueConstraint, name="uq_status", ) COLLATION = CollationAttribute(name="utf8mb4_0900_ai_ci") @@ -75,7 +84,7 @@ tablespace="innodb_index", ) SECOND_INDEX = UseIndex("idx_display_label_suffix", type="BTREE") -GENERATED = GeneratedColumnExpression( +GENERATED = ColumnGeneratedConstraint( DIALECT, Column(DIALECT, "order_id"), GeneratedColumnType.STORED, @@ -95,17 +104,15 @@ TABLE_CONSTRAINTS = [ TableConstraint( DIALECT, - TableConstraintType.FOREIGN_KEY, + branch=TableForeignKeyConstraint( + DIALECT, ["tenant_id"], (Table(DIALECT, "tenant"), ["id"]) + ), name="fk_order_tenant", - columns=["tenant_id"], - foreign_key_table=Table(DIALECT, "tenant"), - foreign_key_columns=["id"], ), TableConstraint( DIALECT, - TableConstraintType.UNIQUE, + branch=TableUniqueConstraint(DIALECT, ["tenant_id", "order_id"]), name="uq_order_pair", - columns=["tenant_id", "order_id"], ), ] TABLE_INDEXES = [ @@ -277,11 +284,11 @@ def test_field_markers_preserve_declared_objects_and_order(): constraints = SyncDDLSource.column_constraints("status") assert constraints[0] is DEFAULT_CONSTRAINT.constraint assert constraints[1] is UNIQUE_CONSTRAINT.constraint - assert constraints[0].default_value is DEFAULT_VALUE - assert [item.constraint_type for item in constraints] == [ - ColumnConstraintType.DEFAULT, - ColumnConstraintType.UNIQUE, - ColumnConstraintType.NOT_NULL, + assert constraints[0].branch.value is DEFAULT_VALUE + assert [type(item.branch) for item in constraints] == [ + ColumnDefaultConstraint, + ColumnUniqueConstraint, + ColumnNotNullConstraint, ] attributes = SyncDDLSource.column_attributes("status") @@ -335,18 +342,18 @@ def test_primary_keys_use_physical_column_names_and_declaration_order(): } for field in ("tenant_id", "order_id"): - constraint_types = [ - item.constraint_type for item in SyncDDLSource.column_constraints(field) + constraint_kinds = [ + type(item.branch) for item in SyncDDLSource.column_constraints(field) ] - assert ColumnConstraintType.PRIMARY_KEY not in constraint_types - assert ColumnConstraintType.NOT_NULL in constraint_types + assert ColumnPrimaryKeyConstraint not in constraint_kinds + assert ColumnNotNullConstraint in constraint_kinds single_types = [ - item.constraint_type for item in SyncSingleKey.column_constraints("record_id") + type(item.branch) for item in SyncSingleKey.column_constraints("record_id") ] assert single_types == [ - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.NOT_NULL, + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, ] assert SyncSingleKey.columns_name() == {"record_id": "record_pk"} assert SyncSingleKey.column_data_type("record_id") is SINGLE_SQL_TYPE_MARKER @@ -359,12 +366,12 @@ def test_table_declarations_preserve_identity_order_and_composite_primary_key(): assert constraints is not TABLE_CONSTRAINTS assert constraints[0] is TABLE_CONSTRAINTS[0] assert constraints[1] is TABLE_CONSTRAINTS[1] - assert [item.constraint_type for item in constraints] == [ - TableConstraintType.FOREIGN_KEY, - TableConstraintType.UNIQUE, - TableConstraintType.PRIMARY_KEY, + assert [type(item.branch) for item in constraints] == [ + TableForeignKeyConstraint, + TableUniqueConstraint, + TablePrimaryKeyConstraint, ] - assert constraints[2].columns == ["tenant_key", "order_key"] + assert constraints[2].branch.columns == ["tenant_key", "order_key"] assert len(TABLE_CONSTRAINTS) == 2 indexes = SyncDDLSource.table_indexes() @@ -385,7 +392,7 @@ def test_table_declarations_preserve_identity_order_and_composite_primary_key(): def test_collected_table_options_render_with_mysql_dialect(): table_options = SyncDDLSource.table_options() - expression = CreateTableExpression( + expression = CreateTableStatement( DIALECT, Table(DIALECT, "ddl_source_orders"), [ColumnDefinition(DIALECT, "id", IntegerType(DIALECT))], @@ -445,8 +452,8 @@ def test_sync_and_async_sources_collect_the_same_declarations(): async_constraints = AsyncDDLSource.column_constraints("status") assert sync_constraints[0] is async_constraints[0] assert sync_constraints[1] is async_constraints[1] - assert [item.constraint_type for item in async_constraints] == [ - item.constraint_type for item in sync_constraints + assert [type(item.branch) for item in async_constraints] == [ + type(item.branch) for item in sync_constraints ] sync_indexes = SyncDDLSource.column_indexes("label") @@ -467,12 +474,12 @@ def test_sync_and_async_sources_collect_the_same_declarations(): async_table_constraints = AsyncDDLSource.table_constraints() assert sync_table_constraints[0] is async_table_constraints[0] assert sync_table_constraints[1] is async_table_constraints[1] - assert async_table_constraints[2].columns == ["tenant_key", "order_key"] + assert async_table_constraints[2].branch.columns == ["tenant_key", "order_key"] for model in (SyncSingleKey, AsyncSingleKey): - assert [item.constraint_type for item in model.column_constraints("record_id")] == [ - ColumnConstraintType.PRIMARY_KEY, - ColumnConstraintType.NOT_NULL, + assert [type(item.branch) for item in model.column_constraints("record_id")] == [ + ColumnPrimaryKeyConstraint, + ColumnNotNullConstraint, ] assert model.column_name("record_id") == "record_pk" assert model.column_data_type("record_id") is SINGLE_SQL_TYPE_MARKER diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py index ba307060..e003af99 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -1,7 +1,7 @@ # tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py """MySQL inline-index support for direct DDL expressions. -The DDLSource index declaration is passed directly to CreateTableExpression +The DDLSource index declaration is passed directly to CreateTableStatement on a dialect that advertises ``supports_inline_index()``. """ @@ -13,7 +13,7 @@ from rhosocial.activerecord.base import UseIndex from rhosocial.activerecord.backend.expression import ( ColumnDefinition, - CreateTableExpression, + CreateTableStatement, ) from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -34,7 +34,7 @@ def mysql_dialect(): def _create_indexed_table(): dialect = mysql_dialect() - return CreateTableExpression( + return CreateTableStatement( dialect, Table(dialect, Indexed.__table_name__), columns=[ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py index 4b114ff8..337ffd17 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py @@ -12,7 +12,7 @@ from rhosocial.activerecord.backend.dialect import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import ( Column, - InsertExpression, + InsertStatement, Literal, OnConflictClause, ValuesSource, @@ -41,7 +41,7 @@ def test_multiple_on_conflict_clauses_rejected(self, dialect): source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause1 = OnConflictClause(dialect, conflict_target=["col_a"], do_nothing=True) clause2 = OnConflictClause(dialect, conflict_target=["col_b"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, "t"), source=source, on_conflict=[clause1, clause2]) + expr = InsertStatement(dialect, into=Table(dialect, "t"), source=source, on_conflict=[clause1, clause2]) with pytest.raises(UnsupportedFeatureError, match="multiple ON CONFLICT clauses"): expr.to_sql() @@ -57,7 +57,7 @@ def test_on_duplicate_key_update(self, dialect): conflict_target=["id"], update_assignments={"name": Column(dialect, "name", "excluded")}, ) - expr = InsertExpression( + expr = InsertStatement( dialect, into=Table(dialect, "users"), columns=["id", "name"], source=source, on_conflict=clause ) sql, params = expr.to_sql() @@ -71,7 +71,7 @@ def test_on_duplicate_key_do_nothing_noop(self, dialect): """do_nothing renders the MySQL no-op UPDATE id = id.""" source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) clause = OnConflictClause(dialect, conflict_target=["id"], do_nothing=True) - expr = InsertExpression(dialect, into=Table(dialect, "users"), columns=["id"], source=source, on_conflict=clause) + expr = InsertStatement(dialect, into=Table(dialect, "users"), columns=["id"], source=source, on_conflict=clause) sql, params = expr.to_sql() assert sql == ( 'INSERT INTO `users` (`id`) VALUES (%s) ' diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py index cef9f796..23ae9fbe 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_load_data.py @@ -13,7 +13,7 @@ import pytest_asyncio from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.impl.mysql.expression import MySQLLoadDataExpression, LoadDataOptions +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLLoadDataStatement, LoadDataOptions class TestMySQLLoadData: @@ -39,8 +39,8 @@ def test_supports_load_data(self, mysql_backend): assert mysql_backend.dialect.supports_load_data() is True def test_load_data_expression_basic(self, mysql_backend): - """Test basic MySQLLoadDataExpression generation.""" - expr = MySQLLoadDataExpression( + """Test basic MySQLLoadDataStatement generation.""" + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), @@ -54,7 +54,7 @@ def test_load_data_expression_basic(self, mysql_backend): def test_load_data_expression_qualified_database(self, mysql_backend): """A database qualifier is expressible; a bare string could never carry one.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users", catalog_name="other_db"), @@ -65,11 +65,11 @@ def test_load_data_expression_qualified_database(self, mysql_backend): def test_load_data_expression_local(self, mysql_backend): """Test LOAD DATA LOCAL expression.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True), + options=LoadDataOptions(mysql_backend.dialect, local=True), ) sql, params = expr.to_sql() @@ -77,11 +77,11 @@ def test_load_data_expression_local(self, mysql_backend): def test_load_data_expression_fields_terminated(self, mysql_backend): """Test LOAD DATA with custom field terminator.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, fields_terminated_by=","), + options=LoadDataOptions(mysql_backend.dialect, local=True, fields_terminated_by=","), ) sql, params = expr.to_sql() @@ -89,11 +89,11 @@ def test_load_data_expression_fields_terminated(self, mysql_backend): def test_load_data_expression_ignore_lines(self, mysql_backend): """Test LOAD DATA with IGNORE n LINES.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, ignore_lines=1), + options=LoadDataOptions(mysql_backend.dialect, local=True, ignore_lines=1), ) sql, params = expr.to_sql() @@ -101,11 +101,11 @@ def test_load_data_expression_ignore_lines(self, mysql_backend): def test_load_data_expression_replace_mode(self, mysql_backend): """Test LOAD DATA with REPLACE mode.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, replace=True), + options=LoadDataOptions(mysql_backend.dialect, local=True, replace=True), ) sql, params = expr.to_sql() @@ -113,11 +113,11 @@ def test_load_data_expression_replace_mode(self, mysql_backend): def test_load_data_expression_ignore_mode(self, mysql_backend): """Test LOAD DATA with IGNORE mode.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, ignore=True), + options=LoadDataOptions(mysql_backend.dialect, local=True, ignore=True), ) sql, params = expr.to_sql() @@ -126,11 +126,11 @@ def test_load_data_expression_ignore_mode(self, mysql_backend): def test_load_data_replace_and_ignore_mutually_exclusive(self, mysql_backend): """Test that REPLACE and IGNORE are mutually exclusive.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, replace=True, ignore=True), + options=LoadDataOptions(mysql_backend.dialect, local=True, replace=True, ignore=True), ) with pytest.raises(ValueError, match="Cannot use both REPLACE and IGNORE"): @@ -138,11 +138,11 @@ def test_load_data_replace_and_ignore_mutually_exclusive(self, mysql_backend): def test_load_data_expression_with_columns(self, mysql_backend): """Test LOAD DATA with column list.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, column_list=["name", "email", "age"]), + options=LoadDataOptions(mysql_backend.dialect, local=True, column_list=["name", "email", "age"]), ) sql, params = expr.to_sql() @@ -150,11 +150,11 @@ def test_load_data_expression_with_columns(self, mysql_backend): def test_load_data_expression_with_lines_terminated(self, mysql_backend): """Test LOAD DATA with custom line terminator.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, lines_terminated_by="\n"), + options=LoadDataOptions(mysql_backend.dialect, local=True, lines_terminated_by="\n"), ) sql, params = expr.to_sql() @@ -162,11 +162,11 @@ def test_load_data_expression_with_lines_terminated(self, mysql_backend): def test_load_data_expression_character_set(self, mysql_backend): """Test LOAD DATA with character set.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions(local=True, character_set="utf8mb4"), + options=LoadDataOptions(mysql_backend.dialect, local=True, character_set="utf8mb4"), ) sql, params = expr.to_sql() @@ -174,11 +174,11 @@ def test_load_data_expression_character_set(self, mysql_backend): def test_load_data_expression_full_csv(self, mysql_backend): """Test full LOAD DATA expression for CSV import.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=mysql_backend.dialect, file_path="/tmp/data.csv", table=Table(mysql_backend.dialect, "users"), - options=LoadDataOptions( + options=LoadDataOptions(mysql_backend.dialect, local=True, fields_terminated_by=",", fields_enclosed_by='"', @@ -218,7 +218,7 @@ async def test_table(self, async_mysql_backend): async def test_load_data_expression_async(self, async_mysql_backend, test_table): """Test async LOAD DATA expression generation.""" - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect=async_mysql_backend.dialect, file_path="/tmp/test.csv", table=Table(async_mysql_backend.dialect, test_table), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py index 8ade2910..fa0ba84d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_expressions_transaction.py @@ -4,34 +4,34 @@ MySQL Transaction Behavior: - Isolation level must be set BEFORE START TRANSACTION using SET TRANSACTION - START TRANSACTION can include READ ONLY / READ WRITE modes -- The dialect's format_begin_transaction() only returns START TRANSACTION -- SetTransactionExpression is used for isolation level settings +- The dialect's format_begin_transaction_statement() only returns START TRANSACTION +- SetTransactionStatement is used for isolation level settings """ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, + BeginTransactionStatement, + CommitStatement, + RollbackStatement, + SavepointStatement, + ReleaseSavepointStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.transaction import IsolationLevel class TestMySQLBeginTransactionExpression: - """Tests for MySQL BeginTransactionExpression. + """Tests for MySQL BeginTransactionStatement. Note: MySQL does not support isolation level in START TRANSACTION. The dialect's supports_isolation_level_in_begin() returns False. - Use SetTransactionExpression to set isolation level before START TRANSACTION. + Use SetTransactionStatement to set isolation level before START TRANSACTION. """ def test_basic_begin(self, mysql_dialect): """Test basic START TRANSACTION.""" - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) sql, params = expr.to_sql() assert sql == "START TRANSACTION" assert params == () @@ -40,11 +40,11 @@ def test_begin_with_isolation_level_returns_only_start(self, mysql_dialect): """Test that isolation level is NOT included in START TRANSACTION. MySQL requires SET TRANSACTION ISOLATION LEVEL to be executed - separately before START TRANSACTION. The dialect's format_begin_transaction() + separately before START TRANSACTION. The dialect's format_begin_transaction_statement() only returns the START TRANSACTION statement. """ - expr = BeginTransactionExpression(mysql_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = BeginTransactionStatement(mysql_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() # MySQL dialect does NOT include isolation level in START TRANSACTION assert sql == "START TRANSACTION" @@ -54,7 +54,7 @@ def test_begin_with_isolation_level_returns_only_start(self, mysql_dialect): def test_begin_read_only(self, mysql_dialect): """Test START TRANSACTION READ ONLY.""" - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.read_only() sql, params = expr.to_sql() assert sql == "START TRANSACTION READ ONLY" @@ -62,7 +62,7 @@ def test_begin_read_only(self, mysql_dialect): def test_begin_read_write(self, mysql_dialect): """Test START TRANSACTION READ WRITE.""" - expr = BeginTransactionExpression(mysql_dialect) + expr = BeginTransactionStatement(mysql_dialect) expr.read_write() sql, params = expr.to_sql() assert sql == "START TRANSACTION" @@ -72,11 +72,11 @@ def test_begin_with_isolation_and_read_only(self, mysql_dialect): """Test START TRANSACTION with isolation level and READ ONLY. The isolation level is ignored by the dialect in START TRANSACTION. - Use SetTransactionExpression for isolation level, then BeginTransactionExpression + Use SetTransactionStatement for isolation level, then BeginTransactionStatement for READ ONLY mode. """ - expr = BeginTransactionExpression(mysql_dialect) - expr.isolation_level(IsolationLevel.READ_COMMITTED).read_only() + expr = BeginTransactionStatement(mysql_dialect) + expr.with_isolation_level(IsolationLevel.READ_COMMITTED).read_only() sql, params = expr.to_sql() # Only READ ONLY mode is included, isolation level is NOT assert sql == "START TRANSACTION READ ONLY" @@ -96,8 +96,8 @@ def test_begin_with_isolation_returns_start_transaction(self, mysql_dialect, lev MySQL requires separate SET TRANSACTION ISOLATION LEVEL statement. """ - expr = BeginTransactionExpression(mysql_dialect) - expr.isolation_level(level) + expr = BeginTransactionStatement(mysql_dialect) + expr.with_isolation_level(level) sql, params = expr.to_sql() # MySQL dialect does NOT include isolation level assert sql == "START TRANSACTION" @@ -105,7 +105,7 @@ def test_begin_with_isolation_returns_start_transaction(self, mysql_dialect, lev class TestMySQLSetTransactionExpression: - """Tests for MySQL SetTransactionExpression. + """Tests for MySQL SetTransactionStatement. MySQL uses SET TRANSACTION ISOLATION LEVEL before START TRANSACTION to set the isolation level for the next transaction. @@ -113,8 +113,8 @@ class TestMySQLSetTransactionExpression: def test_set_isolation_level(self, mysql_dialect): """Test SET TRANSACTION ISOLATION LEVEL.""" - expr = SetTransactionExpression(mysql_dialect) - expr.isolation_level(IsolationLevel.SERIALIZABLE) + expr = SetTransactionStatement(mysql_dialect) + expr.with_isolation_level(IsolationLevel.SERIALIZABLE) sql, params = expr.to_sql() assert sql == "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" assert params == () @@ -130,15 +130,15 @@ def test_set_isolation_level(self, mysql_dialect): ) def test_all_isolation_levels(self, mysql_dialect, level, expected_name): """Test all isolation levels in SET TRANSACTION.""" - expr = SetTransactionExpression(mysql_dialect) - expr.isolation_level(level) + expr = SetTransactionStatement(mysql_dialect) + expr.with_isolation_level(level) sql, params = expr.to_sql() assert expected_name in sql assert params == () def test_set_read_only(self, mysql_dialect): """Test SET TRANSACTION READ ONLY.""" - expr = SetTransactionExpression(mysql_dialect) + expr = SetTransactionStatement(mysql_dialect) expr.read_only() sql, params = expr.to_sql() assert sql == "SET TRANSACTION READ ONLY" @@ -146,7 +146,7 @@ def test_set_read_only(self, mysql_dialect): def test_set_read_write(self, mysql_dialect): """Test SET TRANSACTION READ WRITE.""" - expr = SetTransactionExpression(mysql_dialect) + expr = SetTransactionStatement(mysql_dialect) expr.read_write() sql, params = expr.to_sql() assert sql == "SET TRANSACTION READ WRITE" @@ -158,21 +158,21 @@ class TestMySQLCommitRollback: def test_commit(self, mysql_dialect): """Test COMMIT statement.""" - expr = CommitTransactionExpression(mysql_dialect) + expr = CommitStatement(mysql_dialect) sql, params = expr.to_sql() assert sql == "COMMIT" assert params == () def test_rollback(self, mysql_dialect): """Test ROLLBACK statement.""" - expr = RollbackTransactionExpression(mysql_dialect) + expr = RollbackStatement(mysql_dialect) sql, params = expr.to_sql() assert sql == "ROLLBACK" assert params == () def test_rollback_to_savepoint(self, mysql_dialect): """Test ROLLBACK TO SAVEPOINT statement.""" - expr = RollbackTransactionExpression(mysql_dialect) + expr = RollbackStatement(mysql_dialect) expr.to_savepoint("my_savepoint") sql, params = expr.to_sql() assert "ROLLBACK" in sql @@ -185,7 +185,7 @@ class TestMySQLSavepoint: def test_savepoint(self, mysql_dialect): """Test SAVEPOINT statement.""" - expr = SavepointExpression(mysql_dialect, "my_savepoint") + expr = SavepointStatement(mysql_dialect, "my_savepoint") sql, params = expr.to_sql() assert "SAVEPOINT" in sql assert "my_savepoint" in sql @@ -193,7 +193,7 @@ def test_savepoint(self, mysql_dialect): def test_release_savepoint(self, mysql_dialect): """Test RELEASE SAVEPOINT statement.""" - expr = ReleaseSavepointExpression(mysql_dialect, "my_savepoint") + expr = ReleaseSavepointStatement(mysql_dialect, "my_savepoint") sql, params = expr.to_sql() assert "RELEASE SAVEPOINT" in sql assert "my_savepoint" in sql @@ -238,7 +238,7 @@ def test_supports_transaction_wait_is_declined(self, mysql_dialect): @pytest.mark.parametrize("spelling", ["wait", "no_wait"]) def test_begin_refuses_wait_by_name(self, mysql_dialect, spelling): - expr = BeginTransactionExpression(mysql_dialect, **{spelling: True}) + expr = BeginTransactionStatement(mysql_dialect, **{spelling: True}) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() expected = "NO WAIT" if spelling == "no_wait" else "WAIT" @@ -249,7 +249,7 @@ def test_begin_refuses_wait_by_name(self, mysql_dialect, spelling): @pytest.mark.parametrize("spelling", ["wait", "no_wait"]) def test_set_refuses_wait_by_name(self, mysql_dialect, spelling): - expr = SetTransactionExpression(mysql_dialect, **{spelling: True}) + expr = SetTransactionStatement(mysql_dialect, **{spelling: True}) with pytest.raises(UnsupportedFeatureError) as exc_info: expr.to_sql() expected = "NO WAIT" if spelling == "no_wait" else "WAIT" @@ -259,7 +259,7 @@ def test_set_refuses_wait_by_name(self, mysql_dialect, spelling): assert "NO WAIT" not in message, message def test_neither_spelling_renders_neither_token(self, mysql_dialect): - begin, _ = BeginTransactionExpression(mysql_dialect).to_sql() - set_tx, _ = SetTransactionExpression(mysql_dialect).to_sql() + begin, _ = BeginTransactionStatement(mysql_dialect).to_sql() + set_tx, _ = SetTransactionStatement(mysql_dialect).to_sql() assert begin == "START TRANSACTION" assert set_tx == "SET TRANSACTION" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py new file mode 100644 index 00000000..d28064b9 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py @@ -0,0 +1,185 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_introspection_queries.py +"""The introspection SELECTs, rendered from their nodes. + +These are render-only assertions (org rules §12): a dialect is constructed, a +node is built, and the SQL is read back. No connection is opened. + +What is pinned here is the part of each query that a caller depends on and +that a rewrite can silently change: the **projection order** (the parsers below +read columns positionally in places), the **parameter order** (schema before +name, so ``(schema, table_name)``), and whether a filter is bound or inline. +The identifier quoting, on the other hand, is deliberately not pinned +character for character -- it is the dialect's to choose, and these tests say +only what the query *asks*. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.introspection import ( + ColumnInfoQueryStatement, + DatabaseInfoQueryStatement, + ForeignKeyQueryStatement, + IndexInfoQueryStatement, + TableListQueryStatement, + TriggerListQueryStatement, + ViewInfoQueryStatement, + ViewListQueryStatement, +) +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +@pytest.fixture +def dialect(): + return MySQLDialect(version=(8, 0, 0)) + + +class TestDatabaseInfo: + def test_projection_and_bound_schema(self, dialect): + sql, params = DatabaseInfoQueryStatement(dialect, schema="app").to_sql() + assert sql == ( + "SELECT `DEFAULT_CHARACTER_SET_NAME`, `DEFAULT_COLLATION_NAME` " + "FROM `information_schema`.`SCHEMATA` WHERE `SCHEMA_NAME` = %s" + ) + assert params == ("app",) + + def test_a_missing_schema_is_bound_as_null(self, dialect): + _, params = DatabaseInfoQueryStatement(dialect).to_sql() + assert params == (None,) + + +class TestTableList: + """The relation is ``information_schema.TABLES`` with the filters as nodes.""" + + def test_default_excludes_the_engine_s_own_schemas(self, dialect): + sql, params = TableListQueryStatement(dialect, schema="app").to_sql() + assert sql.startswith( + "SELECT `TABLE_NAME`, `TABLE_TYPE`, `TABLE_COMMENT`, `TABLE_ROWS`, " + "`DATA_LENGTH`, `AUTO_INCREMENT`, `CREATE_TIME`, `UPDATE_TIME` " + "FROM `information_schema`.`TABLES` WHERE `TABLE_SCHEMA` = %s" + ) + # The exclusion list is a fact about the engine, not caller data, so it + # is inline and the parameter tuple stays at one entry. + assert "'information_schema', 'performance_schema', 'mysql', 'sys'" in sql + assert params == ("app",) + + def test_include_system_drops_the_exclusion(self, dialect): + sql, params = TableListQueryStatement(dialect, schema="app", include_system=True).to_sql() + assert "NOT" not in sql + assert params == ("app",) + + def test_without_views_is_an_inline_type_filter(self, dialect): + sql, params = TableListQueryStatement(dialect, schema="app", include_views=False).to_sql() + assert "`TABLE_TYPE` = 'BASE TABLE'" in sql + assert params == ("app",) + + def test_explicit_table_type_is_bound_last(self, dialect): + sql, params = TableListQueryStatement(dialect, schema="app", table_type="VIEW").to_sql() + assert sql.endswith("`TABLE_TYPE` = %s") + assert params == ("app", "VIEW") + + +class TestColumnAndIndexInfo: + def test_columns_projection_order_and_ordering(self, dialect): + sql, params = ColumnInfoQueryStatement(dialect, "users", schema="app").to_sql() + assert sql == ( + "SELECT `COLUMN_NAME`, `ORDINAL_POSITION`, `COLUMN_DEFAULT`, `IS_NULLABLE`, " + "`DATA_TYPE`, `CHARACTER_MAXIMUM_LENGTH`, `NUMERIC_PRECISION`, `NUMERIC_SCALE`, " + "`COLUMN_TYPE`, `COLUMN_KEY`, `EXTRA`, `COLUMN_COMMENT`, " + "`CHARACTER_SET_NAME`, `COLLATION_NAME` " + "FROM `information_schema`.`COLUMNS` " + "WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s " + "ORDER BY `ORDINAL_POSITION`" + ) + assert params == ("app", "users") + + def test_indexes_projection_and_ordering(self, dialect): + sql, params = IndexInfoQueryStatement(dialect, "users", schema="app").to_sql() + assert sql == ( + "SELECT `INDEX_NAME`, `NON_UNIQUE`, `SEQ_IN_INDEX`, `COLUMN_NAME`, " + "`INDEX_TYPE`, `SUB_PART`, `NULLABLE` " + "FROM `information_schema`.`STATISTICS` " + "WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s " + "ORDER BY `INDEX_NAME`, `SEQ_IN_INDEX`" + ) + assert params == ("app", "users") + + +class TestForeignKey: + """Two relations joined, filtered to rows that actually reference a table.""" + + def test_join_projection_and_predicates(self, dialect): + sql, params = ForeignKeyQueryStatement(dialect, "orders", schema="app").to_sql() + assert sql == ( + "SELECT `kcu`.`CONSTRAINT_NAME`, `kcu`.`COLUMN_NAME`, `kcu`.`ORDINAL_POSITION`, " + "`kcu`.`REFERENCED_TABLE_NAME`, `kcu`.`REFERENCED_COLUMN_NAME`, " + "`rc`.`UPDATE_RULE`, `rc`.`DELETE_RULE` " + "FROM `information_schema`.`KEY_COLUMN_USAGE` AS `kcu` " + "JOIN `information_schema`.`REFERENTIAL_CONSTRAINTS` AS `rc` " + "ON `kcu`.`CONSTRAINT_NAME` = `rc`.`CONSTRAINT_NAME` " + "AND `kcu`.`CONSTRAINT_SCHEMA` = `rc`.`CONSTRAINT_SCHEMA` " + "WHERE `kcu`.`TABLE_SCHEMA` = %s AND `kcu`.`TABLE_NAME` = %s " + "AND `kcu`.`REFERENCED_TABLE_NAME` IS NOT NULL " + "ORDER BY `kcu`.`CONSTRAINT_NAME`, `kcu`.`ORDINAL_POSITION`" + ) + assert params == ("app", "orders") + + +class TestViews: + def test_view_list_has_the_system_exclusion_and_ordering(self, dialect): + sql, params = ViewListQueryStatement(dialect, schema="app").to_sql() + assert sql.startswith( + "SELECT `TABLE_NAME`, `VIEW_DEFINITION`, `CHECK_OPTION`, `IS_UPDATABLE` " + "FROM `information_schema`.`VIEWS` WHERE `TABLE_SCHEMA` = %s" + ) + assert "'information_schema', 'performance_schema', 'mysql', 'sys'" in sql + assert sql.endswith("ORDER BY `TABLE_NAME`") + assert params == ("app",) + + def test_view_list_with_system_objects(self, dialect): + sql, params = ViewListQueryStatement(dialect, schema="app", include_system=True).to_sql() + assert "NOT" not in sql + assert params == ("app",) + + def test_view_info_names_one_view(self, dialect): + sql, params = ViewInfoQueryStatement(dialect, "v_users", schema="app").to_sql() + assert sql == ( + "SELECT `TABLE_NAME`, `VIEW_DEFINITION`, `CHECK_OPTION`, `IS_UPDATABLE` " + "FROM `information_schema`.`VIEWS` " + "WHERE `TABLE_SCHEMA` = %s AND `TABLE_NAME` = %s" + ) + assert params == ("app", "v_users") + + +class TestTriggers: + def test_list_all(self, dialect): + sql, params = TriggerListQueryStatement(dialect, schema="app").to_sql() + assert sql == ( + "SELECT `TRIGGER_NAME`, `EVENT_MANIPULATION`, `EVENT_OBJECT_TABLE`, " + "`ACTION_TIMING`, `ACTION_STATEMENT`, `CREATED` " + "FROM `information_schema`.`TRIGGERS` " + "WHERE `TRIGGER_SCHEMA` = %s ORDER BY `TRIGGER_NAME`" + ) + assert params == ("app",) + + def test_list_for_one_table_binds_the_name_last(self, dialect): + sql, params = TriggerListQueryStatement(dialect, schema="app", table_name="users").to_sql() + assert sql.endswith("`EVENT_OBJECT_TABLE` = %s ORDER BY `TRIGGER_NAME`") + assert params == ("app", "users") + + +class TestNoStatementTextSurvives: + """The formatters build nodes; they do not assemble text. + + The concatenated forms this replaced were ``sql = "SELECT ..."`` plus + ``+=``; if one comes back, this fails rather than waiting for a reviewer to + notice a quoted keyword in a formatter. + """ + + def test_formatters_hold_no_sql_text(self): + import inspect + + from rhosocial.activerecord.backend.impl.mysql.mixins import introspection + + source = inspect.getsource(introspection.MySQLIntrospectionMixin) + for keyword in ('"SELECT ', '"FROM ', '"WHERE ', '"ORDER BY ', "sql += "): + assert keyword not in source, f"{keyword!r} is statement text, not a node" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py new file mode 100644 index 00000000..d9e8e855 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py @@ -0,0 +1,195 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_literal_values.py +"""MySQL's spellings for the value literals -- construction and rendering only. + +Core's one literal node owns the binding decision: ``Literal(d, value)`` +binds the host value behind a placeholder, and ``Literal(d, value, +inline=True)`` -- what a DDL syntax position passes, the one that accepts +no bind parameters -- writes the value as SQL text through the dialect's +controlled literal construction. MySQL's own spellings live in the second +tier of that construction, ``MySQLLiteralValueMixin.format_literal``, and +everything else falls through to the base. + +Every literal form asserted here was measured, not transcribed. Measured +on 8.0.46 (scenario ``mysql_80``, 192.168.1.3:14682) and on the server +behind scenario ``mysql_97`` (192.168.1.3:14686, which answers +``SELECT VERSION()`` with ``26.7.0``): + +* ``SELECT X'0102FF'`` returns ``b'\\x01\\x02\\xff'``; a + ``VARBINARY(16) DEFAULT X'0102FF'`` fills the column with those bytes on + insert. Lower-case ``x'0102ff'`` is accepted too; the upper-case spelling + is what this dialect renders. +* ``CHAR(36) DEFAULT '550e8400-e29b-41d4-a716-446655440000'`` reads the + hyphenated canonical text back verbatim -- MySQL has no UUID type, so + the bare quoted text is the whole spelling. +* Every INTERVAL literal attempt is refused with errno 1064 (syntax + error): ``SELECT INTERVAL 1 DAY`` and ``DEFAULT INTERVAL 1 DAY`` in each + column shape tried. ``INTERVAL`` exists only as the keyword parameter of + date arithmetic, so a ``timedelta`` has no branch in this dialect's + ``format_literal`` and the base's explicit ``TypeError`` is the answer. +* The tier-1 spellings the base renders are all accepted: ``DEFAULT TRUE`` + / ``DEFAULT FALSE`` / ``DEFAULT 42`` / ``DEFAULT 1.5`` / ``DEFAULT 1.25`` + / ``DEFAULT 'x''y'`` / ``DEFAULT '2026-10-17'`` / ``DEFAULT '10:30:00'`` + / ``DEFAULT '2026-10-17 10:00:00'`` each read back as the same value. +* Under the default ``sql_mode`` the backslash is an escape character + (measured: the server reads ``'a\\b'`` as ``'a\\x08'``), which is why this + dialect's ``escape_sql_string`` doubles backslashes for every inline + string tier-1 renders, and stops doubling under ``NO_BACKSLASH_ESCAPES``. + +The measurements are re-measured end to end against a running server by +``../test_mysql_literal_values_live.py``: an expression test starts no +connection and asserts what this dialect renders (§12), and the re-measurement +needs a server, so it lives with the scenario-driven tests. +""" + +import datetime +import decimal +import uuid + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +@pytest.fixture +def dialect(): + return MySQLDialect() + + +class TestBinarySpelling: + """``X'0102FF'`` -- the hex form from MySQL's literal grammar.""" + + def test_data_position_binds_the_bytes(self, dialect): + sql, params = Literal(dialect, b"\x01\x02").to_sql() + assert sql == "%s" + assert params == (b"\x01\x02",) + + def test_inline_position_spells_hex(self, dialect): + sql, params = Literal(dialect, b"\x01\x02\xff", inline=True).to_sql() + assert sql == "X'0102FF'" + assert params == () + + def test_the_digits_are_uppercase(self, dialect): + sql, _ = Literal(dialect, b"\xab\xcd\xef", inline=True).to_sql() + assert sql == "X'ABCDEF'" + + def test_an_empty_value_is_the_empty_literal(self, dialect): + sql, _ = Literal(dialect, b"", inline=True).to_sql() + assert sql == "X''" + + def test_bytearray_is_spelled_from_the_bytes_it_holds(self, dialect): + sql, _ = Literal(dialect, bytearray(b"\x01"), inline=True).to_sql() + assert sql == "X'01'" + + +class TestUUIDSpelling: + """The bare quoted canonical text -- all MySQL has is a text home.""" + + CANONICAL = "c73d9175-0a1d-48c6-8d30-df165461328b" + + def test_data_position_binds_the_uuid(self, dialect): + value = uuid.UUID(self.CANONICAL) + sql, params = Literal(dialect, value).to_sql() + assert sql == "%s" + assert params == (value,) + + def test_inline_position_is_the_quoted_canonical_text(self, dialect): + sql, params = Literal(dialect, uuid.UUID(self.CANONICAL), inline=True).to_sql() + assert sql == f"'{self.CANONICAL}'" + assert params == () + + +class TestIntervalStaysRefused: + """No ``timedelta`` branch in this dialect's ``format_literal``, on purpose. + + MySQL refuses every interval literal shape with errno 1064 (measured, + see the module docstring): ``INTERVAL`` is the keyword parameter of + date arithmetic, never a value with a literal form. The base's explicit + ``TypeError`` is the answer, and the structural absence is asserted + rather than papered over. + """ + + def test_data_position_still_binds(self, dialect): + span = datetime.timedelta(days=2, hours=3) + sql, params = Literal(dialect, span).to_sql() + assert sql == "%s" + assert params == (span,) + + def test_inline_position_keeps_the_base_refusal(self, dialect): + with pytest.raises(TypeError, match="has no inline SQL literal form"): + Literal(dialect, datetime.timedelta(days=1), inline=True).to_sql() + + +class TestTierOneAgreesWithTheBase: + """The audit: nothing overridden for the kinds MySQL spells like core. + + Each assertion is the base's spelling reached through this dialect's + ``format_literal`` (so MySQL's escaping applies), pinned here so a base + change that breaks MySQL's grammar is caught by this file rather than + in the matrix, where the row would only say "renders". + """ + + def test_null_binds_then_renders_as_the_keyword(self, dialect): + assert Literal(dialect, None).to_sql() == ("%s", (None,)) + assert Literal(dialect, None, inline=True).to_sql() == ("NULL", ()) + + def test_booleans_render_as_keywords(self, dialect): + assert Literal(dialect, True, inline=True).to_sql() == ("TRUE", ()) + assert Literal(dialect, False, inline=True).to_sql() == ("FALSE", ()) + + def test_integers_render_as_numeric_text(self, dialect): + assert Literal(dialect, 18, inline=True).to_sql() == ("18", ()) + assert Literal(dialect, -7, inline=True).to_sql() == ("-7", ()) + + def test_floats_render_as_their_repr(self, dialect): + assert Literal(dialect, 0.5, inline=True).to_sql() == ("0.5", ()) + assert Literal(dialect, 15e-03, inline=True).to_sql() == ("0.015", ()) + + def test_decimals_render_exact(self, dialect): + value = decimal.Decimal("12.34") + assert Literal(dialect, value, inline=True).to_sql() == ("12.34", ()) + + def test_strings_double_the_quotes(self, dialect): + sql, _ = Literal(dialect, "today's", inline=True).to_sql() + assert sql == "'today''s'" + + def test_strings_double_the_backslash_under_the_default_sql_mode(self, dialect): + # The server reads a single backslash as an escape (measured: + # 'a\b' comes back as 'a\x08'), so the dialect doubles backslashes + # under the default mode for the literal to mean what the host holds. + sql, _ = Literal(dialect, "a\\b", inline=True).to_sql() + assert sql == "'a\\\\b'" + + def test_strings_keep_the_backslash_under_no_backslash_escapes(self): + dialect = MySQLDialect(sql_mode="NO_BACKSLASH_ESCAPES,STRICT_TRANS_TABLES") + sql, _ = Literal(dialect, "a\\b", inline=True).to_sql() + assert sql == "'a\\b'" + + def test_dates_render_as_quoted_iso(self, dialect): + sql, _ = Literal(dialect, datetime.date(2026, 10, 17), inline=True).to_sql() + assert sql == "'2026-10-17'" + + def test_times_render_as_quoted_iso(self, dialect): + sql, _ = Literal(dialect, datetime.time(10, 3, 56), inline=True).to_sql() + assert sql == "'10:03:56'" + + def test_datetimes_render_as_quoted_iso_with_a_space_separator(self, dialect): + sql, _ = Literal( + dialect, datetime.datetime(2024, 8, 15, 10, 59, 43), inline=True + ).to_sql() + assert sql == "'2024-08-15 10:59:43'" + + +class TestMROOrder: + """The composed order is what resolves the tier-2 spellings.""" + + def test_the_local_mixin_wins_over_the_base(self, dialect): + assert dialect.format_literal.__func__.__qualname__.startswith( + "MySQLLiteralValueMixin" + ) + + def test_inline_literals_route_through_the_local_mixin(self, dialect): + # format_literal_expression hands inline=True literals to + # format_literal, so the MRO test and the rendering test agree. + sql, _ = Literal(dialect, b"\x01", inline=True).to_sql() + assert sql == "X'01'" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py new file mode 100644 index 00000000..594d70c8 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py @@ -0,0 +1,285 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression/test_mysql_show_statements.py +"""MySQL's SHOW statements, rendered from their nodes. + +Two things are asserted here, and they are the two the wave asked for: + +* **Each SHOW command is a node with constructor parameters.** No fluent + setters remain, so an instance is complete the moment it is built and + ``get_params()`` is the whole artifact. Every case therefore also rebuilds + the node from its own parameters and requires the same SQL -- a parameter the + class forgot to store under its own name fails there rather than surviving as + a silently dropped filter. +* **The rendering is the dialect's.** These tests construct a dialect and + render; they open no connection (org rules §12). + +The parameter positions are part of the assertion: ``SHOW COLUMNS FROM t LIKE +%s`` binds the pattern, while ``SHOW WARNINGS LIMIT n`` inlines the count -- +the command grammar takes a literal there, so a placeholder would be a syntax +error rather than a parameter. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression.show import ( + ShowBinaryLogsStatement, + ShowCharsetStatement, + ShowCollationStatement, + ShowColumnsStatement, + ShowCreateTableStatement, + ShowCreateTriggerStatement, + ShowCreateViewStatement, + ShowDatabasesStatement, + ShowEnginesStatement, + ShowErrorsStatement, + ShowExpression, + ShowGrantsStatement, + ShowIndexStatement, + ShowMasterStatusStatement, + ShowPluginsStatement, + ShowProcessListStatement, + ShowSlaveStatusStatement, + ShowStatusStatement, + ShowTableStatusStatement, + ShowTablesStatement, + ShowTriggersStatement, + ShowVariablesStatement, + ShowWarningsStatement, +) + + +@pytest.fixture +def dialect(): + return MySQLDialect(version=(8, 0, 0)) + + +class TestConstructorOnly: + """Every parameter is a constructor parameter; nothing is filled in later.""" + + def test_no_fluent_setter_survives(self): + setters = [ + name + for name in dir(ShowExpression) + if name.startswith("with_") or name in ("schema", "like", "full") + ] + assert setters == [] + + @pytest.mark.parametrize( + "build", + [ + lambda d: ShowCreateTableStatement(d, "t"), + lambda d: ShowCreateViewStatement(d, "v"), + lambda d: ShowColumnsStatement(d, "t", full=True, like_pattern="id%"), + lambda d: ShowIndexStatement(d, "t"), + lambda d: ShowTablesStatement(d, full=True, like_pattern="t%"), + lambda d: ShowDatabasesStatement(d, like_pattern="a%"), + lambda d: ShowTableStatusStatement(d, like_pattern="t%"), + lambda d: ShowTriggersStatement(d, table_name="t"), + lambda d: ShowCreateTriggerStatement(d, "trg"), + lambda d: ShowVariablesStatement(d, like_pattern="max%"), + lambda d: ShowStatusStatement(d, like_pattern="Ssl_version", session=False), + lambda d: ShowProcessListStatement(d, full=True), + lambda d: ShowWarningsStatement(d, limit=3), + lambda d: ShowErrorsStatement(d, limit=3), + lambda d: ShowEnginesStatement(d), + lambda d: ShowCharsetStatement(d, like_pattern="utf%"), + lambda d: ShowCollationStatement(d, like_pattern="utf8%"), + lambda d: ShowGrantsStatement(d, user="root", host="localhost"), + lambda d: ShowPluginsStatement(d), + lambda d: ShowBinaryLogsStatement(d), + lambda d: ShowMasterStatusStatement(d), + lambda d: ShowSlaveStatusStatement(d), + ], + ids=lambda build: build(MySQLDialect(version=(8, 0, 0))).__class__.__name__, + ) + def test_params_rebuild_the_same_statement(self, dialect, build): + expr = build(dialect) + expected = expr.to_sql() + rebuilt = type(expr)(dialect, **expr.get_params()) + assert rebuilt.to_sql() == expected + + +class TestShowCreate: + """``SHOW CREATE ...`` names one object, database included.""" + + def test_unqualified_table(self, dialect): + sql, params = ShowCreateTableStatement(dialect, "users").to_sql() + assert sql == "SHOW CREATE TABLE `users`" + assert params == () + + def test_database_qualifies_the_table(self, dialect): + sql, _ = ShowCreateTableStatement(dialect, "users", schema="app").to_sql() + assert sql == "SHOW CREATE TABLE `app`.`users`" + + def test_view(self, dialect): + sql, _ = ShowCreateViewStatement(dialect, "v", schema="app").to_sql() + assert sql == "SHOW CREATE VIEW `app`.`v`" + + def test_trigger(self, dialect): + sql, _ = ShowCreateTriggerStatement(dialect, "trg").to_sql() + assert sql == "SHOW CREATE TRIGGER `trg`" + + +class TestShowColumnsAndIndex: + """The pattern is bound; FULL and the verb are spelled, not parameterised.""" + + def test_plain(self, dialect): + sql, params = ShowColumnsStatement(dialect, "users", schema="app").to_sql() + assert sql == "SHOW COLUMNS FROM `app`.`users`" + assert params == () + + def test_full_and_like(self, dialect): + sql, params = ShowColumnsStatement( + dialect, "users", full=True, like_pattern="id%" + ).to_sql() + assert sql == "SHOW FULL COLUMNS FROM `users` LIKE %s" + assert params == ("id%",) + + def test_index(self, dialect): + sql, params = ShowIndexStatement(dialect, "users", schema="app").to_sql() + assert sql == "SHOW INDEX FROM `app`.`users`" + assert params == () + + +class TestShowTablesAndDatabases: + def test_tables_full_like_and_schema(self, dialect): + sql, params = ShowTablesStatement( + dialect, schema="app", full=True, like_pattern="u%" + ).to_sql() + assert sql == "SHOW FULL TABLES FROM `app` LIKE %s" + assert params == ("u%",) + + def test_tables_without_filters(self, dialect): + assert ShowTablesStatement(dialect).to_sql() == ("SHOW TABLES", ()) + + def test_databases_like(self, dialect): + assert ShowDatabasesStatement(dialect, like_pattern="a%").to_sql() == ( + "SHOW DATABASES LIKE %s", + ("a%",), + ) + + def test_databases_without_pattern(self, dialect): + assert ShowDatabasesStatement(dialect).to_sql() == ("SHOW DATABASES", ()) + + def test_table_status(self, dialect): + assert ShowTableStatusStatement(dialect, schema="app", like_pattern="u%").to_sql() == ( + "SHOW TABLE STATUS FROM `app` LIKE %s", + ("u%",), + ) + + +class TestShowVariablesAndStatus: + """Session is the default; the GLOBAL word is the difference.""" + + def test_variables_session(self, dialect): + assert ShowVariablesStatement(dialect, like_pattern="max%").to_sql() == ( + "SHOW VARIABLES LIKE %s", + ("max%",), + ) + + def test_variables_global(self, dialect): + assert ShowVariablesStatement( + dialect, like_pattern="gtid_executed", session=False + ).to_sql() == ("SHOW GLOBAL VARIABLES LIKE %s", ("gtid_executed",)) + + def test_status_session(self, dialect): + assert ShowStatusStatement(dialect, like_pattern="Ssl_version").to_sql() == ( + "SHOW STATUS LIKE %s", + ("Ssl_version",), + ) + + def test_status_global(self, dialect): + assert ShowStatusStatement(dialect, like_pattern="Ssl_version", session=False).to_sql() == ( + "SHOW GLOBAL STATUS LIKE %s", + ("Ssl_version",), + ) + + +class TestShowTriggers: + """``SHOW TRIGGERS`` filters by table through LIKE, not through a clause.""" + + def test_all(self, dialect): + assert ShowTriggersStatement(dialect, schema="app").to_sql() == ( + "SHOW TRIGGERS FROM `app`", + (), + ) + + def test_one_table(self, dialect): + assert ShowTriggersStatement(dialect, schema="app", table_name="users").to_sql() == ( + "SHOW TRIGGERS FROM `app` LIKE %s", + ("users",), + ) + + +class TestLimitAndFull: + """The count is a literal: the grammar takes a number, not a bind slot.""" + + def test_processlist(self, dialect): + assert ShowProcessListStatement(dialect).to_sql() == ("SHOW PROCESSLIST", ()) + + def test_full_processlist(self, dialect): + assert ShowProcessListStatement(dialect, full=True).to_sql() == ( + "SHOW FULL PROCESSLIST", + (), + ) + + def test_warnings_limit_is_inline(self, dialect): + assert ShowWarningsStatement(dialect, limit=10).to_sql() == ( + "SHOW WARNINGS LIMIT 10", + (), + ) + + def test_errors_limit_is_inline(self, dialect): + assert ShowErrorsStatement(dialect, limit=5).to_sql() == ( + "SHOW ERRORS LIMIT 5", + (), + ) + + +class TestParameterless: + """Commands with no parameters at all -- each its own node.""" + + @pytest.mark.parametrize( + "node, expected", + [ + (ShowEnginesStatement, "SHOW ENGINES"), + (ShowPluginsStatement, "SHOW PLUGINS"), + (ShowBinaryLogsStatement, "SHOW BINARY LOGS"), + (ShowMasterStatusStatement, "SHOW MASTER STATUS"), + (ShowSlaveStatusStatement, "SHOW SLAVE STATUS"), + ], + ids=lambda value: getattr(value, "__name__", ""), + ) + def test_renders(self, dialect, node, expected): + assert node(dialect).to_sql() == (expected, ()) + + +class TestGrants: + def test_current_account(self, dialect): + assert ShowGrantsStatement(dialect).to_sql() == ("SHOW GRANTS", ()) + + def test_named_account(self, dialect): + assert ShowGrantsStatement(dialect, user="root", host="localhost").to_sql() == ( + "SHOW GRANTS FOR %s@%s", + ("root", "localhost"), + ) + + def test_user_without_host(self, dialect): + assert ShowGrantsStatement(dialect, user="root").to_sql() == ( + "SHOW GRANTS FOR %s", + ("root",), + ) + + +class TestCharsetAndCollation: + def test_charset(self, dialect): + assert ShowCharsetStatement(dialect, like_pattern="utf%").to_sql() == ( + "SHOW CHARACTER SET LIKE %s", + ("utf%",), + ) + + def test_collation(self, dialect): + assert ShowCollationStatement(dialect, like_pattern="utf8%").to_sql() == ( + "SHOW COLLATION LIKE %s", + ("utf8%",), + ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py new file mode 100644 index 00000000..f1f2f5bf --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py @@ -0,0 +1,1216 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/expression_roundtrip_harness.py +""" +Expression serialization round-trip / contract test helpers (MySQL-local copy). + +Vendored from the shared testsuite at +``python-activerecord-testsuite@4ef2551`` (formerly +``src/rhosocial/activerecord/testsuite/utils/expression.py``) when that suite's +``fix/column-constraint-gates`` branch dropped the module in ``e05edb0`` with +the direction "round-trip coverage moves to the owning repository": the suite +keeps the ActiveRecord-layer contracts and must not grow an opinion about +which expressions exist, so each backend now owns the harness its own matrix +runs on. Fix drift here deliberately; do not re-import it from the suite. + +This module is the test-only home (NOT shipped as part of the core library's +runtime API) for constructing expression instances and asserting they survive +all three serializations. + +After registering a class into the ExpressionRegistry, expression instances +must satisfy: + +- ``get_params()`` reflects every ``__init__`` parameter +- A round-trip through the three encoding channels returns a structurally + equivalent instance (``get_params()`` equal) +- When the chosen backend's dialect supports ``to_sql()``, the SQL keyword + rendering is identical +""" + +import collections.abc +import dataclasses +import inspect +import re +import typing +import warnings +from typing import Any, Callable, Dict, Optional, Type + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraintAction`` +# are constraint actions, not relations, and a substring test would hand them +# a Table. ``SchemaObject`` is the base every catalogue object shares, so an +# annotation naming it wants an object too. +_MENTIONS_RELATION = re.compile( + r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" +) + +# An INSERT's data source. It is an expression now, so it needs a real one -- +# the generic placeholder cannot invent the rows a VALUES list is made of. +_MENTIONS_INSERT_SOURCE = re.compile(r"\bInsertDataSource\b") + + +def _empty_container_for(annotation: Any, dialect: Any): + """Return an empty container for a parameterised alias, else None. + + A parameterised alias like ``List[str]`` or ``Dict[str, BaseExpression]`` + says what the *elements* are, never the container itself, so there is no + catalogue object to hand back -- only an empty container. Deciding this + before the relation-name test is what keeps ``typing.List[str]`` from being + read as a ``Table``: ``typing.get_origin`` on it gives ``list``, but the + relation test only ever saw the alias' own name, and ``List[str]`` matches + ``Sequence``/``Table``-family fragments well enough to be mistaken for one. + + Returns ``None`` when ``annotation`` is not a parameterised container, which + is the caller's signal to keep reading. + """ + origin = typing.get_origin(annotation) + if origin is None: + return None + if origin in (list, tuple, set, frozenset): + return [] + if origin is dict: + return {} + # ``Sequence[str]`` and friends resolve to the collections.abc ABC rather + # than to ``list``, so match on the abstract base instead of the concrete + # one the annotation happens to name. + if isinstance(origin, type) and issubclass(origin, (collections.abc.Sequence, collections.abc.Set)): + return [] + if isinstance(origin, type) and issubclass(origin, collections.abc.Mapping): + return {} + return None + + +def _mentions_expression_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names an expression node, whatever it is called. + + This used to be a substring test for ``"Expression"``, which is a coupling + to the class *names*: renaming ``QueryExpression`` to ``QueryStatement`` + (规范 4.9: a statement is a statement) silently stopped matching, the + parameter fell through to the ``"x"`` fallback, and the failure surfaced + later as ``'str' object has no attribute 'to_sql'`` from inside a + formatter. The structural question -- does this annotation name a + ``BaseExpression`` subclass? -- does not care what the class is called. + """ + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + candidates = [raw] + if typing.get_origin(annotation) is not None: + candidates.extend(typing.get_args(annotation)) + for candidate in candidates: + if isinstance(candidate, type) and issubclass(candidate, BaseExpression): + return True + return False + + +def _is_predicate_class(annotation: Any, raw: Any) -> bool: + """Whether the annotation names a predicate node, whatever it is called. + + Same structural question as :func:`_mentions_expression_class` answers for + expressions, asked of the predicate family: a rename inside the family must + not change what this heuristic builds either. + """ + from rhosocial.activerecord.backend.expression.bases import SQLPredicate + + for candidate in (raw, annotation): + if isinstance(candidate, type) and issubclass(candidate, SQLPredicate): + return True + return False + + +def _placeholder_for(param: inspect.Parameter, dialect: Any = None): + """Return a heuristic construction value for a required parameter.""" + annotation = param.annotation + if annotation is not inspect.Parameter.empty: + origin = typing.get_origin(annotation) + raw = annotation if origin is None else origin + if isinstance(annotation, str): + text = annotation + else: + text = getattr(raw, "__name__", "") + container = _empty_container_for(annotation, dialect) + if container is not None: + return container + if raw in (list, tuple, set): + return [] + if raw is dict: + return {} + if _MENTIONS_RELATION.search(text): + return _relation(dialect, text) + if _MENTIONS_INSERT_SOURCE.search(text): + return _values_source(dialect) + if raw is str or "str" in text: + return "x" + # A typed value expression wraps the call it renders, so the parameter + # wants a FunctionCall and not a bare name. This must be tested before + # the str branch above: "FunctionCall" contains no "str", but the + # fallback below returns "x" for it, which the wrapper then calls + # to_sql() on. + if "FunctionCall" in text: + return _function_call(dialect) + if "DataType" in text or "Type" in text.split(".")[-1:]: + from rhosocial.activerecord.backend.expression.types import IntegerType + + return IntegerType(dialect) + if raw is int: + return 1 + if raw is float: + return 1.0 + if raw is bool: + return True + # A predicate slot wants a predicate, not a value. SQLPredicate is a + # BaseExpression subclass like the values are, so the structural + # expression branch below hands it a Literal -- and the constructors + # that type a parameter as a predicate refuse a Literal on their + # isinstance check (core's DomainCheckConstraint.condition is the one + # in the matrix today). Asked structurally when the annotation + # resolves; the name test beside it covers forward references. + if (not isinstance(annotation, str) and _is_predicate_class(annotation, raw)) or ( + "Predicate" in text + ): + return _comparison(dialect) + # Asked structurally when the annotation resolves: what a class is + # called says nothing about what it is, and a rename must not change + # what this heuristic builds. The substring tests beside it remain for + # forward references, which introspection cannot resolve -- and they + # must list every name the library might grow, which is exactly why + # the structural test is the one that leads. + if ( + "Expression" in text + or "Subquery" in text + or "Statement" in text + or (not isinstance(annotation, str) and _mentions_expression_class(annotation, raw)) + ): + return _literal(dialect) + + name = param.name + if name in ("value",): + return 1 + if name in ("values", "columns", "args", "params", "predicates", "expressions"): + return [] + if name in ("left", "right") or "predicate" in name or "condition" in name: + return _comparison(dialect) + if "expression" in name or name in ("expr", "operand", "subquery", "query"): + return _literal(dialect) + return "x" + + +def _literal(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + + return Literal(dialect, 1) + + +def _function_call(dialect): + """A niladic function call, for a parameter that wraps one. + + The typed value expressions (``BooleanValue`` and its siblings) + hold a ``call`` and render it through ``to_sql()``. Handing them a string + builds an instance that cannot render, and the failure surfaces as + ``'str' object has no attribute 'to_sql'`` from inside the wrapper rather + than as a construction problem. + """ + from rhosocial.activerecord.backend.expression.core import FunctionCall + + return FunctionCall(dialect, "FUNC", niladic=True) + + +def _comparison(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + return ComparisonPredicate(dialect, "=", Column(dialect, "a"), _literal(dialect)) + + +def _try_construct(cls, dialect): + """Construct an instance of cls with heuristic arguments; None on failure.""" + sig = inspect.signature(cls.__init__) + # DataType-family declares dialect as an optional trailing keyword. + has_dialect = "dialect" in sig.parameters + first_param = next((p for p in sig.parameters if p != "self"), None) + dialect_as_kw = first_param != "dialect" + args = [] + kwargs = {} + skipped_defaulted_positional = False + for pname, param in sig.parameters.items(): + if pname in ("self", "dialect"): + continue + if param.kind == inspect.Parameter.VAR_KEYWORD: + continue + if param.kind == inspect.Parameter.VAR_POSITIONAL: + if not skipped_defaulted_positional: + args.append(_literal(dialect)) + continue + if param.default is not inspect.Parameter.empty: + if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + skipped_defaulted_positional = True + continue + placeholder = _placeholder_for(param, dialect) + # placeholder None → default case; real value provided otherwise + if param.kind == inspect.Parameter.KEYWORD_ONLY: + kwargs[pname] = placeholder + else: + args.append(placeholder) + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if not has_dialect: + return cls(*args, **kwargs) + if dialect_as_kw: + return cls(*args, dialect=dialect, **kwargs) + return cls(dialect, *args, **kwargs) + except Exception: + return None + + +def special_constructors(): + """Explicit constructors for classes whose ``__init__`` rejects heuristic + placeholder values. Keyed by a suffix that matches the class's full + module.ClassName. Registered by name only — this is a static registry and + backends may register their own entries for backend-specific expressions. + + **A key that matches no class is dead code, and nothing says so.** + :func:`make_instance` dispatches on ``full_name.endswith(key)``, so + renaming a class turns its entry into a literal that matches nothing: the + class then falls through to :func:`_try_construct`, the source is reported + as ``"heuristic"`` instead of ``"special"``, and if the heuristic also + fails the class is silently reported as unconstructible rather than as + mis-registered. This is how ``"advanced_functions.JSONExpression"`` came to + match nothing at all once core split that one class into + ``JSONDocumentValue`` and ``JSONTextValue``. + + The failure is one-directional, which is what makes it dangerous: renaming + the key once breaks the match silently, and a *single* key covering both + new names is not expressible, because ``endswith`` compares a suffix. A + class that was split needs one entry per resulting class. + + :func:`register_special_constructor` writes to the module-level + ``_SPECIAL_REGISTRY``, which is what :func:`make_instance` reads. Note that + this function rebuilds its dict on every call, so a registration made + elsewhere is **not** visible to a caller that invokes + ``special_constructors()`` directly — core's expression-contract test does + exactly that, and therefore sees only the literals below. + """ + return { + "statements.ddl_table.ColumnDefinition": _column_definition, + "statements.ddl_table.ColumnConstraint": _column_constraint, + "statements.ddl_table.ForeignKeyConstraint": _foreign_key_constraint, + "statements.ddl_table.TableConstraint": _table_constraint, + "statements.ddl_table.ColumnForeignKeyConstraint": _column_fk_constraint, + "statements.ddl_table.TableForeignKeyConstraint": _table_fk_constraint, + "statements.ddl_table.TablePrimaryKeyConstraint": _table_primary_key, + "statements.ddl_table.TableUniqueConstraint": _table_unique, + "statements.dml.ValuesSource": _values_source, + "statements.ddl_sequence.CreateSequenceStatement": _create_sequence_expr, + "statements.ddl_sequence.DropSequenceStatement": _drop_sequence_expr, + "statements.ddl_sequence.AlterSequenceStatement": _alter_sequence_expr, + "statements.ddl_type.CreateTypeStatement": _create_type_expr, + "statements.ddl_type.AlterTypeStatement": _alter_type_expr, + "statements.ddl_type.DropTypeStatement": _drop_type_expr, + "statements.ddl_domain.CreateDomainStatement": _create_domain_expr, + "statements.ddl_domain.AlterDomainStatement": _alter_domain_expr, + "statements.ddl_domain.DropDomainStatement": _drop_domain_expr, + "statements.ddl_function.CreateFunctionStatement": _create_function_expr, + "statements.ddl_function.DropFunctionStatement": _drop_function_expr, + "statements.ddl_schema.CreateSchemaStatement": _create_schema_expr, + "statements.ddl_schema.DropSchemaStatement": _drop_schema_expr, + "statements.ddl_database.CreateDatabaseStatement": _create_database_expr, + "statements.ddl_database.DropDatabaseStatement": _drop_database_expr, + # core splits what was one JSONExpression into a document and a text + # class, so one key cannot cover both: endswith matches a suffix. + "advanced_functions.JSONDocumentValue": _json_document_expr, + "advanced_functions.JSONTextValue": _json_text_expr, + "query_parts.JoinClause": _join_expr, + "statements.ddl_partition.PartitionClause": _partition_clause, + "statements.dml.OnConflictClause": _on_conflict, + "statements.dml.UpdateStatement": _update_expr, + "statements.dml.InsertStatement": _insert_expr, + "statements.dml.DeleteStatement": _delete_expr, + "statements.ddl_alter.AlterTableStatement": _alter_table_expr, + "types.array.ArrayType": _array_type, + "statements.ddl_view.CreateViewStatement": _create_view_expr, + "graph.GraphVertex": _graph_vertex, + "graph.GraphEdge": _graph_edge, + "graph.VertexTable": _node_table, + "graph.EdgeTable": _edge_table, + "graph.GraphTableExpression": _graph_table_expr, + "graph.CreatePropertyGraphStatement": _create_property_graph_expr, + "graph.DropPropertyGraphStatement": _drop_property_graph_expr, + "graph.AlterPropertyGraphStatement": _alter_property_graph_expr, + "datetime.ExtractExpression": _extract_expr, + "datetime.DatePartExpression": _datepart_expr, + "datetime.DateTruncExpression": _datetrunc_expr, + "datetime.IntervalExpression": _interval_expr, + "datetime.DatetimeDiffExpression": _datetime_diff_expr, + "datetime.DatetimeSubtractExpression": _datetime_sub_expr, + "datetime.DatetimeAddExpression": _datetime_add_expr, + } + + +def _column_definition(dialect): + from rhosocial.activerecord.backend.expression.statements import ColumnDefinition + from rhosocial.activerecord.backend.expression.types import IntegerType + + return ColumnDefinition(dialect, "col", IntegerType(dialect)) + + +# ---------------------------------------------------------------- constraint nodes +# A constraint is a clause plus the branch that says which kind it is, so the +# two levels need a branch each: the clause cannot be built without saying what +# it constrains, and the branches whose payload is a relation or a column list +# need one the heuristic guess has no way to supply. + + +def _column_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnNotNullConstraint, + ) + + return ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)) + + +def _foreign_key_constraint(dialect): + """The construction sugar over a table-level FOREIGN KEY branch. + + Same reason as the branch itself: what it points at has to be a real + ``Table``, which a guessed placeholder cannot be. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ForeignKeyConstraint, + ) + + return ForeignKeyConstraint( + dialect, + columns=["col"], + foreign_key_table=Table(dialect, "parent"), + foreign_key_columns=["id"], + ) + + +def _table_constraint(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraint, + TableUniqueConstraint, + ) + + return TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["col"])) + + +def _column_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + ) + + return ColumnForeignKeyConstraint(dialect, (Table(dialect, "parent"), ["id"])) + + +def _table_fk_constraint(dialect): + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.statements import ( + TableForeignKeyConstraint, + ) + + return TableForeignKeyConstraint(dialect, ["col"], (Table(dialect, "parent"), ["id"])) + + +def _table_primary_key(dialect): + from rhosocial.activerecord.backend.expression.statements import ( + TablePrimaryKeyConstraint, + ) + + return TablePrimaryKeyConstraint(dialect, ["col"]) + + +def _table_unique(dialect): + from rhosocial.activerecord.backend.expression.statements import TableUniqueConstraint + + return TableUniqueConstraint(dialect, ["col"]) + + +def _json_document_expr(dialect): + """``->``: a JSON *document*, reached by path. + + ``path`` and ``operation`` keep their defaults, so this renders exactly + what the pre-split single ``JSONExpression`` factory rendered. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONDocumentValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONDocumentValue(dialect, Literal(dialect, '{"a": 1}'), path="$.a") + + +def _json_text_expr(dialect): + """``->>``: the *text* of a JSON path. + + ``JSONTextValue`` declares no ``__init__`` of its own, so it inherits + the document class's -- including ``operation="->"``. The default is + therefore inherited too, and this factory has to say ``->>`` explicitly. + Leaving it off would hand the contract test a ``JSONTextValue`` whose + ``operation`` says ``->``, which is the one thing the class exists to + distinguish. + + The rendered SQL is unaffected either way: the formatters branch on + ``expr.operation``, not on the class, and dispatch is on ``format_method``, + which both classes share. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import ( + JSONTextValue, + ) + from rhosocial.activerecord.backend.expression.core import Literal + + return JSONTextValue( + dialect, Literal(dialect, '{"a": 1}'), path="$.a", operation="->>" + ) + + +def _values_source(dialect): + """A ``VALUES`` clause with one row of one column. + + ``ValuesSource`` rejects an empty ``values_list``, and the heuristic filler + returns ``[]`` for any ``list``-origin annotation -- which is why this class + needs an explicit constructor at all. ``[]`` would also be the wrong thing + to satisfy it with: a ``VALUES`` clause with no rows is not an INSERT + source, and the zero-row spelling already has its own class, + ``DefaultValuesSource``, sitting next to this one. + """ + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ValuesSource + + return ValuesSource(dialect, [[Literal(dialect, 1)]]) + + +def _array_type(dialect): + """An array of integers -- one dimension. + + ``ArrayType`` requires an ``element_type`` and raises ``TypeError`` without + one ("an array with no element type is not a type"). Two things stop the + heuristic filler from reaching that requirement, and neither is fixable by + giving the parameter a default: + + * the filler skips defaulted parameters, so it calls ``ArrayType(dialect)`` + with nothing else; and + * ``dialect`` must come first and ``element_type`` must follow it, so a + *required* ``element_type`` after a defaulted ``dialect`` is not + expressible in Python at all. + + Making the parameter required therefore cannot help, and a default of + ``None`` is already what core declares. So the element type has to be + supplied here, as a concrete ``DataType``. + """ + from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + IntegerType, + ) + + return ArrayType(dialect, element_type=IntegerType(dialect)) + + +def _sequence(dialect): + """A sequence, by kind.""" + from rhosocial.activerecord.backend.expression.objects import Sequence + + return Sequence(dialect, "s") + + +def _create_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + CreateSequenceStatement, + ) + + return CreateSequenceStatement(dialect, _sequence(dialect)) + + +def _drop_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + DropSequenceStatement, + ) + + return DropSequenceStatement(dialect, _sequence(dialect)) + + +def _alter_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceStatement, + ) + + return AlterSequenceStatement(dialect, _sequence(dialect)) + + +def _create_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeDefinition + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + CreateTypeStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateTypeStatement( + dialect, Type(dialect, "t"), _DummyTypeDefinition(dialect, IntegerType(dialect)) + ) + + +def _alter_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeStatement, + ) + + return AlterTypeStatement( + dialect, Type(dialect, "t"), [_DummyTypeAlterAction(dialect, "t2")] + ) + + +def _drop_type_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeStatement, + ) + + return DropTypeStatement(dialect, Type(dialect, "t")) + + +def _create_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + CreateDomainStatement, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateDomainStatement(dialect, Domain(dialect, "d"), IntegerType(dialect)) + + +def _alter_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainStatement, + SetDomainDefaultAction, + ) + + return AlterDomainStatement( + dialect, Domain(dialect, "d"), [SetDomainDefaultAction(dialect, Literal(dialect, 1))] + ) + + +def _drop_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DropDomainStatement, + ) + + return DropDomainStatement(dialect, Domain(dialect, "d")) + + +def _create_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + CreateFunctionStatement, + ) + + return CreateFunctionStatement(dialect, Function(dialect, "f")) + + +def _drop_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + DropFunctionStatement, + ) + + return DropFunctionStatement(dialect, Function(dialect, "f")) + + +def _create_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaStatement, + ) + + return CreateSchemaStatement(dialect, Schema(dialect, "s")) + + +def _drop_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaStatement, + ) + + return DropSchemaStatement(dialect, Schema(dialect, "s")) + + +def _create_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseStatement, + ) + + return CreateDatabaseStatement(dialect, Database(dialect, "d")) + + +def _drop_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + DropDatabaseStatement, + ) + + return DropDatabaseStatement(dialect, Database(dialect, "d")) + + +def _node_table(dialect): + """A graph vertex table.""" + from rhosocial.activerecord.backend.expression.graph import VertexTable + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return VertexTable(dialect, NodeTable(dialect, "n")) + + +def _edge_table(dialect): + """A graph edge table declaration.""" + from rhosocial.activerecord.backend.expression.graph import EdgeTable + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return EdgeTable(dialect, EdgeTableObject(dialect, "e"), ["s"], ["d"]) + + +def _graph_vertex(dialect): + from rhosocial.activerecord.backend.expression.graph import GraphVertex + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return GraphVertex(dialect, "v", NodeTable(dialect, "n")) + + +def _graph_edge(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + GraphEdge, + GraphEdgeDirection, + ) + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return GraphEdge(dialect, "e", EdgeTableObject(dialect, "e"), + GraphEdgeDirection.RIGHT) + + +def _graph_table_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + ColumnsClause, + GraphColumn, + GraphTableExpression, + MatchClause, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return GraphTableExpression( + dialect, + PropertyGraph(dialect, "g"), + MatchClause( + dialect, + _graph_vertex(dialect), + _graph_edge(dialect), + _graph_vertex(dialect), + ), + ColumnsClause(dialect, GraphColumn("v", "name")), + ) + + +def _create_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return CreatePropertyGraphStatement( + dialect, PropertyGraph(dialect, "g"), [_node_table(dialect)] + ) + + +def _drop_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + DropPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return DropPropertyGraphStatement(dialect, PropertyGraph(dialect, "g")) + + +def _alter_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphStatement, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return AlterPropertyGraphStatement( + dialect, + PropertyGraph(dialect, "g"), + "ADD", + "VERTEX TABLES", + vertex_tables=[_node_table(dialect)], + ) + + +def _table(dialect, name="t"): + """The catalogue object a statement acts on.""" + from rhosocial.activerecord.backend.expression.objects import Table + + return Table(dialect, name) + + +def _relation(dialect, annotation): + """The object an annotation asks for, chosen by the kind it names. + + The kind is not decoration: CREATE VIEW will not accept a Table, and DROP + MATERIALIZED VIEW will not accept a View. Longest name first, because + ``MaterializedView`` contains ``View`` and the more specific kind is the one + an annotation naming it means. + """ + import rhosocial.activerecord.backend.expression.objects as objects + + for kind in ("MaterializedView", "ForeignTable", "NodeTable", "EdgeTable", + "Sequence", "Index", "View", "Table", "Type", "Domain", + "Function", "Trigger", "Schema", "Database"): + if re.search(rf"\b{kind}\b", annotation): + return getattr(objects, kind)(dialect, "t") + return _table(dialect) + + +def _source(dialect, name="t"): + """A row source over the object a statement acts on. + + A statement that *names* a relation takes the object; a statement that + *reads rows from* one takes a source over it. The two are not + interchangeable, so they get their own helper rather than one that guesses + which position it is filling. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + + return NamedRelationRef(dialect, Table(dialect, name)) + + +def _join_expr(dialect): + from rhosocial.activerecord.backend.expression.query_parts import JoinClause + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.core import Column + + return JoinClause( + dialect, + left_table=_source(dialect, "a"), + right_table=_source(dialect, "b"), + condition=ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")), + ) + + +def _partition_clause(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, + ) + + return PartitionClause(dialect, method=PartitionStrategy.RANGE, keys=[Column(dialect, "created_at")]) + + +def _on_conflict(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import OnConflictClause + + return OnConflictClause(dialect, do_nothing=True, conflict_target=["id"]) + + +def _insert_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ( + InsertStatement, + ValuesSource, + ) + + return InsertStatement(dialect, into=_table(dialect), source=ValuesSource(dialect, [[Literal(dialect, 1)]])) + + +def _delete_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.dml import DeleteStatement + + return DeleteStatement(dialect, tables=_table(dialect)) + + +def _update_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import UpdateStatement + + return UpdateStatement(dialect, table=_table(dialect), assignments={"a": Literal(dialect, 1)}) + + +def _alter_table_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumnAction, + AlterTableStatement, + DropColumnAction, + ) + + column = _column_definition(dialect) + return AlterTableStatement( + dialect, + table=_table(dialect, "t"), + actions=[DropColumnAction(dialect, "a"), AddColumnAction(dialect, column=column)], + ) + + +def _create_view_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.objects import View + from rhosocial.activerecord.backend.expression.statements.ddl_view import CreateViewStatement + from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement + + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=_source(dialect, "t")) + return CreateViewStatement(dialect, view=View(dialect, "v"), query=query) + + +def _extract_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ExtractExpression + + return ExtractExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datepart_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatePartExpression + + return DatePartExpression(dialect, "YEAR", Column(dialect, "created_at")) + + +def _datetrunc_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DateTruncExpression + + return DateTruncExpression(dialect, "year", Column(dialect, "created_at")) + + +def _interval_expr(dialect): + from rhosocial.activerecord.backend.expression.datetime import IntervalExpression + + return IntervalExpression(dialect, 7, "day") + + +def _datetime_diff_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import DatetimeDiffExpression + + return DatetimeDiffExpression(dialect, "day", Column(dialect, "a"), Column(dialect, "b")) + + +def _datetime_sub_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DatetimeSubtractExpression, + IntervalExpression, + ) + + return DatetimeSubtractExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +def _datetime_add_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.datetime import ( + DatetimeAddExpression, + IntervalExpression, + ) + + return DatetimeAddExpression(dialect, Column(dialect, "a"), IntervalExpression(dialect, 1, "day")) + + +_SPECIAL_REGISTRY: Dict[str, Callable] = special_constructors() + + +def register_special_constructor(fqn_suffix: str, factory: Callable) -> None: + """Register an explicit constructor for an expression class. + + ``fqn_suffix`` must match the tail of the class's fully qualified name + (e.g. ``"json.MySQLJSONObjectExpression"``). ``factory(dialect)`` must + return an expression instance. + """ + _SPECIAL_REGISTRY[fqn_suffix] = factory + + +def make_instance(cls: Type[BaseExpression], dialect: Any): + """Best-effort construction for an expression class. + + Returns (instance, source) where source is "special", "heuristic", or + (None, "failed"). + """ + full_name = f"{cls.__module__}.{cls.__name__}" + special = next((fn for s, fn in _SPECIAL_REGISTRY.items() if full_name.endswith(s)), None) + if special is not None: + try: + return special(dialect), "special" + except Exception: + return None, "special-failed" + instance = _try_construct(cls, dialect) + if instance is None: + return None, "failed" + return instance, "heuristic" + + +def collect_expression_classes(package_path: str) -> Dict[str, Type[BaseExpression]]: + """Walk a package subtree and collect every defined BaseExpression subclass.""" + import importlib + import pkgutil + + pkg = importlib.import_module(package_path) + classes: Dict[str, Type[BaseExpression]] = {} + for _, modname, _ in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + "."): + try: + mod = importlib.import_module(modname) + except Exception: + continue + for name in dir(mod): + obj = getattr(mod, name, None) + if ( + isinstance(obj, type) + and obj.__module__ == modname + and issubclass(obj, BaseExpression) + and obj is not BaseExpression + and not inspect.isabstract(obj) + ): + classes[f"{modname}.{name}"] = obj + return classes + + +def register_all(classes: Dict[str, Type[BaseExpression]]) -> None: + """Hand the collected classes to the deserialization kind whitelist. + + Core split the class index out of the serialization module into a + registry: deserialization resolves a node to its class through the + framework's default registry, not through the ``(dialect, kind)`` render + registry -- the two are separate mechanisms and only the former names + expression classes. Core and the built-in backends populate that registry + on first use; registering one of those classes again is a no-op, and the + backend classes collected by the matrix are what this call adds. + """ + from rhosocial.activerecord.backend.expression.serialization import default_registry + + registry = default_registry() + for cls in classes.values(): + registry.register_node(cls) + + +def assert_params_equal(a: Any, b: Any, path: str = "params") -> None: + """Deep-compare two get_params() outputs, treating nested BaseExpression + instances as structurally equal and nested dataclasses as recursively compared. + """ + if a is b: + return + if isinstance(a, BaseExpression) and isinstance(b, BaseExpression): + assert_params_equal(a.get_params(), b.get_params(), path + ".") + return + if dataclasses.is_dataclass(a) and not isinstance(a, type): + assert dataclasses.is_dataclass(b) and not isinstance(b, type), ( + f"{path}: expected dataclass instance, got {type(b).__name__}" + ) + for f in dataclasses.fields(a): + assert_params_equal(getattr(a, f.name), getattr(b, f.name), f"{path}.{f.name}") + return + assert type(a) is type(b) or ( + isinstance(a, (list, tuple, dict)) and isinstance(b, (list, tuple, dict)) + ), f"{path}: type mismatch {type(a).__name__} vs {type(b).__name__}" + if isinstance(a, dict): + assert set(a) == set(b), f"{path}: keys differ {set(a) ^ set(b)}" + for k in a: + assert_params_equal(a[k], b[k], f"{path}.{k}") + return + if isinstance(a, (list, tuple)): + assert len(a) == len(b), f"{path}: length differ" + for i, (x, y) in enumerate(zip(a, b)): + assert_params_equal(x, y, f"{path}[{i}]") + return + assert a == b, f"{path}: {a!r} != {b!r}" + + +def roundtrip_expression(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert an instance round-trips losslessly through dict / JSON / XML.""" + original = instance.get_params() + for restored in ( + _rt_dict(instance, dialect), + _rt_json(instance, dialect), + _rt_xml(instance, dialect), + ): + assert_params_equal(restored.get_params(), original, fqn) + + +def _rt_dict(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize + + return deserialize(serialize(instance), dialect) + + +def _rt_json(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_json, serialize_json + + return deserialize_json(serialize_json(instance), dialect) + + +def _rt_xml(instance, dialect): + from rhosocial.activerecord.backend.expression.serialization import deserialize_xml, serialize_xml + + return deserialize_xml(serialize_xml(instance), dialect) + + +def sql_consistent(fqn: str, instance: BaseExpression, dialect: Any) -> None: + """Assert to_sql is identical after round-trip, when the dialect supports it.""" + try: + expected = instance.to_sql() + except Exception: + return + for restored in (_rt_dict(instance, dialect), _rt_json(instance, dialect), _rt_xml(instance, dialect)): + assert restored.to_sql() == expected, fqn + + +def parse_roundtrip_failures( + dialect: Any, + registry: Dict[str, Any], + *, + widening: Optional[Dict[str, Any]] = None, + skip: Any = (), +) -> Any: + """Sweep *registry* and return the parse round-trip failures as text. + + Every dialect's ``parse_type`` is the introspection inverse of its + ``format_data_type_`` renderings, and two invariants make a + dialect coherent: + + **String stability** -- parsing a rendering and re-rendering the + answer must produce the identical string. Whatever ``parse`` answers, + its answer must name the same storage the server holds, or the schema + differ would report a change no one made. + + **Class honesty** -- the answer is either the declared instance + itself (``parsed == declared``, the round-trip-equal case) or the + *documented* answer class for that declaration, recorded in the + owning backend's test file with its reason. + + This is the vendored equivalent of the testsuite's shared sweep + (``testsuite/utils/parse_contract.py``), which the testsuite deleted + together with its round-trip harness when round-trip coverage moved + to the owning repository. The two decisions in it that outlived that + move are unchanged: a class the registry cannot construct or cannot + render belongs to the round-trip matrix's own lists, and a + ``CustomType`` answer is never blessed for a rendered word. + + Args: + dialect: the dialect whose ``parse_type`` is under test. + registry: a mapping of fully-qualified name to class, as the + backend's round-trip module keeps it (``REGISTERED`` or + ``ALL_CLASSES``). Only concrete ``DataType`` subclasses are + swept. + widening: the backend's documented answer table, + ``{declared class name: answer}``. An entry here asserts the + parse answers with exactly that class and that the answer + re-renders the identical string, **unless** the value is a + ``(class name, expected string)`` tuple: that form is for the + documented answers whose re-render deliberately *differs*. + skip: class names to leave out of the sweep entirely, in addition + to the built-in ``CustomType`` skip. + + Returns: + A list of human-readable failures; empty is the contract met. + """ + from rhosocial.activerecord.backend.expression.types import CustomType, DataType + + widening = dict(widening or {}) + skipped = set(skip) | {"CustomType"} + failures = [] + for fqn in sorted(registry): + cls = registry[fqn] + if not (isinstance(cls, type) and issubclass(cls, DataType)): + continue + if cls is DataType or cls is CustomType: + continue + if cls.__name__ in skipped: + continue + instance, source = make_instance(cls, dialect) + if instance is None: + continue # the matrix's unbuildable list owns this class + try: + rendered, _params = instance.to_sql() + except Exception: + continue # the matrix's pinned non-renders own this class + try: + parsed = dialect.parse_type(rendered) + except Exception as exc: + failures.append( + "%s renders %r but parse_type raises %s: %s" + % (cls.__name__, rendered, type(exc).__name__, exc) + ) + continue + if isinstance(parsed, CustomType): + failures.append( + "%s renders %r but parse_type answers CustomType -- a " + "dialect must recognise its own renderings" + % (cls.__name__, rendered) + ) + continue + expected_entry = widening.get(cls.__name__) + if expected_entry is None: + if parsed != instance: + failures.append( + "%s renders %r but parse_type answers %r -- neither " + "the declaration nor a documented widening" + % (cls.__name__, rendered, parsed) + ) + continue + if isinstance(expected_entry, tuple): + expected, expected_restring = expected_entry + else: + expected, expected_restring = expected_entry, None + if type(parsed).__name__ != expected: + failures.append( + "%s renders %r; the documented answer is %s but " + "parse_type answers %s" + % (cls.__name__, rendered, expected, type(parsed).__name__) + ) + continue + try: + re_rendered, _ = parsed.to_sql() + except Exception as exc: + failures.append( + "%s renders %r; the documented answer %s re-renders with " + "%s: %s" + % (cls.__name__, rendered, expected, type(exc).__name__, exc) + ) + continue + if expected_restring is not None: + if re_rendered != expected_restring: + failures.append( + "%s renders %r; the documented answer %s re-renders " + "%r but the table says %r" + % (cls.__name__, rendered, expected, + re_rendered, expected_restring) + ) + elif re_rendered != rendered: + failures.append( + "%s renders %r but the documented answer %s re-renders " + "%r -- the strings must name the same storage, or the " + "table entry must record the documented re-render as a " + "(class, string) tuple" + % (cls.__name__, rendered, expected, re_rendered) + ) + return failures + + +__all__ = [ + "assert_params_equal", + "collect_expression_classes", + "make_instance", + "parse_roundtrip_failures", + "register_all", + "register_special_constructor", + "roundtrip_expression", + "sql_consistent", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py index 96f7ae1a..4d4ddd68 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/conftest.py @@ -11,6 +11,15 @@ # SQL script for creating test tables +# +# The DROP list is ordered by dependency and runs before every CREATE, so a +# previous run that failed *during* setup leaves nothing that would make this +# one fail. That case is real: a fixture generator that raises before its +# ``yield`` never reaches its teardown, so a server touched by an earlier +# failure keeps the tables -- and the next setup then dies on MySQL's +# "tablespace exists" (error 1813), which is about the InnoDB space rather +# than about the row in the dictionary. Dropping first makes setup idempotent, +# which is what keeps that from ever starting. _TABLES_SQL = """ SET FOREIGN_KEY_CHECKS = 0; DROP TABLE IF EXISTS post_tags; @@ -130,6 +139,27 @@ SET FOREIGN_KEY_CHECKS = 1; """ +# A RANGE-partitioned table. The partition column has to be part of the +# primary key in MySQL, hence the composite key. +_PARTITIONED_TABLE_SQL = """ + DROP TABLE IF EXISTS partitioned_events; + CREATE TABLE partitioned_events ( + id INT NOT NULL, + created_at DATE NOT NULL, + payload VARCHAR(64) DEFAULT NULL, + PRIMARY KEY (id, created_at) + ) ENGINE=InnoDB COMMENT='partitioned introspection fixture' + PARTITION BY RANGE (YEAR(created_at)) ( + PARTITION p2020 VALUES LESS THAN (2021), + PARTITION p2021 VALUES LESS THAN (2022), + PARTITION p_max VALUES LESS THAN MAXVALUE + ); +""" + +_CLEANUP_PARTITIONED_TABLE_SQL = """ + DROP TABLE IF EXISTS partitioned_events; +""" + @pytest.fixture(scope="function") def backend_with_tables(mysql_backend): @@ -207,3 +237,29 @@ async def async_backend_with_trigger(async_mysql_backend): await async_mysql_backend.executescript(_CLEANUP_TRIGGER_SQL) except Exception: pass + + +@pytest.fixture(scope="function") +def backend_with_partitioned_table(backend_with_tables): + """Fixture providing backend with a RANGE-partitioned table.""" + backend_with_tables.introspector.clear_cache() + backend_with_tables.executescript(_PARTITIONED_TABLE_SQL) + yield backend_with_tables + try: + backend_with_tables.introspector.clear_cache() + backend_with_tables.executescript(_CLEANUP_PARTITIONED_TABLE_SQL) + except Exception: + pass + + +@pytest_asyncio.fixture(scope="function") +async def async_backend_with_partitioned_table(async_backend_with_tables): + """Async fixture providing backend with a RANGE-partitioned table.""" + async_backend_with_tables.introspector.clear_cache() + await async_backend_with_tables.executescript(_PARTITIONED_TABLE_SQL) + yield async_backend_with_tables + try: + async_backend_with_tables.introspector.clear_cache() + await async_backend_with_tables.executescript(_CLEANUP_PARTITIONED_TABLE_SQL) + except Exception: + pass diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py index 77e93991..674e3d44 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_cache.py @@ -77,7 +77,7 @@ def test_invalidate_specific_scope(self, backend_with_tables): # Database cache should be cleared db_info2 = backend_with_tables.introspector.get_database_info() # Check internal cache was cleared for database scope - backend_with_tables.introspector._make_cache_key(IntrospectionScope.DATABASE) + backend_with_tables.introspector.cache.key(IntrospectionScope.DATABASE) # The new result should be cached now assert db_info2 is not None @@ -126,13 +126,13 @@ class TestCacheExpiration: def test_cache_ttl(self, backend_with_tables): """Test that cache has TTL configured.""" # Check that TTL is set - assert hasattr(backend_with_tables.introspector, "_cache_ttl") - assert backend_with_tables.introspector._cache_ttl > 0 + assert hasattr(backend_with_tables.introspector.cache, "ttl") + assert backend_with_tables.introspector.cache.ttl > 0 def test_expired_cache_not_returned(self, mysql_backend_single): """Test that expired cache entries are not returned.""" # Set very short TTL - mysql_backend_single.introspector._cache_ttl = 0.01 # 10ms + mysql_backend_single.introspector.cache.ttl = 0.01 # 10ms # Get database info db_info1 = mysql_backend_single.introspector.get_database_info() @@ -151,8 +151,8 @@ class TestCacheThreadSafety: """Tests for cache thread safety.""" def test_cache_lock_exists(self, backend_with_tables): - """Test that cache lock exists.""" - assert hasattr(backend_with_tables.introspector, "_cache_lock") + """Test that cache lock exists (owned by the cache component).""" + assert hasattr(backend_with_tables.introspector.cache, "_lock") @pytest.mark.skip( reason="MySQL connections are not thread-safe; concurrent DB access on a single connection is expected to fail" @@ -198,7 +198,7 @@ class TestCacheKeys: def test_cache_key_generation(self, mysql_backend_single): """Test that cache keys are generated correctly.""" - key = mysql_backend_single.introspector._make_cache_key( + key = mysql_backend_single.introspector.cache.key( IntrospectionScope.TABLE, "users", schema=mysql_backend_single.config.database ) @@ -207,7 +207,7 @@ def test_cache_key_generation(self, mysql_backend_single): def test_cache_key_with_extra(self, mysql_backend_single): """Test cache key with extra component.""" - key = mysql_backend_single.introspector._make_cache_key( + key = mysql_backend_single.introspector.cache.key( IntrospectionScope.TABLE, schema=mysql_backend_single.config.database, extra="True" ) @@ -216,9 +216,9 @@ def test_cache_key_with_extra(self, mysql_backend_single): def test_cache_key_uniqueness(self, mysql_backend_single): """Test that different parameters produce different keys.""" - key1 = mysql_backend_single.introspector._make_cache_key(IntrospectionScope.TABLE, "users") - key2 = mysql_backend_single.introspector._make_cache_key(IntrospectionScope.TABLE, "posts") - key3 = mysql_backend_single.introspector._make_cache_key(IntrospectionScope.COLUMN, "users") + key1 = mysql_backend_single.introspector.cache.key(IntrospectionScope.TABLE, "users") + key2 = mysql_backend_single.introspector.cache.key(IntrospectionScope.TABLE, "posts") + key3 = mysql_backend_single.introspector.cache.key(IntrospectionScope.COLUMN, "users") assert key1 != key2 assert key1 != key3 diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py index a64e13bd..6336ba7c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_columns.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - ColumnInfo, + IntrospectionColumnInfo, ColumnNullable, ) @@ -18,14 +18,14 @@ class TestListColumns: """Tests for list_columns method.""" def test_list_columns_returns_column_info(self, backend_with_tables): - """Test that list_columns returns ColumnInfo objects.""" + """Test that list_columns returns IntrospectionColumnInfo objects.""" columns = backend_with_tables.introspector.list_columns("users") assert isinstance(columns, list) assert len(columns) > 0 for col in columns: - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) def test_list_columns_users_table(self, backend_with_tables): """Test columns for users table.""" @@ -63,7 +63,7 @@ def test_get_column_info_existing(self, backend_with_tables): col = backend_with_tables.introspector.get_column_info("users", "email") assert col is not None - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) assert col.name == "email" def test_get_column_info_nonexistent_column(self, backend_with_tables): @@ -235,14 +235,14 @@ class TestAsyncColumnIntrospection: @pytest.mark.asyncio async def test_async_list_columns(self, async_backend_with_tables): - """Test async list_columns returns ColumnInfo objects.""" + """Test async list_columns returns IntrospectionColumnInfo objects.""" columns = await async_backend_with_tables.introspector.list_columns("users") assert isinstance(columns, list) assert len(columns) > 0 for col in columns: - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) @pytest.mark.asyncio async def test_async_get_column_info(self, async_backend_with_tables): @@ -250,7 +250,7 @@ async def test_async_get_column_info(self, async_backend_with_tables): col = await async_backend_with_tables.introspector.get_column_info("users", "email") assert col is not None - assert isinstance(col, ColumnInfo) + assert isinstance(col, IntrospectionColumnInfo) assert col.name == "email" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py index a4986123..43e62827 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database.py @@ -8,17 +8,17 @@ import pytest -from rhosocial.activerecord.backend.introspection.types import DatabaseInfo +from rhosocial.activerecord.backend.introspection.types import IntrospectionDatabaseInfo class TestDatabaseInfo: """Tests for database information introspection.""" def test_get_database_info(self, mysql_backend): - """Test get_database_info returns valid DatabaseInfo.""" + """Test get_database_info returns valid IntrospectionDatabaseInfo.""" db_info = mysql_backend.introspector.get_database_info() - assert isinstance(db_info, DatabaseInfo) + assert isinstance(db_info, IntrospectionDatabaseInfo) assert db_info.name is not None assert db_info.vendor == "MySQL" assert db_info.version is not None @@ -135,10 +135,10 @@ class TestAsyncDatabaseInfo: @pytest.mark.asyncio async def test_async_get_database_info(self, async_mysql_backend): - """Test async get_database_info returns valid DatabaseInfo.""" + """Test async get_database_info returns valid IntrospectionDatabaseInfo.""" db_info = await async_mysql_backend.introspector.get_database_info() - assert isinstance(db_info, DatabaseInfo) + assert isinstance(db_info, IntrospectionDatabaseInfo) assert db_info.name is not None assert db_info.vendor == "MySQL" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py new file mode 100644 index 00000000..1dbf3535 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py @@ -0,0 +1,128 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_database_parser.py +"""Parser-level tests for MySQL database and table introspection. + +``introspect database`` used to crash when the run had not adapted the +dialect: the parser joined the backend's private ``_version``, which is +``None`` until a server has been asked. These tests call the parsers with no +server at all -- the row the query would have returned is supplied directly +-- so they fail on the parser rather than on a missing database. +""" + +from types import SimpleNamespace + +from rhosocial.activerecord.backend.impl.mysql.introspection import ( + SyncMySQLIntrospector, +) +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.introspection.types import TableType + + +class _NoServerBackend: + """The parts of a MySQL backend an introspector reads, and nothing else.""" + + def __init__(self, version=None, cached_version=None): + self.config = SimpleNamespace(database="shop") + self.dialect = MySQLDialect(version) + self._cached_version = cached_version + + def get_cached_server_version(self): + return self._cached_version + + +class _UnusedExecutor: + """An executor that fails loudly if a parser-level test asks for SQL.""" + + def execute(self, sql, params=()): + raise AssertionError("a parser-level test must not execute SQL") + + +def _introspector(**kwargs) -> SyncMySQLIntrospector: + return SyncMySQLIntrospector(_NoServerBackend(**kwargs), _UnusedExecutor()) + + +_SCHEMATA_ROW = { + "DEFAULT_CHARACTER_SET_NAME": "utf8mb4", + "DEFAULT_COLLATION_NAME": "utf8mb4_0900_ai_ci", +} + + +class TestParseDatabaseInfo: + """The record is built from the row and the version, never a private.""" + + def test_unadapted_version_is_reported_as_unknown(self): + """Before adaptation there is no version: say "unknown", not 8.0.0.""" + info = _introspector().parse_database_info([_SCHEMATA_ROW]) + + assert info.version == "unknown" + assert info.version_tuple == (0, 0, 0) + + def test_row_values_fill_the_rest_of_the_record(self): + info = _introspector().parse_database_info([_SCHEMATA_ROW]) + + assert info.name == "shop" + assert info.vendor == "MySQL" + assert info.encoding == "utf8mb4" + assert info.collation == "utf8mb4_0900_ai_ci" + # Not selected by the statement, so not invented here. + assert info.table_count is None + assert info.size_bytes is None + + def test_adapted_version_is_reported(self): + info = _introspector(version=(8, 0, 36)).parse_database_info([_SCHEMATA_ROW]) + + assert info.version == "8.0.36" + assert info.version_tuple == (8, 0, 36) + + def test_backend_cache_is_used_when_the_dialect_has_none(self): + """A connected-but-unadapted backend still knows what it measured.""" + info = _introspector(cached_version=(5, 7, 44)).parse_database_info( + [_SCHEMATA_ROW] + ) + + assert info.version == "5.7.44" + assert info.version_tuple == (5, 7, 44) + + def test_no_rows_leaves_the_row_derived_fields_empty(self): + info = _introspector(version=(8, 0, 36)).parse_database_info([]) + + assert info.name == "shop" + assert info.encoding is None + assert info.collation is None + + +class TestParseTables: + """The catalog's kind is carried through instead of being flattened.""" + + def test_table_kinds_are_reported_as_named(self): + rows = [ + {"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE"}, + {"TABLE_NAME": "user_summary", "TABLE_TYPE": "VIEW"}, + {"TABLE_NAME": "INNODB_METRICS", "TABLE_TYPE": "SYSTEM VIEW"}, + ] + + tables = _introspector().parse_tables(rows, schema=None) + + assert [table.name for table in tables] == [ + "users", + "user_summary", + "INNODB_METRICS", + ] + assert [table.table_type for table in tables] == [ + TableType.BASE_TABLE, + TableType.VIEW, + TableType.SYSTEM_TABLE, + ] + + def test_schema_defaults_to_the_connected_database(self): + rows = [{"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE"}] + + tables = _introspector().parse_tables(rows, schema=None) + + assert tables[0].schema == "shop" + + def test_an_explicit_schema_wins_over_the_default(self): + rows = [{"TABLE_NAME": "users", "TABLE_TYPE": "BASE TABLE"}] + + tables = _introspector().parse_tables(rows, schema="archive") + + assert tables[0].schema == "archive" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py index 5736f861..66629f4c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_foreign_keys.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - ForeignKeyInfo, + IntrospectionForeignKeyInfo, ReferentialAction, ) @@ -18,14 +18,14 @@ class TestListForeignKeys: """Tests for list_foreign_keys method.""" def test_list_foreign_keys_returns_fk_info(self, backend_with_tables): - """Test that list_foreign_keys returns ForeignKeyInfo objects.""" + """Test that list_foreign_keys returns IntrospectionForeignKeyInfo objects.""" fks = backend_with_tables.introspector.list_foreign_keys("posts") assert isinstance(fks, list) assert len(fks) > 0 for fk in fks: - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) def test_list_foreign_keys_posts_table(self, backend_with_tables): """Test foreign keys on posts table.""" @@ -86,7 +86,7 @@ def test_get_foreign_key_info_existing(self, backend_with_tables): fk = backend_with_tables.introspector.get_foreign_key_info("posts", fk_name) assert fk is not None - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) assert fk.name == fk_name def test_get_foreign_key_info_nonexistent(self, backend_with_tables): @@ -258,14 +258,14 @@ class TestAsyncForeignKeyIntrospection: @pytest.mark.asyncio async def test_async_list_foreign_keys(self, async_backend_with_tables): - """Test async list_foreign_keys returns ForeignKeyInfo objects.""" + """Test async list_foreign_keys returns IntrospectionForeignKeyInfo objects.""" fks = await async_backend_with_tables.introspector.list_foreign_keys("posts") assert isinstance(fks, list) assert len(fks) > 0 for fk in fks: - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) @pytest.mark.asyncio async def test_async_get_foreign_key_info(self, async_backend_with_tables): @@ -277,7 +277,7 @@ async def test_async_get_foreign_key_info(self, async_backend_with_tables): fk = await async_backend_with_tables.introspector.get_foreign_key_info("posts", fk_name) assert fk is not None - assert isinstance(fk, ForeignKeyInfo) + assert isinstance(fk, IntrospectionForeignKeyInfo) assert fk.name == fk_name @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py new file mode 100644 index 00000000..aa6d2296 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py @@ -0,0 +1,117 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_options_cli.py +"""Tests for the ``table-options`` and ``partitions`` CLI subcommands. + +The subcommands are exercised through ``main([...])`` rather than through +the handler directly, so argument parsing, backend construction, dispatch +to ``backend.introspector`` and rendering by the output provider are all +covered. JSON output is used because it is machine-checkable. +""" + +import json + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.__main__ import main +from rhosocial.activerecord.backend.impl.mysql.cli.introspect import INTROSPECT_TYPES + +from mysql_live_server import mysql_server_available + +pytestmark = pytest.mark.skipif( + not mysql_server_available(), + reason="No MySQL server reachable from the configured scenarios", +) + +PARTITIONED_TABLE = "partitioned_events" + + +def _cli_args(backend, *rest) -> list: + """Build CLI arguments that reach the same server as *backend*.""" + config = backend.config + args = [ + "introspect", + *rest, + "--host", + config.host, + "--port", + str(config.port), + "--database", + config.database, + "--user", + config.username, + "--password", + config.password or "", + "--charset", + config.charset or "utf8mb4", + ] + if getattr(config, "ssl_disabled", None): + args.extend(["--ssl", "disabled"]) + return args + ["-o", "json"] + + +def _run(capsys, args) -> str: + main(args) + captured = capsys.readouterr() + return captured.out + + +class TestIntrospectTypes: + """The new families are offered by the parser.""" + + def test_new_families_are_registered(self): + assert "table-options" in INTROSPECT_TYPES + assert "partitions" in INTROSPECT_TYPES + + +class TestTableOptionsCommand: + """Tests for ``introspect table-options``.""" + + def test_lists_all_tables(self, backend_with_tables, capsys): + out = _run(capsys, _cli_args(backend_with_tables, "table-options")) + + payload = json.loads(out) + assert payload + names = {row["table_name"] for row in payload} + assert "users" in names + users = next(row for row in payload if row["table_name"] == "users") + assert users["engine"] == "InnoDB" + assert "data_length" in users + + def test_reports_one_table(self, backend_with_tables, capsys): + out = _run(capsys, _cli_args(backend_with_tables, "table-options", "users")) + + payload = json.loads(out) + assert len(payload) == 1 + assert payload[0]["table_name"] == "users" + assert payload[0]["engine"] == "InnoDB" + + def test_unknown_table_exits_non_zero(self, backend_with_tables, capsys): + with pytest.raises(SystemExit) as excinfo: + main( + _cli_args( + backend_with_tables, "table-options", "table_options_does_not_exist" + ) + ) + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err + + +class TestPartitionsCommand: + """Tests for ``introspect partitions``.""" + + def test_emits_partitions(self, backend_with_partitioned_table, capsys): + out = _run( + capsys, + _cli_args(backend_with_partitioned_table, "partitions", PARTITIONED_TABLE), + ) + + payload = json.loads(out) + assert len(payload) == 3 + assert [row["partition_name"] for row in payload] == ["p2020", "p2021", "p_max"] + + def test_requires_a_table_name(self, backend_with_tables, capsys): + with pytest.raises(SystemExit) as excinfo: + main(_cli_args(backend_with_tables, "partitions")) + + assert excinfo.value.code == 1 + assert "required" in capsys.readouterr().err diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py new file mode 100644 index 00000000..b7edad78 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py @@ -0,0 +1,168 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_partitions.py +"""Tests for MySQL partition introspection. + +``list_partitions`` answers from ``information_schema.PARTITIONS``. A +partitioned table reports one row per partition (one per partition and +subpartition pair, when subpartitioned); a table that is not partitioned +has only a NULL-named row, which is filtered out so an empty list means +exactly "not partitioned". +""" + +from typing import Any, Dict, List, Tuple + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.introspection.types import ( + MySQLPartitionInfo, +) +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, + SyncIntrospectorExecutor, +) + +from mysql_live_server import mysql_server_available + +pytestmark = pytest.mark.skipif( + not mysql_server_available(), + reason="No MySQL server reachable from the configured scenarios", +) + +PARTITIONED_TABLE = "partitioned_events" + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that reports how many questions it was asked.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return super().execute(sql, params) + + +class AsyncCountingExecutor(AsyncIntrospectorExecutor): + """The async counterpart of :class:`CountingExecutor`.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + async def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return await super().execute(sql, params) + + +class TestListPartitions: + """Tests for list_partitions against a live server.""" + + def test_returns_typed_partitions(self, backend_with_partitioned_table): + partitions = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert len(partitions) == 3 + assert all(isinstance(partition, MySQLPartitionInfo) for partition in partitions) + assert [p.partition_name for p in partitions] == ["p2020", "p2021", "p_max"] + + def test_reports_method_expression_and_ordinals(self, backend_with_partitioned_table): + partitions = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert [p.partition_method for p in partitions] == ["RANGE"] * 3 + assert [p.partition_ordinal_position for p in partitions] == [1, 2, 3] + for partition in partitions: + assert partition.partition_expression + assert "YEAR" in partition.partition_expression.upper() + assert "created_at" in partition.partition_expression + assert partition.schema == backend_with_partitioned_table.config.database + assert partition.table_name == PARTITIONED_TABLE + + def test_reports_boundaries_and_sizes(self, backend_with_partitioned_table): + partitions = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert [p.partition_description for p in partitions] == [ + "2021", + "2022", + "MAXVALUE", + ] + for partition in partitions: + assert partition.table_rows is None or isinstance(partition.table_rows, int) + assert partition.data_length is None or isinstance(partition.data_length, int) + assert partition.index_length is None or isinstance(partition.index_length, int) + + def test_non_partitioned_table_returns_empty(self, backend_with_tables): + tables = backend_with_tables.introspector.list_tables() + assert "users" in {table.name for table in tables} + + assert backend_with_tables.introspector.list_partitions("users") == [] + + def test_missing_table_returns_empty(self, backend_with_tables): + assert ( + backend_with_tables.introspector.list_partitions( + "partitions_does_not_exist" + ) + == [] + ) + + def test_cache_returns_the_same_list(self, backend_with_partitioned_table): + first = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + second = backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert first is second + + def test_warm_cache_asks_the_server_nothing(self, backend_with_partitioned_table): + introspector = backend_with_partitioned_table.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_partitioned_table) + introspector._executor = executor + + introspector.list_partitions(PARTITIONED_TABLE) + cold = executor.calls + introspector.list_partitions(PARTITIONED_TABLE) + + assert cold > 0 + assert executor.calls == cold + + +class TestAsyncListPartitions: + """Async tests for partition introspection.""" + + @pytest.mark.asyncio + async def test_returns_typed_partitions(self, async_backend_with_partitioned_table): + partitions = await async_backend_with_partitioned_table.introspector.list_partitions( + PARTITIONED_TABLE + ) + + assert len(partitions) == 3 + assert all(isinstance(partition, MySQLPartitionInfo) for partition in partitions) + assert [p.partition_name for p in partitions] == ["p2020", "p2021", "p_max"] + assert [p.partition_ordinal_position for p in partitions] == [1, 2, 3] + + @pytest.mark.asyncio + async def test_non_partitioned_table_returns_empty(self, async_backend_with_tables): + partitions = await async_backend_with_tables.introspector.list_partitions("users") + + assert partitions == [] + + @pytest.mark.asyncio + async def test_warm_cache_asks_the_server_nothing(self, async_backend_with_partitioned_table): + introspector = async_backend_with_partitioned_table.introspector + introspector.clear_cache() + executor = AsyncCountingExecutor(async_backend_with_partitioned_table) + introspector._executor = executor + + await introspector.list_partitions(PARTITIONED_TABLE) + cold = executor.calls + await introspector.list_partitions(PARTITIONED_TABLE) + + assert cold > 0 + assert executor.calls == cold diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py new file mode 100644 index 00000000..64aeb08f --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py @@ -0,0 +1,198 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_table_options.py +"""Tests for MySQL table storage-options introspection. + +``list_table_options`` / ``get_table_options`` answer from +``information_schema.TABLES``: engine, row format, collation, the +self-managed auto-increment counter, comment, extra create options and the +server's data/index byte accounting. These are MySQL-only questions with +no cross-backend type, so the results are the typed +:class:`MySQLTableOptions` records rather than plain dicts. +""" + +from dataclasses import FrozenInstanceError +from typing import Any, Dict, List, Tuple + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.introspection.types import ( + MySQLTableOptions, +) +from rhosocial.activerecord.backend.introspection.executor import ( + AsyncIntrospectorExecutor, + SyncIntrospectorExecutor, +) +from rhosocial.activerecord.backend.introspection.types import IntrospectionScope + +from mysql_live_server import mysql_server_available + +pytestmark = pytest.mark.skipif( + not mysql_server_available(), + reason="No MySQL server reachable from the configured scenarios", +) + + +class CountingExecutor(SyncIntrospectorExecutor): + """An executor that reports how many questions it was asked.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return super().execute(sql, params) + + +class AsyncCountingExecutor(AsyncIntrospectorExecutor): + """The async counterpart of :class:`CountingExecutor`.""" + + def __init__(self, backend: Any) -> None: + super().__init__(backend) + self.calls = 0 + + async def execute(self, sql: str, params: Tuple = ()) -> List[Dict[str, Any]]: + self.calls += 1 + return await super().execute(sql, params) + + +class TestListTableOptions: + """Tests for list_table_options against a live server.""" + + def test_returns_typed_non_empty_results(self, backend_with_tables): + options = backend_with_tables.introspector.list_table_options() + + assert options + assert all(isinstance(option, MySQLTableOptions) for option in options) + names = {option.table_name for option in options} + assert "users" in names + + def test_reports_storage_fields_for_users(self, backend_with_tables): + options = backend_with_tables.introspector.list_table_options() + users = next(option for option in options if option.table_name == "users") + + assert users.schema == backend_with_tables.config.database + assert users.engine == "InnoDB" + assert users.row_format + assert users.table_collation + assert users.table_comment is not None + assert isinstance(users.data_length, int) + assert isinstance(users.index_length, int) + assert users.total_length == users.data_length + users.index_length + + def test_result_is_frozen(self, backend_with_tables): + options = backend_with_tables.introspector.list_table_options() + with pytest.raises(FrozenInstanceError): + options[0].engine = "MyISAM" # type: ignore[misc] + + def test_cache_returns_the_same_list(self, backend_with_tables): + first = backend_with_tables.introspector.list_table_options() + second = backend_with_tables.introspector.list_table_options() + + assert first is second + + def test_warm_cache_asks_the_server_nothing(self, backend_with_tables): + introspector = backend_with_tables.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_tables) + introspector._executor = executor + + introspector.list_table_options() + cold = executor.calls + introspector.list_table_options() + + assert cold > 0 + assert executor.calls == cold + + +class TestGetTableOptions: + """Tests for get_table_options against a live server.""" + + def test_returns_typed_result(self, backend_with_tables): + options = backend_with_tables.introspector.get_table_options("users") + + assert isinstance(options, MySQLTableOptions) + assert options.table_name == "users" + assert options.engine == "InnoDB" + + def test_missing_table_returns_none(self, backend_with_tables): + assert ( + backend_with_tables.introspector.get_table_options( + "table_options_does_not_exist" + ) + is None + ) + + def test_cache_returns_the_same_record(self, backend_with_tables): + first = backend_with_tables.introspector.get_table_options("users") + second = backend_with_tables.introspector.get_table_options("users") + + assert first is second + + def test_warm_cache_asks_the_server_nothing(self, backend_with_tables): + introspector = backend_with_tables.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_tables) + introspector._executor = executor + + introspector.get_table_options("users") + cold = executor.calls + introspector.get_table_options("users") + + assert cold > 0 + assert executor.calls == cold + + def test_invalidating_the_table_drops_the_record(self, backend_with_tables): + introspector = backend_with_tables.introspector + introspector.clear_cache() + executor = CountingExecutor(backend_with_tables) + introspector._executor = executor + + introspector.get_table_options("users") + cold = executor.calls + introspector.invalidate_cache(scope=IntrospectionScope.TABLE, name="users") + introspector.get_table_options("users") + + assert executor.calls > cold + + +class TestAsyncTableOptions: + """Async tests for table storage-options introspection.""" + + @pytest.mark.asyncio + async def test_returns_typed_non_empty_results(self, async_backend_with_tables): + options = await async_backend_with_tables.introspector.list_table_options() + + assert options + assert all(isinstance(option, MySQLTableOptions) for option in options) + assert "users" in {option.table_name for option in options} + + @pytest.mark.asyncio + async def test_returns_typed_record(self, async_backend_with_tables): + options = await async_backend_with_tables.introspector.get_table_options("users") + + assert isinstance(options, MySQLTableOptions) + assert options.engine == "InnoDB" + + @pytest.mark.asyncio + async def test_missing_table_returns_none(self, async_backend_with_tables): + options = await async_backend_with_tables.introspector.get_table_options( + "table_options_does_not_exist" + ) + + assert options is None + + @pytest.mark.asyncio + async def test_warm_cache_asks_the_server_nothing(self, async_backend_with_tables): + introspector = async_backend_with_tables.introspector + introspector.clear_cache() + executor = AsyncCountingExecutor(async_backend_with_tables) + introspector._executor = executor + + await introspector.list_table_options() + await introspector.get_table_options("users") + cold = executor.calls + await introspector.list_table_options() + await introspector.get_table_options("users") + + assert cold > 0 + assert executor.calls == cold diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py index 9ddaf9c1..dfd2c8c8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_tables.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - TableInfo, + IntrospectionTableInfo, TableType, ) @@ -18,14 +18,14 @@ class TestListTables: """Tests for list_tables method.""" def test_list_tables_returns_table_info(self, backend_with_tables): - """Test that list_tables returns TableInfo objects.""" + """Test that list_tables returns IntrospectionTableInfo objects.""" tables = backend_with_tables.introspector.list_tables() assert isinstance(tables, list) assert len(tables) > 0 for table in tables: - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) def test_list_tables_includes_created_tables(self, backend_with_tables): """Test that created tables are listed.""" @@ -70,7 +70,7 @@ def test_get_table_info_existing(self, backend_with_tables): table = backend_with_tables.introspector.get_table_info("users") assert table is not None - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) assert table.name == "users" def test_get_table_info_nonexistent(self, backend_with_tables): @@ -190,14 +190,14 @@ class TestAsyncTableIntrospection: @pytest.mark.asyncio async def test_async_list_tables(self, async_backend_with_tables): - """Test async list_tables returns TableInfo objects.""" + """Test async list_tables returns IntrospectionTableInfo objects.""" tables = await async_backend_with_tables.introspector.list_tables() assert isinstance(tables, list) assert len(tables) > 0 for table in tables: - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) @pytest.mark.asyncio async def test_async_get_table_info(self, async_backend_with_tables): @@ -205,7 +205,7 @@ async def test_async_get_table_info(self, async_backend_with_tables): table = await async_backend_with_tables.introspector.get_table_info("users") assert table is not None - assert isinstance(table, TableInfo) + assert isinstance(table, IntrospectionTableInfo) assert table.name == "users" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py index 17878637..9bfaaad5 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_triggers.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - TriggerInfo, + IntrospectionTriggerInfo, ) @@ -31,11 +31,11 @@ def test_list_triggers_with_trigger(self, backend_with_trigger): assert "update_user_timestamp" in trigger_names def test_list_triggers_returns_trigger_info(self, backend_with_trigger): - """Test that list_triggers returns TriggerInfo objects.""" + """Test that list_triggers returns IntrospectionTriggerInfo objects.""" triggers = backend_with_trigger.introspector.list_triggers() for trigger in triggers: - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) def test_list_triggers_caching(self, backend_with_trigger): """Test that trigger list is cached.""" @@ -61,7 +61,7 @@ def test_get_trigger_info_existing(self, backend_with_trigger): trigger = backend_with_trigger.introspector.get_trigger_info("update_user_timestamp") assert trigger is not None - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) assert trigger.name == "update_user_timestamp" def test_get_trigger_info_nonexistent(self, backend_with_trigger): @@ -189,7 +189,7 @@ class TestAsyncTriggerIntrospection: @pytest.mark.asyncio async def test_async_list_triggers(self, async_backend_with_trigger): - """Test async list_triggers returns TriggerInfo objects.""" + """Test async list_triggers returns IntrospectionTriggerInfo objects.""" triggers = await async_backend_with_trigger.introspector.list_triggers() trigger_names = [t.name for t in triggers] @@ -201,7 +201,7 @@ async def test_async_get_trigger_info(self, async_backend_with_trigger): trigger = await async_backend_with_trigger.introspector.get_trigger_info("update_user_timestamp") assert trigger is not None - assert isinstance(trigger, TriggerInfo) + assert isinstance(trigger, IntrospectionTriggerInfo) assert trigger.name == "update_user_timestamp" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py index f217df46..d8815f07 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_introspection_views.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.introspection.types import ( - ViewInfo, + IntrospectionViewInfo, ) @@ -17,14 +17,14 @@ class TestListViews: """Tests for list_views method.""" def test_list_views_returns_view_info(self, backend_with_view): - """Test that list_views returns ViewInfo objects.""" + """Test that list_views returns IntrospectionViewInfo objects.""" views = backend_with_view.introspector.list_views() assert isinstance(views, list) assert len(views) > 0 for view in views: - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) def test_list_views_includes_created_view(self, backend_with_view): """Test that created views are listed.""" @@ -57,7 +57,7 @@ def test_get_view_info_existing(self, backend_with_view): view = backend_with_view.introspector.get_view_info("user_summary") assert view is not None - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) assert view.name == "user_summary" def test_get_view_info_nonexistent(self, backend_with_view): @@ -141,14 +141,14 @@ class TestAsyncViewIntrospection: @pytest.mark.asyncio async def test_async_list_views(self, async_backend_with_view): - """Test async list_views returns ViewInfo objects.""" + """Test async list_views returns IntrospectionViewInfo objects.""" views = await async_backend_with_view.introspector.list_views() assert isinstance(views, list) assert len(views) > 0 for view in views: - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) @pytest.mark.asyncio async def test_async_get_view_info(self, async_backend_with_view): @@ -156,7 +156,7 @@ async def test_async_get_view_info(self, async_backend_with_view): view = await async_backend_with_view.introspector.get_view_info("user_summary") assert view is not None - assert isinstance(view, ViewInfo) + assert isinstance(view, IntrospectionViewInfo) assert view.name == "user_summary" @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py index 52794e4d..380b1ed0 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/introspection/test_status_introspector.py @@ -226,18 +226,18 @@ class TestMySQLStatusIntrospectorMixin: """Tests for MySQLStatusIntrospectorMixin helper methods.""" def test_parse_variable_value_int(self, mysql_backend): - """Test _parse_variable_value handles integers.""" + """Test parse_variable_value handles integers.""" status = mysql_backend.introspector.status - result = status._parse_variable_value("42") + result = status.parse_variable_value("42") assert result == 42 assert isinstance(result, int) def test_parse_variable_value_str(self, mysql_backend): - """Test _parse_variable_value preserves non-integer strings.""" + """Test parse_variable_value preserves non-integer strings.""" status = mysql_backend.introspector.status - result = status._parse_variable_value("utf8mb4") + result = status.parse_variable_value("utf8mb4") assert result == "utf8mb4" assert isinstance(result, str) @@ -507,19 +507,19 @@ class TestAsyncMySQLStatusIntrospectorMixin: @pytest.mark.asyncio async def test_parse_variable_value_int(self, async_mysql_backend): - """Test _parse_variable_value handles integers.""" + """Test parse_variable_value handles integers.""" status = async_mysql_backend.introspector.status - result = status._parse_variable_value("42") + result = status.parse_variable_value("42") assert result == 42 assert isinstance(result, int) @pytest.mark.asyncio async def test_parse_variable_value_str(self, async_mysql_backend): - """Test _parse_variable_value preserves non-integer strings.""" + """Test parse_variable_value preserves non-integer strings.""" status = async_mysql_backend.introspector.status - result = status._parse_variable_value("utf8mb4") + result = status.parse_variable_value("utf8mb4") assert result == "utf8mb4" assert isinstance(result, str) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py index 36c51853..42a86a80 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_json_duality_view_execution.py @@ -8,8 +8,8 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.expression import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityViewDMLTag, @@ -42,7 +42,7 @@ def _product_spec(*columns, tags=()): def _create_products_view(backend, spec, *, replace=False): - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( backend.dialect, "products_dv", spec, @@ -163,7 +163,7 @@ def test_drop_view(self, duality_backend): backend = duality_backend _create_products_view(backend, _product_spec("_id")) - drop_expr = DropJsonDualityViewExpression(backend.dialect, "products_dv") + drop_expr = DropJsonDualityViewStatement(backend.dialect, "products_dv") backend.execute(*drop_expr.to_sql(), options=ExecutionOptions(stmt_type=StatementType.DDL)) result = _select_products_view_metadata(backend) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py index 22b7be73..77da6f68 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/mysql/test_view_execution.py @@ -11,12 +11,12 @@ Column, ComparisonPredicate, Literal, - QueryExpression, - CreateViewExpression, - DropViewExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, + QueryStatement, + CreateViewStatement, + DropViewStatement, + CreateMaterializedViewStatement, + DropMaterializedViewStatement, + RefreshMaterializedViewStatement, ) from rhosocial.activerecord.backend.expression.query_parts import WhereClause from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -114,13 +114,13 @@ def test_create_view_basic(self, mysql_view_backend): """Test basic CREATE VIEW executes successfully.""" dialect = mysql_view_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, "users"), ) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query) sql, params = create_view.to_sql() @@ -140,7 +140,7 @@ def test_create_view_with_where(self, mysql_view_backend): """Test CREATE VIEW with WHERE clause.""" dialect = mysql_view_backend.dialect - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users"), @@ -152,7 +152,7 @@ def test_create_view_with_where(self, mysql_view_backend): ), ) - create_view = CreateViewExpression(dialect, view=View(dialect, "active_users"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "active_users"), query=query) sql, params = create_view.to_sql() @@ -171,19 +171,19 @@ def test_create_or_replace_view(self, mysql_view_backend): """Test CREATE OR REPLACE VIEW.""" dialect = mysql_view_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query) sql, params = create_view.to_sql() mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) # Create again with OR REPLACE - query2 = QueryExpression( + query2 = QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, "users") ) - create_view2 = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query2, replace=True) + create_view2 = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query2, replace=True) sql, params = create_view2.to_sql() result = mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -193,14 +193,14 @@ def test_drop_view(self, mysql_view_backend): """Test DROP VIEW.""" dialect = mysql_view_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) - create_view = CreateViewExpression(dialect, view=View(dialect, "user_view"), query=query) + create_view = CreateViewStatement(dialect, view=View(dialect, "user_view"), query=query) sql, params = create_view.to_sql() mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) - drop_view = DropViewExpression(dialect, view=View(dialect, "user_view")) + drop_view = DropViewStatement(dialect, view=View(dialect, "user_view")) sql, params = drop_view.to_sql() result = mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -210,7 +210,7 @@ def test_drop_view_if_exists(self, mysql_view_backend): """Test DROP VIEW IF EXISTS.""" dialect = mysql_view_backend.dialect - drop_view = DropViewExpression(dialect, view=View(dialect, "nonexistent_view"), if_exists=True) + drop_view = DropViewStatement(dialect, view=View(dialect, "nonexistent_view"), if_exists=True) sql, params = drop_view.to_sql() result = mysql_view_backend.execute(sql, params, options=ExecutionOptions(stmt_type=StatementType.DDL)) @@ -224,9 +224,9 @@ def test_create_materialized_view_raises_error(self, mysql_view_backend): """Test that CREATE MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = mysql_view_backend.dialect - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) + query = QueryStatement(dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users")) - create_mv = CreateMaterializedViewExpression(dialect, view=MaterializedView(dialect, "test_mv"), query=query) + create_mv = CreateMaterializedViewStatement(dialect, view=MaterializedView(dialect, "test_mv"), query=query) with pytest.raises(UnsupportedFeatureError) as exc_info: create_mv.to_sql() @@ -238,7 +238,7 @@ def test_drop_materialized_view_raises_error(self, mysql_view_backend): """Test that DROP MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = mysql_view_backend.dialect - drop_mv = DropMaterializedViewExpression(dialect, view=MaterializedView(dialect, "test_mv")) + drop_mv = DropMaterializedViewStatement(dialect, view=MaterializedView(dialect, "test_mv")) with pytest.raises(UnsupportedFeatureError) as exc_info: drop_mv.to_sql() @@ -249,7 +249,7 @@ def test_refresh_materialized_view_raises_error(self, mysql_view_backend): """Test that REFRESH MATERIALIZED VIEW raises UnsupportedFeatureError.""" dialect = mysql_view_backend.dialect - refresh_mv = RefreshMaterializedViewExpression(dialect, view=MaterializedView(dialect, "test_mv")) + refresh_mv = RefreshMaterializedViewStatement(dialect, view=MaterializedView(dialect, "test_mv")) with pytest.raises(UnsupportedFeatureError) as exc_info: refresh_mv.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py index 941884f2..3d38f3e9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_admin_statement_coverage.py @@ -13,6 +13,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import BaseExpression from rhosocial.activerecord.backend.expression.objects import Function, Procedure, Table from rhosocial.activerecord.backend.impl.mysql import expression as mysql_expr from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -26,10 +27,17 @@ def dialect(): return MySQLDialect() -class _InspectableExpr: - """Minimal stand-in for a core expression exposing to_sql().""" +class _InspectableExpr(BaseExpression): + """Minimal stand-in for a core expression exposing to_sql(). + + Subclasses :class:`BaseExpression` because the formatters dispatch an + "expression vs bare host value" slot with ``isinstance`` -- the same + judgement core's ``format_insert_values_source`` makes -- so the stand-in + has to be a declared expression node, not a duck-typed object. + """ def __init__(self, sql="1 + 1", params=()): + super().__init__(None) self._sql = sql self._params = params @@ -39,7 +47,7 @@ def to_sql(self): class TestFlushBranches: def test_no_write_to_binlog_multi_options(self, dialect): - expr = mysql_expr.MySQLFlushExpression( + expr = mysql_expr.MySQLFlushStatement( dialect, [mysql_expr.FlushOption.PRIVILEGES, mysql_expr.FlushOption.LOGS], no_write_to_binlog=True, @@ -48,14 +56,14 @@ def test_no_write_to_binlog_multi_options(self, dialect): assert sql == "FLUSH NO_WRITE_TO_BINLOG PRIVILEGES, LOGS" def test_empty_raises(self, dialect): - expr = mysql_expr.MySQLFlushExpression(dialect, []) + expr = mysql_expr.MySQLFlushStatement(dialect, []) with pytest.raises(ValueError, match="at least one option"): expr.to_sql() def test_non_strict_skips_validation(self, dialect): dialect.strict_validation = False try: - sql, _ = mysql_expr.MySQLFlushExpression(dialect, []).to_sql() + sql, _ = mysql_expr.MySQLFlushStatement(dialect, []).to_sql() assert sql == "FLUSH " finally: dialect.strict_validation = True @@ -63,7 +71,7 @@ def test_non_strict_skips_validation(self, dialect): class TestCacheIndex: def test_cache_index_without_indexes(self, dialect): - expr = mysql_expr.MySQLCacheIndexExpression( + expr = mysql_expr.MySQLCacheIndexStatement( dialect, [{"table": Table(dialect, "t1")}, {"table": Table(dialect, "t2", catalog_name="db")}], "shared", @@ -72,14 +80,14 @@ def test_cache_index_without_indexes(self, dialect): assert sql == "CACHE INDEX `t1`, `db`.`t2` IN `shared`" def test_cache_index_with_indexes(self, dialect): - expr = mysql_expr.MySQLCacheIndexExpression( + expr = mysql_expr.MySQLCacheIndexStatement( dialect, [{"table": Table(dialect, "t1"), "indexes": ["i1", "i2"]}], "shared" ) sql, _ = expr.to_sql() assert sql == "CACHE INDEX `t1` INDEX (`i1`, `i2`) IN `shared`" def test_load_index_into_cache(self, dialect): - expr = mysql_expr.MySQLLoadIndexIntoCacheExpression( + expr = mysql_expr.MySQLLoadIndexIntoCacheStatement( dialect, [{"table": Table(dialect, "t1"), "indexes": ["i1"]}] ) sql, _ = expr.to_sql() @@ -88,24 +96,24 @@ def test_load_index_into_cache(self, dialect): class TestComponentPlugin: def test_install_component_multi(self, dialect): - expr = mysql_expr.MySQLInstallComponentExpression(dialect, ["comp1", "comp2"]) + expr = mysql_expr.MySQLInstallComponentStatement(dialect, ["comp1", "comp2"]) sql, _ = expr.to_sql() assert sql == "INSTALL COMPONENT 'comp1', 'comp2'" def test_uninstall_component_multi(self, dialect): - expr = mysql_expr.MySQLUninstallComponentExpression(dialect, ["comp1", "comp2"]) + expr = mysql_expr.MySQLUninstallComponentStatement(dialect, ["comp1", "comp2"]) sql, _ = expr.to_sql() assert sql == "UNINSTALL COMPONENT 'comp1', 'comp2'" def test_uninstall_plugin(self, dialect): - expr = mysql_expr.MySQLUninstallPluginExpression(dialect, "p") + expr = mysql_expr.MySQLUninstallPluginStatement(dialect, "p") sql, _ = expr.to_sql() assert sql == "UNINSTALL PLUGIN `p`" class TestClone: def test_clone_instance_from(self, dialect): - expr = mysql_expr.MySQLCloneExpression( + expr = mysql_expr.MySQLCloneStatement( dialect, from_user="root", from_host="donor.example.com", @@ -119,46 +127,46 @@ def test_clone_instance_from(self, dialect): ) def test_clone_instance_default_port(self, dialect): - expr = mysql_expr.MySQLCloneExpression(dialect, from_user="u", from_host="h") + expr = mysql_expr.MySQLCloneStatement(dialect, from_user="u", from_host="h") sql, _ = expr.to_sql() assert sql == "CLONE INSTANCE FROM 'u'@'h' PORT = 3306" def test_clone_local(self, dialect): - expr = mysql_expr.MySQLCloneExpression( + expr = mysql_expr.MySQLCloneStatement( dialect, from_data_directory="/from", to_data_directory="/to" ) sql, _ = expr.to_sql() assert sql == "CLONE LOCAL DATA DIRECTORY '/from' TO DATA DIRECTORY = '/to'" def test_clone_local_no_dest(self, dialect): - expr = mysql_expr.MySQLCloneExpression(dialect, from_data_directory="/from") + expr = mysql_expr.MySQLCloneStatement(dialect, from_data_directory="/from") sql, _ = expr.to_sql() assert sql == "CLONE LOCAL DATA DIRECTORY '/from'" class TestInstanceCommands: def test_restart(self, dialect): - sql, _ = mysql_expr.MySQLRestartExpression(dialect).to_sql() + sql, _ = mysql_expr.MySQLRestartStatement(dialect).to_sql() assert sql == "RESTART" def test_binlog(self, dialect): - sql, _ = mysql_expr.MySQLBinlogExpression(dialect, "ZWNvZGVk").to_sql() + sql, _ = mysql_expr.MySQLBinlogStatement(dialect, "ZWNvZGVk").to_sql() assert sql == "BINLOG 'ZWNvZGVk'" class TestHandler: def test_open_no_alias(self, dialect): - expr = mysql_expr.MySQLHandlerOpenExpression(dialect, Table(dialect, "t")) + expr = mysql_expr.MySQLHandlerOpenStatement(dialect, Table(dialect, "t")) sql, _ = expr.to_sql() assert sql == "HANDLER `t` OPEN" def test_open_alias(self, dialect): - expr = mysql_expr.MySQLHandlerOpenExpression(dialect, Table(dialect, "t"), alias="a") + expr = mysql_expr.MySQLHandlerOpenStatement(dialect, Table(dialect, "t"), alias="a") sql, _ = expr.to_sql() assert sql == "HANDLER `t` OPEN AS `a`" def test_read_first_no_extras(self, dialect): - expr = mysql_expr.MySQLHandlerReadExpression( + expr = mysql_expr.MySQLHandlerReadStatement( dialect, Table(dialect, "t"), mysql_expr.HandlerReadMode.FIRST ) sql, params = expr.to_sql() @@ -166,7 +174,7 @@ def test_read_first_no_extras(self, dialect): assert params == () def test_read_index_key_value(self, dialect): - expr = mysql_expr.MySQLHandlerReadExpression( + expr = mysql_expr.MySQLHandlerReadStatement( dialect, Table(dialect, "t"), mysql_expr.HandlerReadMode.NEXT, @@ -180,7 +188,7 @@ def test_read_index_key_value(self, dialect): def test_read_where_limit_expr_key(self, dialect): where = _InspectableExpr("x > %s", (2,)) key = _InspectableExpr("3 + 4", ()) - expr = mysql_expr.MySQLHandlerReadExpression( + expr = mysql_expr.MySQLHandlerReadStatement( dialect, Table(dialect, "t", catalog_name="db"), mysql_expr.HandlerReadMode.LAST, @@ -193,20 +201,20 @@ def test_read_where_limit_expr_key(self, dialect): assert params == (2,) def test_close(self, dialect): - expr = mysql_expr.MySQLHandlerCloseExpression(dialect, Table(dialect, "t", catalog_name="db")) + expr = mysql_expr.MySQLHandlerCloseStatement(dialect, Table(dialect, "t", catalog_name="db")) sql, _ = expr.to_sql() assert sql == "HANDLER `db`.`t` CLOSE" class TestDo: def test_plain_values(self, dialect): - expr = mysql_expr.MySQLDoExpression(dialect, [1, "x"]) + expr = mysql_expr.MySQLDoStatement(dialect, [1, "x"]) sql, params = expr.to_sql() assert sql == "DO %s, %s" assert params == (1, "x") def test_expression_values(self, dialect): - expr = mysql_expr.MySQLDoExpression(dialect, [_InspectableExpr("1 + 1", ())]) + expr = mysql_expr.MySQLDoStatement(dialect, [_InspectableExpr("1 + 1", ())]) sql, params = expr.to_sql() assert sql == "DO 1 + 1" assert params == () @@ -214,23 +222,23 @@ def test_expression_values(self, dialect): class TestKillShutdownHelp: def test_kill_query(self, dialect): - sql, _ = mysql_expr.MySQLKillExpression( + sql, _ = mysql_expr.MySQLKillStatement( dialect, 42, mysql_expr.KillTarget.QUERY ).to_sql() assert sql == "KILL QUERY 42" def test_shutdown(self, dialect): - sql, _ = mysql_expr.MySQLShutdownExpression(dialect).to_sql() + sql, _ = mysql_expr.MySQLShutdownStatement(dialect).to_sql() assert sql == "SHUTDOWN" def test_help(self, dialect): - sql, _ = mysql_expr.MySQLHelpExpression(dialect, "CREATE TABLE").to_sql() + sql, _ = mysql_expr.MySQLHelpStatement(dialect, "CREATE TABLE").to_sql() assert sql == "HELP 'CREATE TABLE'" class TestAccountManagement: def test_create_user(self, dialect): - expr = mysql_expr.MySQLCreateUserExpression( + expr = mysql_expr.MySQLCreateUserStatement( dialect, [mysql_expr.AccountSpec("alice"), mysql_expr.AccountSpec("bob", "%.example.com")], if_not_exists=True, @@ -243,12 +251,12 @@ def test_create_user(self, dialect): ) def test_create_user_no_options(self, dialect): - expr = mysql_expr.MySQLCreateUserExpression(dialect, [mysql_expr.AccountSpec("u", "h")]) + expr = mysql_expr.MySQLCreateUserStatement(dialect, [mysql_expr.AccountSpec("u", "h")]) sql, _ = expr.to_sql() assert sql == "CREATE USER 'u'@'h'" def test_drop_user(self, dialect): - expr = mysql_expr.MySQLDropUserExpression( + expr = mysql_expr.MySQLDropUserStatement( dialect, [mysql_expr.AccountSpec("u", "h")], if_exists=True ) sql, _ = expr.to_sql() @@ -257,7 +265,7 @@ def test_drop_user(self, dialect): class TestGrantRevoke: def test_grant_with_columns_and_option(self, dialect): - expr = mysql_expr.MySQLGrantExpression( + expr = mysql_expr.MySQLGrantStatement( dialect, [ mysql_expr.GrantPrivilege("SELECT", ["c1", "c2"]), @@ -273,14 +281,14 @@ def test_grant_with_columns_and_option(self, dialect): ) def test_grant_default_object(self, dialect): - expr = mysql_expr.MySQLGrantExpression( + expr = mysql_expr.MySQLGrantStatement( dialect, [mysql_expr.GrantPrivilege("ALL PRIVILEGES")], [mysql_expr.AccountSpec("u")] ) sql, _ = expr.to_sql() assert sql == "GRANT ALL PRIVILEGES ON *.* TO 'u'@'%'" def test_revoke_with_columns(self, dialect): - expr = mysql_expr.MySQLRevokeExpression( + expr = mysql_expr.MySQLRevokeStatement( dialect, [mysql_expr.GrantPrivilege("SELECT", ["c1"])], [mysql_expr.AccountSpec("u", "h")], @@ -290,7 +298,7 @@ def test_revoke_with_columns(self, dialect): assert sql == "REVOKE SELECT (`c1`) ON db.t FROM 'u'@'h'" def test_revoke_default_object(self, dialect): - expr = mysql_expr.MySQLRevokeExpression( + expr = mysql_expr.MySQLRevokeStatement( dialect, [mysql_expr.GrantPrivilege("SELECT")], [mysql_expr.AccountSpec("u")] ) sql, _ = expr.to_sql() @@ -299,7 +307,7 @@ def test_revoke_default_object(self, dialect): class TestLoadXMLBranches: def test_full_options(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/f.xml", Table(dialect, "t"), @@ -317,12 +325,12 @@ def test_full_options(self, dialect): ) def test_validation_type_error(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, 123, Table(dialect, "t")) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, 123, Table(dialect, "t")) with pytest.raises(TypeError): expr.to_sql() def test_validation_priority_local_conflict(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/f.xml", Table(dialect, "t"), @@ -333,24 +341,24 @@ def test_validation_priority_local_conflict(self, dialect): expr.to_sql() def test_validation_negative_ignore(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_count=-1) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_count=-1) with pytest.raises(ValueError, match="non-negative"): expr.to_sql() def test_validation_bad_unit(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_unit="COLS") + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/f.xml", Table(dialect, "t"), ignore_unit="COLS") with pytest.raises(ValueError, match="'LINES' or 'ROWS'"): expr.to_sql() def test_validation_table_not_object(self, dialect): """A bare string cannot say which database it belongs to.""" - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/f.xml", 5) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/f.xml", 5) with pytest.raises(TypeError): expr.to_sql() def test_qualified_target_renders_database(self, dialect): """LOAD XML may now name a database it previously could not.""" - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/f.xml", Table(dialect, "t", catalog_name="app") ) sql, _ = expr.to_sql() @@ -397,22 +405,22 @@ class TestNonStrictValidation: """strict=False short-circuits validate() in each expression.""" def test_flush(self, dialect): - mysql_expr.MySQLFlushExpression(dialect, []).validate(strict=False) + mysql_expr.MySQLFlushStatement(dialect, []).validate(strict=False) def test_load_xml(self, dialect): - mysql_expr.MySQLLoadXMLEXpression(dialect, 123, 456).validate(strict=False) + mysql_expr.MySQLLoadXMLStatement(dialect, 123, 456).validate(strict=False) def test_maintenance(self, dialect): - mysql_expr.MySQLAnalyzeTableExpression(dialect, []).validate(strict=False) + mysql_expr.MySQLAnalyzeTableStatement(dialect, []).validate(strict=False) def test_rename(self, dialect): - mysql_expr.MySQLRenameTableExpression(dialect, []).validate(strict=False) + mysql_expr.MySQLRenameTableStatement(dialect, []).validate(strict=False) def test_routine(self, dialect): - mysql_expr.MySQLCreateProcedureExpression(dialect, 123).validate(strict=False) + mysql_expr.MySQLCreateProcedureStatement(dialect, 123).validate(strict=False) def test_call(self, dialect): - mysql_expr.MySQLCallExpression(dialect, 123).validate(strict=False) + mysql_expr.MySQLCallStatement(dialect, 123).validate(strict=False) def test_table_statement(self, dialect): mysql_expr.MySQLTableStatement(dialect, 5).validate(strict=False) @@ -425,14 +433,14 @@ class TestRoutineBranches: """Exercise stored-routine option and validation branches.""" def test_create_procedure_without_body_and_2tx(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp", catalog_name="db"), params=[("x", "INT")] ) sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE `db`.`sp` (`x` INT)" def test_create_function_all_options(self, dialect): - expr = mysql_expr.MySQLCreateFunctionExpression( + expr = mysql_expr.MySQLCreateFunctionStatement( dialect, Function(dialect, "fn", catalog_name="db"), returns="INT", @@ -446,13 +454,13 @@ def test_create_function_all_options(self, dialect): ) def test_drop_function_if_exists(self, dialect): - sql, _ = mysql_expr.MySQLDropFunctionExpression( + sql, _ = mysql_expr.MySQLDropFunctionStatement( dialect, Function(dialect, "fn"), if_exists=True ).to_sql() assert sql == "DROP FUNCTION IF EXISTS `fn`" def test_call_with_none_and_expr(self, dialect): - expr = mysql_expr.MySQLCallExpression( + expr = mysql_expr.MySQLCallStatement( dialect, Procedure(dialect, "sp", catalog_name="db"), [None, _InspectableExpr("NOW()", ())], ) @@ -462,8 +470,8 @@ def test_call_with_none_and_expr(self, dialect): def test_routine_invalid_name(self, dialect): for cls, kw in ( - (mysql_expr.MySQLCreateProcedureExpression, {"body": "x"}), - (mysql_expr.MySQLCallExpression, {}), + (mysql_expr.MySQLCreateProcedureStatement, {"body": "x"}), + (mysql_expr.MySQLCallStatement, {}), ): expr = cls(dialect, 123, **kw) with pytest.raises(TypeError): @@ -477,7 +485,7 @@ def test_routine_tuple_form_is_refused(self, dialect): own ``validate``, and the formatter asks about the shared base class the two concrete kinds both derive from. """ - expr = mysql_expr.MySQLCreateProcedureExpression(dialect, ("db", "sp")) + expr = mysql_expr.MySQLCreateProcedureStatement(dialect, ("db", "sp")) with pytest.raises( TypeError, match=r"must be a RoutineObject, got tuple", @@ -485,7 +493,7 @@ def test_routine_tuple_form_is_refused(self, dialect): expr.to_sql() def test_call_tuple_form_is_refused(self, dialect): - expr = mysql_expr.MySQLCallExpression(dialect, ("db", "sp")) + expr = mysql_expr.MySQLCallStatement(dialect, ("db", "sp")) with pytest.raises( TypeError, match=r"must be a RoutineObject, got tuple", @@ -494,7 +502,7 @@ def test_call_tuple_form_is_refused(self, dialect): def test_routine_string_form_is_refused(self, dialect): """A bare string cannot say which database the routine lives in.""" - expr = mysql_expr.MySQLCreateProcedureExpression(dialect, "sp") + expr = mysql_expr.MySQLCreateProcedureStatement(dialect, "sp") with pytest.raises( TypeError, match=r"must be a RoutineObject, got str", @@ -515,7 +523,7 @@ def test_routine_guard_does_not_depend_on_strict_validation(self): """ loose = MySQLDialect() loose.strict_validation = False - expr = mysql_expr.MySQLCallExpression(loose, 123) + expr = mysql_expr.MySQLCallStatement(loose, 123) with pytest.raises(TypeError, match=r"must be a RoutineObject, got int"): expr.to_sql() @@ -556,14 +564,14 @@ def test_values_empty(self, dialect): class TestMaintenanceBranches: def test_checksum_option(self, dialect): - expr = mysql_expr.MySQLChecksumTableExpression( + expr = mysql_expr.MySQLChecksumTableStatement( dialect, [Table(dialect, "t1")], option=mysql_expr.ChecksumTableOption.QUICK ) sql, _ = expr.to_sql() assert sql == "CHECKSUM TABLE `t1` QUICK" def test_analyze_no_write_local_binlog(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression( + expr = mysql_expr.MySQLAnalyzeTableStatement( dialect, [Table(dialect, "t1")], no_write_to_binlog=mysql_expr.NoWriteToBinlogOption.NO_WRITE_TO_BINLOG, @@ -573,25 +581,25 @@ def test_analyze_no_write_local_binlog(self, dialect): def test_qualified_tables(self, dialect): """The (schema, table) tuple is gone; an object carries the database.""" - expr = mysql_expr.MySQLAnalyzeTableExpression( + expr = mysql_expr.MySQLAnalyzeTableStatement( dialect, [Table(dialect, "t1", catalog_name="app"), Table(dialect, "t2")] ) sql, _ = expr.to_sql() assert sql == "ANALYZE TABLE `app`.`t1`, `t2`" def test_empty_tables(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, []) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, []) with pytest.raises(ValueError, match="at least one table"): expr.to_sql() def test_tuple_form_is_refused(self, dialect): """A 2-tuple used to be the only way to qualify a maintenance target.""" - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, [("app", "t1")]) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, [("app", "t1")]) with pytest.raises(TypeError, match="relation objects"): expr.to_sql() def test_bad_table_type(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, [123]) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, [123]) with pytest.raises(TypeError): expr.to_sql() @@ -602,25 +610,36 @@ def test_unsupported_operation(self, dialect): class _UnsupportedMaintenanceExpr(MySQLTableMaintenanceExpression): - """Maintenance base with an unsupported operation keyword.""" + """Maintenance node whose operation keyword MySQL has no support gate for. + + The base declares no entry of its own -- it is an abstract base, not a + node (§9) -- so this subclass names one. The entry reads ``operation`` off + whatever it is handed, which is what makes the unsupported keyword reach + the support gate instead of the dispatch. + """ operation = "VACUUM" + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_mysql_analyze_table_statement" + class TestRenameTableBranches: def test_invalid_pair(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression(dialect, [(Table(dialect, "a"),)]) + expr = mysql_expr.MySQLRenameTableStatement(dialect, [(Table(dialect, "a"),)]) with pytest.raises(ValueError): expr.to_sql() def test_non_object_names(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression(dialect, [(1, 2)]) + expr = mysql_expr.MySQLRenameTableStatement(dialect, [(1, 2)]) with pytest.raises(TypeError): expr.to_sql() def test_cross_database_rename(self, dialect): """A rename may now span databases, which a string pair could not.""" - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "t", catalog_name="old_db"), Table(dialect, "t", catalog_name="new_db"))], ) @@ -631,21 +650,21 @@ def test_cross_database_rename(self, dialect): class TestRoutineParamBranches: def test_invalid_param_tuple(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=[("a", "x", "INT", "EXTRA")] ) with pytest.raises(ValueError, match="Invalid parameter"): expr.to_sql() def test_two_tuple_param(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=[("x", "INT")] ) sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE `sp` (`x` INT)" def test_plain_string_param(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=["IN p INT"] ) sql, _ = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py index b3a9cefd..bb5bd880 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_auto_increment_ddl.py @@ -12,7 +12,8 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, + CreateTableStatement, ColumnDefinition, ColumnConstraint, + ColumnDefaultConstraint, ColumnNotNullConstraint, ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import ( BooleanType, IntegerType, TextType, TimestampType, VarCharType, @@ -27,19 +28,19 @@ def dialect(): def _pk(dialect): - return [ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + return [ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True))] def _build_table(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, "test_tbl"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "name", TextType(dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "flag", BooleanType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, True))]), ColumnDefinition(dialect, "code", VarCharType(dialect, 16)), ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect)), ], @@ -58,20 +59,20 @@ def test_all_standard_types_compile(self, dialect): assert isinstance(params, tuple) def test_boolean_default_true(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "bool_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), ColumnDefinition(dialect, "flag", BooleanType(dialect=dialect), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, True))]), ], ) sql, _ = dialect.format_create_table_statement(expr) assert "TINYINT" in sql.upper() def test_timestamp_type(self, dialect): - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "ts_test"), columns=[ ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=_pk(dialect)), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py index fd9b96f7..1b30bac1 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_backend_explain.py @@ -13,7 +13,7 @@ SyncExplainBackendProtocol, AsyncExplainBackendProtocol, ) -from rhosocial.activerecord.backend.expression import RawSQLExpression +from rhosocial.activerecord.backend.expression import RawSQL from rhosocial.activerecord.backend.expression.statements import ExplainOptions from rhosocial.activerecord.backend.impl.mysql.explain import MySQLExplainResult, MySQLExplainRow @@ -105,39 +105,39 @@ async def test_async_backend_implements_protocol(self, async_mysql_backend): class TestSyncExplainBasic: def test_explain_returns_mysql_explain_result(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert isinstance(result, MySQLExplainResult) def test_result_has_rows(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert len(result.rows) > 0 def test_result_row_type(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) for row in result.rows: assert isinstance(row, MySQLExplainRow) def test_result_has_sql(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert "explain_orders" in result.sql.lower() assert result.sql.upper().startswith("EXPLAIN") def test_result_has_duration(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert result.duration >= 0.0 def test_result_has_raw_rows(self, indexed_backend): dialect = indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr) assert isinstance(result.raw_rows, list) assert len(result.raw_rows) == len(result.rows) @@ -152,7 +152,7 @@ class TestSyncExplainIndexAnalysis: def test_full_scan_detection(self, indexed_backend): """SELECT * FROM table without WHERE → full scan (type='ALL').""" dialect = indexed_backend.dialect - result = indexed_backend.explain(RawSQLExpression(dialect, "SELECT * FROM explain_orders")) + result = indexed_backend.explain(RawSQL(dialect, "SELECT * FROM explain_orders")) assert result.analyze_index_usage() == "full_scan" assert result.is_full_scan is True assert result.is_index_used is False @@ -162,7 +162,7 @@ def test_index_with_lookup_detection(self, indexed_backend): """SELECT * … WHERE indexed_col = ? → index lookup + table read.""" dialect = indexed_backend.dialect result = indexed_backend.explain( - RawSQLExpression(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") + RawSQL(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") ) usage = result.analyze_index_usage() # Could be index_with_lookup or covering_index depending on optimizer @@ -174,7 +174,7 @@ def test_covering_index_detection(self, indexed_backend): """SELECT indexed_col FROM table WHERE indexed_col = ? → covering index.""" dialect = indexed_backend.dialect result = indexed_backend.explain( - RawSQLExpression(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") + RawSQL(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") ) usage = result.analyze_index_usage() # Both columns are in the covering index (order_id, sku) @@ -185,7 +185,7 @@ def test_covering_index_detection(self, indexed_backend): def test_row_fields_present(self, indexed_backend): """Verify MySQLExplainRow has expected attribute names.""" dialect = indexed_backend.dialect - result = indexed_backend.explain(RawSQLExpression(dialect, "SELECT * FROM explain_orders")) + result = indexed_backend.explain(RawSQL(dialect, "SELECT * FROM explain_orders")) row = result.rows[0] # All expected fields must exist (may be None for some) assert hasattr(row, "id") @@ -206,21 +206,21 @@ class TestAsyncExplainBasic: @pytest.mark.asyncio async def test_explain_returns_mysql_explain_result(self, async_indexed_backend): dialect = async_indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = await async_indexed_backend.explain(expr) assert isinstance(result, MySQLExplainResult) @pytest.mark.asyncio async def test_result_has_rows(self, async_indexed_backend): dialect = async_indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = await async_indexed_backend.explain(expr) assert len(result.rows) > 0 @pytest.mark.asyncio async def test_result_has_sql_and_duration(self, async_indexed_backend): dialect = async_indexed_backend.dialect - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = await async_indexed_backend.explain(expr) assert result.sql.upper().startswith("EXPLAIN") assert result.duration >= 0.0 @@ -228,14 +228,14 @@ async def test_result_has_sql_and_duration(self, async_indexed_backend): @pytest.mark.asyncio async def test_full_scan_detection(self, async_indexed_backend): dialect = async_indexed_backend.dialect - result = await async_indexed_backend.explain(RawSQLExpression(dialect, "SELECT * FROM explain_orders")) + result = await async_indexed_backend.explain(RawSQL(dialect, "SELECT * FROM explain_orders")) assert result.is_full_scan is True @pytest.mark.asyncio async def test_index_used_detection(self, async_indexed_backend): dialect = async_indexed_backend.dialect result = await async_indexed_backend.explain( - RawSQLExpression(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") + RawSQL(dialect, "SELECT * FROM explain_orders WHERE status = 'pending'") ) assert result.is_index_used is True @@ -243,7 +243,7 @@ async def test_index_used_detection(self, async_indexed_backend): async def test_covering_index_detection(self, async_indexed_backend): dialect = async_indexed_backend.dialect result = await async_indexed_backend.explain( - RawSQLExpression(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") + RawSQL(dialect, "SELECT order_id, sku FROM explain_order_items WHERE order_id = 1") ) assert result.is_covering_index is True @@ -262,7 +262,7 @@ def test_format_json_when_supported(self, indexed_backend): from rhosocial.activerecord.backend.expression.statements import ExplainFormat opts = ExplainOptions(format=ExplainFormat.JSON) - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr, opts) # We get back a MySQLExplainResult; raw_rows should be non-empty assert isinstance(result, MySQLExplainResult) @@ -276,7 +276,7 @@ def test_format_tree_when_supported(self, indexed_backend): from rhosocial.activerecord.backend.expression.statements import ExplainFormat opts = ExplainOptions(format=ExplainFormat.TREE) - expr = RawSQLExpression(dialect, "SELECT * FROM explain_orders") + expr = RawSQL(dialect, "SELECT * FROM explain_orders") result = indexed_backend.explain(expr, opts) assert isinstance(result, MySQLExplainResult) assert len(result.raw_rows) > 0 diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py index 41378bd2..135870c8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_cli.py @@ -103,14 +103,14 @@ def test_parse_args_introspect_subcommand(self): assert args.type == "tables" def test_parse_args_introspect_with_name(self): - """Test introspect subcommand with table name.""" + """Test introspect subcommand with an object name.""" from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args - with patch.object(sys, "argv", ["mysql", "introspect", "table", "users"]): + with patch.object(sys, "argv", ["mysql", "introspect", "columns", "users"]): args = parse_args() assert args.command == "introspect" - assert args.type == "table" + assert args.type == "columns" assert args.name == "users" def test_parse_args_introspect_valid_types(self): @@ -898,16 +898,21 @@ def test_handle_info_with_version(self, capsys): class TestCLIIntrospectArgParsing: """Tests for introspect argument parsing edge cases.""" - def test_introspect_with_schema(self): - """Test introspect with --schema option.""" - from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args + def test_introspect_has_no_namespace_flag(self): + """MySQL has one namespace level and calls it a database. - with patch.object(sys, "argv", ["mysql", "introspect", "tables", "--schema", "mydb"]): - args = parse_args() + ``--schema`` named the same level under a word MySQL does not + use, so it is gone: the run connects to a database with + ``--database`` and asks about that database. The flag must not + come back as an alias, because an alias lets a caller believe it + named a level that does not exist here. + """ + from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args - assert args.command == "introspect" - assert args.type == "tables" - assert args.schema == "mydb" + with patch.object( + sys, "argv", ["mysql", "introspect", "tables", "--schema", "mydb"] + ), pytest.raises(SystemExit): + parse_args() def test_introspect_with_include_system(self): """Test introspect with --include-system flag.""" @@ -923,11 +928,6 @@ def test_introspect_all_types_with_name(self): """Test introspect types that require name parameter.""" from rhosocial.activerecord.backend.impl.mysql.__main__ import parse_args - # table type with name - with patch.object(sys, "argv", ["mysql", "introspect", "table", "users"]): - args = parse_args() - assert args.type == "table" - assert args.name == "users" # columns type with name with patch.object(sys, "argv", ["mysql", "introspect", "columns", "orders"]): diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py index 81398cc4..960c6ff2 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_create_table_like.py @@ -3,12 +3,12 @@ MySQL CREATE TABLE ... LIKE syntax tests. This module tests the MySQL-specific LIKE syntax for CREATE TABLE statements. -The LIKE form is modelled by CreateTableLikeExpression and rendered by the +The LIKE form is modelled by CreateTableLikeStatement and rendered by the dialect's ``format_create_table_like_statement`` (MySQL inherits the generic TableMixin implementation). """ -from rhosocial.activerecord.backend.expression import CreateTableLikeExpression +from rhosocial.activerecord.backend.expression import CreateTableLikeStatement from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.objects import Table @@ -19,7 +19,7 @@ class TestMySQLCreateTableLike: def test_basic_like_syntax(self): """Test basic CREATE TABLE ... LIKE syntax.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression(dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users")) + expr = CreateTableLikeStatement(dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users")) sql, params = expr.to_sql() assert sql == "CREATE TABLE `users_copy` LIKE `users`" @@ -28,7 +28,7 @@ def test_basic_like_syntax(self): def test_like_with_if_not_exists(self): """Test CREATE TABLE ... LIKE with IF NOT EXISTS.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users"), if_not_exists=True ) sql, params = expr.to_sql() @@ -39,7 +39,7 @@ def test_like_with_if_not_exists(self): def test_like_with_temporary(self): """Test CREATE TEMPORARY TABLE ... LIKE.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "temp_users"), like_table=Table(dialect, "users"), temporary=True ) sql, params = expr.to_sql() @@ -50,7 +50,7 @@ def test_like_with_temporary(self): def test_like_with_schema_qualified_table(self): """Test CREATE TABLE ... LIKE with a source table in another database.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "users_copy"), like_table=Table(dialect, "users", catalog_name="production"), @@ -63,7 +63,7 @@ def test_like_with_schema_qualified_table(self): def test_like_with_temporary_and_if_not_exists(self): """Test CREATE TEMPORARY TABLE ... LIKE with IF NOT EXISTS.""" dialect = MySQLDialect() - expr = CreateTableLikeExpression( + expr = CreateTableLikeStatement( dialect, table=Table(dialect, "temp_users_copy"), like_table=Table(dialect, "users", catalog_name="test_db"), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py index af4bfdb2..15f8462c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_expression_roundtrip_all.py @@ -41,10 +41,12 @@ class into ``pytest.skip``, which shares a green tick with "correctly nobody listed is a failure, and so is a row left behind after the class started working. -Discovery is ``collect_expression_classes``, never -``ExpressionRegistry._registry``. The registry is process-global and grows as +Discovery is ``collect_expression_classes``, never the render registry in +``backend.expression.registry``. That registry is process-global and grows as sibling modules import other backends, so three runs of this file measured -three different denominators. +three different denominators. The two are different mechanisms anyway: the +registry indexes ``(dialect class, kind)`` to a formatter, while the +serialization side indexes a kind to its expression class. Coverage spans two packages. ``backend.expression`` is core's own class library, collected here as well as this backend's, because outside the single @@ -59,7 +61,7 @@ class into ``pytest.skip``, which shares a green tick with "correctly import pytest -from rhosocial.activerecord.testsuite.utils.expression import ( +from expression_roundtrip_harness import ( collect_expression_classes, make_instance, register_all, @@ -195,6 +197,59 @@ def _table(d, name="t"): "types.MySQLEnumType": "values must be a non-empty list of labels.", "types.MySQLSetType": "values must be a non-empty list of labels.", "types.MySQLVectorType": "dim must be a positive integer.", + # Core predicates whose operand slots the generic guess fills with plain + # strings (the core's wildcard-split commit calls this out: value nodes in + # operand positions, supplied as strings). The factories hand them real + # value nodes, exactly as the core's own dummy2 matrix registers them. + "predicates.LikePredicate": "op is annotated str and the two operands " + "are string forward references, so the generic constructor supplies " + "plain strings where value nodes belong and the instance cannot render.", + "predicates.ILikePredicate": "column is annotated Any, so the generic " + "constructor supplies a plain string where a value node belongs and the " + "instance cannot render.", + # The string-function family whose operands are ``SQLValue`` forward + # references: the generic guess hands plain strings where value nodes + # belong, and the classes compare a Literal's value against a bound + # (``count.value < 0``) that a string cannot survive on the way back. + # The factories hand real value nodes. + "functions.string.ChrFunction": "code is a SQLValue forward reference, so " + "the generic constructor supplies a plain string where a value node " + "belongs.", + "functions.string.SpaceFunction": "count is a SQLValue forward reference, " + "so the generic constructor supplies a plain string where a value node " + "belongs.", + "functions.string.LeftFunction": "count is a SQLValue forward reference, " + "so the generic constructor supplies a plain string whose second " + "construction trips the n >= 0 comparison.", + "functions.string.RightFunction": "count is a SQLValue forward reference, " + "so the generic constructor supplies a plain string whose second " + "construction trips the n >= 0 comparison.", + "functions.string.OverlayExpression": "target / replacement / start are " + "SQLValue forward references, so the generic constructor supplies plain " + "strings where value nodes belong.", + "functions.string.SubstringExpression": "target / start are SQLValue " + "forward references, so the generic constructor supplies plain strings " + "whose second construction trips the start >= 1 comparison.", + # The two extract spellings: ``field`` reaches ``normalize_datetime_field`` + # at construction, and the generic guess is not a word the vocabulary + # names. + "datetime.ExtractFieldExpression": "field must name a DateTimeField member " + "or its word; the generic constructor supplies \"x\".", + "datetime._ExtractBase": "field must name a DateTimeField member or its " + "word; the generic constructor supplies \"x\".", + # The three nodes whose operation is a closed set of *words*, validated at + # construction. The generic constructor guesses a string for an unannotated + # ``op`` and the node refuses it, so the factories hand a real word form. + "operators.ArithmeticOperation": "op is validated against the wire word " + "forms (add / sub / mul / div / mod); the generic constructor supplies " + "\"x\", which is not one of them.", + "query_sources.SetOperation": "op is validated against the wire word " + "forms (union / union_all / intersect / except); the generic constructor " + "supplies \"x\", which is not one of them.", + "predicates.ComparisonPredicate": "op is validated at construction by " + "normalize_comparison_operator, which accepts a ComparisonOperator member, " + "a wire word, or a SQL character; the generic constructor supplies \"x\", " + "which is none of them, so the factory hands ComparisonOperator.EQ.", } #: Every class with a dedicated constructor, in one mapping, so @@ -255,7 +310,73 @@ def partition_maintenance(d, cls_name): return getattr(mod, cls_name)(d, table=Table(d, "t"), partitions=["p0"]) + from rhosocial.activerecord.backend.expression.operators import ArithmeticOperation + from rhosocial.activerecord.backend.expression.predicates import ( + ComparisonOperator, + ComparisonPredicate, + ILikePredicate, + LikeOperator, + LikePredicate, + ) + from rhosocial.activerecord.backend.expression.query_sources import SetOperation + from rhosocial.activerecord.backend.expression.statements.dql import ( + QueryStatement, + ) + from rhosocial.activerecord.backend.expression.datetime import ( + ExtractFieldExpression, + _ExtractBase, + ) + from rhosocial.activerecord.backend.expression.functions.string import ( + ChrFunction, + LeftFunction, + OverlayExpression, + RightFunction, + SpaceFunction, + SubstringExpression, + ) + factories = { + "predicates.LikePredicate": + lambda d: LikePredicate(d, LikeOperator.LIKE, Column(d, "a"), Literal(d, "%x%")), + "predicates.ILikePredicate": + lambda d: ILikePredicate(d, Column(d, "a"), "%x%"), + "predicates.ComparisonPredicate": + lambda d: ComparisonPredicate( + d, ComparisonOperator.EQ, Column(d, "a"), Literal(d, 1) + ), + "operators.ArithmeticOperation": + lambda d: ArithmeticOperation( + d, "add", Column(d, "a"), Column(d, "b") + ), + "query_sources.SetOperation": + lambda d: SetOperation( + d, + op="union", + left=QueryStatement( + d, select=[Column(d, "id")], from_=Table(d, "t") + ), + right=QueryStatement( + d, select=[Column(d, "id")], from_=Table(d, "other") + ), + ), + "functions.string.ChrFunction": lambda d: ChrFunction(d, Literal(d, 65)), + "functions.string.SpaceFunction": lambda d: SpaceFunction(d, Literal(d, 3)), + "functions.string.LeftFunction": + lambda d: LeftFunction(d, Literal(d, "abcdef"), Literal(d, 3)), + "functions.string.RightFunction": + lambda d: RightFunction(d, Literal(d, "abcdef"), Literal(d, 3)), + "functions.string.OverlayExpression": + lambda d: OverlayExpression( + d, Literal(d, "abcdef"), Literal(d, "XY"), Literal(d, 2), Literal(d, 3) + ), + "functions.string.SubstringExpression": + lambda d: SubstringExpression( + d, Literal(d, "abcdef"), Literal(d, 1), Literal(d, 3) + ), + "datetime.ExtractFieldExpression": + lambda d: ExtractFieldExpression(d, "YEAR", Column(d, "created_at")), + "datetime._ExtractBase": + lambda d: _ExtractBase(d, "YEAR", Column(d, "created_at")), "match_against.MySQLMatchAgainstExpression": lambda d: MySQLMatchAgainstExpression(d, columns=["title"], search_string="x"), "json.MySQLJSONObjectExpression": @@ -348,35 +469,26 @@ def partition_maintenance(d, cls_name): # :data:`_DECLARED_GAP`, which is where a class that constructs but does not # render belongs. _UNBUILDABLE = { - "statements.ddl_alter.AlterConstraint": - "constraint_name defaults to None and the constructor rejects it, so " - "the generic placeholder is never reached.", + # RESET names its option through the enum the constructor normalises + # through, so the generic guess ("x") is refused at construction rather + # than at render time. + "admin.MySQLResetStatement": + "option must be a ResetOption member or its word; the constructor " + "normalises through the enum and refuses \"x\" at construction.", "statements.ddl_alter.AlterConstraintAction": - "same shape as AlterConstraint: an abstract constraint action with no " - "constructible default.", - "statements.ddl_alter.AlterTableConstraint": - "same shape as AlterConstraint.", - "statements.ddl_alter.ValidateConstraint": - "constraint_name defaults to None and the constructor rejects it.", - "statements.ddl_alter.ValidateConstraintAction": - "same shape as ValidateConstraint.", - "statements.ddl_alter.ValidateTableConstraint": - "same shape as ValidateConstraint.", + "constraint_kind is a required keyword-only parameter naming the " + "branch class, and constraint_name defaults to None and is refused; " + "the generic placeholder reaches neither.", "statements.ddl_domain.AddDomainCheckAction": "check must be a DomainCheckConstraint instance; the generic " "constructor supplies a string.", - "types.enum_.EnumType": - "values defaults to None and EnumType requires a non-empty list.", - "types.custom.CustomType": - "raw defaults to '' and CustomType requires a SQL type name; dialect " - "comes first, so a required raw behind that default is not expressible.", "types.MySQLVarBinaryType": "length defaults to None and the class requires a length, so no " "signature makes it required at the filler's call site.", "uuid.UUIDConstantExpression": "which must name 'nil' or 'max' and __init__ rejects anything else, so " "the filler's guess is refused at construction.", - "uuid.UUIDCastExpression": + "uuid.UUIDCastFunction": "expression is annotated Any, so the filler supplies a string where the " "node requires an expression to cast.", "uuid.UUIDGenerationExpression": @@ -409,30 +521,40 @@ def partition_maintenance(d, cls_name): # Each of these has a ``supports_*`` method answering True while the # formatter the expression dispatches to does not exist, so a caller that # asks whether the feature is available is told yes and is then refused. - "statements.ddl_schema.CreateSchemaExpression": ( + "statements.ddl_schema.CreateSchemaStatement": ( "format_create_schema_statement", "DECLARED BUT NOT RENDERED. MySQLSchemaMixin.supports_create_schema() " "answers True -- MySQL takes SCHEMA as a synonym for DATABASE -- yet " "MySQLDialect defines no format_create_schema_statement. The dialect " "claims the capability and refuses the statement.", ), - "statements.ddl_schema.DropSchemaExpression": ( + "statements.ddl_schema.DropSchemaStatement": ( "format_drop_schema_statement", - "DECLARED BUT NOT RENDERED. Same shape as CreateSchemaExpression: " + "DECLARED BUT NOT RENDERED. Same shape as CreateSchemaStatement: " "MySQLSchemaMixin.supports_drop_schema() answers True and " "format_drop_schema_statement is absent.", ), "table_expr.MySQLInlineIndexExpression": ( - "format_inline_index", + "format_mysql_inline_index_expression", "DECLARED BUT NOT RENDERED. MySQLDDLTableMixin." "supports_inline_index() answers True, and the expression dispatches " - "to format_inline_index, which MySQLDialect does not define. CREATE " + "to format_mysql_inline_index_expression, which MySQLDialect does not define. CREATE " "TABLE renders its inline indexes through format_index_definition, so " "the class is unreachable from any rendering path in this backend -- " "nothing in src or tests constructs it.", ), + # --- The clause travels as a node; its rendering is inline. ----------- + "load_data.LoadDataOptions": ( + "format_load_data_options", + "DECLARED BUT NOT RENDERED. The LOAD DATA statement's own formatter " + "renders the options inline, reading the fields this clause declares; " + "no dialect defines a standing-alone entry for it. The clause is a " + "node because a constructor parameter is a scalar or an expression " + "instance (§10) and a dataclass is neither -- not because it renders " + "by itself.", + ), # --- SQL/PGQ: property graphs are not a MySQL feature. ---------------- - "graph.AlterPropertyGraphExpression": ( + "graph.AlterPropertyGraphStatement": ( "format_alter_property_graph_statement", "SQL/PGQ: MySQL has no property graphs, so no " "format_alter_property_graph_statement exists.", @@ -441,17 +563,19 @@ def partition_maintenance(d, cls_name): "format_graph_columns_clause", "SQL/PGQ graph column list; no format_graph_columns_clause.", ), - "graph.CreatePropertyGraphExpression": ( + "graph.CreatePropertyGraphStatement": ( "format_create_property_graph_statement", "SQL/PGQ: no format_create_property_graph_statement.", ), - "graph.DropPropertyGraphExpression": ( + "graph.DropPropertyGraphStatement": ( "format_drop_property_graph_statement", "SQL/PGQ: no format_drop_property_graph_statement.", ), - "graph.EdgeTable": ( - "format_edge_table", - "SQL/PGQ edge table; no format_edge_table.", + "graph.EdgeTableDefinition": ( + "format_edge_table_definition", + "SQL/PGQ edge table definition; no format_edge_table_definition. The " + "row was pinned as graph.EdgeTable / format_edge_table until core " + "renamed the declaration class.", ), "graph.GraphTableExpression": ( "format_graph_table_expression", @@ -466,18 +590,18 @@ def partition_maintenance(d, cls_name): "SQL/PGQ vertex table; no format_vertex_table.", ), # --- PIVOT / UNPIVOT are T-SQL and Oracle, not MySQL. ----------------- - "pivot.PivotExpression": ( - "format_pivot_expression", + "pivot.Pivot": ( + "format_pivot", "PIVOT is T-SQL/Oracle; MySQL spells it as GROUP BY with conditional " "aggregates and declares no formatter.", ), - "pivot.UnpivotExpression": ( - "format_unpivot_expression", + "pivot.Unpivot": ( + "format_unpivot", "UNPIVOT is T-SQL/Oracle; MySQL declares no formatter.", ), # --- Case-insensitive matching is written LOWER() = LOWER() here. ----- - "predicates.ILIKEExpression": ( - "format_ilike_expression", + "predicates.ILikePredicate": ( + "format_ilike_predicate", "MySQL has no ILIKE operator; case-insensitive matching is written " "LOWER(a) = LOWER(b), and no formatter exists.", ), @@ -489,8 +613,8 @@ def partition_maintenance(d, cls_name): "base that implements it.", ), # --- COMMENT ON is SQL-standard/PostgreSQL. --------------------------- - "statements.ddl_comment.CommentOnExpression": ( - "format_comment_statement", + "statements.ddl_comment.CommentOnStatement": ( + "format_comment_on_statement", "COMMENT ON is SQL-standard/PostgreSQL. MySQL spells a table comment " "as a CREATE TABLE option and a column comment inline, which " "format_column_definition already renders.", @@ -506,23 +630,30 @@ def partition_maintenance(d, cls_name): # the dispatch, which is the same declared gap as the property-graph rows # above and stated for the same reason. Naming a sequence is untouched: # SequenceNameMixin stays, so Sequence(dialect, "s").to_sql() renders. - "statements.ddl_sequence.CreateSequenceExpression": ( + "statements.ddl_sequence.CreateSequenceStatement": ( "format_create_sequence_statement", "MySQL has no sequence object; AUTO_INCREMENT columns are how rows are " "numbered. MySQLDialect mixes in no base providing this formatter.", ), - "statements.ddl_sequence.DropSequenceExpression": ( + "statements.ddl_sequence.DropSequenceStatement": ( "format_drop_sequence_statement", "MySQL has no sequence object; no format_drop_sequence_statement.", ), - "statements.ddl_sequence.AlterSequenceExpression": ( + "statements.ddl_sequence.AlterSequenceStatement": ( "format_alter_sequence_statement", "MySQL has no sequence object; no format_alter_sequence_statement.", ), # --- XML functions are not in MySQL. ----------------------------------- + # The S1-c PASSING clause rides the XML querying expressions; MySQL has + # none of that family, so the clause's own formatter is absent too and the + # dispatch refuses it -- the same declared gap as the rows below it. + "xml.PassingClause": ( + "format_passing_clause", + "XML functions are not in MySQL; no format_passing_clause.", + ), "xml.XMLAggExpression": ( - "format_xmlagg_expression", - "XML functions are not in MySQL; no format_xmlagg_expression.", + "format_xml_agg_expression", + "XML functions are not in MySQL; no format_xml_agg_expression.", ), # The four that used to be pinned in _UNBUILDABLE, because the harness read # their ``Sequence[X]`` parameter as the catalogue object Sequence and could @@ -531,60 +662,80 @@ def partition_maintenance(d, cls_name): # exists. They were unreachable from the matrix before and are accounted for # here now, which is the outcome the _UNBUILDABLE rows asked for. "xml.XMLAttributesExpression": ( - "format_xmlattributes_expression", - "XML functions are not in MySQL; no format_xmlattributes_expression. " + "format_xml_attributes_expression", + "XML functions are not in MySQL; no format_xml_attributes_expression. " "This row was in _UNBUILDABLE until testsuite stopped reading " "attributes: Sequence[XMLAttribute] as the catalogue object Sequence.", ), "xml.XMLConcatExpression": ( - "format_xmlconcat_expression", - "XML functions are not in MySQL; no format_xmlconcat_expression. This " + "format_xml_concat_expression", + "XML functions are not in MySQL; no format_xml_concat_expression. This " "row was in _UNBUILDABLE until testsuite stopped reading parts: " "Sequence[BaseExpression] as the catalogue object Sequence.", ), "xml.XMLForestExpression": ( - "format_xmlforest_expression", - "XML functions are not in MySQL; no format_xmlforest_expression. This " + "format_xml_forest_expression", + "XML functions are not in MySQL; no format_xml_forest_expression. This " "row was in _UNBUILDABLE until testsuite stopped reading items: " "Sequence[XMLForestItem] as the catalogue object Sequence.", ), "xml.XMLTableExpression": ( - "format_xmltable_expression", - "XML functions are not in MySQL; no format_xmltable_expression. This " + "format_xml_table_expression", + "XML functions are not in MySQL; no format_xml_table_expression. This " "row was in _UNBUILDABLE until testsuite stopped reading columns: " "Sequence[XMLTableColumn] as the catalogue object Sequence.", ), "xml.XMLCommentExpression": ( - "format_xmlcomment_expression", - "XML functions are not in MySQL; no format_xmlcomment_expression.", + "format_xml_comment_expression", + "XML functions are not in MySQL; no format_xml_comment_expression.", ), "xml.XMLElementExpression": ( - "format_xmlelement_expression", - "XML functions are not in MySQL; no format_xmlelement_expression.", + "format_xml_element_expression", + "XML functions are not in MySQL; no format_xml_element_expression.", ), - "xml.XMLExistsExpression": ( - "format_xmlexists_expression", - "XML functions are not in MySQL; no format_xmlexists_expression.", + "xml.XMLExistsPredicate": ( + "format_xmlexists_predicate", + "XML functions are not in MySQL; no format_xmlexists_predicate.", ), "xml.XMLPIExpression": ( "format_xmlpi_expression", "XML functions are not in MySQL; no format_xmlpi_expression.", ), "xml.XMLParseExpression": ( - "format_xmlparse_expression", - "XML functions are not in MySQL; no format_xmlparse_expression.", + "format_xml_parse_expression", + "XML functions are not in MySQL; no format_xml_parse_expression.", ), "xml.XMLQueryExpression": ( - "format_xmlquery_expression", - "XML functions are not in MySQL; no format_xmlquery_expression.", + "format_xml_query_expression", + "XML functions are not in MySQL; no format_xml_query_expression.", ), "xml.XMLRootExpression": ( - "format_xmlroot_expression", - "XML functions are not in MySQL; no format_xmlroot_expression.", + "format_xml_root_expression", + "XML functions are not in MySQL; no format_xml_root_expression.", ), "xml.XMLSerializeExpression": ( - "format_xmlserialize_expression", - "XML functions are not in MySQL; no format_xmlserialize_expression.", + "format_xml_serialize_expression", + "XML functions are not in MySQL; no format_xml_serialize_expression.", + ), + # --- The standard spelling of what MySQL renders under its own node. ---- + # Both rows are the honest outcome of splitting one entry into two: this + # dialect renders CREATE/DROP FUNCTION through MySQLCreateFunctionStatement + # and MySQLDropFunctionStatement, whose contract is the stored routine MySQL + # actually has, and it names no entry for core's SQL/PSM nodes. Those two + # used to reach the MySQL entry, which read ``expr.name`` off a node that + # carries ``expr.function`` and raised AttributeError -- a claim of support + # followed by a crash. Naming no entry is the support claim being withdrawn. + "statements.ddl_function.CreateFunctionStatement": ( + "format_create_function_statement", + "MySQL models CREATE FUNCTION as a stored routine " + "(MySQLCreateFunctionStatement, entry format_mysql_create_function_statement); " + "core's SQL/PSM node carries function/parameters/language, and this " + "dialect declares no entry for that shape.", + ), + "statements.ddl_function.DropFunctionStatement": ( + "format_drop_function_statement", + "Same split as CreateFunctionStatement: MySQLDropFunctionStatement " + "renders DROP FUNCTION; core's node has no entry here.", ), } @@ -600,52 +751,34 @@ def partition_maintenance(d, cls_name): # interesting half -- every row below is a refusal that is the right answer to # a wrong value. _NOT_RENDERED = { - # --- A MySQL formatter shadows the core one and reads other slots. ---- - "statements.ddl_function.CreateFunctionExpression": ( - "AttributeError", - "MySQLRoutineMixin.format_create_function_statement reads expr.name; " - "core's CreateFunctionExpression carries expr.function, so the MySQL " - "routine formatter shadows the core one for an expression it cannot " - "render.", - ), - "statements.ddl_function.DropFunctionExpression": ( - "AttributeError", - "Same shadowing as CreateFunctionExpression.", - ), - "query_sources.JSONTableExpression": ( - "AttributeError", - "MySQLJSONFunctionMixin.format_json_table_expression reads json_doc / " - "path / columns; core's JSONTableExpression exposes a different " - "shape, so the MySQL formatter cannot read it.", - ), # --- The dialect correctly refuses what the harness invented. --------- - "collation.CollateExpression": ( + "collation.CollateFunction": ( "ValueError", "The generic constructor supplies \"x\" for collation_name and MySQL's " "validate_collation_name refuses a collation it does not know. The " "refusal is the right answer to a wrong value.", ), - "routine.MySQLCallExpression": ( + "routine.MySQLCallStatement": ( "TypeError", "name must be a Procedure or Function object; the generic constructor " "supplies a string. Refusing is the object-kind guard working.", ), - "routine.MySQLCreateFunctionExpression": ( + "routine.MySQLCreateFunctionStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", ), - "routine.MySQLCreateProcedureExpression": ( + "routine.MySQLCreateProcedureStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", ), - "routine.MySQLDropFunctionExpression": ( + "routine.MySQLDropFunctionStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", ), - "routine.MySQLDropProcedureExpression": ( + "routine.MySQLDropProcedureStatement": ( "TypeError", "name must be a routine object; the generic constructor supplies a " "string.", @@ -718,30 +851,92 @@ def partition_maintenance(d, cls_name): "A window specification needs at least one of PARTITION BY / ORDER BY " "/ FRAME; the generic constructor supplies none.", ), - "admin.MySQLFlushExpression": ( + "admin.MySQLFlushStatement": ( "ValueError", "FLUSH needs at least one option; the generic constructor supplies an " "empty list.", ), - "admin.MySQLHandlerCloseExpression": ( + "admin.MySQLHandlerCloseStatement": ( "TypeError", "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", ), - "admin.MySQLHandlerOpenExpression": ( + "admin.MySQLHandlerOpenStatement": ( "TypeError", "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", ), - "admin.MySQLHandlerReadExpression": ( + "admin.MySQLHandlerReadStatement": ( "TypeError", "table must be a Table object; the generic constructor supplies a " "string, and the dialect's object-kind guard refuses it.", ), - "admin.MySQLResetExpression": ( + # --- The declared-result annotations render through a formatter that + # --- reads an optional field not every node declares. ---------------- + # ``format_declared_result`` (a core dialect mixin) reads + # ``expr.source.alias`` directly. ``alias`` is an optional field and + # not every node declares it -- ``Literal`` has none -- so any + # annotation over a bare literal is refused with AttributeError from + # inside the renderer. §11.2 spells the expectation: an optional field + # is a class attribute with a default, and a formatter reads declared + # fields. These rows name the defect so the table says to come back + # when the formatter or the base declares it. + "declared._Annotation": ( "AttributeError", - "option must be a ResetOption member; the generic constructor supplies " - "a string.", + "format_declared_result reads expr.source.alias; the annotation's " + "source is a Literal, which declares no alias. The same defect sits " + "under every row below -- one entry per class because the table is " + "keyed by class.", + ), + "declared.ArrayAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.BinaryAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.BooleanAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.IntegerAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.JSONAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.NumberAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.TemporalAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.TextAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.UUIDAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", + ), + "declared.XMLAnnotation": ( + "AttributeError", + "format_declared_result reads expr.source.alias; a Literal source " + "declares no alias.", ), "alter_column.MySQLAddColumn": ( "AttributeError", @@ -753,7 +948,7 @@ def partition_maintenance(d, cls_name): "Temporal options cannot be empty, and the generic constructor cannot " "know which one to pick.", ), - "json_duality_view.CreateJsonDualityViewExpression": ( + "json_duality_view.CreateJsonDualityViewStatement": ( "AttributeError", "The JSON duality view takes duality object specs; the generic " "constructor supplies strings.", @@ -771,36 +966,31 @@ def partition_maintenance(d, cls_name): "subquery must be a duality object select spec; the generic " "constructor supplies a string.", ), - "maintenance.MySQLAnalyzeTableExpression": ( + "maintenance.MySQLAnalyzeTableStatement": ( "ValueError", "ANALYZE TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLCheckTableExpression": ( + "maintenance.MySQLCheckTableStatement": ( "ValueError", "CHECK TABLE needs at least one table; the generic constructor supplies " "an empty list.", ), - "maintenance.MySQLChecksumTableExpression": ( + "maintenance.MySQLChecksumTableStatement": ( "ValueError", "CHECKSUM TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLOptimizeTableExpression": ( + "maintenance.MySQLOptimizeTableStatement": ( "ValueError", "OPTIMIZE TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLRepairTableExpression": ( + "maintenance.MySQLRepairTableStatement": ( "ValueError", "REPAIR TABLE needs at least one table; the generic constructor " "supplies an empty list.", ), - "maintenance.MySQLTableMaintenanceExpression": ( - "ValueError", - "A whole-table maintenance statement needs at least one table; the " - "generic constructor supplies an empty list.", - ), "optimizer_hint.MySQLOptimizerHintExpression": ( "AttributeError", "hints must be objects carrying variable / value; the generic " @@ -826,41 +1016,36 @@ def partition_maintenance(d, cls_name): "target_count must be below current_count; the generic constructor " "supplies 1 for both.", ), - "rename_table.MySQLRenameTableExpression": ( + "rename_table.MySQLRenameTableStatement": ( "ValueError", "renames must be a non-empty list of (old, new) Table pairs; the " "generic constructor supplies an empty list.", ), - "statements.ddl_alter.AddColumn": ( + "statements.ddl_alter.AddColumnAction": ( "AttributeError", "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_alter.AddTableConstraint": ( + "statements.ddl_alter.AddTableConstraintAction": ( "AttributeError", "constraint must be a TableConstraint; the generic constructor supplies " "a string.", ), - "statements.ddl_alter.ChangeColumn": ( + "statements.ddl_alter.ChangeColumnAction": ( "AttributeError", "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_alter.ModifyColumn": ( + "statements.ddl_alter.ModifyColumnAction": ( "AttributeError", "column must be a ColumnDefinition; the generic constructor supplies a " "string.", ), - "statements.ddl_trigger.CreateTriggerExpression": ( + "statements.ddl_trigger.CreateTriggerStatement": ( "AttributeError", "timing must be a TriggerTiming member; the generic constructor " "supplies a string.", ), - "statements.ddl_table.ColumnConstraint": ( - "ValueError", - "constraint_type must be a ColumnConstraintType member or its string " - "value; the generic constructor supplies \"x\".", - ), "statements.ddl_table.ReferencesClause": ( "ValueError", "REFERENCES needs at least one referenced column; the generic " @@ -873,15 +1058,11 @@ def partition_maintenance(d, cls_name): "MERGE at all -- upsert is ON DUPLICATE KEY UPDATE -- so this row is " "core's shape rather than MySQL's.", ), - "statements.dml.MergeExpression": ( + "statements.dml.MergeStatement": ( "AttributeError", "source must be a row source; the generic constructor supplies a " "string. MySQL has no MERGE.", ), - "table_expr.MySQLStorageOptionsExpression": ( - "AttributeError", - "Built without the options mapping its formatter reads.", - ), "table_statement.MySQLValuesExpression": ( "ValueError", "VALUES needs at least one ROW(...); the generic constructor supplies " @@ -889,6 +1070,112 @@ def partition_maintenance(d, cls_name): ), } +# Classes that build, whose round-trip through the artifact does not deliver +# an equivalent instance -- whether the parameter comparison catches it or the +# re-render after the trip does -- for a reason that lives outside this +# repository. Keyed by fqn suffix; the value is +# ``(exception type name, reason)`` and both are asserted, so a row cannot +# quietly start describing a different failure. +# +# The serialization half of this matrix has no other escape hatch, and it +# needs exactly one: every buildable class must survive all three channels, +# and these classes cannot, because a *core* class breaks the trip -- an enum +# whose spelling the XML channel mangles, an enum whose member the value +# vocabulary refuses to write, a constructor that refuses the word its own +# artifact carries, or a parameter whose type has no artifact form at all. +# The owner is the core worktree; each reason names the defect and the class +# it sits in, and the exactness test below makes a core fix delete its own +# row: once the class round-trips, a row left behind is a failure. +_ROUNDTRIP_DEFECT = { + # --- A constructor that keeps the artifact's word instead of the ------- + # --- member, so the restored parameter is not the one that went out. --- + # §10: the constructor takes the serialized form and normalises it. These + # store what the artifact hands back, so a parameter that left as a member + # returns as a plain ``str`` -- and everything downstream that reads + # ``.value`` only finds it on the member. + # + # The four enum families below are the residue of a core fix that made the + # member *be* its word on the wire: the artifact now carries 'DOCUMENT', + # 'CASCADE', 'VIRTUAL' as a plain string, and the reader has nothing left + # to normalise with. The spelling half of the defect is gone; the + # normalisation half is not. + "xml.XMLParseExpression": ( + "AssertionError", + "CORE DEFECT: core's XMLParseExpression stores ``document_type`` raw, " + "so the word the artifact carries ('DOCUMENT') comes back as a str " + "where the original carried the XMLParseDocumentType member. The " + "spelling itself is now right -- the XML channel writes the word, not " + "'XMLParseDocumentType.DOCUMENT'.", + ), + "xml.XMLSerializeExpression": ( + "AssertionError", + "CORE DEFECT: same raw-store of ``document_type`` in " + "XMLSerializeExpression -- the restored parameter is the word, not the " + "XMLSerializeDocumentType member.", + ), + "statements.ddl_table.ForeignKeyConstraint": ( + "AssertionError", + "CORE DEFECT: core's ReferentialAction is a str enum now, so the " + "artifact carries 'CASCADE' -- and TableForeignKeyConstraint stores " + "``on_delete`` / ``on_update`` raw, so the restored constraint carries " + "the word where the original carried the member.", + ), + "statements.ddl_table.ColumnGeneratedConstraint": ( + "AssertionError", + "CORE DEFECT: core's GeneratedColumnType is a str enum now, so the " + "artifact carries 'VIRTUAL' -- but the constructor stores " + "``storage_type`` without normalising, so the restored constraint " + "carries the word where the original carried the member.", + ), + # --- A constructor that refuses its own artifact's word. --------------- + # §10: the constructor accepts the serialized form and normalises it. + # These two do the opposite -- the entry is narrower than the wire. + "statements.ddl_table.CreateTableCloneStatement": ( + "TypeError", + "CORE DEFECT: the constructor refuses the word its artifact carries " + "('mode must be a CreateTableCloneMode, got str') instead of " + "normalising it, so the JSON channel's own output cannot be read " + "back.", + ), + "statements.ddl_trigger.CreateTriggerStatement": ( + "AssertionError", + "CORE DEFECT: the constructor stores ``level`` raw, so the restored " + "instance carries the plain word where the original carried the " + "TriggerLevel member -- and the formatter reads ``.value``, which " + "only the member has.", + ), + "graph.GraphEdge": ( + "AssertionError", + "CORE DEFECT: the constructor stores ``direction`` raw; the restored " + "instance carries the plain word, and format_graph_edge's " + "isinstance guard refuses it on re-render.", + ), + "advanced_functions.ExistsPredicate": ( + "AssertionError", + "CORE DEFECT: the constructor renders its subquery input at " + "construction time (``query_input, query_params = query.to_sql()``), " + "and the artifact carries that tuple as a JSON array; the " + "constructor does not normalise the array back to a tuple, so the " + "restored statement binds a list where the original bound a tuple.", + ), + # --- A parameter whose type has no artifact form. ----------------------- + # §10 names exactly two parameter forms: a language-neutral scalar, or an + # expression instance. A dataclass is neither, and the core's own + # artifact-contract test states the absence: a dataclass has no place in + # the artifact at all. + "graph.GraphTableExpression": ( + "TypeError", + "CORE DEFECT: GraphColumn is a dataclass carried in the columns " + "parameter -- neither scalar nor node, so LiteralKind.of refuses it " + "and the graph-table contract has no artifact form.", + ), + "statements.ddl_view.CreateViewStatement": ( + "TypeError", + "CORE DEFECT: ViewOptions is a dataclass carried in the options " + "parameter -- neither scalar nor node, so LiteralKind.of refuses it.", + ), +} + def _suffix(fqn): """The fqn suffix the exemption tables are keyed by.""" @@ -964,7 +1251,18 @@ class TestExpressionSerialization: def test_get_params_roundtrip(self, expr_case, mysql_dialect): suffix, instance = expr_case - roundtrip_expression(suffix, instance, mysql_dialect) + try: + roundtrip_expression(suffix, instance, mysql_dialect) + except Exception as exc: + row = _ROUNDTRIP_DEFECT.get(suffix) + assert row is not None, ( + f"{suffix}: round-trip raised {type(exc).__name__}, which is " + f"not a named row in _ROUNDTRIP_DEFECT: {exc}" + ) + assert type(exc).__name__ == row[0], ( + f"{suffix}: _ROUNDTRIP_DEFECT says {row[0]} but it raised " + f"{type(exc).__name__}: {exc}" + ) class TestExpressionRendering: @@ -1007,7 +1305,18 @@ def test_to_sql_is_classified(self, expr_case, mysql_dialect): assert isinstance(rendered, tuple) and len(rendered) == 2, ( f"{suffix}: to_sql() returned {rendered!r}, expected a (sql, params) tuple" ) - sql_consistent(suffix, instance, mysql_dialect) + try: + sql_consistent(suffix, instance, mysql_dialect) + except Exception as exc: + # A class whose round-trip is broken by a named defect cannot be + # asked to render identically *after* that trip: the restored + # instance is exactly the one the defect mangles. The row already + # accounts for the class, and test_roundtrip_defect_table_is_exact + # is what says the row still describes a live failure. + assert suffix in _ROUNDTRIP_DEFECT, ( + f"{suffix}: the restored instance failed where the original " + f"rendered, and no _ROUNDTRIP_DEFECT row accounts for it: {exc}" + ) # --------------------------------------------------------------------------- @@ -1077,6 +1386,59 @@ def _verdicts(mysql_dialect): return out +def _roundtrip_defects(mysql_dialect): + """What every buildable class actually does when asked to round-trip. + + The observed half of ``_ROUNDTRIP_DEFECT``'s exactness assertion, built + the same way the two tests build their cases: every buildable class, + constructed, walked through all three channels, and -- where the + original renders -- asked to render identically after the trip. + ``sql_consistent`` guards its own ``to_sql`` call, so a class whose + rendering is refused contributes only its serialization verdict. + """ + failures = {} + for fqn in _MATRIX_FQNS: + instance, _source = make_instance(ALL_CLASSES[fqn], mysql_dialect) + if instance is None: + continue + suffix = _suffix(fqn) + try: + roundtrip_expression(suffix, instance, mysql_dialect) + sql_consistent(suffix, instance, mysql_dialect) + except Exception as exc: + failures[suffix] = type(exc).__name__ + return failures + + +def test_roundtrip_defect_table_is_exact(mysql_dialect): + """_ROUNDTRIP_DEFECT lists every class whose round-trip fails, and only those. + + A row means the trip does not deliver an equivalent instance -- whether + the parameter comparison catches it or the re-render does. Exact in both + directions, like the other tables: a core fix that makes a class + round-trip leaves a stale row behind, and that is a failure -- the table + cannot outlive the defect it describes. A new failure with no row is a + failure too, for the usual reason. + """ + observed = _roundtrip_defects(mysql_dialect) + missing = sorted(set(observed) - set(_ROUNDTRIP_DEFECT)) + stale = sorted(set(_ROUNDTRIP_DEFECT) - set(observed)) + assert not missing, ( + "these classes fail to round-trip and are not pinned in " + f"_ROUNDTRIP_DEFECT: {missing}" + ) + assert not stale, ( + f"these rows in _ROUNDTRIP_DEFECT are stale -- the classes now " + f"round-trip (a core fix landed; delete the rows): {stale}" + ) + for suffix, (exc_name, reason) in _ROUNDTRIP_DEFECT.items(): + assert exc_name == observed[suffix], ( + f"_ROUNDTRIP_DEFECT[{suffix!r}] says {exc_name} but it raises " + f"{observed[suffix]}" + ) + assert reason.strip(), f"_ROUNDTRIP_DEFECT[{suffix!r}] has no reason" + + def test_unbuildable_table_is_exact(mysql_dialect): """_UNBUILDABLE lists every unbuildable class, and nothing else.""" observed = { @@ -1183,7 +1545,7 @@ class from being in both, nor from a class having no verdict at all. This def test_constructor_tables_are_exact(): """Every row has a registered factory, and every factory has a row.""" - from rhosocial.activerecord.testsuite.utils import expression as harness + import expression_roundtrip_harness as harness installed = set(harness._SPECIAL_REGISTRY) - set(harness.special_constructors()) listed = set(DEDICATED_CONSTRUCTORS) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py index 0ac58de2..e6389843 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_fulltext_index.py @@ -11,10 +11,10 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.statements import ( - CreateFulltextIndexExpression, + CreateFulltextIndexStatement, ) from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( - FulltextMatchExpression, + FulltextMatch, ) from rhosocial.activerecord.backend.impl.mysql.expression import MySQLMatchAgainstExpression from rhosocial.activerecord.backend.expression.objects import Index, Table @@ -38,7 +38,7 @@ def test_supports_fulltext_index_version_check(self): assert dialect_80.supports_fulltext_index() def test_format_match_against(self): - """Test format_match_against method.""" + """Test format_mysql_match_against_expression method.""" dialect = MySQLDialect(version=(8, 0, 0)) expr = MySQLMatchAgainstExpression( @@ -96,7 +96,7 @@ def test_format_fulltext_match_natural_mode(self): """Test MATCH ... AGAINST with natural language mode.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = FulltextMatchExpression(dialect, columns=["title", "content"], search_term="database system") + expr = FulltextMatch(dialect, columns=["title", "content"], search_term="database system") sql, params = dialect.format_fulltext_match(expr) assert "MATCH(`title`, `content`)" in sql @@ -108,7 +108,7 @@ def test_format_fulltext_match_boolean_mode(self): """Test MATCH ... AGAINST with boolean mode.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = FulltextMatchExpression(dialect, columns=["title", "content"], search_term="+database +system", mode="BOOLEAN") + expr = FulltextMatch(dialect, columns=["title", "content"], search_term="+database +system", mode="BOOLEAN") sql, params = dialect.format_fulltext_match(expr) assert "MATCH(`title`, `content`)" in sql @@ -120,7 +120,7 @@ def test_format_fulltext_match_query_expansion(self): """Test MATCH ... AGAINST with query expansion.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = FulltextMatchExpression(dialect, columns=["content"], search_term="database", mode="QUERY EXPANSION") + expr = FulltextMatch(dialect, columns=["content"], search_term="database", mode="QUERY EXPANSION") sql, params = dialect.format_fulltext_match(expr) assert "MATCH(`content`)" in sql @@ -132,7 +132,7 @@ def test_format_create_fulltext_index(self): """Test CREATE FULLTEXT INDEX statement formatting.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateFulltextIndexExpression( + expr = CreateFulltextIndexStatement( dialect=dialect, index=Index(dialect, "idx_content"), table=Table(dialect, "articles"), columns=["title", "content"] ) sql, params = expr.to_sql() @@ -147,7 +147,7 @@ def test_format_create_fulltext_index_with_parser(self): """Test CREATE FULLTEXT INDEX with parser plugin.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateFulltextIndexExpression( + expr = CreateFulltextIndexStatement( dialect=dialect, index=Index(dialect, "idx_content"), table=Table(dialect, "articles"), columns=["content"], parser="ngram" ) sql, params = expr.to_sql() @@ -161,11 +161,11 @@ def test_fulltext_unsupported_raises_error(self): dialect = MySQLDialect(version=(5, 5, 0)) with pytest.raises(Exception): # UnsupportedFeatureError # noqa: B017 - expr = FulltextMatchExpression(dialect, columns=["title"], search_term="test") + expr = FulltextMatch(dialect, columns=["title"], search_term="test") dialect.format_fulltext_match(expr) with pytest.raises(Exception): # UnsupportedFeatureError # noqa: B017 - expr = CreateFulltextIndexExpression( + expr = CreateFulltextIndexStatement( dialect=dialect, index=Index(dialect, "idx_test"), table=Table(dialect, "test_table"), columns=["content"] ) expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py new file mode 100644 index 00000000..ec784d98 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py @@ -0,0 +1,198 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_introspect_surface.py +"""The introspect type surface: what the command offers, and what NAME means. + +No server is needed here. These tests cover the layer that drifted before: a +family can be offered by the parser while no branch answers it, and a NAME can +be accepted by the parser while no code uses it. Both are invisible to a +test that only talks to a live server, because the failure looks like an empty +answer rather than an error. +""" + +import argparse +import inspect +from typing import Optional + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.cli import introspect as introspect_cli +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +class _Args: + """The two attributes the rules look at, and nothing else.""" + + def __init__(self, type_: str, name: Optional[str] = None) -> None: + self.type = type_ + self.name = name + + +class TestTheTypeSurface: + def test_the_type_list_is_the_capability_table(self): + assert list(introspect_cli._TYPE_CAPABILITIES) == introspect_cli.INTROSPECT_TYPES + + def test_no_removed_aggregate_is_offered(self): + assert "table" not in introspect_cli.INTROSPECT_TYPES + + def test_every_switch_exists_on_the_dialect(self): + for name, switch in introspect_cli._TYPE_CAPABILITIES.items(): + assert hasattr(MySQLDialect, switch), f"{name} names a switch that does not exist" + + def test_every_name_rule_covers_a_declared_type(self): + assert set(introspect_cli._NAME_RULE) == set(introspect_cli.INTROSPECT_TYPES) + + def test_help_lists_every_type(self, capsys): + parser = introspect_cli.create_parser(argparse.ArgumentParser().add_subparsers()) + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + out = capsys.readouterr().out + for name in introspect_cli.INTROSPECT_TYPES: + assert name in out + + def test_every_type_has_a_branch_in_both_paths(self): + """A type with no branch prints nothing and exits 0 -- the worst shape.""" + sync_source = inspect.getsource(introspect_cli._handle_introspect_sync) + async_source = inspect.getsource(introspect_cli._handle_introspect_async) + for name in introspect_cli.INTROSPECT_TYPES: + assert f'args.type == "{name}"' in sync_source, name + assert f'args.type == "{name}"' in async_source, name + + +class TestTheNameRule: + @pytest.mark.parametrize("type_", ["tables", "database"]) + def test_a_name_the_type_answers_whole_is_refused(self, type_, capsys): + with pytest.raises(SystemExit) as excinfo: + introspect_cli._validate_name(_Args(type_, "users")) + + assert excinfo.value.code == 2 + assert "takes no name" in capsys.readouterr().err + + @pytest.mark.parametrize("type_", ["columns", "indexes", "foreign-keys", "partitions"]) + def test_a_required_name_must_be_given(self, type_, capsys): + """A missing required NAME is an incomplete invocation, not a refusal. + + It exits 1 like a missing ``--database``; 2 is reserved for a request + this command declines to serve. + """ + with pytest.raises(SystemExit) as excinfo: + introspect_cli._validate_name(_Args(type_)) + + assert excinfo.value.code == 1 + assert "required" in capsys.readouterr().err + + @pytest.mark.parametrize("type_", ["views", "triggers", "table-options"]) + def test_an_optional_name_may_be_given_or_not(self, type_): + introspect_cli._validate_name(_Args(type_)) + introspect_cli._validate_name(_Args(type_, "x")) + + +class TestTheCapabilityGate: + def test_a_refused_family_exits_two(self, capsys): + class _Dialect: + @staticmethod + def supports_trigger_introspection() -> bool: + return False + + class _Backend: + dialect = _Dialect() + + with pytest.raises(SystemExit) as excinfo: + introspect_cli._require_capability(_Args("triggers"), _Backend()) + + assert excinfo.value.code == 2 + assert "not supported by this server" in capsys.readouterr().err + + def test_a_stub_without_a_dialect_is_not_refused(self): + """The offline tests stand a stub in for the backend; it has no dialect.""" + introspect_cli._require_capability(_Args("triggers"), object()) + + +class TestTheViewsBranch: + """``views NAME`` answers for one view, and says so when there is no such view.""" + + def _args(self, name: Optional[str]) -> argparse.Namespace: + return argparse.Namespace( + output="json", + rich_ascii=False, + type="views", + name=name, + database="mydb", + is_async=False, + named_connection=None, + include_system=False, + ) + + def test_a_named_view_is_asked_for_by_name(self, monkeypatch): + seen = {} + + class _View: + name = "users_v" + schema = "mydb" + definition = "CREATE VIEW users_v AS SELECT 1" + + class _Introspector: + @staticmethod + def get_view_info(view_name, schema=None): + seen["name"] = view_name + return _View() + + @staticmethod + def list_views(schema=None): # pragma: no cover - must not be called + raise AssertionError("the whole family was listed despite a name") + + class _Backend: + dialect = MySQLDialect() + _connection = None + + def __init__(self, **_kwargs): + self.introspector = _Introspector() + + def connect(self): + pass + + def introspect_and_adapt(self): + pass + + def disconnect(self): + pass + + monkeypatch.setattr(introspect_cli, "MySQLBackend", _Backend) + monkeypatch.setattr( + introspect_cli, "resolve_connection_config_from_args", lambda _args: None + ) + + introspect_cli.handle(self._args("users_v")) + + assert seen["name"] == "users_v" + + def test_an_unknown_view_exits_one(self, monkeypatch, capsys): + class _Introspector: + @staticmethod + def get_view_info(view_name, schema=None): + return None + + class _Backend: + dialect = MySQLDialect() + _connection = None + + def __init__(self, **_kwargs): + self.introspector = _Introspector() + + def connect(self): + pass + + def introspect_and_adapt(self): + pass + + def disconnect(self): + pass + + monkeypatch.setattr(introspect_cli, "MySQLBackend", _Backend) + monkeypatch.setattr( + introspect_cli, "resolve_connection_config_from_args", lambda _args: None + ) + + with pytest.raises(SystemExit) as excinfo: + introspect_cli.handle(self._args("nope")) + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py index 3b637e40..5a126e24 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_arrow_expression.py @@ -8,8 +8,8 @@ Core used to have a single ``JSONExpression`` covering both access operators and has since split it in two, one per operator: ``->`` yields a document and -is a ``JSONDocumentExpression``, ``->>`` yields text and is a -``JSONTextExpression``. Both render through the same ``format_json_expression`` +is a ``JSONDocumentValue``, ``->>`` yields text and is a +``JSONTextValue``. Both render through the same ``format_json_expression`` dispatch and both formatters here still branch on ``expr.operation``, so the class names below record which operator each test means and nothing about the SQL changed. @@ -19,9 +19,9 @@ class names below record which operator each test means and nothing about the from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.advanced_functions import ( - JSONDocumentExpression, + JSONDocumentValue, JSONPathMode, - JSONTextExpression, + JSONTextValue, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression.types import MySQLSignedType, MySQLUnsignedType @@ -34,28 +34,28 @@ def dialect(): class TestArrowExpression: def test_arrow_column_identifier(self, dialect): - """``->`` yields a document, so it is a JSONDocumentExpression.""" - expr = JSONDocumentExpression(dialect, "data", "$.name", "->") + """``->`` yields a document, so it is a JSONDocumentValue.""" + expr = JSONDocumentValue(dialect, "data", "$.name", "->") sql, params = expr.to_sql() assert sql == "`data`->'$.name'" assert params == () def test_arrow_operator_identifier(self, dialect): - """``->>`` yields text, so it is a JSONTextExpression.""" - expr = JSONTextExpression(dialect, "data", "$.name", "->>") + """``->>`` yields text, so it is a JSONTextValue.""" + expr = JSONTextValue(dialect, "data", "$.name", "->>") sql, params = expr.to_sql() assert sql == "`data`->>'$.name'" assert params == () def test_arrow_with_nested_column_expression(self, dialect): col = Column(dialect, "payload") - expr = JSONDocumentExpression(dialect, col, "$.a.b", "->") + expr = JSONDocumentValue(dialect, col, "$.a.b", "->") sql, params = expr.to_sql() assert sql == "`payload`->'$.a.b'" assert params == () def test_arrow_with_cast_types(self, dialect): - expr = JSONTextExpression( + expr = JSONTextValue( dialect, "data", "$.age", "->>", mode=JSONPathMode.ARROW ).cast(MySQLUnsignedType(dialect)) sql, params = expr.to_sql() @@ -63,14 +63,14 @@ def test_arrow_with_cast_types(self, dialect): assert params == () def test_arrow_with_alias(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", alias="nm") + expr = JSONDocumentValue(dialect, "data", "$.name", "->", alias="nm") sql, params = expr.to_sql() assert sql == "`data`->'$.name' AS `nm`" assert params == () def test_arrow_forced_mode_renders_arrow(self): old = MySQLDialect(version=(5, 7, 0)) - expr = JSONDocumentExpression(old, "data", "$.name", "->", mode=JSONPathMode.ARROW) + expr = JSONDocumentValue(old, "data", "$.name", "->", mode=JSONPathMode.ARROW) sql, params = expr.to_sql() assert sql == "`data`->'$.name'" assert params == () @@ -80,11 +80,11 @@ def test_arrow_other_operator_uses_placeholder(self, dialect): ``=`` is not one of the two operators core split, and it says nothing about the result type -- it is a comparison, so it yields a boolean and - neither JSONDocumentExpression nor JSONTextExpression means it. The + neither JSONDocumentValue nor JSONTextValue means it. The base class is used as a plain carrier for the node; the branch is chosen by ``operation`` alone, so either class renders this identically. """ - expr = JSONDocumentExpression(dialect, "data", "$.name", "=") + expr = JSONDocumentValue(dialect, "data", "$.name", "=") sql, params = expr.to_sql() assert sql == "(`data` = %s)" assert params == ("$.name",) @@ -92,7 +92,7 @@ def test_arrow_other_operator_uses_placeholder(self, dialect): def test_arrow_other_operator_with_column_expression(self, dialect): """As above: ``@>`` is not an arrow, so no JSON path class denotes it.""" col = Column(dialect, "payload") - expr = JSONDocumentExpression(dialect, col, "someval", "@>") + expr = JSONDocumentValue(dialect, col, "someval", "@>") sql, params = expr.to_sql() assert sql == "(`payload` @> %s)" assert params == ("someval",) @@ -101,14 +101,14 @@ def test_arrow_other_operator_with_column_expression(self, dialect): class TestFunctionExpression: def test_function_extract(self, dialect): """``->`` is JSON_EXTRACT and yields a document.""" - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION) + expr = JSONDocumentValue(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name')" assert params == () def test_function_unquote(self, dialect): """``->>`` unquotes and yields text.""" - expr = JSONTextExpression(dialect, "data", "$.name", "->>", mode=JSONPathMode.FUNCTION) + expr = JSONTextValue(dialect, "data", "$.name", "->>", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "JSON_UNQUOTE(JSON_EXTRACT(`data`, '$.name'))" assert params == () @@ -119,20 +119,20 @@ def test_function_other_operator(self, dialect): Same as the arrow-mode case: the base class is a carrier and ``operation`` alone picks the branch. """ - expr = JSONDocumentExpression(dialect, "data", "$.name", "@>", mode=JSONPathMode.FUNCTION) + expr = JSONDocumentValue(dialect, "data", "$.name", "@>", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "`data` @> '$.name'" assert params == () def test_function_with_column_expression(self, dialect): col = Column(dialect, "payload") - expr = JSONDocumentExpression(dialect, col, "$.a", "->", mode=JSONPathMode.FUNCTION) + expr = JSONDocumentValue(dialect, col, "$.a", "->", mode=JSONPathMode.FUNCTION) sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`payload`, '$.a')" assert params == () def test_function_with_cast_types(self, dialect): - expr = JSONTextExpression( + expr = JSONTextValue( dialect, "data", "$.age", "->>", mode=JSONPathMode.FUNCTION ).cast(MySQLSignedType(dialect)) sql, params = expr.to_sql() @@ -140,7 +140,7 @@ def test_function_with_cast_types(self, dialect): assert params == () def test_function_with_alias(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION, alias="nm") + expr = JSONDocumentValue(dialect, "data", "$.name", "->", mode=JSONPathMode.FUNCTION, alias="nm") sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name') AS `nm`" assert params == () @@ -148,30 +148,30 @@ def test_function_with_alias(self, dialect): class TestModeDispatch: def test_auto_uses_arrow_when_supported(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->") + expr = JSONDocumentValue(dialect, "data", "$.name", "->") sql, _ = expr.to_sql() assert "JSON_EXTRACT" not in sql assert "->" in sql def test_auto_falls_back_to_function_when_unsupported(self): old = MySQLDialect(version=(5, 7, 0)) - expr = JSONDocumentExpression(old, "data", "$.name", "->") + expr = JSONDocumentValue(old, "data", "$.name", "->") sql, params = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name')" assert params == () def test_string_mode_coercion(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.name", "->", mode="function") + expr = JSONDocumentValue(dialect, "data", "$.name", "->", mode="function") sql, _ = expr.to_sql() assert sql == "JSON_EXTRACT(`data`, '$.name')" def test_escaped_path_backslash(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.a\\b", "->") + expr = JSONDocumentValue(dialect, "data", "$.a\\b", "->") sql, _ = expr.to_sql() assert sql == r"`data`->'$.a\\b'" def test_escaped_path_single_quote(self, dialect): - expr = JSONDocumentExpression(dialect, "data", "$.a'b", "->") + expr = JSONDocumentValue(dialect, "data", "$.a'b", "->") sql, _ = expr.to_sql() assert sql == "`data`->'$.a''b'" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py index 25d79d28..7ac471a6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_duality_view_expressions.py @@ -3,8 +3,8 @@ Tests for MySQL JSON Duality View expression classes (MySQL 9.7+). Tests SQL generation for: -- CreateJsonDualityViewExpression -- DropJsonDualityViewExpression +- CreateJsonDualityViewStatement +- DropJsonDualityViewStatement - DualityObjectSpec with various tag combinations - Nested duality objects """ @@ -12,8 +12,8 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression import ( - CreateJsonDualityViewExpression, - DropJsonDualityViewExpression, + CreateJsonDualityViewStatement, + DropJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityNestedMapping, @@ -55,7 +55,7 @@ def test_basic_read_only_view(self, dialect_97): from_table="users", from_alias="t", ) - expr = CreateJsonDualityViewExpression(dialect_97, "users_dv", spec) + expr = CreateJsonDualityViewStatement(dialect_97, "users_dv", spec) sql, params = expr.to_sql() assert "CREATE JSON RELATIONAL DUALITY VIEW" in sql @@ -76,7 +76,7 @@ def test_writable_view_all_tags(self, dialect_97): ], from_table="orders", ) - expr = CreateJsonDualityViewExpression(dialect_97, "orders_dv", spec) + expr = CreateJsonDualityViewStatement(dialect_97, "orders_dv", spec) sql, params = expr.to_sql() assert "WITH(INSERT,UPDATE,DELETE)" in sql @@ -93,7 +93,7 @@ def test_partial_tags_insert_only(self, dialect_97): from_table="items", from_alias="t", ) - expr = CreateJsonDualityViewExpression(dialect_97, "items_dv", spec) + expr = CreateJsonDualityViewStatement(dialect_97, "items_dv", spec) sql, _ = expr.to_sql() assert "WITH(INSERT)" in sql @@ -107,7 +107,7 @@ def test_or_replace(self, dialect_97): from_table="products", from_alias="t", ) - expr = CreateJsonDualityViewExpression(dialect_97, "products_dv", spec, replace=True) + expr = CreateJsonDualityViewStatement(dialect_97, "products_dv", spec, replace=True) sql, _ = expr.to_sql() assert "CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW" in sql @@ -133,7 +133,7 @@ def test_nested_object(self, dialect_97): from_table="orders", from_alias="o", ) - expr = CreateJsonDualityViewExpression(dialect_97, "orders_full_dv", root_spec) + expr = CreateJsonDualityViewStatement(dialect_97, "orders_full_dv", root_spec) sql, params = expr.to_sql() assert "WITH(INSERT,UPDATE,DELETE)" in sql @@ -149,14 +149,14 @@ class TestDropJsonDualityViewExpression: """Test DROP VIEW SQL generation for duality views.""" def test_drop_view(self, dialect_97): - expr = DropJsonDualityViewExpression(dialect_97, "orders_dv") + expr = DropJsonDualityViewStatement(dialect_97, "orders_dv") sql, params = expr.to_sql() assert sql == "DROP VIEW `orders_dv`" assert params == () def test_drop_view_if_exists(self, dialect_97): - expr = DropJsonDualityViewExpression(dialect_97, "orders_dv", if_exists=True) + expr = DropJsonDualityViewStatement(dialect_97, "orders_dv", if_exists=True) sql, params = expr.to_sql() assert sql == "DROP VIEW IF EXISTS `orders_dv`" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py index 3e64ce26..4a0290e4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_expressions.py @@ -67,15 +67,23 @@ def test_json_object_basic(self): assert "name" in params assert "age" in params - def test_json_object_with_kwargs(self): - """Test JSON object creation with keyword arguments.""" + def test_json_object_from_pairs(self): + """Test JSON object creation from an iterable of pairs. + + The constructor used to also take the pairs as ``**kwargs``, but a + parameter whose identity depends on how many extras arrived has no + artifact spelling (§4.11.4); the mapping and the pair list are the + two shapes that name every member, and both name them up front. + """ dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLJSONObjectExpression(dialect, name="John", age=30).as_("obj") + expr = MySQLJSONObjectExpression(dialect, [("name", "John"), ("age", 30)]).as_("obj") sql, params = expr.to_sql() assert "JSON_OBJECT" in sql assert "AS `obj`" in sql + assert "John" in params + assert 30 in params class TestMySQLJSONArrayExpression: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py index ed08e678..cfb55a3c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_json_functions_backend.py @@ -118,7 +118,7 @@ def test_json_contains_function(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_json_contains") def test_format_json_extract_integration(self, mysql_backend): - """Test format_json_extract with database execution.""" + """Test format_mysql_json_extract_expression with database execution.""" if mysql_backend.dialect.version < (5, 7, 8): pytest.skip("JSON functions require MySQL 5.7.8+") @@ -254,7 +254,7 @@ async def test_async_json_contains_function(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_json_extract_integration(self, async_mysql_backend): - """Test format_json_extract with database execution (async).""" + """Test format_mysql_json_extract_expression with database execution (async).""" if async_mysql_backend.dialect.version < (5, 7, 8): pytest.skip("JSON functions require MySQL 5.7.8+") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py index 6abbbb83..e527f4c9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py @@ -63,7 +63,7 @@ def test_charset_version_gating(): def test_auto_increment_and_row_format_rendered(): - from rhosocial.activerecord.backend.expression import CreateTableExpression + from rhosocial.activerecord.backend.expression import CreateTableStatement from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.mysql.expression import MySQLRowFormat @@ -72,7 +72,7 @@ def test_auto_increment_and_row_format_rendered(): options = MySQLCreateTableOptions( dialect, auto_increment=1000, row_format=MySQLRowFormat.DYNAMIC ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, Table(dialect, "t"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py new file mode 100644 index 00000000..9794f88d --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py @@ -0,0 +1,133 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_classed_functions_and_bare_values.py +"""Follow-up coverage for the classed function entry points and the bare VALUES cells. + +Two threads of the core waves land in this dialect here: + +* :class:`CharLengthFunction` asks the characters question, and core's + default ``LENGTH`` spelling counts **bytes** on MySQL -- measured on + 8.0.46: ``LENGTH('aé')`` answers 3 where ``CHAR_LENGTH('aé')`` answers + 2, so the default would answer a different question than this node asks. + The override spells ``CHAR_LENGTH``; the node's ``unit`` never enters + the SQL (the octet and bit semantics have their own dedicated nodes, + whose defaults -- ``OCTET_LENGTH`` / ``BIT_LENGTH`` -- are spellings + MySQL accepts as-is). +* An INSERT's VALUES cells may be bare host values since core ``8e2e4d82`` + -- a cell is either an expression, rendered through its own ``to_sql()``, + or a plain value that becomes a bind parameter -- and this dialect now + delegates that branch to the base ``format_insert_values_source`` instead + of re-implementing the per-cell loop (the old loop called + ``val.to_sql()`` on every cell and could not accept a bare value at all). +""" + +import pytest + +from rhosocial.activerecord.backend.expression import CharLengthFunction, Column, Literal +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.statements import InsertStatement, ValuesSource +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLInsertExpression +from rhosocial.activerecord.backend.impl.mysql.functions.math_enhanced import round_ + + +@pytest.fixture +def dialect(): + return MySQLDialect() + + +class TestCharLengthSpelling: + """The characters question, spelled the way MySQL answers it.""" + + def test_the_default_unit_spells_char_length(self, dialect): + sql, params = CharLengthFunction(dialect, Column(dialect, "name")).to_sql() + assert sql == "CHAR_LENGTH(`name`)" + assert params == () + + def test_the_unit_never_enters_the_sql(self, dialect): + # The node records which length semantics the caller asked for, and + # the octet and bit semantics have their own dedicated nodes + # (OctetLengthFunction / BitLengthFunction); this entry point answers + # the characters question whatever the record says. + for unit in ("char", "octet", "bit"): + sql, _ = CharLengthFunction( + dialect, Column(dialect, "name"), unit=unit + ).to_sql() + assert sql == "CHAR_LENGTH(`name`)" + + def test_alias_survives_the_override(self, dialect): + sql, _ = CharLengthFunction(dialect, Column(dialect, "name")).as_("len").to_sql() + assert sql == "CHAR_LENGTH(`name`) AS `len`" + + def test_the_override_resolves_from_the_mysql_function_mixin(self, dialect): + assert dialect.format_char_length_function.__func__.__qualname__.startswith( + "MySQLFunctionMixin" + ) + + def test_the_unknown_unit_is_refused_at_construction(self, dialect): + with pytest.raises(ValueError, match="unknown length unit"): + CharLengthFunction(dialect, Column(dialect, "name"), unit="words") + + +class TestInsertBareValuesCells: + """A VALUES cell is an expression or a bare host value, per the base contract.""" + + def _insert(self, d, rows, columns=("a", "b")): + return InsertStatement( + d, + into=Table(d, "t"), + source=ValuesSource(d, [list(row) for row in rows]), + columns=list(columns), + ) + + def test_expression_and_bare_cells_share_one_row(self, dialect): + stmt = self._insert(dialect, [(Column(dialect, "cnt"), 5)]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (`cnt`, %s)" + assert params == (5,) + + def test_all_bare_cells_become_bind_parameters(self, dialect): + stmt = self._insert(dialect, [(1, "x"), (2, "y")]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s), (%s, %s)" + assert params == (1, "x", 2, "y") + + def test_expression_cells_still_render_through_their_own_formatters(self, dialect): + stmt = self._insert(dialect, [(Literal(dialect, "a"), Literal(dialect, 7))]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s)" + assert params == ("a", 7) + + def test_a_literal_cell_binds_like_a_bare_one(self, dialect): + # Core collapsed the value kinds into one Literal node that owns the + # binding decision; at a data position it binds behind the same + # placeholder a bare host value gets. + stmt = self._insert(dialect, [(Literal(dialect, 3), 4)]) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`, `b`) VALUES (%s, %s)" + assert params == (3, 4) + + def test_replace_keeps_working_over_bare_cells(self, dialect): + stmt = MySQLInsertExpression( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[1, "x"]]), + columns=["a", "b"], + replace=True, + ) + sql, params = stmt.to_sql() + assert sql == "REPLACE INTO `t` (`a`, `b`) VALUES (%s, %s)" + assert params == (1, "x") + + +class TestClassedEntryPointsCompose: + """The classed nodes keep working inside the statements that carry them.""" + + def test_a_round_function_inside_a_values_cell(self, dialect): + stmt = InsertStatement( + dialect, + into=Table(dialect, "t"), + source=ValuesSource(dialect, [[round_(dialect, Literal(dialect, 3.14159), 2)]]), + columns=["a"], + ) + sql, params = stmt.to_sql() + assert sql == "INSERT INTO `t` (`a`) VALUES (ROUND(%s, %s))" + assert params == (3.14159, 2) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py index e4599de0..d212e848 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_clause_pair_guard.py @@ -34,24 +34,25 @@ from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.expression.objects import Table, View from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate -from rhosocial.activerecord.backend.expression.query_sources import CTEExpression +from rhosocial.activerecord.backend.expression.query_sources import CTEClause from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraint, - ColumnConstraintType, - CreateTableAsExpression, + ColumnForeignKeyConstraint, + CreateTableAsStatement, + TableCheckConstraint, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, ) from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( - TruncateExpression, + TruncateStatement, ) from rhosocial.activerecord.backend.expression.statements.ddl_view import ( - DropViewExpression, + DropViewStatement, ) -from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression +from rhosocial.activerecord.backend.expression.statements.dql import QueryStatement from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - SetTransactionExpression, + BeginTransactionStatement, + SetTransactionStatement, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression import ( @@ -71,17 +72,14 @@ def _table(d, name="t"): def _query(d): - return QueryExpression(d, select=[Column(d, "id")], from_=_table(d)) + return QueryStatement(d, select=[Column(d, "id")], from_=_table(d)) def _fk(d, **kw): return TableConstraint( d, - TableConstraintType.FOREIGN_KEY, + branch=TableForeignKeyConstraint(d, ["a"], (_table(d, "t2"), ["b"])), name="fk", - columns=["a"], - foreign_key_table=_table(d, "t2"), - foreign_key_columns=["b"], **kw, ) @@ -89,9 +87,10 @@ def _fk(d, **kw): def _check(d, **kw): return TableConstraint( d, - TableConstraintType.CHECK, + branch=TableCheckConstraint( + d, ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")) + ), name="ck", - check_condition=ComparisonPredicate(d, "=", Column(d, "a"), Column(d, "b")), **kw, ) @@ -99,9 +98,8 @@ def _check(d, **kw): def _column_fk(d, **kw): return ColumnConstraint( d, - ColumnConstraintType.FOREIGN_KEY, + branch=ColumnForeignKeyConstraint(d, (_table(d, "t2"), ["b"])), name="fk", - foreign_key_reference=(_table(d, "t2"), ["b"]), **kw, ) @@ -172,56 +170,56 @@ def sql(self, dialect, **kwargs): b_render=r"WITHOUT VALIDATION\b", ), PairCase( - "TruncateExpression.cascade", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.cascade", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "cascade", "restrict", a_error=r"CASCADE\b", b_error=r"RESTRICT\b", ), PairCase( - "TruncateExpression.restart_identity", - lambda d, **kw: TruncateExpression(d, _table(d), **kw), + "TruncateStatement.restart_identity", + lambda d, **kw: TruncateStatement(d, _table(d), **kw), "restart_identity", "continue_identity", a_error=r"RESTART IDENTITY\b", b_error=r"CONTINUE IDENTITY\b", ), PairCase( - "DropViewExpression.cascade", - lambda d, **kw: DropViewExpression(d, View(d, "v"), **kw), + "DropViewStatement.cascade", + lambda d, **kw: DropViewStatement(d, View(d, "v"), **kw), "cascade", "restrict", a_error=r"CASCADE\b", b_error=r"RESTRICT\b", ), PairCase( - "BeginTransactionExpression.deferrable", - lambda d, **kw: BeginTransactionExpression(d, **kw), + "BeginTransactionStatement.deferrable", + lambda d, **kw: BeginTransactionStatement(d, **kw), "deferrable", "not_deferrable", a_error=r"(? 0) NOT ENFORCED`` round-trips as + ``CHECK ((`v` > 0)) /*!80016 NOT ENFORCED */`` while ``CHECK (v > 0) + ENFORCED`` renders identically to a constraint where the clause was + never typed. The switch is still ``True`` -- the clause is accepted -- + but any switch claiming ENFORCED support must say the rendering + cannot prove it, and both switches that claim it do. + """ + for switch in ("supports_constraint_enforced", "supports_alter_constraint_enforced"): + doc = getattr(MySQLConstraintMixin, switch).__doc__ or "" + assert "ENFORCED" in doc + assert "cannot" in doc or "identical" in doc, ( + f"{switch} claims ENFORCED support without saying the " + f"rendered DDL cannot show that it was written" + ) + + +class TestMySQLIsNotConflatedWithMariaDB: + """The two servers differ on exactly the clauses that look portable. + + Both are MySQL-family and share a ``CREATE TABLE`` surface, so these are + the verdicts most likely to be copied between them by mistake. Measured + against MariaDB 13.1 in the same session as the MySQL measurements. + """ + + #: MariaDB 13.1: rejects ENFORCED/NOT ENFORCED outright, but *does* + #: accept the idempotent drop spelling. MySQL is the reverse on both. + MARIADB_DIFFERS = ( + "supports_constraint_enforced", + "supports_alter_constraint_enforced", + "supports_drop_constraint_if_exists", + ) + + @pytest.mark.parametrize("switch", MARIADB_DIFFERS) + def test_docstring_separates_this_backend_from_mariadb(self, switch): + doc = getattr(MySQLConstraintMixin, switch).__doc__ or "" + assert "MariaDB" in doc, ( + f"{switch} is one of the clauses where MariaDB answers " + f"differently, so the docstring has to say which server it " + f"measured" + ) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py index 4b0557b0..0208fdd9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py @@ -2,8 +2,8 @@ """Explicit MySQLDialect database DDL capability + rendering tests.""" from rhosocial.activerecord.backend.expression.statements.ddl_database import ( - CreateDatabaseExpression, - DropDatabaseExpression, + CreateDatabaseStatement, + DropDatabaseStatement, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.objects import Database @@ -20,11 +20,11 @@ def test_database_capabilities(): def test_create_database_renders(): - sql, params = CreateDatabaseExpression(_dialect(), database=Database(_dialect(), "app")).to_sql() + sql, params = CreateDatabaseStatement(_dialect(), database=Database(_dialect(), "app")).to_sql() assert "CREATE DATABASE" in sql assert params == () def test_drop_database_renders(): - sql, _ = DropDatabaseExpression(_dialect(), database=Database(_dialect(), "app")).to_sql() + sql, _ = DropDatabaseStatement(_dialect(), database=Database(_dialect(), "app")).to_sql() assert "DROP DATABASE" in sql diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_explain_examples.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_explain_examples.py index 39e123d9..a995df45 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_explain_examples.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_explain_examples.py @@ -9,7 +9,7 @@ ComparisonPredicate, Literal, LogicalPredicate, - QueryExpression, + QueryStatement, ) from rhosocial.activerecord.backend.expression.functions import ( date_add, @@ -114,7 +114,7 @@ def _category_created_filter(dialect): def _datetime_expression_query(dialect): - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "id"), @@ -138,7 +138,7 @@ class TestSyncMySQLDateTimeIntervalExplainExamples: def test_created_at_range_uses_datetime_index(self, temporal_indexed_backend): dialect = temporal_indexed_backend.dialect result = temporal_indexed_backend.explain( - QueryExpression( + QueryStatement( dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=Table(dialect, "explain_temporal_events"), @@ -160,7 +160,7 @@ def test_created_at_range_uses_datetime_index(self, temporal_indexed_backend): def test_category_created_range_uses_composite_datetime_index(self, temporal_indexed_backend): dialect = temporal_indexed_backend.dialect result = temporal_indexed_backend.explain( - QueryExpression( + QueryStatement( dialect, select=[ Column(dialect, "id"), @@ -180,7 +180,7 @@ def test_category_created_range_uses_composite_datetime_index(self, temporal_ind def test_covering_datetime_index_query_is_detected(self, temporal_indexed_backend): dialect = temporal_indexed_backend.dialect result = temporal_indexed_backend.explain( - QueryExpression( + QueryStatement( dialect, select=[Column(dialect, "category"), Column(dialect, "created_at")], from_=Table(dialect, "explain_temporal_events"), @@ -221,7 +221,7 @@ class TestAsyncMySQLDateTimeIntervalExplainExamples: async def test_category_created_range_uses_composite_datetime_index(self, async_temporal_indexed_backend): dialect = async_temporal_indexed_backend.dialect result = await async_temporal_indexed_backend.explain( - QueryExpression( + QueryStatement( dialect, select=[ Column(dialect, "id"), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_expressions.py index b7fc0e4b..c2313ede 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_datetime_interval_expressions.py @@ -4,7 +4,7 @@ import pytest from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression import Column, Literal, QueryStatement from rhosocial.activerecord.backend.expression.functions import ( date_add, date_diff, @@ -141,7 +141,7 @@ def test_alias_and_cast(self, mysql_dialect: MySQLDialect): def test_query_expression_integration(self, mysql_dialect: MySQLDialect): shifted = date_add(mysql_dialect, Column(mysql_dialect, "created_at"), 1, "day") - query = QueryExpression( + query = QueryStatement( mysql_dialect, select=[extract(mysql_dialect, "year", Column(mysql_dialect, "created_at"))], from_="events", diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py index 9121db3b..e8e7dbfd 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_coverage.py @@ -17,11 +17,11 @@ import pytest from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AlterColumn, - AlterTableExpression, + AlterColumnAction, + AlterTableStatement, ColumnAlterOperation, ) -from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateStatement from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.objects import Function, Procedure, Table from rhosocial.activerecord.backend.impl.mysql import expression as mysql_expr @@ -37,7 +37,7 @@ class TestRenameTable: """Test MySQL RENAME TABLE statement.""" def test_single_rename(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "old_name"), Table(dialect, "new_name"))] ) sql, params = expr.to_sql() @@ -45,21 +45,21 @@ def test_single_rename(self, dialect): assert params == () def test_multi_rename(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "a"), Table(dialect, "b")), (Table(dialect, "c"), Table(dialect, "d"))] ) sql, params = expr.to_sql() assert sql == "RENAME TABLE `a` TO `b`, `c` TO `d`" def test_cross_database_rename(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression( + expr = mysql_expr.MySQLRenameTableStatement( dialect, [(Table(dialect, "t", catalog_name="src"), Table(dialect, "t", catalog_name="dst"))] ) sql, _ = expr.to_sql() assert sql == "RENAME TABLE `src`.`t` TO `dst`.`t`" def test_empty_raises(self, dialect): - expr = mysql_expr.MySQLRenameTableExpression(dialect, []) + expr = mysql_expr.MySQLRenameTableStatement(dialect, []) with pytest.raises(ValueError, match="at least one"): expr.to_sql() @@ -73,7 +73,7 @@ class TestTruncateTable: """Test MySQL TRUNCATE TABLE statement.""" def test_basic(self, dialect): - sql, params = TruncateExpression(dialect, table=Table(dialect, "users")).to_sql() + sql, params = TruncateStatement(dialect, table=Table(dialect, "users")).to_sql() assert sql == "TRUNCATE TABLE `users`" assert params == () @@ -86,38 +86,38 @@ def test_supports_flags(self, dialect): def test_restart_identity_unsupported(self, dialect): with pytest.raises(UnsupportedFeatureError, match="RESTART IDENTITY"): - TruncateExpression(dialect, table=Table(dialect, "users"), restart_identity=True).to_sql() + TruncateStatement(dialect, table=Table(dialect, "users"), restart_identity=True).to_sql() def test_continue_identity_unsupported(self, dialect): with pytest.raises(UnsupportedFeatureError, match="CONTINUE IDENTITY"): - TruncateExpression(dialect, table=Table(dialect, "users"), continue_identity=True).to_sql() + TruncateStatement(dialect, table=Table(dialect, "users"), continue_identity=True).to_sql() def test_cascade_unsupported(self, dialect): with pytest.raises(UnsupportedFeatureError, match="CASCADE"): - TruncateExpression(dialect, table=Table(dialect, "users"), cascade=True).to_sql() + TruncateStatement(dialect, table=Table(dialect, "users"), cascade=True).to_sql() def test_restrict_unsupported(self, dialect): with pytest.raises(UnsupportedFeatureError, match="RESTRICT"): - TruncateExpression(dialect, table=Table(dialect, "users"), restrict=True).to_sql() + TruncateStatement(dialect, table=Table(dialect, "users"), restrict=True).to_sql() class TestAlterColumnDefault: """Test ALTER TABLE ... ALTER COLUMN {SET DEFAULT | DROP DEFAULT}.""" def test_set_default_string(self, dialect): - action = AlterColumn(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC") - sql, params = AlterTableExpression(dialect, Table(dialect, "t"), [action]).to_sql() + action = AlterColumnAction(dialect, "col", ColumnAlterOperation.SET_DEFAULT, new_value="ABC") + sql, params = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `col` SET DEFAULT 'ABC'" in sql assert params == () def test_set_default_integer(self, dialect): - action = AlterColumn(dialect, "num", ColumnAlterOperation.SET_DEFAULT, new_value=5) - sql, _ = AlterTableExpression(dialect, Table(dialect, "t"), [action]).to_sql() + action = AlterColumnAction(dialect, "num", ColumnAlterOperation.SET_DEFAULT, new_value=5) + sql, _ = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `num` SET DEFAULT 5" in sql def test_drop_default(self, dialect): - action = AlterColumn(dialect, "col", ColumnAlterOperation.DROP_DEFAULT) - sql, params = AlterTableExpression(dialect, Table(dialect, "t"), [action]).to_sql() + action = AlterColumnAction(dialect, "col", ColumnAlterOperation.DROP_DEFAULT) + sql, params = AlterTableStatement(dialect, Table(dialect, "t"), [action]).to_sql() assert "ALTER COLUMN `col` DROP DEFAULT" in sql assert params == () @@ -126,13 +126,13 @@ class TestTableMaintenance: """Test MySQL whole-table maintenance statements.""" def test_analyze_table(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression(dialect, [Table(dialect, "t1"), Table(dialect, "t2")]) + expr = mysql_expr.MySQLAnalyzeTableStatement(dialect, [Table(dialect, "t1"), Table(dialect, "t2")]) sql, params = expr.to_sql() assert sql == "ANALYZE TABLE `t1`, `t2`" assert params == () def test_check_table(self, dialect): - expr = mysql_expr.MySQLCheckTableExpression( + expr = mysql_expr.MySQLCheckTableStatement( dialect, [Table(dialect, "t1")], options=[mysql_expr.CheckTableOption.QUICK] ) sql, _ = expr.to_sql() @@ -140,26 +140,26 @@ def test_check_table(self, dialect): def test_checksum_table(self, dialect): """The (schema, table) tuple is replaced by the object's catalog slot.""" - expr = mysql_expr.MySQLChecksumTableExpression( + expr = mysql_expr.MySQLChecksumTableStatement( dialect, [Table(dialect, "t", catalog_name="db")] ) sql, _ = expr.to_sql() assert sql == "CHECKSUM TABLE `db`.`t`" def test_optimize_table(self, dialect): - expr = mysql_expr.MySQLOptimizeTableExpression(dialect, [Table(dialect, "t1")]) + expr = mysql_expr.MySQLOptimizeTableStatement(dialect, [Table(dialect, "t1")]) sql, _ = expr.to_sql() assert sql == "OPTIMIZE TABLE `t1`" def test_repair_table(self, dialect): - expr = mysql_expr.MySQLRepairTableExpression( + expr = mysql_expr.MySQLRepairTableStatement( dialect, [Table(dialect, "t1")], options=[mysql_expr.RepairTableOption.USE_FRM] ) sql, _ = expr.to_sql() assert sql == "REPAIR TABLE `t1` USE_FRM" def test_no_write_to_binlog(self, dialect): - expr = mysql_expr.MySQLAnalyzeTableExpression( + expr = mysql_expr.MySQLAnalyzeTableStatement( dialect, [Table(dialect, "t1")], no_write_to_binlog=mysql_expr.NoWriteToBinlogOption.LOCAL ) sql, _ = expr.to_sql() @@ -180,37 +180,37 @@ class TestStoredRoutines: """Test MySQL stored procedure / function / CALL statements.""" def test_create_procedure(self, dialect): - expr = mysql_expr.MySQLCreateProcedureExpression( + expr = mysql_expr.MySQLCreateProcedureStatement( dialect, Procedure(dialect, "sp"), params=[("IN", "x", "INT")], body="BEGIN END" ) sql, _ = expr.to_sql() assert sql == "CREATE PROCEDURE `sp` (IN `x` INT) BEGIN END" def test_drop_procedure(self, dialect): - expr = mysql_expr.MySQLDropProcedureExpression(dialect, Procedure(dialect, "sp"), if_exists=True) + expr = mysql_expr.MySQLDropProcedureStatement(dialect, Procedure(dialect, "sp"), if_exists=True) sql, _ = expr.to_sql() assert sql == "DROP PROCEDURE IF EXISTS `sp`" def test_create_function(self, dialect): - expr = mysql_expr.MySQLCreateFunctionExpression( + expr = mysql_expr.MySQLCreateFunctionStatement( dialect, Function(dialect, "fn"), returns="INT", deterministic=True, body="RETURN 1" ) sql, _ = expr.to_sql() assert sql == "CREATE FUNCTION `fn` () RETURNS INT DETERMINISTIC RETURN 1" def test_drop_function(self, dialect): - expr = mysql_expr.MySQLDropFunctionExpression(dialect, Function(dialect, "fn")) + expr = mysql_expr.MySQLDropFunctionStatement(dialect, Function(dialect, "fn")) sql, _ = expr.to_sql() assert sql == "DROP FUNCTION `fn`" def test_call(self, dialect): - expr = mysql_expr.MySQLCallExpression(dialect, Procedure(dialect, "sp"), [1, "a"]) + expr = mysql_expr.MySQLCallStatement(dialect, Procedure(dialect, "sp"), [1, "a"]) sql, params = expr.to_sql() assert sql == "CALL `sp` (%s, %s)" assert params == (1, "a") def test_call_qualified_database(self, dialect): - expr = mysql_expr.MySQLCallExpression( + expr = mysql_expr.MySQLCallStatement( dialect, Procedure(dialect, "sp", catalog_name="app"), [1] ) sql, _ = expr.to_sql() @@ -252,19 +252,19 @@ class TestLoadXML: """Test MySQL LOAD XML statement.""" def test_basic(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression(dialect, "/tmp/data.xml", Table(dialect, "t")) + expr = mysql_expr.MySQLLoadXMLStatement(dialect, "/tmp/data.xml", Table(dialect, "t")) sql, _ = expr.to_sql() assert sql == "LOAD XML INFILE '/tmp/data.xml' INTO TABLE `t`" def test_qualified_database(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/data.xml", Table(dialect, "t", catalog_name="app") ) sql, _ = expr.to_sql() assert sql == "LOAD XML INFILE '/tmp/data.xml' INTO TABLE `app`.`t`" def test_local(self, dialect): - expr = mysql_expr.MySQLLoadXMLEXpression( + expr = mysql_expr.MySQLLoadXMLStatement( dialect, "/tmp/data.xml", Table(dialect, "t"), local=True ) sql, _ = expr.to_sql() @@ -275,27 +275,27 @@ class TestAdminCommands: """Test MySQL administrative utility commands.""" def test_flush(self, dialect): - expr = mysql_expr.MySQLFlushExpression(dialect, [mysql_expr.FlushOption.PRIVILEGES]) + expr = mysql_expr.MySQLFlushStatement(dialect, [mysql_expr.FlushOption.PRIVILEGES]) sql, _ = expr.to_sql() assert sql == "FLUSH PRIVILEGES" def test_reset(self, dialect): - expr = mysql_expr.MySQLResetExpression(dialect, mysql_expr.ResetOption.MASTER) + expr = mysql_expr.MySQLResetStatement(dialect, mysql_expr.ResetOption.MASTER) sql, _ = expr.to_sql() assert sql == "RESET MASTER" def test_kill(self, dialect): - expr = mysql_expr.MySQLKillExpression(dialect, 42) + expr = mysql_expr.MySQLKillStatement(dialect, 42) sql, _ = expr.to_sql() assert sql == "KILL CONNECTION 42" def test_install_plugin(self, dialect): - expr = mysql_expr.MySQLInstallPluginExpression(dialect, "p", "libp.so") + expr = mysql_expr.MySQLInstallPluginStatement(dialect, "p", "libp.so") sql, _ = expr.to_sql() assert sql == "INSTALL PLUGIN `p` SONAME 'libp.so'" def test_grant(self, dialect): - expr = mysql_expr.MySQLGrantExpression( + expr = mysql_expr.MySQLGrantStatement( dialect, [mysql_expr.GrantPrivilege("SELECT")], [mysql_expr.AccountSpec("u", "localhost")], diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py index d1cafc0c..571d6bd5 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py @@ -13,15 +13,18 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, + CreateTableStatement, + DropTableStatement, + ForeignKeyConstraint, IndexDefinition, + TableCheckConstraint, TableConstraint, - TableConstraintType, - ForeignKeyConstraint, + TablePrimaryKeyConstraint, + TableUniqueConstraint, ) from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.statements import ReferentialAction @@ -44,8 +47,8 @@ class TestMySQLStorageOptions: def test_engine_option(self): """Test ENGINE storage option.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, storage_options={"ENGINE": "InnoDB"} ) sql, params = expr.to_sql() @@ -54,8 +57,8 @@ def test_engine_option(self): def test_charset_option(self): """Test DEFAULT CHARSET storage option.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, storage_options={"DEFAULT CHARSET": "utf8mb4"} ) sql, params = expr.to_sql() @@ -64,8 +67,8 @@ def test_charset_option(self): def test_collate_option(self): """Test COLLATE storage option.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, storage_options={"COLLATE": "utf8mb4_unicode_ci"} ) sql, params = expr.to_sql() @@ -74,8 +77,8 @@ def test_collate_option(self): def test_multiple_storage_options(self): """Test multiple storage options combined.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, @@ -89,8 +92,8 @@ def test_multiple_storage_options(self): def test_storage_options_with_if_not_exists(self): """Test storage options with IF NOT EXISTS.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=columns, @@ -108,8 +111,8 @@ class TestMySQLTableComment: def test_table_comment(self): """Test table-level COMMENT.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "用户信息表")), ) @@ -119,8 +122,8 @@ def test_table_comment(self): def test_table_comment_with_storage_options(self): """Test table COMMENT with storage options.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, @@ -135,8 +138,8 @@ def test_table_comment_with_storage_options(self): def test_table_comment_special_characters(self): """Test table COMMENT with special characters.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test"), columns=columns, table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "测试's表")), ) @@ -147,9 +150,9 @@ def test_table_comment_via_table_options(self): """Test table COMMENT via typed CreateTableOptions (new API).""" from rhosocial.activerecord.backend.expression.statements.ddl_table import CreateTableOptions dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] opts = CreateTableOptions(dialect, comment=TableCommentClause(dialect, "typed comment")) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_options=opts, ) sql, params = expr.to_sql() @@ -158,11 +161,11 @@ def test_table_comment_via_table_options(self): def test_engine_charset_via_table_options(self): """Test ENGINE/CHARSET/COLLATE via typed MySQLCreateTableOptions.""" dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] + columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))])] opts = MySQLCreateTableOptions( dialect, engine="InnoDB", charset="utf8mb4", collate="utf8mb4_unicode_ci" ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test"), columns=columns, table_options=opts, ) sql, params = expr.to_sql() @@ -179,11 +182,11 @@ def test_column_comment(self): dialect = MySQLDialect() columns = [ ColumnDefinition(dialect, - "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment=ColumnCommentClause(dialect, "主键ID") + "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], comment=ColumnCommentClause(dialect, "主键ID") ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, "用户名")), ] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns) sql, params = expr.to_sql() assert "COMMENT '主键ID'" in sql assert "COMMENT '用户名'" in sql @@ -193,11 +196,11 @@ def test_column_comment_with_table_comment(self): dialect = MySQLDialect() columns = [ ColumnDefinition(dialect, - "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment=ColumnCommentClause(dialect, "主键") + "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))], comment=ColumnCommentClause(dialect, "主键") ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, "名称")), ] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "用户表")), ) @@ -218,12 +221,12 @@ def test_auto_increment_primary_key(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], ) ] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns) sql, params = expr.to_sql() assert "AUTO_INCREMENT" in sql assert "PRIMARY KEY" in sql @@ -236,13 +239,13 @@ def test_auto_increment_with_comment(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], comment=ColumnCommentClause(dialect, "自增主键"), ) ] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns) sql, params = expr.to_sql() assert "AUTO_INCREMENT" in sql assert "COMMENT '自增主键'" in sql @@ -255,12 +258,12 @@ def test_auto_increment_not_null(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], ) ] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "test"), columns=columns) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "test"), columns=columns) sql, params = expr.to_sql() assert "NOT NULL" in sql assert "AUTO_INCREMENT" in sql @@ -273,11 +276,11 @@ def test_simple_index(self): """Test simple INDEX definition.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_name", ["name"])] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) sql, params = expr.to_sql() assert "INDEX `idx_name`" in sql assert "(`name`)" in sql @@ -286,11 +289,11 @@ def test_unique_index(self): """Test UNIQUE INDEX definition.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_email", ["email"], unique=True)] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) sql, params = expr.to_sql() assert "UNIQUE INDEX" in sql assert "idx_email" in sql @@ -299,12 +302,12 @@ def test_composite_index(self): """Test composite index on multiple columns.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "user_id", IntegerType(dialect)), ColumnDefinition(dialect, "created_at", DateTimeType(dialect)), ] indexes = [IndexDefinition(dialect, "idx_user_created", ["user_id", "created_at"])] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "orders"), columns=columns, indexes=indexes) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "orders"), columns=columns, indexes=indexes) sql, params = expr.to_sql() assert "`user_id`, `created_at`" in sql or "`user_id`,`created_at`" in sql @@ -312,11 +315,11 @@ def test_index_with_type(self): """Test INDEX with USING clause.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_name", ["name"], type="BTREE")] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) sql, params = expr.to_sql() assert "USING BTREE" in sql @@ -324,11 +327,11 @@ def test_hash_index(self): """Test HASH index type.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "key", VarCharType(dialect, 100)), ] indexes = [IndexDefinition(dialect, "idx_key", ["key"], type="HASH")] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "cache"), columns=columns, indexes=indexes) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "cache"), columns=columns, indexes=indexes) sql, params = expr.to_sql() assert "USING HASH" in sql @@ -336,12 +339,12 @@ def test_multiple_indexes(self): """Test multiple inline indexes.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 100)), ColumnDefinition(dialect, "username", VarCharType(dialect, 50)), ] indexes = [IndexDefinition(dialect, "idx_email", ["email"], unique=True), IndexDefinition(dialect, "idx_username", ["username"])] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "users"), columns=columns, indexes=indexes) sql, params = expr.to_sql() assert "UNIQUE INDEX `idx_email`" in sql assert "INDEX `idx_username`" in sql @@ -404,12 +407,12 @@ def test_enum_in_column_definition(self): dialect = MySQLDialect() status_enum = MySQLEnumType(dialect, ["draft", "published", "archived"]) columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, - "status", status_enum, constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)] + "status", status_enum, constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))] ), ] - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "articles"), columns=columns) + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "articles"), columns=columns) sql, params = expr.to_sql() assert "ENUM('draft','published','archived')" in sql @@ -465,8 +468,8 @@ def test_primary_key_constraint(self): """Test PRIMARY KEY table constraint.""" dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect)), ColumnDefinition(dialect, "name", VarCharType(dialect, 100))] - table_constraints = [TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id"])] - expr = CreateTableExpression( + table_constraints = [TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id"]))] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=table_constraints ) sql, params = expr.to_sql() @@ -476,11 +479,11 @@ def test_unique_constraint(self): """Test UNIQUE table constraint.""" dialect = MySQLDialect() columns = [ - ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect))]), ColumnDefinition(dialect, "email", VarCharType(dialect, 100)), ] - table_constraints = [TableConstraint(dialect, TableConstraintType.UNIQUE, columns=["email"])] - expr = CreateTableExpression( + table_constraints = [TableConstraint(dialect, branch=TableUniqueConstraint(dialect, ["email"]))] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=table_constraints ) sql, params = expr.to_sql() @@ -490,8 +493,8 @@ def test_composite_primary_key(self): """Test composite PRIMARY KEY.""" dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "user_id", IntegerType(dialect)), ColumnDefinition(dialect, "role_id", IntegerType(dialect))] - table_constraints = [TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["user_id", "role_id"])] - expr = CreateTableExpression( + table_constraints = [TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["user_id", "role_id"]))] + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "user_roles"), columns=columns, table_constraints=table_constraints ) sql, params = expr.to_sql() @@ -510,7 +513,7 @@ def test_foreign_key_constraint(self): dialect, columns=["user_id"], foreign_key_table=Table(dialect, "users"), foreign_key_columns=["id"], on_delete=ReferentialAction.CASCADE, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=columns, table_constraints=[fk], ) sql, params = expr.to_sql() @@ -526,7 +529,7 @@ def test_foreign_key_with_on_update(self): foreign_key_columns=["id"], on_delete=ReferentialAction.NO_ACTION, on_update=ReferentialAction.SET_NULL, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=columns, table_constraints=[fk], ) sql, params = expr.to_sql() @@ -543,12 +546,10 @@ def test_check_constraint(self): ColumnDefinition(dialect, "age", IntegerType(dialect)), ] check = TableConstraint( - dialect, TableConstraintType.CHECK, - check_condition=ComparisonPredicate( - dialect, ">=", Column(dialect, "age"), Literal(dialect, 0) - ), - ) - expr = CreateTableExpression( + dialect, + branch=TableCheckConstraint(dialect, ComparisonPredicate( dialect, ">=", Column(dialect, "age"), Literal(dialect, 0, inline=True) )) +) + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=[check], ) sql, params = expr.to_sql() @@ -561,7 +562,7 @@ class TestMySQLDropTable: def test_drop_table_if_exists(self): """Test DROP TABLE IF EXISTS.""" dialect = MySQLDialect() - expr = DropTableExpression(dialect=dialect, table=Table(dialect, "test_table"), if_exists=True) + expr = DropTableStatement(dialect=dialect, table=Table(dialect, "test_table"), if_exists=True) sql, params = expr.to_sql() assert sql == "DROP TABLE IF EXISTS `test_table`" assert params == () @@ -569,7 +570,7 @@ def test_drop_table_if_exists(self): def test_drop_table_without_if_exists(self): """Test DROP TABLE without IF EXISTS.""" dialect = MySQLDialect() - expr = DropTableExpression(dialect=dialect, table=Table(dialect, "test_table"), if_exists=False) + expr = DropTableStatement(dialect=dialect, table=Table(dialect, "test_table"), if_exists=False) sql, params = expr.to_sql() assert sql == "DROP TABLE `test_table`" assert params == () @@ -588,27 +589,27 @@ def test_complete_table_creation(self): "id", BigIntType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), ], comment=ColumnCommentClause(dialect, "Primary key"), ), ColumnDefinition(dialect, "name", VarCharType(dialect, 100), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], comment=ColumnCommentClause(dialect, "User name"), ), ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], comment=ColumnCommentClause(dialect, "Email address"), ), ColumnDefinition(dialect, "status", status_enum, - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))], comment=ColumnCommentClause(dialect, "User status"), ), ColumnDefinition(dialect, "created_at", DateTimeType(dialect), comment=ColumnCommentClause(dialect, "Creation timestamp")), @@ -616,7 +617,7 @@ def test_complete_table_creation(self): indexes = [IndexDefinition(dialect, "idx_email", ["email"], unique=True), IndexDefinition(dialect, "idx_status", ["status"])] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py index a43a24a4..0bb16efc 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py @@ -7,9 +7,23 @@ from rhosocial.activerecord.backend.expression import ( Column, ColumnDefinition, - CreateTableExpression, - QueryExpression, - CreateViewExpression, + CreateTableStatement, + QueryStatement, + CreateViewStatement, +) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.statements import ( + ConstraintValidation, + ForeignKeyConstraint, + TableCheckConstraint, + TableConstraint, + TableForeignKeyConstraint, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddTableConstraintAction, + AlterConstraintAction, + DropTableConstraintAction, + ValidateConstraintAction, ) from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption @@ -44,10 +58,10 @@ def test_materialized_view_not_supported(self): def test_create_view_check_option_gated(self): """WITH CHECK OPTION must fail fast when the capability is off.""" dialect = MySQLDialect() - query = QueryExpression( + query = QueryStatement( dialect, select=[Column(dialect, "id")], from_=Table(dialect, "t") ) - expr = CreateViewExpression( + expr = CreateViewStatement( dialect, view=View(dialect, "v"), query=query, @@ -147,7 +161,7 @@ class Plain(ActiveRecord): id: int dialect = MySQLDialect() - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, Plain.__table_name__), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -160,7 +174,7 @@ class Plain(ActiveRecord): def test_table_inherits_is_propagated_and_rejected(self): dialect = MySQLDialect(version=(8, 0, 0)) assert dialect.supports_table_inheritance() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, InheritedTable.__table_name__), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -174,7 +188,7 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = MySQLDialect(version=(8, 0, 0)) assert dialect.supports_table_tablespace() is False - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, TablespacedTable.__table_name__), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -184,3 +198,179 @@ def test_table_tablespace_is_propagated_and_rejected(self): assert expression.tablespace == "ts_data" with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): expression.to_sql() + + +class TestMeasuredConstraintLifecycleCapabilities: + """The constraint-lifecycle probes answer what the server was measured to do. + + Every verdict asserted here was measured on Community Server 26.7.0 + (192.168.1.3:14686) by running the ``ALTER TABLE`` statement and reading + ``SHOW CREATE TABLE`` back; the probe docstrings in + ``MySQLConstraintMixin`` record the same measurements, errnos included. + + One of them is the reason this class exists: a switch that answers + ``False`` for a spelling the server *accepts* (FK ``MATCH``) is not + conservative, it is the difference between output the caller can trust and + output that silently means less than it looks. + """ + + MEASURED_VERSION = (26, 7, 0) + + def _dialect(self): + return MySQLDialect(version=self.MEASURED_VERSION) + + def _check(self, dialect, **kw): + return TableConstraint( + dialect, + branch=TableCheckConstraint(dialect, ComparisonPredicate( dialect, "=", Column(dialect, "a"), Column(dialect, "b") )), + name="ck", + **kw +) + + def _fk(self, dialect, **kw): + return ForeignKeyConstraint( + dialect, + name="fk", + columns=["a"], + foreign_key_table=Table(dialect, "t2"), + foreign_key_columns=["b"], + **kw, + ) + + def test_add_constraint_is_declared_true(self): + """ADD CONSTRAINT works for all four kinds, so the master switch is on.""" + dialect = self._dialect() + assert dialect.supports_add_constraint() is True + + def test_drop_constraint_is_declared_true(self): + """DROP CONSTRAINT works (for UNIQUE and FK), so the master switch is on.""" + dialect = self._dialect() + assert dialect.supports_drop_constraint() is True + + def test_drop_constraint_if_exists_is_declined_and_refused_by_name(self): + """``DROP CONSTRAINT IF EXISTS`` is a parser error, so it is refused here. + + The server answers ``1064 (42000) ... near 'IF EXISTS x'``, so the + failure precedes name resolution and no errno can be caught to make + the drop re-runnable. + """ + dialect = self._dialect() + assert dialect.supports_drop_constraint_if_exists() is False + action = DropTableConstraintAction(dialect, "uq_content", if_exists=True) + with pytest.raises(UnsupportedFeatureError, match="IF EXISTS"): + action.to_sql() + + def test_novalidate_is_declined_and_refused_by_name(self): + """``NOT VALID`` is a parser error (``1064 ... near 'VALID'``).""" + dialect = self._dialect() + assert dialect.supports_constraint_novalidate() is False + constraint = self._check(dialect, validation=ConstraintValidation.NOVALIDATE) + with pytest.raises(UnsupportedFeatureError, match="NOT VALID"): + AddTableConstraintAction(dialect, constraint).to_sql() + + def test_validate_constraint_is_declined_and_refused_by_name(self): + """``VALIDATE CONSTRAINT`` is a parser error (``1064 ... near 'VALIDATE``).""" + dialect = self._dialect() + assert dialect.supports_validate_constraint() is False + with pytest.raises(UnsupportedFeatureError, match="VALIDATE CONSTRAINT"): + ValidateConstraintAction(dialect, "ck").to_sql() + + def test_alter_constraint_enforced_is_declared_for_check_only(self): + """``ALTER CONSTRAINT [NOT] ENFORCED`` is the one form the server takes. + + Measured: a CHECK accepts both keywords (and the ``/*!80016 NOT + ENFORCED */`` marker follows), while a FOREIGN KEY answers ``3941 + (HY000): Altering constraint enforcement is not supported ...``. + The probe answers the verb's existence and is version-gated; the + CHECK-only narrowing is the formatter's, which refuses the other + kinds by name. + """ + dialect = self._dialect() + assert dialect.supports_alter_constraint_enforced() is True + assert ( + MySQLDialect(version=(8, 0, 15)).supports_alter_constraint_enforced() + is False + ) + + def test_alter_constraint_enforced_renders_for_check_only(self): + """The rendering follows the probe: CHECK renders, FOREIGN KEY refuses.""" + dialect = self._dialect() + sql, params = AlterConstraintAction( + dialect, "ck", not_enforced=True, constraint_kind=TableCheckConstraint + ).to_sql() + assert sql == "ALTER CONSTRAINT `ck` NOT ENFORCED" + assert params == () + + with pytest.raises(UnsupportedFeatureError, match="ENFORCED"): + AlterConstraintAction( + dialect, "fk", enforced=True, constraint_kind=TableForeignKeyConstraint + ).to_sql() + + def test_alter_constraint_defers_to_the_deferrable_probe(self): + """``ALTER CONSTRAINT ck DEFERRABLE`` is a 1064, not an ENFORCED answer.""" + dialect = self._dialect() + assert dialect.supports_deferrable_constraint() is False + # The enforcement verb exists, but DEFERRABLE is not an attribute it + # alters -- that refusal belongs to the deferrable family, which is + # False on the same 1064. + assert dialect.supports_alter_constraint_enforced() is True + + def test_fk_match_is_parsed_and_dropped_not_merely_refused(self): + """FK ``MATCH`` parses and is discarded, so it is refused *by name*. + + The server accepts ``MATCH FULL`` and then drops the clause, so the + rendering is not emitted: a caller reading ``MATCH FULL`` back from + the DDL would believe something no engine enforces. + """ + dialect = self._dialect() + assert dialect.supports_fk_match() is False + constraint = self._fk(dialect, match_type="FULL") + with pytest.raises(UnsupportedFeatureError, match="MATCH"): + constraint.to_sql() + + def test_enforcement_is_version_gated_and_check_only(self): + """``[NOT] ENFORCED`` needs 8.0.16, and only CHECK takes it.""" + assert MySQLDialect(version=(8, 0, 15)).supports_constraint_enforced() is False + assert MySQLDialect(version=(8, 0, 16)).supports_constraint_enforced() is True + dialect = self._dialect() + sql, params = self._check(dialect, not_enforced=True).to_sql() + assert sql.endswith("NOT ENFORCED") + # The params are a tuple: core's branch-level formatters hand back + # tuples, and the production-level renderer normalises to one. The old + # fat-tag collector returned a list here. + assert params == () + + with pytest.raises(UnsupportedFeatureError, match="FOREIGN KEY NOT ENFORCED"): + self._fk(dialect, not_enforced=True).to_sql() + + +class TestConstraintProbeDocstringsCarryTheMeasurement: + """A probe without its evidence is a number with no source. + + Each switch asserted here is answered from a live server, so the + docstring has to say which version, and what the server answered -- + errno or error text -- or the verdict cannot be re-checked without + re-measuring everything. + """ + + #: The probe name, and the marker that must appear in its docstring: the + #: measured server version, an errno, or the shape of the refusal. + EVIDENCE = { + "supports_check_constraint": "26.7.0", + "supports_add_constraint": "26.7.0", + "supports_drop_constraint": "3940", + "supports_drop_constraint_if_exists": "1064", + "supports_constraint_novalidate": "1064", + "supports_validate_constraint": "1064", + "supports_alter_constraint_enforced": "3941", + "supports_constraint_enforced": "26.7.0", + "supports_fk_match": "parsed away", + "supports_deferrable_constraint": "1064", + } + + @pytest.mark.parametrize("probe, marker", EVIDENCE.items(), ids=sorted(EVIDENCE)) + def test_docstring_records_the_measured_evidence(self, probe, marker): + doc = getattr(MySQLDialect, probe).__doc__ or "" + assert doc, f"{probe} has no docstring: the measurement has nowhere to live" + assert marker in doc, f"{probe} docstring does not record {marker!r}" + assert "26.7.0" in doc, f"{probe} names no measured server version" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index 765f0e40..03ee97e9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -11,9 +11,9 @@ from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, ) from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.types import VarCharType @@ -33,10 +33,9 @@ def dialect(): def test_mysql_format_column_definition_default_string_escaping(dialect): """Test DEFAULT constraint string is escaped in MySQL.""" constraint = ColumnConstraint( - dialect, - constraint_type=ColumnConstraintType.DEFAULT, - default_value="test's value", - ) + dialect, + branch=ColumnDefaultConstraint(dialect, "test's value") +) col_def = ColumnDefinition(dialect, "test_col", VarCharType(dialect, 255), constraints=[constraint]) @@ -52,18 +51,54 @@ def test_mysql_format_column_definition_comment_string_escaping(dialect): assert "Comment with ''single quote''" in sql -def test_mysql_escape_sql_string(dialect): - """Test MySQL inherits _escape_sql_string.""" - result = dialect._escape_sql_string("Table's comment") +def test_mysqlescape_sql_string(dialect): + """Test MySQL inherits escape_sql_string.""" + result = dialect.escape_sql_string("Table's comment") assert result == "Table''s comment" -def test_mysql_validate_data_type(dialect): - """Test MySQL inherits _validate_data_type.""" - assert dialect._validate_data_type("VARCHAR(255)") - assert dialect._validate_data_type("INT") - assert dialect._validate_data_type("BIGINT") - assert not dialect._validate_data_type("INT; DROP TABLE users--") +def test_mysql_has_no_type_name_rule_of_its_own(dialect): + """There is no per-backend character rule for a type name. + + The framework does not decide what an engine accepts: a name is checked + by the grammar, which is about what a name may *be*, and whether the type + exists is MySQL's answer to give. + """ + assert not hasattr(dialect, "_validate_data_type") + + +def test_mysqla_type_position_refuses_a_payload(dialect): + """A type name is a name, and the grammar is what says so. + + The backend has no character filter of its own any more: the type + position is guarded by the grammar, which is the framework's rule for + what a name may be, and the server decides whether the type exists. + """ + from rhosocial.activerecord.backend.expression import Literal + from rhosocial.activerecord.backend.expression.statements import ( + AlterColumnAction, + ) + from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, + ) + from rhosocial.activerecord.backend.expression.xml import ( + XMLSerializeExpression, + ) + + # A name is a name: the position takes it. + XMLSerializeExpression(dialect, Literal(dialect, "x"), "TEXT") + AlterColumnAction(dialect, "c", "SET DATA TYPE", new_value="TEXT") + + # A value that is not a name is refused, and refused before the + # statement is ever rendered. + with pytest.raises(InvalidTypeNameError): + XMLSerializeExpression( + dialect, Literal(dialect, "x"), "TEXT; DROP TABLE users--" + ) + with pytest.raises(InvalidTypeNameError): + AlterColumnAction( + dialect, "c", "SET DATA TYPE", new_value="TEXT; DROP TABLE users--" + ) def test_mysql_format_column_definition_data_type_validation(dialect): @@ -95,7 +130,7 @@ def test_mysql_json_table_path_escaping(dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "key''s" in sql assert "'; DROP" not in sql @@ -116,7 +151,7 @@ def test_mysql_json_table_column_path_escaping(dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "field''s" in sql @@ -137,7 +172,7 @@ def test_mysql_json_table_alias_quoted(dialect): alias="test_alias", ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) # MySQL uses backticks for identifier quotes assert "`test_alias`" in sql @@ -169,53 +204,53 @@ def test_mysql_format_cast_expression_rejects_injection(dialect): class TestMySQLEscapeSqlStringBackslash: - """Tests for MySQL _escape_sql_string with backslash escaping.""" + """Tests for MySQL escape_sql_string with backslash escaping.""" - def test_escape_sql_string_backslash_escaped(self, dialect): + def testescape_sql_string_backslash_escaped(self, dialect): """Test backslash is properly escaped in MySQL.""" - result = dialect._escape_sql_string("test\\value") + result = dialect.escape_sql_string("test\\value") assert "\\\\" in result - def test_escape_sql_string_backslash_and_quote(self, dialect): + def testescape_sql_string_backslash_and_quote(self, dialect): """Test both backslash and single quote are escaped.""" - result = dialect._escape_sql_string("test\\'value") + result = dialect.escape_sql_string("test\\'value") assert "\\\\" in result assert "''" in result - def test_escape_sql_string_preserves_others(self, dialect): + def testescape_sql_string_preserves_others(self, dialect): """Test other characters are preserved.""" - result = dialect._escape_sql_string('test"double"value') + result = dialect.escape_sql_string('test"double"value') assert 'test"double"value' in result -def test_escape_sql_string_no_backslash_escapes_mode(): +def testescape_sql_string_no_backslash_escapes_mode(): """Under NO_BACKSLASH_ESCAPES backslash is literal and NOT doubled.""" from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect d = MySQLDialect((8, 0, 0), sql_mode="NO_BACKSLASH_ESCAPES") assert d.no_backslash_escapes is True # Backslash preserved (literal), quotes still doubled - assert d._escape_sql_string(r"C:\path") == r"C:\path" - assert d._escape_sql_string("it's") == "it''s" + assert d.escape_sql_string(r"C:\path") == r"C:\path" + assert d.escape_sql_string("it's") == "it''s" -def test_escape_sql_string_default_mode_doubles_backslash(): +def testescape_sql_string_default_mode_doubles_backslash(): """Under default SQL mode backslash is doubled, quotes doubled.""" from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect d = MySQLDialect((8, 0, 0), sql_mode="STRICT_TRANS_TABLES") assert d.no_backslash_escapes is False - assert d._escape_sql_string(r"C:\path") == r"C:\\path" - assert d._escape_sql_string("it's") == "it''s" + assert d.escape_sql_string(r"C:\path") == r"C:\\path" + assert d.escape_sql_string("it's") == "it''s" -def test_escape_sql_string_mode_whitelist_default(): +def testescape_sql_string_mode_whitelist_default(): """Dialect defaults to STRICT_TRANS_TABLES (backslash escapes).""" from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect d = MySQLDialect((8, 0, 0)) assert d.no_backslash_escapes is False - assert d._escape_sql_string(r"C:\path") == r"C:\\path" + assert d.escape_sql_string(r"C:\path") == r"C:\\path" class TestMySQLJSONTableTypeValidation: @@ -236,11 +271,19 @@ def test_json_table_valid_data_type(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "VARCHAR(255)" in sql def test_json_table_invalid_data_type_rejected(self, dialect): - """Test invalid data type in JSON_TABLE column is rejected.""" + """A value that is not a type name is refused at the type position. + + The refusal comes from the grammar, so it names the value rather than + the position. + """ + from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, + ) + expr = MySQLJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', @@ -254,8 +297,8 @@ def test_json_table_invalid_data_type_rejected(self, dialect): ], ) - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_json_table_expression(expr) + with pytest.raises(InvalidTypeNameError): + dialect.format_mysql_json_table_expression(expr) class TestMySQLJSONTableErrorHandling: @@ -277,7 +320,7 @@ def test_json_table_valid_error_handling_null(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "NULL ON ERROR" in sql def test_json_table_valid_error_handling_error(self, dialect): @@ -296,7 +339,7 @@ def test_json_table_valid_error_handling_error(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "ERROR ON ERROR" in sql def test_json_table_valid_error_handling_default(self, dialect): @@ -316,7 +359,7 @@ def test_json_table_valid_error_handling_default(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "DEFAULT" in sql assert "fallback" in sql @@ -337,7 +380,7 @@ def test_json_table_invalid_error_handling_rejected(self, dialect): ) with pytest.raises(ValueError, match="Invalid error_handling"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) class TestMySQLJSONTableDefaultValueEscaping: @@ -360,7 +403,7 @@ def test_json_table_default_value_escaped(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "it''s broken" in sql assert "'; DROP" not in sql @@ -383,7 +426,7 @@ def test_json_table_json_doc_string(self, dialect): ], ) - sql, params = dialect.format_json_table_expression(expr) + sql, params = dialect.format_mysql_json_table_expression(expr) assert "key" in sql def test_json_table_json_doc_to_sql_protocol_rejected_by_validate(self, dialect): @@ -417,7 +460,7 @@ def to_sql(self): ) with pytest.raises(TypeError, match="json_doc must be str"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) def test_json_table_json_doc_invalid_type_rejected(self, dialect): """Test json_doc with invalid type is rejected (raises before our check).""" @@ -435,7 +478,7 @@ def test_json_table_json_doc_invalid_type_rejected(self, dialect): ) with pytest.raises(TypeError, match="json_doc must be str"): - dialect.format_json_table_expression(expr) + dialect.format_mysql_json_table_expression(expr) class TestMySQLCreateTableCommentEscaping: @@ -443,13 +486,13 @@ class TestMySQLCreateTableCommentEscaping: def test_create_table_comment_escaped(self, dialect): """Test table-level COMMENT is properly escaped.""" - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableOptions, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=[], @@ -464,13 +507,13 @@ def test_create_table_comment_escaped(self, dialect): def test_create_table_comment_with_backslash(self, dialect): """Test table-level COMMENT with backslash is properly escaped.""" - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableOptions, ) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "test_table"), columns=[], @@ -490,28 +533,30 @@ def test_create_table_comment_with_backslash(self, dialect): def test_storage_options_normal_key_and_value(dialect): """Normal storage option key is plain, string value is quoted and escaped.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "InnoDB"})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {"ENGINE": "InnoDB"})) assert "ENGINE='InnoDB'" in sql def test_storage_options_string_value_escaped(dialect): """String value with single quote is properly escaped.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "It's"})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {"ENGINE": "It's"})) assert "It''s" in sql def test_storage_options_int_value(dialect): """Integer value is not quoted.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"AUTO_INCREMENT": 1000})) + sql, params = dialect.format_storage_options_expression(StorageOptionsExpression(dialect, {"AUTO_INCREMENT": 1000})) assert "AUTO_INCREMENT=1000" in sql def test_storage_options_string_injection_value_escaped(dialect): """String value with injection payload is safely escaped inside quotes.""" from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression - sql, params = dialect.format_storage_options(StorageOptionsExpression(dialect, {"ENGINE": "x'; DROP TABLE t--"})) + sql, params = dialect.format_storage_options_expression( + StorageOptionsExpression(dialect, {"ENGINE": "x'; DROP TABLE t--"}) + ) assert "'x''; DROP TABLE t--'" in sql # The single quote inside is doubled, so the payload cannot break out assert sql.count("'") % 2 == 0, f"Unbalanced quotes: {sql}" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py index d64dee1d..356aa616 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_identity_execution.py @@ -36,10 +36,10 @@ from rhosocial.activerecord.backend.expression.statements import ( AutoIncrementClause, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, - CreateTableExpression, - DropTableExpression, + ColumnPrimaryKeyConstraint, + CreateTableStatement, + DropTableStatement, IdentityClause, ) from rhosocial.activerecord.backend.expression.types import IntegerType @@ -56,7 +56,8 @@ def _auto_increment_column(dialect, name="id"): IntegerType(dialect), constraints=[ ColumnConstraint( - dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True + dialect, + branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True) ) ], ) @@ -107,7 +108,7 @@ def test_marker_renders(self, mysql_dialect): def test_rendered_create_table_executes(self, mysql_backend): dialect = mysql_backend.dialect table = Table(dialect, "_probe_ai_exec") - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, table, [_auto_increment_column(dialect)] ) sql, _ = expression.to_sql() @@ -117,7 +118,7 @@ def test_rendered_create_table_executes(self, mysql_backend): ) outcome = confirm_expression_execution( - mysql_backend, expression, teardown=[DropTableExpression(dialect, table)] + mysql_backend, expression, teardown=[DropTableStatement(dialect, table)] ) assert outcome is ExecutionOutcome.ACCEPTED @@ -134,7 +135,7 @@ class TestProbeGatesTheDdl: def test_withdrawn_probe_refuses_the_column(self): dialect = _DeclinedAutoIncrementDialect((8, 0, 36)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "t"), [_auto_increment_column(dialect)] ) with pytest.raises(UnsupportedFeatureError, match="AUTO_INCREMENT column"): @@ -148,7 +149,7 @@ def test_table_seed_option_is_not_a_column_clause(self): the column formatter's probe. """ dialect = _DeclinedAutoIncrementDialect((8, 0, 36)) - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "t"), [ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -206,7 +207,7 @@ def test_request_is_refused_not_executed(self, mysql_backend, build): def test_identity_attribute_is_refused(self, mysql_backend): """The AR-layer attribute path used to silently drop ALWAYS and start.""" dialect = mysql_backend.dialect - expression = CreateTableExpression( + expression = CreateTableStatement( dialect, Table(dialect, "_probe_identity_attr"), [ diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py new file mode 100644 index 00000000..f7a70633 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py @@ -0,0 +1,81 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_literal_values_live.py +"""The live half of the literal-spelling verification. + +The tier-2 answers this wave recorded -- ``X'0102FF'`` for binary, the bare +quoted canonical text for a UUID, no literal form at all for an INTERVAL -- +are claims about what a server accepts and hands back. Asserting them needs a +running server, so they live here, in the scenario-driven part of the suite, +and not in the expression tests beside them: an expression test starts no +connection, builds a tree and checks what the dialect renders (§12). What is +verified here is the round trip through MySQL itself, on every scenario the +suite reaches. + +The rendering assertions -- the half that needs no connection -- stay in +``expression/test_mysql_literal_values.py``. +""" + +import pytest + + +class TestLiveServerReadsTheLiteralsBack: + """The tier-2 answers re-measured end to end against the running server. + + The DDL read-back goes over raw SQL on purpose: the literal forms are + this dialect's own product, and what is verified here is what the + wave's answers claim -- the server accepts the spelling and hands the + same value back. + """ + + def test_binary_default_roundtrips(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") + try: + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_bin (v VARBINARY(16) DEFAULT X'0102FF')" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_bin () VALUES ()") + rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_bin") + assert [row["v"] for row in rows] == [b"\x01\x02\xff"] + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_bin") + + def test_uuid_default_roundtrips(self, mysql_backend): + canonical = "550e8400-e29b-41d4-a716-446655440000" + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") + try: + mysql_backend.execute( + f"CREATE TABLE ar_mysql_typed_uuid (v CHAR(36) DEFAULT '{canonical}')" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_uuid () VALUES ()") + rows = mysql_backend.fetch_all("SELECT v FROM ar_mysql_typed_uuid") + assert [row["v"] for row in rows] == [canonical] + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_uuid") + + def test_interval_default_is_refused(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") + try: + with pytest.raises(Exception): # noqa: B017 - the refusal is the point + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_iv (v VARCHAR(10) DEFAULT INTERVAL 1 DAY)" + ) + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_iv") + + def test_tier_one_defaults_are_accepted(self, mysql_backend): + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") + try: + mysql_backend.execute( + "CREATE TABLE ar_mysql_typed_t1 (" + " b TINYINT DEFAULT TRUE," + " n TINYINT DEFAULT 42," + " f DOUBLE DEFAULT 1.5," + " s VARCHAR(16) DEFAULT 'x''y'," + " d DATE DEFAULT '2026-10-17'," + " ts DATETIME DEFAULT '2026-10-17 10:00:00'" + ")" + ) + mysql_backend.execute("INSERT INTO ar_mysql_typed_t1 () VALUES ()") + rows = mysql_backend.fetch_all("SELECT * FROM ar_mysql_typed_t1") + assert len(rows) == 1 + finally: + mysql_backend.execute("DROP TABLE IF EXISTS ar_mysql_typed_t1") \ No newline at end of file diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py index 76dd5adc..9368e975 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_math_enhanced_functions.py @@ -13,6 +13,15 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal, NumericColumn +from rhosocial.activerecord.backend.expression.functions.math import ( + CeilFunction, + FloorFunction, + ModuloFunction, + PowerFunction, + RoundFunction, + SquareRootFunction, + TruncateFunction, +) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.functions.math_enhanced import ( round_, @@ -35,9 +44,13 @@ class TestMySQLMathEnhancedFunctions: def test_round__default(self, mysql_dialect: MySQLDialect): """Test round_() with default precision.""" result = round_(mysql_dialect, Column(mysql_dialect, "value")) - sql, _ = result.to_sql() + assert isinstance(result, RoundFunction) + sql, params = result.to_sql() assert "ROUND(" in sql assert "`value`" in sql + # The factory's signature keeps precision=0 meaning "write the + # scale operand", so the default renders with a bound zero as before. + assert params == (0,) def test_round__with_precision(self, mysql_dialect: MySQLDialect): """Test round_() with precision.""" @@ -53,12 +66,18 @@ def test_round__with_literal(self, mysql_dialect: MySQLDialect): assert "`price`" not in sql def test_pow(self, mysql_dialect: MySQLDialect): - """Test pow() function.""" + """Test pow() function. + + The classed entry point spells POWER -- the standard name. POW is the + MySQL synonym and answered the same value; the classed node carries + the standard spelling. + """ result = pow( mysql_dialect, Column(mysql_dialect, "base"), Literal(mysql_dialect, 2) ) + assert isinstance(result, PowerFunction) sql, _ = result.to_sql() - assert "POW(" in sql + assert "POWER(" in sql def test_pow_both_columns(self, mysql_dialect: MySQLDialect): """Test pow() with both column references.""" @@ -67,20 +86,23 @@ def test_pow_both_columns(self, mysql_dialect: MySQLDialect): Column(mysql_dialect, "x"), NumericColumn(mysql_dialect, "y"), ) + assert isinstance(result, PowerFunction) sql, _ = result.to_sql() - assert "POW(" in sql + assert "POWER(" in sql def test_power(self, mysql_dialect: MySQLDialect): """Test power() function (alias for POW).""" result = power( mysql_dialect, Literal(mysql_dialect, 2), Literal(mysql_dialect, 3) ) + assert isinstance(result, PowerFunction) sql, _ = result.to_sql() assert "POWER(" in sql def test_sqrt(self, mysql_dialect: MySQLDialect): """Test sqrt() function.""" result = sqrt(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, SquareRootFunction) sql, _ = result.to_sql() assert "SQRT(" in sql assert "`value`" in sql @@ -96,6 +118,7 @@ def test_mod(self, mysql_dialect: MySQLDialect): result = mod( mysql_dialect, Column(mysql_dialect, "total"), Literal(mysql_dialect, 10) ) + assert isinstance(result, ModuloFunction) sql, _ = result.to_sql() assert "MOD(" in sql @@ -112,6 +135,7 @@ def test_mod_both_columns(self, mysql_dialect: MySQLDialect): def test_ceil(self, mysql_dialect: MySQLDialect): """Test ceil() function.""" result = ceil(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, CeilFunction) sql, _ = result.to_sql() assert "CEIL(" in sql assert "`value`" in sql @@ -125,6 +149,7 @@ def test_ceil_with_literal(self, mysql_dialect: MySQLDialect): def test_floor(self, mysql_dialect: MySQLDialect): """Test floor() function.""" result = floor(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, FloorFunction) sql, _ = result.to_sql() assert "FLOOR(" in sql assert "`value`" in sql @@ -138,6 +163,7 @@ def test_floor_with_literal(self, mysql_dialect: MySQLDialect): def test_trunc(self, mysql_dialect: MySQLDialect): """Test trunc() function (becomes TRUNCATE in MySQL).""" result = trunc(mysql_dialect, Column(mysql_dialect, "value")) + assert isinstance(result, TruncateFunction) sql, _ = result.to_sql() assert "TRUNCATE(" in sql assert "`value`" in sql @@ -276,7 +302,7 @@ def test_pow_with_numeric_looking_exponent(self, mysql_dialect: MySQLDialect): mysql_dialect, Column(mysql_dialect, "base"), NumericColumn(mysql_dialect, "2") ) sql, params = result.to_sql() - assert "POW(" in sql + assert "POWER(" in sql assert "`2`" in sql assert 2 not in params @@ -333,8 +359,12 @@ def test_avg_with_numeric_looking_name(self, mysql_dialect: MySQLDialect): def test_bare_string_is_rejected_not_guessed(self, mysql_dialect: MySQLDialect): """A bare string used to be parsed into a number or a column, silently. - The factory no longer decides what an argument meant: an argument that - is not an expression fails to render instead of quietly becoming one. + The factory no longer decides what an argument meant: an argument + that is not an expression is refused where the call happens, with + the factory named -- it used to surface as an ``AttributeError`` + from inside the renderer, and the classed nodes' own operand lift + would have *parsed* a numeric-looking string back into the number + it spells, the exact guessing this refuses. """ - with pytest.raises(AttributeError): - sqrt(mysql_dialect, "16").to_sql() + with pytest.raises(TypeError, match="sqrt takes an expression"): + sqrt(mysql_dialect, "16") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 533d812a..4e004df4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -5,26 +5,30 @@ from rhosocial.activerecord.backend.expression.statements import ( ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, ColumnDefinition, + ColumnNotNullConstraint, + ColumnNullableConstraint, + ColumnUniqueConstraint, ForeignKeyConstraint, IndexDefinition, ReferentialAction, TableConstraint, - TableConstraintType, + TableForeignKeyConstraint, + TablePrimaryKeyConstraint, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - AddColumn, - AlterTableExpression, - ChangeColumn, - DropColumn, - DropTableConstraint, - ModifyColumn, + AddColumnAction, + AlterTableStatement, + ChangeColumnAction, + DropColumnAction, + DropTableConstraintAction, + ModifyColumnAction, ) from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, + CreateTriggerStatement, + DropTriggerStatement, TriggerEvent, TriggerLevel, TriggerTiming, @@ -33,7 +37,7 @@ from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression.load_data import ( LoadDataOptions, - MySQLLoadDataExpression, + MySQLLoadDataStatement, ) from rhosocial.activerecord.backend.expression import ( ForUpdateClause, @@ -83,7 +87,7 @@ MySQLVectorExpression, ) from rhosocial.activerecord.backend.impl.mysql.expression import ( - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, MySQLFindInSetExpression, MySQLFulltextIndexOptionsExpression, MySQLJSONArrayExpression, @@ -115,7 +119,7 @@ class TestMySQLDMLOperationExpressions: def test_load_data_basic(self): dialect = MySQLDialect(version=(8, 0, 0)) dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="/tmp/data.csv", table=Table(dialect, "users") ) sql, params = expr.to_sql() @@ -127,7 +131,7 @@ def test_load_data_basic(self): def test_load_data_qualified_database(self): """LOAD DATA can name a database it previously had no way to express.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "products", catalog_name="warehouse") ) sql, _ = expr.to_sql() @@ -135,8 +139,8 @@ def test_load_data_qualified_database(self): def test_load_data_local_replace(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions(local=True, replace=True) - expr = MySQLLoadDataExpression( + options = LoadDataOptions(dialect, local=True, replace=True) + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "products"), options=options ) sql, params = expr.to_sql() @@ -144,8 +148,8 @@ def test_load_data_local_replace(self): def test_load_data_ignore(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions(local=True, ignore=True) - expr = MySQLLoadDataExpression( + options = LoadDataOptions(dialect, local=True, ignore=True) + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "products"), options=options ) sql, params = expr.to_sql() @@ -153,7 +157,7 @@ def test_load_data_ignore(self): def test_load_data_full_options(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions( + options = LoadDataOptions(dialect, local=True, character_set="utf8mb4", fields_terminated_by=",", @@ -164,7 +168,7 @@ def test_load_data_full_options(self): ignore_lines=1, set_assignments={"created_at": "NOW()"}, ) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="/tmp/import.csv", table=Table(dialect, "logs"), options=options ) sql, params = expr.to_sql() @@ -180,8 +184,8 @@ def test_load_data_full_options(self): def test_load_data_validate_replaces_ignore_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - options = LoadDataOptions(replace=True, ignore=True) - expr = MySQLLoadDataExpression( + options = LoadDataOptions(dialect, replace=True, ignore=True) + expr = MySQLLoadDataStatement( dialect, file_path="data.csv", table=Table(dialect, "t"), options=options ) with pytest.raises(ValueError, match="Cannot use both REPLACE and IGNORE"): @@ -189,7 +193,7 @@ def test_load_data_validate_replaces_ignore_raises(self): def test_load_data_path_with_escape(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLLoadDataExpression( + expr = MySQLLoadDataStatement( dialect, file_path="/path/with'quote.csv", table=Table(dialect, "t") ) sql, params = expr.to_sql() @@ -271,7 +275,7 @@ class TestMySQLTriggerExpressions: def test_create_trigger_before_insert(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "before_insert_user"), table=Table(dialect, "users"), @@ -287,7 +291,7 @@ def test_create_trigger_before_insert(self): def test_create_trigger_after_update(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "after_update_log"), table=Table(dialect, "orders"), @@ -301,7 +305,7 @@ def test_create_trigger_after_update(self): def test_create_trigger_if_not_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "my_trigger"), table=Table(dialect, "t"), @@ -315,7 +319,7 @@ def test_create_trigger_if_not_exists(self): def test_create_trigger_instead_of_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -328,7 +332,7 @@ def test_create_trigger_instead_of_raises(self): def test_create_trigger_for_each_statement_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -342,7 +346,7 @@ def test_create_trigger_for_each_statement_raises(self): def test_create_trigger_with_condition_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -356,7 +360,7 @@ def test_create_trigger_with_condition_raises(self): def test_create_trigger_with_referencing_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -370,7 +374,7 @@ def test_create_trigger_with_referencing_raises(self): def test_create_trigger_multiple_events_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -383,7 +387,7 @@ def test_create_trigger_multiple_events_raises(self): def test_create_trigger_with_update_columns_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTriggerExpression( + expr = CreateTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "bad_trigger"), table=Table(dialect, "t"), @@ -397,7 +401,7 @@ def test_create_trigger_with_update_columns_raises(self): def test_drop_trigger(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = DropTriggerExpression( + expr = DropTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "old_trigger"), ) @@ -406,7 +410,7 @@ def test_drop_trigger(self): def test_drop_trigger_if_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = DropTriggerExpression( + expr = DropTriggerStatement( dialect=dialect, trigger=Trigger(dialect, "old_trigger"), if_exists=True, @@ -478,8 +482,8 @@ class TestMySQLTableDDLExpressions: def test_create_table_like(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "new_table"), like_table=Table(dialect, "source_table"), @@ -489,8 +493,8 @@ def test_create_table_like(self): def test_create_table_like_temporary(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "tmp_table"), like_table=Table(dialect, "source"), @@ -501,8 +505,8 @@ def test_create_table_like_temporary(self): def test_create_table_like_with_database(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "new_table"), like_table=Table(dialect, "source_table", catalog_name="myschema"), @@ -512,8 +516,8 @@ def test_create_table_like_with_database(self): def test_create_table_like_if_not_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableLikeExpression - expr = CreateTableLikeExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableLikeStatement + expr = CreateTableLikeStatement( dialect=dialect, table=Table(dialect, "new_table"), like_table=Table(dialect, "source"), @@ -533,14 +537,13 @@ def test_column_default_expression(self): constraints=[ ColumnConstraint( dialect, - ColumnConstraintType.DEFAULT, - default_value=Literal(dialect, "NOW()"), + branch=ColumnDefaultConstraint(dialect, Literal(dialect, "NOW()")) ) ], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "DEFAULT" in sql assert params == () @@ -552,12 +555,12 @@ def test_column_default_string(self): "status", VarCharType(dialect, 20), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active") + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, "active")) ], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "DEFAULT 'active'" in sql @@ -568,12 +571,12 @@ def test_column_default_numeric(self): "count", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0) + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, 0)) ], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "DEFAULT 0" in sql @@ -583,11 +586,11 @@ def test_column_nullable(self): ColumnDefinition(dialect, "optional", VarCharType(dialect, 100), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)], + constraints=[ColumnConstraint(dialect, branch=ColumnNullableConstraint(dialect))], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "NULL" in sql @@ -597,11 +600,11 @@ def test_column_unique(self): ColumnDefinition(dialect, "email", VarCharType(dialect, 255), - constraints=[ColumnConstraint(dialect, ColumnConstraintType.UNIQUE)], + constraints=[ColumnConstraint(dialect, branch=ColumnUniqueConstraint(dialect))], ), ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression(dialect=dialect, table=Table(dialect, "t"), columns=columns) + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement(dialect=dialect, table=Table(dialect, "t"), columns=columns) sql, params = expr.to_sql() assert "UNIQUE" in sql @@ -609,14 +612,14 @@ def test_named_table_constraint(self): dialect = MySQLDialect(version=(8, 0, 0)) columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] table_constraints = [ - TableConstraint(dialect, - constraint_type=TableConstraintType.PRIMARY_KEY, - name="pk_users", - columns=["id"], + TableConstraint( + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["id"]), + name="pk_users" ) ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "users"), columns=columns, table_constraints=table_constraints ) @@ -628,16 +631,14 @@ def test_foreign_key_table_constraint(self): dialect = MySQLDialect(version=(8, 0, 0)) columns = [ColumnDefinition(dialect, "user_id", IntegerType(dialect))] table_constraints = [ - TableConstraint(dialect, - constraint_type=TableConstraintType.FOREIGN_KEY, - name="fk_orders_user", - columns=["user_id"], - foreign_key_table=Table(dialect, "users"), - foreign_key_columns=["id"], + TableConstraint( + dialect, + branch=TableForeignKeyConstraint(dialect, ["user_id"], (Table(dialect, "users"), ["id"])), + name="fk_orders_user" ) ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "orders"), columns=columns, table_constraints=table_constraints ) @@ -659,8 +660,8 @@ def test_foreign_key_named_constraint_via_subclass(self): on_update=ReferentialAction.CASCADE, ) ] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "payments"), columns=columns, table_constraints=table_constraints ) @@ -669,9 +670,9 @@ def test_foreign_key_named_constraint_via_subclass(self): def test_temporary_table(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "tmp"), columns=columns, temporary=True ) sql, params = expr.to_sql() @@ -685,12 +686,12 @@ def test_create_or_replace_unsupported(self): UnsupportedFeatureError, ) from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, + CreateTableStatement, CreateTableOptions, ) dialect = MySQLDialect(version=(8, 0, 0)) - expr = CreateTableExpression( + expr = CreateTableStatement( dialect, table=Table(dialect, "t"), columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], @@ -704,8 +705,8 @@ def test_inline_index_with_type_numeric(self): dialect = MySQLDialect(version=(8, 0, 0)) columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] indexes = [IndexDefinition(dialect, "idx_id", ["id"], type="BTREE")] - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression - expr = CreateTableExpression( + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "t"), columns=columns, indexes=indexes ) sql, params = expr.to_sql() @@ -713,9 +714,9 @@ def test_inline_index_with_type_numeric(self): def test_numeric_storage_option(self): dialect = MySQLDialect(version=(8, 0, 0)) - from rhosocial.activerecord.backend.expression.statements import CreateTableExpression + from rhosocial.activerecord.backend.expression.statements import CreateTableStatement columns = [ColumnDefinition(dialect, "id", IntegerType(dialect))] - expr = CreateTableExpression( + expr = CreateTableStatement( dialect=dialect, table=Table(dialect, "t"), columns=columns, storage_options={"AUTO_INCREMENT": 1000, "ENGINE": "InnoDB"} ) @@ -733,11 +734,11 @@ class TestMySQLColumnModificationExpressions: def test_modify_column(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -746,12 +747,12 @@ def test_modify_column(self): def test_modify_column_first(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), first=True, ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -760,12 +761,12 @@ def test_modify_column_first(self): def test_modify_column_after(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "name", VarCharType(dialect, 200)), after_column="id", ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -774,12 +775,12 @@ def test_modify_column_after(self): def test_change_column(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ChangeColumn( + action = ChangeColumnAction( dialect=dialect, old_name="username", column=ColumnDefinition(dialect, "login_name", VarCharType(dialect, 150)), ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -787,13 +788,13 @@ def test_change_column(self): def test_change_column_first(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ChangeColumn( + action = ChangeColumnAction( dialect=dialect, old_name="name", column=ColumnDefinition(dialect, "full_name", VarCharType(dialect, 300)), first=True, ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -802,13 +803,13 @@ def test_change_column_first(self): def test_change_column_after(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ChangeColumn( + action = ChangeColumnAction( dialect=dialect, old_name="name", column=ColumnDefinition(dialect, "full_name", VarCharType(dialect, 300)), after_column="id", ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -817,17 +818,17 @@ def test_change_column_after(self): def test_modify_column_with_constraints(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = ModifyColumn( + action = ModifyColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, 255), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ) - expr = AlterTableExpression( + expr = AlterTableStatement( dialect=dialect, table=Table(dialect, "users"), actions=[action] ) sql, params = expr.to_sql() @@ -838,13 +839,13 @@ class TestMySQLAlterTableModifierGuards: """MySQL does not support any IF [NOT] EXISTS qualifiers on ALTER TABLE. Requesting them via ``if_not_exists=True`` / ``if_exists=True`` on - ``AddColumn`` / ``DropColumn`` / ``DropTableConstraint`` must raise - ``UnsupportedFeatureError`` instead of emitting invalid SQL. + ``AddColumnAction`` / ``DropColumnAction`` / ``DropTableConstraintAction`` + must raise ``UnsupportedFeatureError`` instead of emitting invalid SQL. """ def test_add_column_if_not_exists_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = AddColumn( + action = AddColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), if_not_exists=True, @@ -854,13 +855,13 @@ def test_add_column_if_not_exists_raises(self): def test_drop_column_if_exists_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = DropColumn(dialect=dialect, column_name="email", if_exists=True) + action = DropColumnAction(dialect=dialect, column_name="email", if_exists=True) with pytest.raises(UnsupportedFeatureError): action.to_sql() def test_drop_table_constraint_if_exists_raises(self): dialect = MySQLDialect(version=(8, 0, 0)) - action = DropTableConstraint( + action = DropTableConstraintAction( dialect=dialect, constraint_name="fk_users_email", if_exists=True ) with pytest.raises(UnsupportedFeatureError): @@ -869,7 +870,7 @@ def test_drop_table_constraint_if_exists_raises(self): def test_default_qualifiers_keep_behavior(self): dialect = MySQLDialect(version=(8, 0, 0)) - add_action = AddColumn( + add_action = AddColumnAction( dialect=dialect, column=ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), ) @@ -877,12 +878,12 @@ def test_default_qualifiers_keep_behavior(self): assert "ADD COLUMN" in add_sql assert "IF NOT EXISTS" not in add_sql - drop_action = DropColumn(dialect=dialect, column_name="email") + drop_action = DropColumnAction(dialect=dialect, column_name="email") drop_sql, _ = drop_action.to_sql() assert "DROP COLUMN" in drop_sql assert "IF EXISTS" not in drop_sql - drop_constraint = DropTableConstraint( + drop_constraint = DropTableConstraintAction( dialect=dialect, constraint_name="fk_users_email" ) drop_constraint_sql, _ = drop_constraint.to_sql() @@ -1022,8 +1023,14 @@ def test_subpartition_clause_negative_count(self): ) def test_subpartition_clause_wrong_type(self): + """A word the strategy vocabulary does not name is refused. + + The entry is wide -- the member or the word it spells (§10) -- so the + refusal is a ``ValueError`` from the enum the constructor normalises + through, naming the word it cannot place. + """ dialect = MySQLDialect(version=(8, 0, 0)) - with pytest.raises(TypeError): + with pytest.raises(ValueError): MySQLSubpartitionClause( dialect, strategy="INVALID", @@ -1056,7 +1063,7 @@ def test_partition_definition_with_comment(self): mixin.version = (8, 0, 0) from unittest.mock import MagicMock mixin.format_identifier = MagicMock(side_effect=lambda x: f"`{x}`") - mixin._escape_sql_string = MagicMock(side_effect=lambda x: x.replace("'", "\\'")) + mixin.escape_sql_string = MagicMock(side_effect=lambda x: x.replace("'", "\\'")) from rhosocial.activerecord.backend.impl.mysql.expression.partition import MySQLPartitionDefinition defn = MySQLPartitionDefinition( @@ -1119,7 +1126,7 @@ def test_st_geom_from_wkb(self): def test_create_spatial_index(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression(dialect, "idx_spatial", "t", "geom") + expr = MySQLCreateSpatialIndexStatement(dialect, "idx_spatial", "t", "geom") sql, params = expr.to_sql() assert "CREATE SPATIAL INDEX" in sql assert "`idx_spatial`" in sql @@ -1128,7 +1135,7 @@ def test_create_spatial_index(self): def test_create_spatial_index_multi_column(self): dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression( + expr = MySQLCreateSpatialIndexStatement( dialect, "idx_spatial", "t", "geom1, geom2" ) sql, params = expr.to_sql() @@ -1313,7 +1320,7 @@ class TestMySQLJsonDualityViewExpressions: def test_create_duality_view(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + CreateJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, DualityViewDMLTag, @@ -1326,7 +1333,7 @@ def test_create_duality_view(self): ], tags=[DualityViewDMLTag.INSERT, DualityViewDMLTag.UPDATE], ) - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( dialect=dialect, view_name="my_view", root_spec=spec ) sql, params = expr.to_sql() @@ -1335,7 +1342,7 @@ def test_create_duality_view(self): def test_create_or_replace_duality_view(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + CreateJsonDualityViewStatement, DualityObjectSpec, DualityColumnMapping, ) @@ -1345,7 +1352,7 @@ def test_create_or_replace_duality_view(self): DualityColumnMapping(json_key="userId", column_expr="id"), ], ) - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( dialect=dialect, view_name="my_view", root_spec=spec, replace=True ) sql, params = expr.to_sql() @@ -1354,18 +1361,18 @@ def test_create_or_replace_duality_view(self): def test_drop_duality_view(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - DropJsonDualityViewExpression, + DropJsonDualityViewStatement, ) - expr = DropJsonDualityViewExpression(dialect=dialect, view_name="my_view") + expr = DropJsonDualityViewStatement(dialect=dialect, view_name="my_view") sql, params = expr.to_sql() assert sql == "DROP VIEW `my_view`" def test_drop_duality_view_if_exists(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - DropJsonDualityViewExpression, + DropJsonDualityViewStatement, ) - expr = DropJsonDualityViewExpression( + expr = DropJsonDualityViewStatement( dialect=dialect, view_name="my_view", if_exists=True ) sql, params = expr.to_sql() @@ -1374,7 +1381,7 @@ def test_drop_duality_view_if_exists(self): def test_duality_view_with_nested(self): dialect = MySQLDialect(version=(8, 0, 0)) from rhosocial.activerecord.backend.impl.mysql.expression.json_duality_view import ( - CreateJsonDualityViewExpression, + CreateJsonDualityViewStatement, DualityColumnMapping, DualityNestedMapping, DualityObjectSpec, @@ -1395,7 +1402,7 @@ def test_duality_view_with_nested(self): ], nested=[nested], ) - expr = CreateJsonDualityViewExpression( + expr = CreateJsonDualityViewStatement( dialect=dialect, view_name="user_view", root_spec=spec ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py index f7b2e943..a81dbad7 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_explain.py @@ -9,19 +9,19 @@ import pytest from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, Column, - ComparisonPredicate, - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, - CreateTableExpression, - DropTableExpression, + ColumnDefinition, + ColumnNotNullConstraint, + ComparisonPredicate, + CreateTableStatement, + DropTableStatement, IndexDefinition, - InsertExpression, + InsertStatement, Literal, - QueryExpression, + QueryStatement, ValuesSource, - WildcardExpression, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, VarCharType from rhosocial.activerecord.backend.impl.mysql.explain import MySQLExplainResult, MySQLExplainRow @@ -41,17 +41,17 @@ def _partition_value(dialect, value): def _drop_partition_explain_table_expression(dialect): - return DropTableExpression(dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), if_exists=True) def _create_partition_explain_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PARTITION_EXPLAIN_TABLE), columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ], indexes=[ @@ -89,7 +89,7 @@ def _seed_partition_explain_rows_expression(dialect): [2, 100, datetime(2026, 2, 15), "feb"], [3, 200, datetime(2026, 3, 15), "mar"], ] - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, PARTITION_EXPLAIN_TABLE), columns=["id", "tenant_id", "created_at", "payload"], @@ -101,9 +101,9 @@ def _seed_partition_explain_rows_expression(dialect): def _partition_range_query_expression(dialect, start, end): - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), where=ComparisonPredicate( dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start) @@ -115,9 +115,9 @@ def _partition_range_query_expression(dialect, start, end): def _full_scan_query_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py index 9e52dbf1..3c915ee6 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py @@ -449,7 +449,7 @@ def test_subpartition_by_linear_hash(dialect): count=2, ) - sql, params = dialect.format_subpartition_by(sub) + sql, params = dialect.format_mysql_subpartition_clause(sub) assert "SUBPARTITION BY LINEAR HASH" in sql assert "`id`" in sql @@ -466,7 +466,7 @@ def test_subpartition_by_linear_key(dialect): count=4, ) - sql, params = dialect.format_subpartition_by(sub) + sql, params = dialect.format_mysql_subpartition_clause(sub) assert "SUBPARTITION BY LINEAR KEY" in sql assert "`id`" in sql @@ -499,10 +499,21 @@ def test_subpartition_definition_rejects_empty_name(dialect): MySQLSubpartitionDefinition(name="") -def test_subpartition_clause_rejects_invalid_strategy(dialect): - """Subpartition clause should reject non-MySQLSubpartitionStrategy values.""" - with pytest.raises(TypeError, match="must be a MySQLSubpartitionStrategy"): - MySQLSubpartitionClause(dialect, strategy="HASH") # type: ignore[arg-type] +def test_subpartition_clause_accepts_strategy_word(dialect): + """The word the artifact carries is a valid entry, and storage is the member. + + The constructor used to refuse the plain word outright, which made the + serialized form of its own parameter unreadable (§10: the entry accepts + the member or the word it spells, and stores the member). + """ + clause = MySQLSubpartitionClause(dialect, strategy="HASH") # type: ignore[arg-type] + assert clause.strategy is MySQLSubpartitionStrategy.HASH + + +def test_subpartition_clause_rejects_unknown_strategy_word(dialect): + """A word the vocabulary does not name is refused, naming the word.""" + with pytest.raises(ValueError, match="INVALID"): + MySQLSubpartitionClause(dialect, strategy="INVALID") # type: ignore[arg-type] def test_subpartition_clause_rejects_invalid_count(dialect): diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py index 9c6f0f13..8a38c8a0 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_operations.py @@ -7,28 +7,30 @@ import pytest from rhosocial.activerecord.backend.expression import ( + AllColumnsWildcard, Column, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, ComparisonPredicate, - CreateTableExpression, - DropTableExpression, + CountFunction, + CreateTableStatement, + DropTableStatement, FunctionCall, IndexDefinition, - InsertExpression, + InsertStatement, Literal, LogicalPredicate, OrderByClause, - QueryExpression, + QueryStatement, + RowCountWildcard, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ValuesSource, - WildcardExpression, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateTimeType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect -from rhosocial.activerecord.backend.impl.mysql.show import ShowCreateTableExpression +from rhosocial.activerecord.backend.impl.mysql.show import ShowCreateTableStatement from rhosocial.activerecord.backend.impl.mysql.expression import ( MySQLAddPartitionExpression, MySQLCoalescePartitionExpression, @@ -53,7 +55,7 @@ def _drop_table_expression(dialect): - return DropTableExpression(dialect=dialect, table=Table(dialect, PARTITION_TABLE), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, PARTITION_TABLE), if_exists=True) def _base_partition_definitions(dialect: MySQLDialect) -> List[MySQLPartitionDefinition]: @@ -70,12 +72,12 @@ def _base_partition_definitions(dialect: MySQLDialect) -> List[MySQLPartitionDef def _create_partitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PARTITION_TABLE), columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ], indexes=[ @@ -91,7 +93,7 @@ def _create_partitioned_table_expression(dialect): def _insert_events_expression(dialect, rows): - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, PARTITION_TABLE), columns=["id", "created_at", "payload"], @@ -103,7 +105,7 @@ def _insert_events_expression(dialect, rows): def _select_payloads_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, PARTITION_TABLE), @@ -112,7 +114,7 @@ def _select_payloads_expression(dialect): def _select_payload_by_id_expression(dialect, row_id): - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, PARTITION_TABLE), @@ -123,7 +125,7 @@ def _select_payload_by_id_expression(dialect, row_id): def _partition_names_expression(dialect, table_name=None): table_name = table_name or PARTITION_TABLE partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "PARTITION_NAME", alias="name")], from_=partitions, @@ -144,7 +146,7 @@ def _partition_names_expression(dialect, table_name=None): def _partition_metadata_expression(dialect): partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "PARTITION_NAME", alias="name"), @@ -262,12 +264,12 @@ def _assert_base_partition_metadata(rows): def _show_create_table_sql(backend): - row = backend.fetch_one(*ShowCreateTableExpression(backend.dialect, PARTITION_TABLE).to_sql()) + row = backend.fetch_one(*ShowCreateTableStatement(backend.dialect, PARTITION_TABLE).to_sql()) return row.get("Create Table") or row.get("create table") async def _async_show_create_table_sql(backend): - row = await backend.fetch_one(*ShowCreateTableExpression(backend.dialect, PARTITION_TABLE).to_sql()) + row = await backend.fetch_one(*ShowCreateTableStatement(backend.dialect, PARTITION_TABLE).to_sql()) return row.get("Create Table") or row.get("create table") @@ -294,14 +296,14 @@ def _assert_show_create_table_includes_partition_definition(create_sql): def _base_columns_without_pk(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "shard_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "shard_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] def _create_nonpartitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, NEGATIVE_TABLE), columns=_base_columns_without_pk(dialect), @@ -309,7 +311,7 @@ def _create_nonpartitioned_table_expression(dialect): def _create_negative_partitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, NEGATIVE_PARTITIONED_TABLE), columns=_base_columns_without_pk(dialect), @@ -325,7 +327,7 @@ def _create_negative_partitioned_table_expression(dialect): def _create_negative_hash_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, NEGATIVE_HASH_TABLE), columns=_base_columns_without_pk(dialect), @@ -376,15 +378,15 @@ async def _async_teardown_negative_tables(backend): def _drop_named_table_expression(dialect, table_name: str): - return DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True) def _production_columns(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateTimeType(dialect, 6), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateTimeType(dialect, 6), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "tenant_id", BigIntType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ] @@ -398,10 +400,9 @@ def _production_indexes(dialect): def _production_table_constraints(dialect): return [ TableConstraint( - dialect, - TableConstraintType.PRIMARY_KEY, - columns=["id", "created_at"], - ), + dialect, + branch=TablePrimaryKeyConstraint(dialect, ["id", "created_at"]) +), ] @@ -413,7 +414,7 @@ def _partition_definition(dialect, name: str, upper_bound: str): def _create_production_partitioned_table_expression(dialect, partitions): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PRODUCTION_PARTITION_TABLE), columns=_production_columns(dialect), @@ -431,7 +432,7 @@ def _create_production_partitioned_table_expression(dialect, partitions): def _create_production_archive_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PRODUCTION_ARCHIVE_TABLE), columns=_production_columns(dialect), @@ -441,7 +442,7 @@ def _create_production_archive_table_expression(dialect): def _create_production_maxvalue_partitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, PRODUCTION_MAXVALUE_TABLE), columns=_production_columns(dialect), @@ -462,7 +463,7 @@ def _create_production_maxvalue_partitioned_table_expression(dialect): def _insert_production_events_into_expression(dialect, table_name: str, rows): - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, table_name), columns=["id", "created_at", "tenant_id", "payload"], @@ -499,7 +500,7 @@ def _select_production_payloads_from_expression( ), predicate, ) - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, table_name), @@ -519,24 +520,24 @@ def _select_production_payloads_expression(dialect, start, end, *, tenant_id: Op def _select_production_count_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_PARTITION_TABLE), ) def _select_archive_count_expression(dialect): - return QueryExpression( + return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, PRODUCTION_ARCHIVE_TABLE), ) def _partition_metadata_expression_for_table(dialect, table_name: str): partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "PARTITION_NAME", alias="name"), @@ -597,9 +598,9 @@ def _reorganize_maxvalue_partition_expression(dialect): def _production_range_query_for_table_expression(dialect, table_name: str, start, end, *, tenant_id: int): - return QueryExpression( + return QueryStatement( dialect, - select=[WildcardExpression(dialect)], + select=[AllColumnsWildcard(dialect)], from_=Table(dialect, table_name), where=LogicalPredicate( dialect, @@ -1226,15 +1227,15 @@ async def test_partition_ddl_triggers_implicit_commit( def _subpartition_columns(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "region", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "region", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] def _create_subpartitioned_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, SUBPARTITION_TABLE), columns=_subpartition_columns(dialect), @@ -1276,7 +1277,7 @@ async def _async_create_subpartitioned_table(backend): def _subpartition_metadata(backend): partitions = Table(dialect=backend.dialect, name="PARTITIONS", catalog_name="information_schema") return backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect=backend.dialect, select=[ Column(backend.dialect, "PARTITION_NAME", alias="name"), @@ -1316,7 +1317,7 @@ def _subpartition_metadata(backend): async def _async_subpartition_metadata(backend): partitions = Table(dialect=backend.dialect, name="PARTITIONS", catalog_name="information_schema") return await backend.fetch_all( - *QueryExpression( + *QueryStatement( dialect=backend.dialect, select=[ Column(backend.dialect, "PARTITION_NAME", alias="name"), @@ -1378,7 +1379,7 @@ def test_insert_into_subpartitioned_table_and_query(self, mysql_backend): """Rows inserted into a subpartitioned table should be queryable through the parent.""" _create_subpartitioned_table(mysql_backend) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, SUBPARTITION_TABLE), columns=["id", "created_at", "region", "payload"], @@ -1396,7 +1397,7 @@ def test_insert_into_subpartitioned_table_and_query(self, mysql_backend): ) mysql_backend.execute(*insert.to_sql()) - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[Column(mysql_backend.dialect, "payload")], from_=Table(mysql_backend.dialect, SUBPARTITION_TABLE), @@ -1430,7 +1431,7 @@ async def test_insert_into_subpartitioned_table_and_query(self, async_mysql_back """Rows inserted into subpartitioned table should be queryable (async).""" await _async_create_subpartitioned_table(async_mysql_backend) try: - insert = InsertExpression( + insert = InsertStatement( dialect=async_mysql_backend.dialect, into=Table(async_mysql_backend.dialect, SUBPARTITION_TABLE), columns=["id", "created_at", "region", "payload"], @@ -1445,7 +1446,7 @@ async def test_insert_into_subpartitioned_table_and_query(self, async_mysql_back ), ) await async_mysql_backend.execute(*insert.to_sql()) - query = QueryExpression( + query = QueryStatement( dialect=async_mysql_backend.dialect, select=[Column(async_mysql_backend.dialect, "payload")], from_=Table(async_mysql_backend.dialect, SUBPARTITION_TABLE), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py index 542540f4..2687e88e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_strategy_operations.py @@ -7,21 +7,22 @@ from rhosocial.activerecord.backend.expression import ( Column, ColumnConstraint, - ColumnConstraintType, ColumnDefinition, + ColumnNotNullConstraint, ComparisonPredicate, - CreateTableExpression, - DropTableExpression, + CountFunction, + CreateTableStatement, + DropTableStatement, FunctionCall, - InsertExpression, + InsertStatement, Literal, LogicalPredicate, OrderByClause, - QueryExpression, + QueryStatement, + RowCountWildcard, TableConstraint, - TableConstraintType, + TablePrimaryKeyConstraint, ValuesSource, - WildcardExpression, ) from rhosocial.activerecord.backend.expression.types import BigIntType, DateType, VarCharType from rhosocial.activerecord.backend.impl.mysql.expression import ( @@ -64,15 +65,15 @@ def _drop_named_table_expression(dialect, table_name: str): - return DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True) + return DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True) def _base_columns(dialect): return [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "category", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "category", VarCharType(dialect, 32), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "payload", VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ] @@ -81,7 +82,7 @@ def _partition_value(dialect, value): def _create_range_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, RANGE_TABLE), columns=_base_columns(dialect), @@ -97,7 +98,7 @@ def _create_range_table_expression(dialect): def _create_range_columns_multi_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, RANGE_COLUMNS_MULTI_TABLE), columns=_base_columns(dialect), @@ -123,7 +124,7 @@ def _create_range_columns_multi_table_expression(dialect): def _create_list_columns_multi_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, LIST_COLUMNS_MULTI_TABLE), columns=_base_columns(dialect), @@ -151,7 +152,7 @@ def _create_list_columns_multi_table_expression(dialect): def _create_list_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, LIST_TABLE), columns=_base_columns(dialect), @@ -173,7 +174,7 @@ def _create_list_table_expression(dialect): def _create_list_columns_table_expression(dialect): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, LIST_COLUMNS_TABLE), columns=_base_columns(dialect), @@ -195,7 +196,7 @@ def _create_list_columns_table_expression(dialect): def _create_hash_table_expression(dialect, table_name: str, *, linear: bool = False): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=_base_columns(dialect), @@ -209,7 +210,7 @@ def _create_hash_table_expression(dialect, table_name: str, *, linear: bool = Fa def _create_key_table_expression(dialect, table_name: str, *, linear: bool = False): - return CreateTableExpression( + return CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=_base_columns(dialect), @@ -223,7 +224,7 @@ def _create_key_table_expression(dialect, table_name: str, *, linear: bool = Fal def _insert_rows_expression(dialect, table_name: str, rows: Sequence[Sequence[object]]): - return InsertExpression( + return InsertStatement( dialect=dialect, into=Table(dialect, table_name), columns=["id", "shard_id", "category", "payload"], @@ -235,7 +236,7 @@ def _insert_rows_expression(dialect, table_name: str, rows: Sequence[Sequence[ob def _select_payloads_expression(dialect, table_name: str): - return QueryExpression( + return QueryStatement( dialect, select=[Column(dialect, "payload")], from_=Table(dialect, table_name), @@ -244,16 +245,16 @@ def _select_payloads_expression(dialect, table_name: str): def _select_count_expression(dialect, table_name: str): - return QueryExpression( + return QueryStatement( dialect, - select=[FunctionCall(dialect, "COUNT", WildcardExpression(dialect)).as_("count")], + select=[CountFunction(dialect, RowCountWildcard(dialect)).as_("count")], from_=Table(dialect, table_name), ) def _partition_metadata_expression(dialect, table_name: str): partitions = Table(dialect, "PARTITIONS", catalog_name="information_schema") - return QueryExpression( + return QueryStatement( dialect, select=[ Column(dialect, "PARTITION_NAME", alias="name"), @@ -568,13 +569,13 @@ def test_null_in_range_partition_goes_to_first_partition(self, mysql_backend): table = "ar_mysql_null_range" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), # nullable ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -589,7 +590,7 @@ def test_null_in_range_partition_goes_to_first_partition(self, mysql_backend): ).to_sql() ) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -613,13 +614,13 @@ def test_null_in_list_partition_with_explicit_values_in_works(self, mysql_backen table = "ar_mysql_null_list" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -640,7 +641,7 @@ def test_null_in_list_partition_with_explicit_values_in_works(self, mysql_backen ).to_sql() ) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -664,13 +665,13 @@ def test_null_in_hash_partition_accepts_null_key(self, mysql_backend): table = "ar_mysql_null_hash" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -682,7 +683,7 @@ def test_null_in_hash_partition_accepts_null_key(self, mysql_backend): ).to_sql() ) try: - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -706,13 +707,13 @@ def test_null_in_key_partition_accepts_null_key(self, mysql_backend): table = "ar_mysql_null_key" dialect = mysql_backend.dialect columns = [ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "shard_id", BigIntType(dialect=dialect)), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ] mysql_backend.execute(*_drop_named_table_expression(mysql_backend.dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=mysql_backend.dialect, table=Table(mysql_backend.dialect, table), columns=columns, @@ -725,7 +726,7 @@ def test_null_in_key_partition_accepts_null_key(self, mysql_backend): ) try: # KEY partition using `id` which is NOT NULL; verify normal inserts work - insert = InsertExpression( + insert = InsertStatement( dialect=mysql_backend.dialect, into=Table(mysql_backend.dialect, table), columns=["id", "shard_id", "payload"], @@ -756,16 +757,16 @@ def test_key_with_empty_parenthesis_uses_all_primary_key_columns(self, mysql_bac dialect = mysql_backend.dialect mysql_backend.execute(*_drop_named_table_expression(dialect, table).to_sql()) mysql_backend.execute( - *CreateTableExpression( + *CreateTableStatement( dialect=dialect, table=Table(dialect, table), columns=[ - ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), - ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), + ColumnDefinition(dialect, "created_at", DateType(dialect), constraints=[ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect))]), ColumnDefinition(dialect, "payload", VarCharType(dialect, 255)), ], table_constraints=[ - TableConstraint(dialect, TableConstraintType.PRIMARY_KEY, columns=["id", "created_at"]), + TableConstraint(dialect, branch=TablePrimaryKeyConstraint(dialect, ["id", "created_at"])), ], partition=MySQLPartitionByKey( mysql_backend.dialect, diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py index c46a0c91..84a51a4c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py @@ -334,6 +334,14 @@ def test_no_interface_overlap_between_protocols(self): assert len(members) > 0, f"Protocol {name} has no members defined" excluded_overlaps = { + + # The CTAS bind-parameter probe: the backend's table protocol + + # derives from the generic CTAS protocol, so both declare it. + + ('CreateTableAsSupport', 'MySQLTableSupport'), + + ('MySQLTableSupport', 'CreateTableAsSupport'), # Each pair below is a MySQL-specific protocol deliberately # restating part of a generic one, which is the whole point of a # backend protocol: MySQL's spelling and capability probes differ, @@ -411,54 +419,54 @@ class TestMySQLExpressionDialectSeparation: """ EXPRESSION_DIALECT_PAIRS = [ - ("MySQLLoadDataExpression", "format_load_data_statement"), - ("MySQLJSONTableExpression", "format_json_table_expression"), - ("MySQLJSONExtractExpression", "format_json_extract"), - ("MySQLJSONObjectExpression", "format_json_object"), - ("MySQLJSONArrayExpression", "format_json_array"), - ("MySQLJSONContainsExpression", "format_json_contains"), - ("MySQLSTGeomFromTextExpression", "format_st_geom_from_text"), - ("MySQLSTDistanceExpression", "format_st_distance"), - ("MySQLSTWithinExpression", "format_st_within"), - ("MySQLSTContainsExpression", "format_st_contains"), - ("MySQLVectorExpression", "format_vector_literal"), - ("MySQLDistanceEuclideanExpression", "format_distance_euclidean"), - ("MySQLDistanceCosineExpression", "format_distance_cosine"), - ("MySQLDistanceDotExpression", "format_distance_dot"), - ("MySQLMatchAgainstExpression", "format_match_against"), - ("MySQLOptimizerHintExpression", "format_optimizer_hint"), - ("MySQLPartitionMaxValue", "format_partition_value"), - ("MySQLPartitionValue", "format_partition_value"), - ("MySQLAddPartitionExpression", "format_add_partition_statement"), - ("MySQLDropPartitionExpression", "format_drop_partition_statement"), - ("MySQLTruncatePartitionExpression", "format_truncate_partition_statement"), - ("MySQLReorganizePartitionExpression", "format_reorganize_partition_statement"), - ("MySQLExchangePartitionExpression", "format_exchange_partition_statement"), - ("MySQLRenameTableExpression", "format_rename_table_statement"), - ("MySQLTableStatement", "format_table_statement"), - ("MySQLValuesExpression", "format_values_statement"), - ("MySQLAnalyzeTableExpression", "format_table_maintenance_statement"), - ("MySQLCheckTableExpression", "format_table_maintenance_statement"), - ("MySQLChecksumTableExpression", "format_table_maintenance_statement"), - ("MySQLOptimizeTableExpression", "format_table_maintenance_statement"), - ("MySQLRepairTableExpression", "format_table_maintenance_statement"), - ("MySQLCreateProcedureExpression", "format_create_procedure_statement"), - ("MySQLDropProcedureExpression", "format_drop_procedure_statement"), - ("MySQLCreateFunctionExpression", "format_create_function_statement"), - ("MySQLDropFunctionExpression", "format_drop_function_statement"), - ("MySQLCallExpression", "format_call_statement"), - ("MySQLLoadXMLEXpression", "format_load_xml_statement"), - ("MySQLFlushExpression", "format_flush_statement"), - ("MySQLResetExpression", "format_reset_statement"), - ("MySQLCacheIndexExpression", "format_cache_index_statement"), - ("MySQLKillExpression", "format_kill_statement"), - ("MySQLInstallPluginExpression", "format_install_plugin_statement"), - ("MySQLGrantExpression", "format_grant_statement"), - ("MySQLCreateUserExpression", "format_create_user_statement"), - ("MySQLCloneExpression", "format_clone_statement"), - ("MySQLRestartExpression", "format_restart_statement"), - ("MySQLHandlerOpenExpression", "format_handler_open_statement"), - ("MySQLDoExpression", "format_do_statement"), + ("MySQLLoadDataStatement", "format_mysql_load_data_statement"), + ("MySQLJSONTableExpression", "format_mysql_json_table_expression"), + ("MySQLJSONExtractExpression", "format_mysql_json_extract_expression"), + ("MySQLJSONObjectExpression", "format_mysql_json_object_expression"), + ("MySQLJSONArrayExpression", "format_mysql_json_array_expression"), + ("MySQLJSONContainsExpression", "format_mysql_json_contains_expression"), + ("MySQLSTGeomFromTextExpression", "format_mysql_st_geom_from_text_expression"), + ("MySQLSTDistanceExpression", "format_mysql_st_distance_expression"), + ("MySQLSTWithinExpression", "format_mysql_st_within_expression"), + ("MySQLSTContainsExpression", "format_mysql_st_contains_expression"), + ("MySQLVectorExpression", "format_mysql_vector_expression"), + ("MySQLDistanceEuclideanExpression", "format_mysql_distance_euclidean_expression"), + ("MySQLDistanceCosineExpression", "format_mysql_distance_cosine_expression"), + ("MySQLDistanceDotExpression", "format_mysql_distance_dot_expression"), + ("MySQLMatchAgainstExpression", "format_mysql_match_against_expression"), + ("MySQLOptimizerHintExpression", "format_mysql_optimizer_hint_expression"), + ("MySQLPartitionMaxValue", "format_mysql_partition_max_value"), + ("MySQLPartitionValue", "format_mysql_partition_value"), + ("MySQLAddPartitionExpression", "format_mysql_add_partition_expression"), + ("MySQLDropPartitionExpression", "format_mysql_drop_partition_expression"), + ("MySQLTruncatePartitionExpression", "format_mysql_truncate_partition_expression"), + ("MySQLReorganizePartitionExpression", "format_mysql_reorganize_partition_expression"), + ("MySQLExchangePartitionExpression", "format_mysql_exchange_partition_expression"), + ("MySQLRenameTableStatement", "format_mysql_rename_table_statement"), + ("MySQLTableStatement", "format_mysql_table_statement"), + ("MySQLValuesExpression", "format_mysql_values_expression"), + ("MySQLAnalyzeTableStatement", "format_mysql_analyze_table_statement"), + ("MySQLCheckTableStatement", "format_mysql_check_table_statement"), + ("MySQLChecksumTableStatement", "format_mysql_checksum_table_statement"), + ("MySQLOptimizeTableStatement", "format_mysql_optimize_table_statement"), + ("MySQLRepairTableStatement", "format_mysql_repair_table_statement"), + ("MySQLCreateProcedureStatement", "format_mysql_create_procedure_statement"), + ("MySQLDropProcedureStatement", "format_mysql_drop_procedure_statement"), + ("MySQLCreateFunctionStatement", "format_mysql_create_function_statement"), + ("MySQLDropFunctionStatement", "format_mysql_drop_function_statement"), + ("MySQLCallStatement", "format_mysql_call_statement"), + ("MySQLLoadXMLStatement", "format_mysql_load_xml_statement"), + ("MySQLFlushStatement", "format_mysql_flush_statement"), + ("MySQLResetStatement", "format_mysql_reset_statement"), + ("MySQLCacheIndexStatement", "format_mysql_cache_index_statement"), + ("MySQLKillStatement", "format_mysql_kill_statement"), + ("MySQLInstallPluginStatement", "format_mysql_install_plugin_statement"), + ("MySQLGrantStatement", "format_mysql_grant_statement"), + ("MySQLCreateUserStatement", "format_mysql_create_user_statement"), + ("MySQLCloneStatement", "format_mysql_clone_statement"), + ("MySQLRestartStatement", "format_mysql_restart_statement"), + ("MySQLHandlerOpenStatement", "format_mysql_handler_open_statement"), + ("MySQLDoStatement", "format_mysql_do_statement"), ] @pytest.mark.parametrize("expr_name,format_method", EXPRESSION_DIALECT_PAIRS) @@ -648,3 +656,43 @@ def test_mixin_public_methods_are_declared_in_protocol(self, protocol, mixin): assert not undeclared, ( f"{mixin.__name__} implements these methods not declared in {protocol.__name__}: {undeclared}" ) + + +class TestTheBackendOwnAnswersAreTheEffectiveOnes: + """A backend mixin's method must resolve to a backend class. + + The other checks in this file are about the *declaration*: the mixin + declares the method, and the protocol declares it too. This one is + about the *resolution*. A core class that wins the MRO makes the + backend's implementation dead code, and the answer a caller gets is + core's rather than the backend's -- so the backend never says what it + means about itself, and nothing else in the suite notices: the + declaration is there either way. + + A protocol class is a declaration rather than an implementation (its + body is ``...``), so a core class may legitimately precede it; only + the mixins are held to this. + """ + + def test_no_backend_method_is_shadowed_by_a_core_class(self): + mro = type(mysql_dialect.MySQLDialect()).__mro__ + own = "rhosocial.activerecord.backend.impl.mysql" + shadowed = [] + for klass in mro: + if not klass.__module__.startswith(own): + continue + if not klass.__name__.endswith("Mixin"): + continue + for name in vars(klass): + if not name.startswith(("format_", "supports_", "get_")): + continue + if not callable(vars(klass)[name]): + continue + winner = next((k for k in mro if name in k.__dict__), None) + if winner is None or winner.__module__.startswith(own): + continue + shadowed.append(f"{klass.__name__}.{name} -> {winner.__name__}") + assert not shadowed, ( + "a core class wins these backend methods, so the backend's " + f"implementation is dead code: {shadowed}" + ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py index 0fa6b535..046faba4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py @@ -25,16 +25,16 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import BaseExpression, Literal from rhosocial.activerecord.backend.expression.statements import ( - AlterDomainExpression, - AlterTypeExpression, - CreateDomainExpression, - CreateTypeExpression, + AlterDomainStatement, + AlterTypeStatement, + CreateDomainStatement, + CreateTypeStatement, DomainCheckConstraint, DomainNullability, DomainValueExpression, DropDomainDefaultAction, - DropDomainExpression, - DropTypeExpression, + DropDomainStatement, + DropTypeStatement, TypeAlterAction, TypeDefinition, ) @@ -113,21 +113,24 @@ def _type_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: definition = _TestTypeDefinition(dialect) action = _TestTypeAlterAction(dialect) return ( - CreateTypeExpression(dialect, Type(dialect, "status"), definition), - AlterTypeExpression(dialect, Type(dialect, "status"), [action]), - DropTypeExpression(dialect, Type(dialect, "status")), + CreateTypeStatement(dialect, Type(dialect, "status"), definition), + AlterTypeStatement(dialect, Type(dialect, "status"), [action]), + DropTypeStatement(dialect, Type(dialect, "status")), definition, action, ) def _domain_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: - condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) + # A CHECK expression accepts no bind parameter either, so every literal + # in the tree says ``inline=True`` itself; a binding literal here would + # be refused at render time rather than silently inlined. + condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline=True) check = DomainCheckConstraint(dialect, condition) return ( - CreateDomainExpression(dialect, Domain(dialect, "positive"), IntegerType(dialect)), - AlterDomainExpression(dialect, Domain(dialect, "positive"), [DropDomainDefaultAction(dialect)]), - DropDomainExpression(dialect, Domain(dialect, "positive")), + CreateDomainStatement(dialect, Domain(dialect, "positive"), IntegerType(dialect)), + AlterDomainStatement(dialect, Domain(dialect, "positive"), [DropDomainDefaultAction(dialect)]), + DropDomainStatement(dialect, Domain(dialect, "positive")), DomainValueExpression(dialect), check, DropDomainDefaultAction(dialect), diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py index 2a7ab524..f84dd45c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py @@ -956,12 +956,15 @@ def test_get_params_reports_both_mysql_attributes(self, dialect): def test_unsigned_is_type_checked(self, dialect): """``unsigned`` becomes a type modifier in the rendered DDL. - The core integer class accepts only a real ``bool``, so - ``unsigned="yes"`` is an error rather than a truthy string reaching - ``BIGINT yes UNSIGNED``. + The refusal is the **formatter's**, because only the backend that has + to render the flag knows whether it can honour it. The expression + collects ``unsigned`` verbatim -- a truthy string must never reach + ``BIGINT yes UNSIGNED``, and saying so is not optional. """ + declared = MySQLBigIntType(dialect, unsigned="yes") + assert declared.unsigned == "yes" with pytest.raises(TypeError, match="unsigned must be a bool"): - MySQLBigIntType(dialect, unsigned="yes") + dialect.format_data_type(declared) @pytest.mark.parametrize( "cls,base", @@ -1075,11 +1078,17 @@ def test_signedness_is_part_of_identity(self, dialect, klass, kwargs, def test_unsigned_is_type_checked(self, dialect, klass, kwargs, signed_sql, unsigned_sql): """It becomes a type modifier in the rendered DDL, so ``1`` is not a - truthy ``True`` and ``"yes"`` is not a spelling of one.""" - with pytest.raises(TypeError, match="unsigned must be a bool"): - klass(dialect, unsigned=1, **kwargs) - with pytest.raises(TypeError, match="unsigned must be a bool"): - klass(dialect, unsigned="yes", **kwargs) + truthy ``True`` and ``"yes"`` is not a spelling of one. + + Construction collects and rendering judges, so each case is asserted + twice: the declaration keeps the value it was handed, and the dialect + names it rather than rendering a signed column for it. + """ + for wrong in (1, "yes"): + declared = klass(dialect, unsigned=wrong, **kwargs) + assert declared.unsigned == wrong + with pytest.raises(TypeError, match="unsigned must be a bool"): + dialect.format_data_type(declared) @pytest.mark.parametrize("klass,kwargs,signed_sql,unsigned_sql", CASES, ids=IDS) @@ -1250,7 +1259,7 @@ def test_takes_no_spelling_and_does_not_inherit_the_four_byte_words(self): ``SPELLINGS`` is narrowed rather than left inherited: taking :class:`IntegerType` would otherwise advertise ``integer`` / ``int``, which MySQL does not accept for this width, and - ``_check_spelling(data_type, MySQLMediumIntType)`` would then hold a + ``check_spelling(data_type, MySQLMediumIntType)`` would then hold a MEDIUMINT to a word it is not. """ assert MySQLMediumIntType.SPELLINGS == ("mediumint",) @@ -1259,11 +1268,18 @@ def test_takes_no_spelling_and_does_not_inherit_the_four_byte_words(self): assert MySQLMediumIntType(dialect=None).spelling == "mediumint" def test_unsigned_is_handled_like_its_siblings(self, dialect): - """Same field, same type check, same place in identity.""" + """Same field, same place in identity, same refusal. + + The shape check belongs to the formatter that has to honour the flag, + so it fires on ``format_data_type`` rather than on construction -- which + is what the other widths in this file are already asserting. + """ assert MySQLMediumIntType.PARAMETERS == ("unsigned", "zerofill") assert MySQLMediumIntType(dialect).identity() == (False, False) + declared = MySQLMediumIntType(dialect, unsigned="yes") + assert declared.unsigned == "yes" with pytest.raises(TypeError, match="unsigned must be a bool"): - MySQLMediumIntType(dialect, unsigned="yes") + dialect.format_data_type(declared) class TestCastTargets: @@ -1565,6 +1581,11 @@ class TestParseRoundTripSweep: "TimeTzType": "TimeType", "TimestampTzType": "TimestampType", "JsonBType": "JsonType", + # The core's generic enum renders ``ENUM('x')`` the same way MySQL + # does, but the server's declaration also carries a charset and a + # collation, so the parse answers the backend's own class -- which + # carries those two fields -- and it re-renders the identical string. + "EnumType": "MySQLEnumType", } #: CAST targets, not column types: ``CAST(x AS SIGNED)`` is the only @@ -1593,7 +1614,10 @@ def registry(self): return registry def test_every_rendered_type_parses_back_coherently(self, dialect, registry): - from rhosocial.activerecord.testsuite.utils.parse_contract import ( + # The sweep is vendored beside the matrix (the testsuite deleted its + # shared harness with round-trip coverage moving to each backend); + # make_instance is the same function the matrix registers with. + from expression_roundtrip_harness import ( parse_roundtrip_failures, ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py index c18c59d4..c4bac354 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_niladic_functions.py @@ -16,9 +16,9 @@ import pytest from rhosocial.activerecord.backend.expression import ( - CreateTableExpression, - DropTableExpression, - QueryExpression, + CreateTableStatement, + DropTableStatement, + QueryStatement, ) from rhosocial.activerecord.backend.expression.core import FunctionCall from rhosocial.activerecord.backend.expression.functions.datetime import ( @@ -28,9 +28,11 @@ now, ) from rhosocial.activerecord.backend.expression.statements import ( - ColumnDefinition, ColumnConstraint, - ColumnConstraintType, + ColumnDefaultConstraint, + ColumnDefinition, + ColumnNotNullConstraint, + ColumnPrimaryKeyConstraint, ) from rhosocial.activerecord.backend.expression.types import IntegerType, TimestampType from rhosocial.activerecord.backend.options import ExecutionOptions @@ -46,7 +48,7 @@ class TestMySQLNiladicSelectContext: def test_select_current_timestamp_niladic(self, mysql_backend): """SELECT CURRENT_TIMESTAMP (niladic, no parentheses) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[current_timestamp(mysql_backend.dialect)], ) @@ -59,7 +61,7 @@ def test_select_current_timestamp_niladic(self, mysql_backend): def test_select_current_timestamp_with_parens(self, mysql_backend): """SELECT CURRENT_TIMESTAMP() (with parentheses) also works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[FunctionCall(mysql_backend.dialect, "CURRENT_TIMESTAMP")], ) @@ -71,7 +73,7 @@ def test_select_current_timestamp_with_parens(self, mysql_backend): def test_select_current_date_niladic(self, mysql_backend): """SELECT CURRENT_DATE (niladic) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[current_date(mysql_backend.dialect)], ) @@ -83,7 +85,7 @@ def test_select_current_date_niladic(self, mysql_backend): def test_select_current_time_niladic(self, mysql_backend): """SELECT CURRENT_TIME (niladic) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[current_time(mysql_backend.dialect)], ) @@ -95,7 +97,7 @@ def test_select_current_time_niladic(self, mysql_backend): def test_select_now(self, mysql_backend): """SELECT NOW() (regular function, always with parentheses) works in MySQL.""" - query = QueryExpression( + query = QueryStatement( dialect=mysql_backend.dialect, select=[now(mysql_backend.dialect)], ) @@ -115,9 +117,9 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): table_name = "test_niladic_ddl_1" # Clean up - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -125,8 +127,8 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -134,7 +136,7 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): "ts", TimestampType(dialect=dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), ], ), ], @@ -151,7 +153,7 @@ def test_ddl_default_current_timestamp_niladic(self, mysql_backend): col_names = [c.name for c in cols] assert "ts" in col_names finally: - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): """DEFAULT CURRENT_TIMESTAMP() (with parentheses) also works in MySQL DDL.""" @@ -159,9 +161,9 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): table_name = "test_niladic_ddl_2" # Clean up - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -169,8 +171,8 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -179,9 +181,9 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): TimestampType(dialect=dialect), constraints=[ ColumnConstraint( - dialect, - ColumnConstraintType.DEFAULT, default_value=FunctionCall(dialect, "CURRENT_TIMESTAMP") - ), + dialect, + branch=ColumnDefaultConstraint(dialect, FunctionCall(dialect, "CURRENT_TIMESTAMP")) +), ], ), ], @@ -197,26 +199,28 @@ def test_ddl_default_current_timestamp_with_parens(self, mysql_backend): col_names = [c.name for c in cols] assert "ts" in col_names finally: - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): """DEFAULT CURRENT_TIMESTAMP(6) (with precision) works in MySQL DDL.""" + from rhosocial.activerecord.backend.expression.core import Literal + dialect = mysql_backend.dialect table_name = "test_niladic_ddl_3" # Clean up - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ - ColumnDefinition(dialect, + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -224,14 +228,19 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): "ts", TimestampType(dialect, 6), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect, 6)), + # A DEFAULT position accepts no bind parameters, so the + # precision literal is composed inline (core §4.3): the + # node's factory takes the expression as-is, and only a + # bind-inlined tree survives format_default_value_clause. + ColumnConstraint(dialect, branch=ColumnDefaultConstraint( + dialect, current_timestamp(dialect, Literal(dialect, 6, inline=True)))), ], ), ], if_not_exists=True, ) sql, params = create.to_sql() - assert "CURRENT_TIMESTAMP(%s)" in sql or "CURRENT_TIMESTAMP(?)" in sql + assert "CURRENT_TIMESTAMP(6)" in sql try: mysql_backend.execute(sql, params) @@ -240,7 +249,7 @@ def test_ddl_default_current_timestamp_with_precision(self, mysql_backend): col_names = [c.name for c in cols] assert "ts" in col_names finally: - mysql_backend.execute(*DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) + mysql_backend.execute(*DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql()) class TestAsyncMySQLNiladicSelectContext: @@ -249,7 +258,7 @@ class TestAsyncMySQLNiladicSelectContext: @pytest.mark.asyncio async def test_async_select_current_timestamp_niladic(self, async_mysql_backend): """SELECT CURRENT_TIMESTAMP (niladic) works in MySQL (async).""" - query = QueryExpression( + query = QueryStatement( dialect=async_mysql_backend.dialect, select=[current_timestamp(async_mysql_backend.dialect)], ) @@ -271,10 +280,10 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac # Clean up await async_mysql_backend.execute( - *DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() + *DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() ) - create = CreateTableExpression( + create = CreateTableStatement( dialect=dialect, table=Table(dialect, table_name), columns=[ @@ -282,8 +291,8 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac "id", IntegerType(dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), - ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL, is_auto_increment=True), + ColumnConstraint(dialect, branch=ColumnPrimaryKeyConstraint(dialect, is_auto_increment=True)), + ColumnConstraint(dialect, branch=ColumnNotNullConstraint(dialect)), ], ), ColumnDefinition( @@ -291,7 +300,7 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac "ts", TimestampType(dialect=dialect), constraints=[ - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint(dialect, branch=ColumnDefaultConstraint(dialect, current_timestamp(dialect))), ], ), ], @@ -306,5 +315,5 @@ async def test_async_ddl_default_current_timestamp_niladic(self, async_mysql_bac assert "ts" in col_names finally: await async_mysql_backend.execute( - *DropTableExpression(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() + *DropTableStatement(dialect=dialect, table=Table(dialect, table_name), if_exists=True).to_sql() ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py index 06a1ffaf..683d37cf 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_set_type_backend.py @@ -100,7 +100,7 @@ def test_find_in_set_function(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_find_in_set") def test_format_set_literal_integration(self, mysql_backend): - """Test format_set_literal with database execution.""" + """Test format_mysql_set_literal_expression with database execution.""" # Create table mysql_backend.execute(""" CREATE TEMPORARY TABLE test_set_literal ( @@ -128,7 +128,7 @@ def test_format_set_literal_integration(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_set_literal") def test_format_find_in_set_integration(self, mysql_backend): - """Test format_find_in_set with database execution.""" + """Test format_mysql_find_in_set_expression with database execution.""" # Create table mysql_backend.execute(""" CREATE TEMPORARY TABLE test_find_format ( @@ -158,7 +158,7 @@ def test_format_find_in_set_integration(self, mysql_backend): mysql_backend.execute("DROP TEMPORARY TABLE IF EXISTS test_find_format") def test_format_set_contains_integration(self, mysql_backend): - """Test format_set_contains with database execution.""" + """Test format_mysql_set_contains_expression with database execution.""" # Create table mysql_backend.execute(""" CREATE TEMPORARY TABLE test_contains_format ( @@ -322,7 +322,7 @@ async def test_async_find_in_set_function(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_set_literal_integration(self, async_mysql_backend): - """Test format_set_literal with database execution (async).""" + """Test format_mysql_set_literal_expression with database execution (async).""" # Create table await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_set_literal ( @@ -351,7 +351,7 @@ async def test_async_format_set_literal_integration(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_find_in_set_integration(self, async_mysql_backend): - """Test format_find_in_set with database execution (async).""" + """Test format_mysql_find_in_set_expression with database execution (async).""" # Create table await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_find_format ( @@ -381,7 +381,7 @@ async def test_async_format_find_in_set_integration(self, async_mysql_backend): @pytest.mark.asyncio async def test_async_format_set_contains_integration(self, async_mysql_backend): - """Test format_set_contains with database execution (async).""" + """Test format_mysql_set_contains_expression with database execution (async).""" # Create table await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_contains ( diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py index 493a1a24..707ec204 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types.py @@ -12,7 +12,7 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.impl.mysql.expression import ( - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, MySQLSpatialLiteralExpression, MySQLSTAsGeoJSONExpression, MySQLSTAsTextExpression, @@ -189,7 +189,7 @@ def test_format_create_spatial_index_supported(self): """Test CREATE SPATIAL INDEX (MySQL 5.7+).""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression(dialect, "idx_location", "places", "geom") + expr = MySQLCreateSpatialIndexStatement(dialect, "idx_location", "places", "geom") sql, params = expr.to_sql() assert "CREATE SPATIAL INDEX" in sql @@ -203,7 +203,7 @@ def test_format_create_spatial_index_unsupported(self): dialect = MySQLDialect(version=(5, 5, 0)) with pytest.raises(Exception): # UnsupportedFeatureError # noqa: B017 - MySQLCreateSpatialIndexExpression(dialect, "idx_location", "places", "geom").to_sql() + MySQLCreateSpatialIndexStatement(dialect, "idx_location", "places", "geom").to_sql() class TestAsyncSpatialTypeProtocol: @@ -248,7 +248,7 @@ async def test_async_format_create_spatial_index(self): """Test async version of CREATE SPATIAL INDEX formatting.""" dialect = MySQLDialect(version=(8, 0, 0)) - expr = MySQLCreateSpatialIndexExpression(dialect, "idx_geom", "table", "col") + expr = MySQLCreateSpatialIndexStatement(dialect, "idx_geom", "table", "col") sql, params = expr.to_sql() assert "CREATE SPATIAL INDEX" in sql diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py index dc28af6a..978c8e8d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_spatial_types_backend.py @@ -9,7 +9,7 @@ import pytest from rhosocial.activerecord.backend.impl.mysql.expression import ( - MySQLCreateSpatialIndexExpression, + MySQLCreateSpatialIndexStatement, MySQLSpatialLiteralExpression, MySQLSTAsGeoJSONExpression, MySQLSTAsTextExpression, @@ -56,7 +56,7 @@ def test_supports_geojson_detection(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_type") def test_format_spatial_literal_without_srid(self, mysql_backend): - """Test format_spatial_literal generates correct SQL without SRID.""" + """Test format_mysql_spatial_literal_expression generates correct SQL without SRID.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_literal ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -77,7 +77,7 @@ def test_format_spatial_literal_without_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_type") def test_format_spatial_literal_with_srid(self, mysql_backend): - """Test format_spatial_literal generates correct SQL with SRID.""" + """Test format_mysql_spatial_literal_expression generates correct SQL with SRID.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_srid ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -101,7 +101,7 @@ def test_format_spatial_literal_with_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_geom_from_text_without_srid(self, mysql_backend): - """Test format_st_geom_from_text generates correct SQL without SRID.""" + """Test format_mysql_st_geom_from_text_expression generates correct SQL without SRID.""" dialect = mysql_backend.dialect expr = MySQLSTGeomFromTextExpression(dialect, "POINT(3 4)") sql, params = expr.to_sql() @@ -112,7 +112,7 @@ def test_format_st_geom_from_text_without_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_geom_from_text_with_srid(self, mysql_backend): - """Test format_st_geom_from_text generates correct SQL with SRID.""" + """Test format_mysql_st_geom_from_text_expression generates correct SQL with SRID.""" dialect = mysql_backend.dialect sql, params = MySQLSpatialLiteralExpression(dialect, "POINT(1 1)", 4326).to_sql() @@ -122,7 +122,7 @@ def test_format_st_geom_from_text_with_srid(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_as_text(self, mysql_backend): - """Test format_st_as_text generates correct SQL.""" + """Test format_mysql_st_as_text_expression generates correct SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_astext ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -143,7 +143,7 @@ def test_format_st_as_text(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_geojson") def test_format_st_as_geojson(self, mysql_backend): - """Test format_st_as_geojson generates correct SQL.""" + """Test format_mysql_st_as_geo_json_expression generates correct SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_geojson ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -165,7 +165,7 @@ def test_format_st_as_geojson(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_distance(self, mysql_backend): - """Test format_st_distance generates correct SQL.""" + """Test format_mysql_st_distance_expression generates correct SQL.""" dialect = mysql_backend.dialect point1_sql, point1_params = MySQLSTGeomFromTextExpression(dialect, "POINT(0 0)").to_sql() @@ -179,7 +179,7 @@ def test_format_st_distance(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_within(self, mysql_backend): - """Test format_st_within generates correct SQL.""" + """Test format_mysql_st_within_expression generates correct SQL.""" dialect = mysql_backend.dialect point_sql, point_params = MySQLSTGeomFromTextExpression(dialect, "POINT(5 5)").to_sql() @@ -193,7 +193,7 @@ def test_format_st_within(self, mysql_backend): @requires_protocol(MySQLSpatialSupport) def test_format_st_contains(self, mysql_backend): - """Test format_st_contains generates correct SQL.""" + """Test format_mysql_st_contains_expression generates correct SQL.""" dialect = mysql_backend.dialect polygon_sql, polygon_params = MySQLSTGeomFromTextExpression(dialect, "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))").to_sql() @@ -207,7 +207,7 @@ def test_format_st_contains(self, mysql_backend): @requires_protocol(MySQLSpatialSupport, "supports_spatial_index") def test_format_create_spatial_index(self, mysql_backend): - """Test format_create_spatial_index generates valid SQL.""" + """Test format_mysql_create_spatial_index_statement generates valid SQL.""" mysql_backend.execute(""" CREATE TEMPORARY TABLE test_spatial_idx ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -217,7 +217,7 @@ def test_format_create_spatial_index(self, mysql_backend): """) dialect = mysql_backend.dialect - index_sql, params = MySQLCreateSpatialIndexExpression( + index_sql, params = MySQLCreateSpatialIndexStatement( dialect, "idx_location", "test_spatial_idx", "location" ).to_sql() @@ -252,7 +252,7 @@ async def test_async_supports_spatial_type_detection(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_spatial_literal(self, async_mysql_backend): - """Test format_spatial_literal generates correct SQL (async).""" + """Test format_mysql_spatial_literal_expression generates correct SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_spatial ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -276,7 +276,7 @@ async def test_async_format_spatial_literal(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_st_geom_from_text(self, async_mysql_backend): - """Test format_st_geom_from_text generates correct SQL (async).""" + """Test format_mysql_st_geom_from_text_expression generates correct SQL (async).""" dialect = async_mysql_backend.dialect sql, params = MySQLSpatialLiteralExpression(dialect, "POINT(10 20)", 4326).to_sql() @@ -287,7 +287,7 @@ async def test_async_format_st_geom_from_text(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_st_distance(self, async_mysql_backend): - """Test format_st_distance generates correct SQL (async).""" + """Test format_mysql_st_distance_expression generates correct SQL (async).""" dialect = async_mysql_backend.dialect point1_sql, point1_params = MySQLSTGeomFromTextExpression(dialect, "POINT(0 0)").to_sql() @@ -302,7 +302,7 @@ async def test_async_format_st_distance(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport) async def test_async_format_st_within(self, async_mysql_backend): - """Test format_st_within generates correct SQL (async).""" + """Test format_mysql_st_within_expression generates correct SQL (async).""" dialect = async_mysql_backend.dialect point_sql, point_params = MySQLSTGeomFromTextExpression(dialect, "POINT(5 5)").to_sql() @@ -317,7 +317,7 @@ async def test_async_format_st_within(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport, "supports_geojson") async def test_async_format_st_as_geojson(self, async_mysql_backend): - """Test format_st_as_geojson generates correct SQL (async).""" + """Test format_mysql_st_as_geo_json_expression generates correct SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_geojson ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -341,7 +341,7 @@ async def test_async_format_st_as_geojson(self, async_mysql_backend): @pytest.mark.asyncio @requires_protocol(MySQLSpatialSupport, "supports_spatial_index") async def test_async_format_create_spatial_index(self, async_mysql_backend): - """Test format_create_spatial_index generates valid SQL (async).""" + """Test format_mysql_create_spatial_index_statement generates valid SQL (async).""" await async_mysql_backend.execute(""" CREATE TEMPORARY TABLE test_async_idx ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -350,7 +350,7 @@ async def test_async_format_create_spatial_index(self, async_mysql_backend): """) dialect = async_mysql_backend.dialect - index_sql, _ = MySQLCreateSpatialIndexExpression( + index_sql, _ = MySQLCreateSpatialIndexStatement( dialect, "idx_loc", "test_async_idx", "location" ).to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py index 20c56e72..85954e83 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal class TestDerivedFieldDeclaration: @@ -20,9 +20,7 @@ def test_descriptor_class_access(self, product_class): def test_derived_field_with_static_expression(self, product_class): """Test DerivedField constructed with a pre-built expression object (non-callable path).""" dialect = product_class.backend().dialect - static_expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 2) - ) + static_expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 2)) df = DerivedField(static_expr) resolved = df.resolve(dialect) assert resolved is static_expr @@ -140,9 +138,7 @@ def test_derived_dict_with_lambda(self, product_class): self._insert(product_class, "D1", 100.0, 4) results = product_class.find_all( derived={ - "my_discount": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.8) - ) + "my_discount": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.8)) } ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -159,9 +155,7 @@ def test_derived_dict_with_derived_field_instance(self, product_class): def test_derived_dict_with_expression_object(self, product_class): self._insert(product_class, "D3", 80.0, 5) dialect = product_class.backend().dialect - expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5) - ) + expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 0.5)) results = product_class.find_all( derived={"half_price": expr} ) @@ -194,9 +188,7 @@ def test_extra_derived_basic(self, product_class): self._insert(product_class, "F", 100.0, 10) results = product_class.find_all( extra_derived={ - "triple_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 3) - ) + "triple_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 3)) } ) assert len(results) == 1 @@ -207,9 +199,7 @@ def test_extra_derived_conflict_raises(self, product_class): with pytest.raises(ValueError, match="conflicts with a declared derived field"): product_class.find_all( extra_derived={ - "discounted_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.5) - ) + "discounted_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.5)) } ) @@ -218,9 +208,7 @@ def test_derived_and_extra_derived_together(self, product_class): results = product_class.find_all( derived=True, extra_derived={ - "double_qty": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "quantity"), Literal(d, 2) - ) + "double_qty": lambda d: ArithmeticOperation(d, "mul", Column(d, "quantity"), Literal(d, 2)) } ) assert results[0].discounted_price == pytest.approx(36.0) @@ -335,9 +323,7 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] def test_use_column_no_conflict_different_names(self): @@ -347,9 +333,7 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py index 3c944830..4934b35f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fields/test_derived_field_async.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal class TestAsyncDerivedFieldDeclaration: @@ -20,9 +20,7 @@ async def test_descriptor_class_access(self, async_product_class): async def test_derived_field_with_static_expression(self, async_product_class): """Test DerivedField constructed with a pre-built expression object (non-callable path).""" dialect = async_product_class.backend().dialect - static_expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 2) - ) + static_expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 2)) df = DerivedField(static_expr) resolved = df.resolve(dialect) assert resolved is static_expr @@ -140,9 +138,7 @@ async def test_derived_dict_with_lambda(self, async_product_class): await self._insert(async_product_class, "D1", 100.0, 4) results = await async_product_class.find_all( derived={ - "my_discount": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.8) - ) + "my_discount": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.8)) } ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -159,9 +155,7 @@ async def test_derived_dict_with_derived_field_instance(self, async_product_clas async def test_derived_dict_with_expression_object(self, async_product_class): await self._insert(async_product_class, "D3", 80.0, 5) dialect = async_product_class.backend().dialect - expr = BinaryArithmeticExpression( - dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5) - ) + expr = ArithmeticOperation(dialect, "mul", Column(dialect, "price"), Literal(dialect, 0.5)) results = await async_product_class.find_all( derived={"half_price": expr} ) @@ -194,9 +188,7 @@ async def test_extra_derived_basic(self, async_product_class): await self._insert(async_product_class, "F", 100.0, 10) results = await async_product_class.find_all( extra_derived={ - "triple_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 3) - ) + "triple_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 3)) } ) assert len(results) == 1 @@ -207,9 +199,7 @@ async def test_extra_derived_conflict_raises(self, async_product_class): with pytest.raises(ValueError, match="conflicts with a declared derived field"): await async_product_class.find_all( extra_derived={ - "discounted_price": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.5) - ) + "discounted_price": lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.5)) } ) @@ -218,9 +208,7 @@ async def test_derived_and_extra_derived_together(self, async_product_class): results = await async_product_class.find_all( derived=True, extra_derived={ - "double_qty": lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "quantity"), Literal(d, 2) - ) + "double_qty": lambda d: ArithmeticOperation(d, "mul", Column(d, "quantity"), Literal(d, 2)) } ) assert results[0].discounted_price == pytest.approx(36.0) @@ -335,9 +323,7 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] async def test_use_column_no_conflict_different_names(self): @@ -347,9 +333,7 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression( - d, "*", Column(d, "price"), Literal(d, 0.9) - ), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py index 8db8dd9d..9bc6eb95 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py @@ -488,7 +488,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) # Python 'str' can be converted to DB 'int' + self.register_type(str, int) # Python 'str' can be converted to DB 'int' def _do_to_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: # The 'notes' attribute on the model is a str, which we convert to an int for the DB. @@ -532,7 +532,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: if value is None: @@ -589,7 +589,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Optional[str]: if value is None: @@ -767,7 +767,7 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): # ============================================================================= from rhosocial.activerecord.base import DerivedField -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal class Product(ActiveRecord): @@ -779,11 +779,11 @@ class Product(ActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), )]] total_value: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), )]] @@ -796,11 +796,11 @@ class AsyncProduct(AsyncActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), )]] total_value: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), )]] @@ -814,11 +814,11 @@ class ProductFormA(ActiveRecord): quantity: int discounted_price: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ) total_value: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ) @@ -831,11 +831,11 @@ class AsyncProductFormA(AsyncActiveRecord): quantity: int discounted_price: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ) total_value: ClassVar[DerivedField] = DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ) @@ -900,11 +900,11 @@ class ProductWithColumnAndAdapter(ActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] total_int: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ), UseAdapter(PriceToIntAdapter(), int)]] @@ -917,10 +917,10 @@ class AsyncProductWithColumnAndAdapter(AsyncActiveRecord): quantity: int discounted_price: ClassVar[Annotated[float, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Literal(d, 0.9)), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Literal(d, 0.9)), ), UseColumn("disc")]] total_int: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "*", Column(d, "price"), Column(d, "quantity")), + lambda d: ArithmeticOperation(d, "mul", Column(d, "price"), Column(d, "quantity")), ), UseAdapter(PriceToIntAdapter(), int)]] diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py index 900034bb..3a7b6049 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py @@ -527,7 +527,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -572,7 +572,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -618,7 +618,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py index c348093f..5a6f6004 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py @@ -515,7 +515,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -556,7 +556,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -602,7 +602,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py index d0e8ce20..f0f74f59 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py @@ -502,7 +502,7 @@ class IntToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(str, int) + self.register_type(str, int) def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: return int(value) @@ -543,7 +543,7 @@ class ListToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(list, str) + self.register_type(list, str) def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: @@ -589,7 +589,7 @@ class JsonToStringAdapter(BaseSQLTypeAdapter): def __init__(self): super().__init__() - self._register_type(dict, str) + self.register_type(dict, str) def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: if value is None: diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py index baca4538..f149cff2 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py @@ -1,202 +1,10 @@ -# src/rhosocial/activerecord/testsuite/feature/basic/transaction/test_async_owner_task.py -"""Async contracts for `AsyncTransactionManager._owner_task` safety warning. - -`AsyncTransactionManager.transaction()` (python-activerecord -`backend/transaction.py:879-943`) tracks which asyncio task currently -owns an active outermost transaction. When a second task enters -`transaction()` on the same backend instance while another task holds -an active outermost transaction, it MUST emit `UserWarning` with a fixed -message text (line 906-915). - -The `_owner_task` lifecycle is bound to `backend.transaction_manager.transaction()` -only. `pool.transaction()` calls `begin_transaction()` / `commit_transaction()` -directly (sync_pool.py:574-576 / async_pool.py:613-615) and does NOT -participate in `_owner_task` tracking. Therefore these tests operate -directly on `backend.transaction_manager` rather than through -`pool.transaction()`. - -Coverage: -- T1 single task nested transaction does not emit warning -- T2 a different task entering while an outer is active emits warning -- T3 warning message text is stable (for log-grep filters) -- T4 owner_task is cleared after outermost transaction exits cleanly -- T5 same task re-entering after clean exit re-acquires ownership +# tests/rhosocial/activerecord_mysql_test/feature/basic/transaction/test_async_owner_task.py """ -import asyncio -import warnings -from typing import Tuple - -import pytest - - -@pytest.fixture -async def mgr_and_backend(async_pool_and_model): - """Acquire a backend via pool.connection() and yield (backend, mgr). - - Mechanism note: we stub `_do_begin / _do_commit / _do_rollback / - _do_create_savepoint / _do_release_savepoint / _do_rollback_savepoint` - on the async transaction manager with no-ops, because the - `_owner_task` warning branch fires *before* `_do_begin()` is awaited - (transaction.py:905-915 runs synchronously at context-manager entry, - only then does `await self.begin()` execute). Stubbing the I/O layer - lets us exercise the pure state machine — including concurrent - multi-task execution on a real asyncio event loop — without dragging - in SQLite's thread-affinity rules, which otherwise cause spurious - "cannot start a transaction within a transaction" when a second - task reuses the same aiosqlite connection. - """ - pool, _model = async_pool_and_model - cm = pool.connection() - backend = await cm.__aenter__() - mgr = backend.transaction_manager - - async def _noop(*args, **kwargs): - return None - - mgr._do_begin = _noop - mgr._do_commit = _noop - mgr._do_rollback = _noop - mgr._do_create_savepoint = _noop - mgr._do_release_savepoint = _noop - mgr._do_rollback_savepoint = _noop - - try: - yield backend, mgr - finally: - try: - await cm.__aexit__(None, None, None) - except Exception: - pass - - -class TestAsyncOwnerTaskNoWarningSameTask: - async def test_same_task_nested_transaction_no_warning(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - with warnings.catch_warnings(): - warnings.simplefilter("error") - async with mgr.transaction(): - async with mgr.transaction(): - pass - - async def test_same_task_reentry_after_clean_exit_no_warning(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - async with mgr.transaction(): - pass - with warnings.catch_warnings(): - warnings.simplefilter("error") - async with mgr.transaction(): - pass - - -class TestAsyncOwnerTaskCrossTaskWarning: - async def test_cross_task_share_emits_user_warning(self, mgr_and_backend): - """A different task entering transaction() during an active outer - transaction owned by another task MUST emit UserWarning. - - Per backend/transaction.py:906-915, the branch fires when: - self._owner_task is not None and self._owner_task is not current_task - It does NOT raise, so the second task can still proceed - the - system is telling the developer to fix their code. - """ - _backend, mgr = mgr_and_backend +Async contract for AsyncTransactionManager._owner_task (MySQL backend). - outer_started = asyncio.Event() - proceed = asyncio.Event() - - async def owner(): - async with mgr.transaction(): - outer_started.set() - await proceed.wait() - - async def second_task(caught): - with warnings.catch_warnings(record=True) as captured: - warnings.simplefilter("always") - async with mgr.transaction(): - pass - caught.extend(captured) - - caught = [] - owner_task = asyncio.create_task(owner()) - second_t = asyncio.create_task(second_task(caught)) - # Let owner enter its outer transaction first. - await asyncio.wait_for(outer_started.wait(), timeout=5.0) - # Yield once so second task enters transaction() and emits warning. - await asyncio.sleep(0.05) - proceed.set() - await asyncio.gather(owner_task, second_t) - - user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] - assert user_warnings, "expected a UserWarning about shared backend" - - async def test_cross_task_warning_message_is_stable(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - - outer_started = asyncio.Event() - proceed = asyncio.Event() - - async def owner(): - async with mgr.transaction(): - outer_started.set() - await proceed.wait() - - async def second_task(caught): - with warnings.catch_warnings(record=True) as captured: - warnings.simplefilter("always") - async with mgr.transaction(): - pass - caught.extend(captured) - - caught = [] - owner_task = asyncio.create_task(owner()) - second_t = asyncio.create_task(second_task(caught)) - await asyncio.wait_for(outer_started.wait(), timeout=5.0) - await asyncio.sleep(0.05) - proceed.set() - await asyncio.gather(owner_task, second_t) - - msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] - assert msgs, "no UserWarning captured" - expected = ( - "Shared async backend instance detected: a different async task " - "already holds an active transaction on this backend. Concurrent " - "use of the same backend instance across tasks can lead to " - "transaction state inconsistency. Use separate backend instances " - "per task or ensure serialized access." - ) - assert msgs[0] == expected - - -class TestAsyncOwnerTaskLifecycle: - async def test_owner_task_cleared_after_outermost_exits(self, mgr_and_backend): - """After a clean outermost commit, _owner_task MUST reset to None. - - Per backend/transaction.py:941-943: - if not self.is_active: - self._owner_task = None - """ - _backend, mgr = mgr_and_backend - async with mgr.transaction(): - assert mgr.is_active - async with mgr.transaction(): - pass - assert mgr._owner_task is None - - async def test_owner_task_cleared_after_outermost_rolls_back(self, mgr_and_backend): - _backend, mgr = mgr_and_backend - with pytest.raises(RuntimeError): - async with mgr.transaction(): - async with mgr.transaction(): - raise RuntimeError("force rollback") - assert mgr._owner_task is None +Imports the shared tests: a second asyncio task entering ``transaction()`` +while another task owns an active outermost transaction is refused with +``TransactionError``, and ownership is released when the outermost one ends. +""" - async def test_owner_task_reacquired_by_same_task_after_clean_exit(self, mgr_and_backend): - """After clean reset, the same task re-entering does not warn and - re-acquires ownership (line 916-917).""" - _backend, mgr = mgr_and_backend - async with mgr.transaction(): - async with mgr.transaction(): - pass - with warnings.catch_warnings(): - warnings.simplefilter("error") - async with mgr.transaction(): - pass +from rhosocial.activerecord.testsuite.feature.basic.transaction.test_async_owner_task import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py b/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py index 20e7009b..6c9e0379 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py @@ -5,7 +5,9 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.collation import collate from rhosocial.activerecord.backend.impl.mysql.mixins import MySQLCollation from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect @@ -70,7 +72,10 @@ def test_column_collate_generates_sql(self, dialect): assert params == () def test_literal_collate_preserves_parameter_binding(self, dialect): - expr = Literal(dialect, "Alice").collate(MySQLCollation.UTF8MB4_0900_AI_CI) + # A Literal is a value node and carries no operation surface of its + # own (rules §4.3), so COLLATE is composed with the collate factory + # over it rather than as a chain on the literal. + expr = collate(Literal(dialect, "Alice"), MySQLCollation.UTF8MB4_0900_AI_CI) sql, params = expr.to_sql() @@ -80,7 +85,9 @@ def test_literal_collate_preserves_parameter_binding(self, dialect): def test_rejects_schema_qualified_collation(self, dialect): expr = Column(dialect, "name").collate("utf8mb4_bin", schema="public") - with pytest.raises(Exception, match="COLLATE options: schema"): + with pytest.raises( + UnsupportedFeatureError, match="COLLATE schema qualification" + ): expr.to_sql() def test_rejects_unsupported_collation(self, dialect): diff --git a/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py index cd7486b2..955cab2c 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/relation/fixtures/models.py @@ -13,7 +13,7 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.base import DerivedField, FieldProxy -from rhosocial.activerecord.backend.expression import BinaryArithmeticExpression, Column, Literal +from rhosocial.activerecord.backend.expression import ArithmeticOperation, Column, Literal from rhosocial.activerecord.backend.expression.functions import ( json_extract_text, length, concat, coalesce ) @@ -159,7 +159,7 @@ class Post(ActiveRecord): # DerivedField: hotness score hotness: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "+", Column(d, "view_count"), Literal(d, 1)), + lambda d: ArithmeticOperation(d, "add", Column(d, "view_count"), Literal(d, 1)), )]] # DerivedField (JSON): first tag from metadata @@ -254,7 +254,7 @@ class AsyncPost(AsyncActiveRecord): )]] hotness: ClassVar[Annotated[int, DerivedField( - lambda d: BinaryArithmeticExpression(d, "+", Column(d, "view_count"), Literal(d, 1)), + lambda d: ArithmeticOperation(d, "add", Column(d, "view_count"), Literal(d, 1)), )]] first_tag: ClassVar[Annotated[str, DerivedField(