From 5ee22648414527bfddfd97fbafa176be4888eb70 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 19 Sep 2026 20:03:06 +0800 Subject: [PATCH 01/52] refactor(mariadb): inherit inline-index capability from core and unify formatting - Drop the private supports_inline_index/format_inline_index declarations from the MariaDB table protocol; the capability now comes from the core TableSupport protocol (default False, MariaDB overrides True) - Converge inline index rendering onto the core format_index_definition override (returns (sql, params)); remove the parallel method - Point CI at the core feature branch that carries the protocol --- .github/workflows/test.yml | 4 ++-- .../activerecord/backend/impl/mariadb/dialect.py | 2 +- .../backend/impl/mariadb/mixins/ddl_table.py | 4 ++-- .../backend/impl/mariadb/protocols/table.py | 11 ----------- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61d5ce24..0f16e278 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,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 @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create MariaDB scenario config @@ -157,7 +157,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 @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create MariaDB scenario config diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 03028a55..8ecbf542 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -609,7 +609,7 @@ def format_create_table_statement( all_params.extend(const_params) for idx_def in expr.indexes: - idx_sql, idx_params = self.format_inline_index(idx_def) + idx_sql, idx_params = self.format_index_definition(idx_def) column_parts.append(idx_sql) all_params.extend(idx_params) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index 0b486d88..b96a910b 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -107,7 +107,7 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: all_params.extend(const_params) for idx_def in expr.indexes: - idx_sql, idx_params = self.format_inline_index(idx_def) + idx_sql, idx_params = self.format_index_definition(idx_def) column_parts.append(idx_sql) all_params.extend(idx_params) @@ -260,7 +260,7 @@ def format_table_constraint( return ' '.join(parts), tuple(params) - def format_inline_index(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: + def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: """Format an inline index definition (MariaDB-specific).""" parts = [] diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py index a19639b2..83397ede 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py @@ -46,13 +46,6 @@ def supports_create_table_like(self) -> bool: """ ... - def supports_inline_index(self) -> bool: - """Whether inline index definitions are supported. - - MariaDB allows INDEX/KEY definitions within CREATE TABLE. - """ - ... - def supports_storage_engine_option(self) -> bool: """Whether ENGINE option is supported. @@ -107,7 +100,3 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: def format_table_constraint(self, t_const) -> Tuple[str, tuple]: """Format a table-level constraint (PRIMARY KEY / UNIQUE / FOREIGN KEY).""" ... - - def format_inline_index(self, idx_def: Any) -> Tuple[str, tuple]: - """Format an inline index definition inside CREATE TABLE.""" - ... From f6afbd51f36910c0c00792ac9aa9b9a78ba31d12 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 19 Sep 2026 20:16:16 +0800 Subject: [PATCH 02/52] fix(mariadb): declare format_index_definition in MariaDB table protocol --- .../activerecord/backend/impl/mariadb/protocols/table.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py index 83397ede..d9c5ae95 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py @@ -100,3 +100,7 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: def format_table_constraint(self, t_const) -> Tuple[str, tuple]: """Format a table-level constraint (PRIMARY KEY / UNIQUE / FOREIGN KEY).""" ... + + def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: + """Format an inline index definition inside CREATE TABLE.""" + ... From c8f0b076663458f468b5551a733621b66bb67e1d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 19 Sep 2026 20:24:44 +0800 Subject: [PATCH 03/52] test(mariadb): cover inline index rendering from derived DDL --- .../ddl/test_derived_ddl_inline_index.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py new file mode 100644 index 00000000..a0282c68 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -0,0 +1,49 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py +"""MariaDB inline-index support for ActiveRecord-derived DDL (positive path). + +SQLite and other SQL-standard dialects cannot inline index definitions, so the +positive rendering path is exercised here on a dialect that supports it +(``supports_inline_index()`` is True). +""" + +from typing import Annotated + +from rhosocial.activerecord.base import UseIndex +from rhosocial.activerecord.base.ddl import TableDDLDeriver +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, +) +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.model import ActiveRecord + + +class Indexed(ActiveRecord): + __table_name__ = "indexed" + + id: int + email: Annotated[str, UseIndex("idx_indexed_email", unique=True)] + + +def mariadb_dialect(): + return MariaDBDialect((10, 11, 0)) + + +def test_create_table_renders_inline_index(): + deriver = TableDDLDeriver(Indexed, mariadb_dialect()) + expression = deriver.create_table() + + assert [index.name for index in expression.indexes] == ["idx_indexed_email"] + sql, _ = expression.to_sql() + assert "UNIQUE INDEX `idx_indexed_email` (`email`)" in sql + + +def test_create_indexes_is_empty_when_inline_capable(): + deriver = TableDDLDeriver(Indexed, mariadb_dialect()) + assert deriver.create_indexes() == [] + + +def test_create_schema_is_a_single_statement(): + deriver = TableDDLDeriver(Indexed, mariadb_dialect()) + plan = deriver.create_schema() + assert len(plan) == 1 + assert isinstance(plan[0], CreateTableExpression) From 297d909ba4c8782fb3ff623922f0cad8fd4bc75d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 19 Sep 2026 20:48:39 +0800 Subject: [PATCH 04/52] test(mariadb): import Annotated with typing_extensions fallback on Python 3.8 --- .../feature/backend/ddl/test_derived_ddl_inline_index.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py index a0282c68..586f6917 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -6,7 +6,10 @@ (``supports_inline_index()`` is True). """ -from typing import Annotated +try: + from typing import Annotated +except ImportError: # Python 3.8 + from typing_extensions import Annotated from rhosocial.activerecord.base import UseIndex from rhosocial.activerecord.base.ddl import TableDDLDeriver From 0e948b8cabae2144e9349ea8910e0df881c308f4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 08:42:47 +0800 Subject: [PATCH 05/52] feat(mariadb): implement native partition expressions and formatting Add MariaDB-owned partition DDL expressions (strategies, boundary values, definitions derived from the generic base declarations) and replace the partition formatting stubs in MariaDBPartitionMixin with real implementations for PARTITION BY strategies, inline definitions, definition options, subpartitions, and boundary values. --- .../impl/mariadb/expression/__init__.py | 28 ++ .../impl/mariadb/expression/partition.py | 243 +++++++++++++++ .../backend/impl/mariadb/mixins/partition.py | 276 +++++++++++++++++- .../test_mariadb_partition_expressions.py | 218 ++++++++++++++ 4 files changed, 762 insertions(+), 3 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index 295f3437..c64caeee 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -63,6 +63,21 @@ MariaDBTableMaintenanceExpression, TableMaintenanceOperation, ) +from .partition import ( + MariaDBPartitionStrategy, + MariaDBSubpartitionStrategy, + MariaDBPartitionMaxValue, + MariaDBPartitionValue, + MariaDBSubpartitionDefinition, + MariaDBPartitionDefinition, + MariaDBPartitionClause, + MariaDBPartitionByRange, + MariaDBPartitionByRangeColumns, + MariaDBPartitionByList, + MariaDBPartitionByListColumns, + MariaDBPartitionByHash, + MariaDBPartitionByKey, +) # DataType subclasses for DDL from .types import ( @@ -114,6 +129,19 @@ "MariaDBRenameIndexExpression", "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", + "MariaDBPartitionStrategy", + "MariaDBSubpartitionStrategy", + "MariaDBPartitionMaxValue", + "MariaDBPartitionValue", + "MariaDBSubpartitionDefinition", + "MariaDBPartitionDefinition", + "MariaDBPartitionClause", + "MariaDBPartitionByRange", + "MariaDBPartitionByRangeColumns", + "MariaDBPartitionByList", + "MariaDBPartitionByListColumns", + "MariaDBPartitionByHash", + "MariaDBPartitionByKey", "MariaDBCallExpression", "MariaDBCreateFunctionExpression", "MariaDBCreateProcedureExpression", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py new file mode 100644 index 00000000..b1319eef --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py @@ -0,0 +1,243 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py +"""MariaDB partition DDL expressions. + +MariaDB implements table partitioning natively through the storage engine +interface using the same ``PARTITION BY`` grammar as its sibling engines. +This module defines MariaDB-owned expression types; it does not depend on any +other backend package. The concrete partition definitions derive from the +generic declarations in ``rhosocial.activerecord.backend.expression.statements``. +""" + +from __future__ import annotations + +from decimal import Decimal +from enum import Enum +from math import isfinite +from typing import Any, Optional, Sequence, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements import ( + PartitionClause, + PartitionDefinition, + SubpartitionDefinition, +) + +if TYPE_CHECKING: # pragma: no cover + from ..dialect import MariaDBDialect + + +class MariaDBPartitionStrategy(Enum): + """MariaDB table partitioning strategies supported by MariaDBPartitionMixin.""" + + RANGE = "RANGE" + RANGE_COLUMNS = "RANGE COLUMNS" + LIST = "LIST" + LIST_COLUMNS = "LIST COLUMNS" + HASH = "HASH" + LINEAR_HASH = "LINEAR HASH" + KEY = "KEY" + LINEAR_KEY = "LINEAR KEY" + + +class MariaDBSubpartitionStrategy(Enum): + """MariaDB subpartitioning strategies. + + MariaDB restricts subpartitioning to HASH and KEY (and their LINEAR + variants) only. RANGE and LIST cannot be used for subpartitioning. + """ + + HASH = "HASH" + KEY = "KEY" + LINEAR_HASH = "LINEAR HASH" + LINEAR_KEY = "LINEAR KEY" + + +class MariaDBPartitionMaxValue(BaseExpression): + """MariaDB MAXVALUE partition boundary token.""" + + def __init__(self, dialect: "MariaDBDialect"): + super().__init__(dialect) + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_value" + + +class MariaDBPartitionValue(BaseExpression): + """Literal value used in MariaDB partition boundary definitions.""" + + def __init__(self, dialect: "MariaDBDialect", value: Any): + super().__init__(dialect) + if isinstance(value, float) and not isfinite(value): + raise ValueError("partition value float must be finite") + if not isinstance(value, (str, int, float, Decimal, type(None))): + from datetime import date, datetime + + if not isinstance(value, (date, datetime)): + raise TypeError( + "partition value must be str, int, float, Decimal, " + f"date, datetime, or None, got {type(value).__name__}" + ) + self.value = value + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_value" + + +class MariaDBSubpartitionDefinition(SubpartitionDefinition): + """A single named subpartition within a MariaDB partition definition. + + MariaDB subpartitions carry no explicit boundary (the ``SUBPARTITION BY`` + template applies), so this subclass adds nothing beyond the base name and + options. + """ + + +class MariaDBPartitionDefinition(PartitionDefinition): + """A MariaDB ``PARTITION ... VALUES ...`` definition. + + Derives from the generic ``PartitionDefinition`` and tightens validation: + MariaDB requires exactly one boundary form (``less_than`` or ``in_values``). + + For single-column LIST COLUMNS, ``in_values`` accepts a flat sequence of + ``BaseExpression``; for multi-column LIST COLUMNS it accepts a sequence of + row tuples (each a sequence of ``BaseExpression``). + + Raises: + ValueError: if both boundaries are provided, or neither is provided. + TypeError: if ``dialect_options`` is not a dict when provided. + """ + + subpartition_definitions: Optional[Sequence["MariaDBSubpartitionDefinition"]] = None + + def __post_init__(self) -> None: + super().__post_init__() + if self.less_than is None and self.in_values is None: + raise ValueError("partition definition requires less_than or in_values") + + +class MariaDBPartitionClause(PartitionClause): + """Base MariaDB partition clause with MariaDB-specific strategy enum.""" + + strategy_type = MariaDBPartitionStrategy + + +class MariaDBPartitionByRange(MariaDBPartitionClause): + """MariaDB PARTITION BY RANGE expression.""" + + def __init__( + self, + dialect: "MariaDBDialect", + keys: Sequence[BaseExpression], + *, + partitions: Optional[Sequence[MariaDBPartitionDefinition]] = None, + ): + super().__init__(dialect, MariaDBPartitionStrategy.RANGE, keys) + self.partitions = list(partitions or []) + + +class MariaDBPartitionByRangeColumns(MariaDBPartitionClause): + """MariaDB PARTITION BY RANGE COLUMNS expression.""" + + def __init__( + self, + dialect: "MariaDBDialect", + keys: Sequence[BaseExpression], + *, + partitions: Optional[Sequence[MariaDBPartitionDefinition]] = None, + ): + super().__init__(dialect, MariaDBPartitionStrategy.RANGE_COLUMNS, keys) + self.partitions = list(partitions or []) + + +class MariaDBPartitionByList(MariaDBPartitionClause): + """MariaDB PARTITION BY LIST expression.""" + + def __init__( + self, + dialect: "MariaDBDialect", + keys: Sequence[BaseExpression], + *, + partitions: Optional[Sequence[MariaDBPartitionDefinition]] = None, + ): + super().__init__(dialect, MariaDBPartitionStrategy.LIST, keys) + self.partitions = list(partitions or []) + + +class MariaDBPartitionByListColumns(MariaDBPartitionClause): + """MariaDB PARTITION BY LIST COLUMNS expression.""" + + def __init__( + self, + dialect: "MariaDBDialect", + keys: Sequence[BaseExpression], + *, + partitions: Optional[Sequence[MariaDBPartitionDefinition]] = None, + ): + super().__init__(dialect, MariaDBPartitionStrategy.LIST_COLUMNS, keys) + self.partitions = list(partitions or []) + + +class MariaDBPartitionByHash(MariaDBPartitionClause): + """MariaDB PARTITION BY HASH expression.""" + + def __init__( + self, + dialect: "MariaDBDialect", + keys: Sequence[BaseExpression], + *, + partitions_count: Optional[int] = None, + linear: bool = False, + ): + method = MariaDBPartitionStrategy.LINEAR_HASH if linear else MariaDBPartitionStrategy.HASH + super().__init__(dialect, method, keys) + self.partitions_count = partitions_count + self.linear = linear + + +class MariaDBPartitionByKey(MariaDBPartitionClause): + """MariaDB PARTITION BY KEY expression. + + MariaDB allows empty ``KEY()`` to use all primary key columns as the + partition key. When ``keys`` is empty or ``None``, this expression + bypasses the base class key validation (which requires at least one key + expression) and produces ``PARTITION BY KEY()``. + """ + + def __init__( + self, + dialect: "MariaDBDialect", + keys: Optional[Sequence[BaseExpression]] = None, + *, + partitions_count: Optional[int] = None, + linear: bool = False, + ): + method = MariaDBPartitionStrategy.LINEAR_KEY if linear else MariaDBPartitionStrategy.KEY + if keys: + super().__init__(dialect, method, keys) + else: + BaseExpression.__init__(self, dialect) + self.method = method.value + self.keys = [] + self.partitions_count = partitions_count + self.linear = linear + + +__all__ = [ + "MariaDBPartitionStrategy", + "MariaDBSubpartitionStrategy", + "MariaDBPartitionMaxValue", + "MariaDBPartitionValue", + "MariaDBSubpartitionDefinition", + "MariaDBPartitionDefinition", + "MariaDBPartitionClause", + "MariaDBPartitionByRange", + "MariaDBPartitionByRangeColumns", + "MariaDBPartitionByList", + "MariaDBPartitionByListColumns", + "MariaDBPartitionByHash", + "MariaDBPartitionByKey", +] diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py index 7d792fa4..c7c2ca93 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py @@ -100,13 +100,283 @@ def supports_repair_partition(self) -> bool: return True def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: - raise NotImplementedError("Partition expression formatting requires MariaDB-specific expression classes") + """Format a MariaDB ``PARTITION BY`` clause. + + Dispatches on the normalized strategy name; the key list and optional + partition definitions are rendered inline. + """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + 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(f"Invalid MariaDB partition method: {expr.method}") + + def _format_partition_key_list(self, keys: Sequence[Any]) -> Tuple[str, tuple]: + key_sql_parts = [] + params: list = [] + for key in keys: + key_sql, key_params = key.to_sql() + key_sql_parts.append(key_sql) + params.extend(key_params) + return ", ".join(key_sql_parts), tuple(params) + + def _append_partition_definitions( + self, sql: str, params: list, partitions: Sequence[Any] + ) -> Tuple[str, tuple]: + if not partitions: + return sql, tuple(params) + definition_sql_parts = [] + for partition in partitions: + definition_sql, definition_params = self.format_partition_definition(partition) + definition_sql_parts.append(definition_sql) + params.extend(definition_params) + return f"{sql} ({', '.join(definition_sql_parts)})", tuple(params) + + def format_partition_by_range(self, expr: Any) -> Tuple[str, tuple]: + """Format ``PARTITION BY RANGE (...)`` with optional definitions.""" + if not self.supports_range_table_partitioning(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError(self.name, "RANGE partitioning") + keys_sql, params = self._format_partition_key_list(expr.keys) + sql = f" PARTITION BY RANGE ({keys_sql})" + return self._append_partition_definitions(sql, list(params), getattr(expr, "partitions", [])) + + def format_partition_by_range_columns(self, expr: Any) -> Tuple[str, tuple]: + """Format ``PARTITION BY RANGE COLUMNS (...)`` with optional definitions.""" + if not self.supports_range_columns_partitioning(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError(self.name, "RANGE COLUMNS partitioning") + keys_sql, params = self._format_partition_key_list(expr.keys) + sql = f" PARTITION BY RANGE COLUMNS ({keys_sql})" + return self._append_partition_definitions(sql, list(params), getattr(expr, "partitions", [])) + + def format_partition_by_list(self, expr: Any) -> Tuple[str, tuple]: + """Format ``PARTITION BY LIST (...)`` with optional definitions.""" + if not self.supports_list_table_partitioning(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError(self.name, "LIST partitioning") + keys_sql, params = self._format_partition_key_list(expr.keys) + sql = f" PARTITION BY LIST ({keys_sql})" + return self._append_partition_definitions(sql, list(params), getattr(expr, "partitions", [])) + + def format_partition_by_list_columns(self, expr: Any) -> Tuple[str, tuple]: + """Format ``PARTITION BY LIST COLUMNS (...)`` with optional definitions.""" + if not self.supports_list_columns_partitioning(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + raise UnsupportedFeatureError(self.name, "LIST COLUMNS partitioning") + keys_sql, params = self._format_partition_key_list(expr.keys) + sql = f" PARTITION BY LIST COLUMNS ({keys_sql})" + return self._append_partition_definitions(sql, list(params), getattr(expr, "partitions", [])) + + def format_partition_by_hash(self, expr: Any) -> Tuple[str, tuple]: + """Format ``PARTITION BY [LINEAR] HASH (...) [PARTITIONS n]``.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if not self.supports_hash_table_partitioning(): + raise UnsupportedFeatureError(self.name, "HASH partitioning") + linear = expr.method.upper() == "LINEAR HASH" + if linear and not self.supports_linear_hash_partitioning(): + raise UnsupportedFeatureError(self.name, "LINEAR HASH partitioning") + keyword = "LINEAR HASH" if linear else "HASH" + keys_sql, params = self._format_partition_key_list(expr.keys) + sql = f" PARTITION BY {keyword} ({keys_sql})" + partitions_count = getattr(expr, "partitions_count", None) + 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: Any) -> Tuple[str, tuple]: + """Format ``PARTITION BY [LINEAR] KEY (...) [PARTITIONS n]``.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if not self.supports_key_table_partitioning(): + raise UnsupportedFeatureError(self.name, "KEY partitioning") + linear = expr.method.upper() == "LINEAR KEY" + if linear and not self.supports_linear_key_partitioning(): + raise UnsupportedFeatureError(self.name, "LINEAR KEY partitioning") + keyword = "LINEAR KEY" if linear else "KEY" + keys_sql, params = self._format_partition_key_list(expr.keys) + sql = f" PARTITION BY {keyword} ({keys_sql})" + partitions_count = getattr(expr, "partitions_count", None) + 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_definition(self, definition: Any) -> Tuple[str, tuple]: - raise NotImplementedError("Partition expression formatting requires MariaDB-specific expression classes") + """Format a MariaDB ``PARTITION ... VALUES ...`` definition.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + params: list = [] + parts = ["PARTITION", self.format_identifier(definition.name)] + if definition.dialect_options and not self.supports_partition_definition_options(): + raise UnsupportedFeatureError(self.name, "partition definition options") + + if definition.less_than is not None: + value_sql_parts = [] + for value in definition.less_than: + value_sql, value_params = value.to_sql() + value_sql_parts.append(value_sql) + params.extend(value_params) + parts.append(f"VALUES LESS THAN ({', '.join(value_sql_parts)})") + elif definition.in_values is not None: + value_sql_parts = [] + for value in definition.in_values: + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + if isinstance(value, BaseExpression): + value_sql, value_params = value.to_sql() + value_sql_parts.append(value_sql) + params.extend(value_params) + else: + inner_parts = [] + for inner in value: + inner_sql, inner_params = inner.to_sql() + inner_parts.append(inner_sql) + params.extend(inner_params) + value_sql_parts.append(f"({', '.join(inner_parts)})") + parts.append(f"VALUES IN ({', '.join(value_sql_parts)})") + else: + raise ValueError("partition definition requires less_than or in_values") + + if definition.dialect_options: + options_sql, options_params = self.format_partition_definition_options( + definition.dialect_options + ) + if options_sql: + parts.append(options_sql) + params.extend(options_params) + + subpartition_defs = getattr(definition, "subpartition_definitions", None) + if subpartition_defs: + if not self.supports_subpartitioning(): + raise UnsupportedFeatureError(self.name, "subpartition definitions") + sub_parts = [] + for sub_def in subpartition_defs: + sub_sql, sub_params = self.format_subpartition_definition(sub_def) + sub_parts.append(sub_sql) + params.extend(sub_params) + parts.append(f"({', '.join(sub_parts)})") + return " ".join(parts), tuple(params) + + def format_partition_definition_options(self, options: dict) -> Tuple[str, tuple]: + """Format MariaDB partition definition options.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if not self.supports_partition_definition_options(): + raise UnsupportedFeatureError(self.name, "partition definition options") + + parts: list = [] + allowed_options = { + "engine": "ENGINE", + "comment": "COMMENT", + "data_directory": "DATA DIRECTORY", + "index_directory": "INDEX DIRECTORY", + "max_rows": "MAX_ROWS", + "min_rows": "MIN_ROWS", + "tablespace": "TABLESPACE", + } + for key, value in options.items(): + normalized = str(key).lower() + if normalized not in allowed_options: + raise ValueError(f"Unsupported partition definition option: {key}") + keyword = allowed_options[normalized] + if normalized in {"engine", "tablespace"}: + if not isinstance(value, str) or not value: + raise TypeError(f"{key} option must be a non-empty string") + parts.append(f"{keyword} {self.format_identifier(value)}") + elif normalized in {"comment", "data_directory", "index_directory"}: + if not isinstance(value, str): + raise TypeError(f"{key} option must be a string") + escaped = self._escape_sql_string(value) + parts.append(f"{keyword} '{escaped}'") + elif normalized in {"max_rows", "min_rows"}: + if not isinstance(value, int) or value < 0: + raise TypeError(f"{key} option must be a non-negative integer") + parts.append(f"{keyword} {value}") + return " ".join(parts), () + + def format_subpartition_definition(self, definition: Any) -> Tuple[str, tuple]: + """Format a single ``SUBPARTITION name ...`` clause.""" + if not definition.name or not definition.name.strip(): + raise ValueError("subpartition name must not be empty") + parts = ["SUBPARTITION", self.format_identifier(definition.name)] + params: list = [] + if definition.dialect_options: + options_sql, options_params = self.format_partition_definition_options( + definition.dialect_options + ) + if options_sql: + parts.append(options_sql) + params.extend(options_params) + return " ".join(parts), tuple(params) def format_partition_value(self, expr: Any) -> Tuple[str, tuple]: - raise NotImplementedError("Partition expression formatting requires MariaDB-specific expression classes") + """Format a MariaDB partition boundary value.""" + from datetime import date, datetime + from decimal import Decimal + from math import isfinite + + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from rhosocial.activerecord.backend.impl.mariadb.expression.partition import ( + MariaDBPartitionMaxValue, + ) + + if isinstance(expr, MariaDBPartitionMaxValue): + 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", () + if isinstance(value, bool): + raise TypeError("partition value must not be bool") + if isinstance(value, int): + return str(value), () + if isinstance(value, float): + if not isfinite(value): + raise ValueError("partition value float must be finite") + return repr(value), () + if isinstance(value, Decimal): + if not value.is_finite(): + raise ValueError("partition value Decimal must be finite") + return str(value), () + if isinstance(value, datetime): + escaped = self._escape_sql_string(value.isoformat(sep=" ")) + return f"'{escaped}'", () + if isinstance(value, date): + escaped = self._escape_sql_string(value.isoformat()) + return f"'{escaped}'", () + if isinstance(value, str): + 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_add_partition_statement(self, expr: Any) -> Tuple[str, tuple]: raise NotImplementedError("Partition expression formatting requires MariaDB-specific expression classes") diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py new file mode 100644 index 00000000..ddfdda9c --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py @@ -0,0 +1,218 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py +"""Tests for MariaDB-owned partition DDL expressions and their formatting.""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, PartitionDefinition +from rhosocial.activerecord.backend.expression.statements import SubpartitionDefinition +from rhosocial.activerecord.backend.impl.mariadb.expression.partition import ( + MariaDBPartitionByHash, + MariaDBPartitionByKey, + MariaDBPartitionByList, + MariaDBPartitionByListColumns, + MariaDBPartitionByRange, + MariaDBPartitionByRangeColumns, + MariaDBPartitionDefinition, + MariaDBPartitionMaxValue, + MariaDBPartitionValue, + MariaDBSubpartitionDefinition, +) + + +@pytest.fixture +def dialect(): + from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect + + return MariaDBDialect(version=(10, 6, 0)) + + +def _value(dialect, value): + return MariaDBPartitionValue(dialect, value) + + +# --------------------------------------------------------------------------- +# Definitions derive from the generic declarations +# --------------------------------------------------------------------------- + + +def test_partition_definition_derives_generic(): + definition = MariaDBPartitionDefinition(name="p0", less_than=[object()]) + assert isinstance(definition, PartitionDefinition) + + +def test_subpartition_definition_derives_generic(): + assert isinstance(MariaDBSubpartitionDefinition(name="sp0"), SubpartitionDefinition) + + +def test_partition_definition_requires_boundary(): + with pytest.raises(ValueError, match="less_than or in_values"): + MariaDBPartitionDefinition(name="p0") + + +def test_partition_definition_rejects_both_boundaries(): + with pytest.raises(ValueError, match="mutually exclusive"): + MariaDBPartitionDefinition(name="p0", less_than=[object()], in_values=[object()]) + + +# --------------------------------------------------------------------------- +# Clause rendering +# --------------------------------------------------------------------------- + + +def test_partition_by_range_with_definitions(dialect): + expr = MariaDBPartitionByRange( + dialect, + [Column(dialect, "created_at")], + partitions=[ + MariaDBPartitionDefinition("p0", less_than=[_value(dialect, "2024-01-01")]), + MariaDBPartitionDefinition("pmax", less_than=[MariaDBPartitionMaxValue(dialect)]), + ], + ) + sql, _ = expr.to_sql() + assert sql == ( + " PARTITION BY RANGE (`created_at`) " + "(PARTITION `p0` VALUES LESS THAN ('2024-01-01'), " + "PARTITION `pmax` VALUES LESS THAN (MAXVALUE))" + ) + + +def test_partition_by_range_columns(dialect): + sql, _ = MariaDBPartitionByRangeColumns(dialect, [Column(dialect, "a")]).to_sql() + assert sql == " PARTITION BY RANGE COLUMNS (`a`)" + + +def test_partition_by_list_columns_multi_column(dialect): + expr = MariaDBPartitionByListColumns( + dialect, + [Column(dialect, "a"), Column(dialect, "b")], + partitions=[ + MariaDBPartitionDefinition( + "p0", + in_values=[ + [_value(dialect, "a"), _value(dialect, "x")], + [_value(dialect, "b"), _value(dialect, "y")], + ], + ) + ], + ) + sql, _ = expr.to_sql() + assert sql == ( + " PARTITION BY LIST COLUMNS (`a`, `b`) " + "(PARTITION `p0` VALUES IN (('a', 'x'), ('b', 'y')))" + ) + + +def test_partition_by_list_single_column(dialect): + expr = MariaDBPartitionByList( + dialect, + [Column(dialect, "region")], + partitions=[ + MariaDBPartitionDefinition("p0", in_values=[_value(dialect, "east")]) + ], + ) + sql, _ = expr.to_sql() + assert sql == " PARTITION BY LIST (`region`) (PARTITION `p0` VALUES IN ('east'))" + + +def test_partition_by_hash_with_count(dialect): + sql, _ = MariaDBPartitionByHash(dialect, [Column(dialect, "id")], partitions_count=4).to_sql() + assert sql == " PARTITION BY HASH (`id`) PARTITIONS 4" + + +def test_partition_by_linear_hash(dialect): + sql, _ = MariaDBPartitionByHash( + dialect, [Column(dialect, "id")], partitions_count=2, linear=True + ).to_sql() + assert sql == " PARTITION BY LINEAR HASH (`id`) PARTITIONS 2" + + +def test_partition_by_key_empty(dialect): + sql, _ = MariaDBPartitionByKey(dialect, partitions_count=2).to_sql() + assert sql == " PARTITION BY KEY () PARTITIONS 2" + + +def test_partition_by_hash_invalid_count(dialect): + with pytest.raises(ValueError, match="positive integer"): + MariaDBPartitionByHash(dialect, [Column(dialect, "id")], partitions_count=0).to_sql() + + +# --------------------------------------------------------------------------- +# Values, options, subpartitions +# --------------------------------------------------------------------------- + + +def test_partition_value_formats(dialect): + assert dialect.format_partition_value(_value(dialect, None)) == ("NULL", ()) + assert dialect.format_partition_value(_value(dialect, 42)) == ("42", ()) + assert dialect.format_partition_value(_value(dialect, "o'brien")) == ("'o''brien'", ()) + assert dialect.format_partition_value(MariaDBPartitionMaxValue(dialect)) == ("MAXVALUE", ()) + + +def test_partition_value_rejects_bool(dialect): + with pytest.raises(TypeError, match="bool"): + dialect.format_partition_value(_value(dialect, True)) + + +def test_partition_definition_options(dialect): + expr = MariaDBPartitionByRange( + dialect, + [Column(dialect, "a")], + partitions=[ + MariaDBPartitionDefinition( + "p0", + less_than=[_value(dialect, 1)], + dialect_options={"comment": "first"}, + ) + ], + ) + sql, _ = expr.to_sql() + assert sql.endswith("PARTITION `p0` VALUES LESS THAN (1) COMMENT 'first')") + + +def test_partition_definition_rejects_unknown_option(dialect): + expr = MariaDBPartitionByRange( + dialect, + [Column(dialect, "a")], + partitions=[ + MariaDBPartitionDefinition( + "p0", less_than=[_value(dialect, 1)], dialect_options={"bogus": 1} + ) + ], + ) + with pytest.raises(ValueError, match="Unsupported partition definition option"): + expr.to_sql() + + +def test_partition_definition_with_subpartitions(dialect): + expr = MariaDBPartitionByRange( + dialect, + [Column(dialect, "a")], + partitions=[ + MariaDBPartitionDefinition( + "p0", + less_than=[_value(dialect, 1)], + subpartition_definitions=[MariaDBSubpartitionDefinition(name="s0")], + ) + ], + ) + sql, _ = expr.to_sql() + assert sql.endswith("(PARTITION `p0` VALUES LESS THAN (1) (SUBPARTITION `s0`))") + + +def test_subpartition_definition_empty_name(): + with pytest.raises(ValueError, match="non-empty string"): + MariaDBSubpartitionDefinition(name="") + + +# --------------------------------------------------------------------------- +# Capability gating +# --------------------------------------------------------------------------- + + +def test_partition_clause_unsupported_method(dialect): + from rhosocial.activerecord.backend.expression import PartitionClause + + clause = PartitionClause(dialect, "RANGE", [Column(dialect, "a")]) + clause.method = "BOGUS" + with pytest.raises(ValueError, match="Invalid MariaDB partition method"): + dialect.format_partition_clause(clause) From 5391e3f8209bc8bfde5259179758c3032af17290 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 10:59:50 +0800 Subject: [PATCH 06/52] feat(mariadb): add MariaDBColumnDefinition and typed column options Introduce the MariaDB-specific column definition (and options) derived from the generic ColumnDefinition / ColumnOptions, carrying typed CHARACTER SET / COLUMN_FORMAT / STORAGE / INVISIBLE attributes with no dialect_options bag. The MariaDB format_column_definition (mixin and dialect) accepts both the generic and MariaDB column definitions and renders the MariaDB-only attributes. Implemented independently of MySQL; export the new classes and cover them with tests. --- .../backend/impl/mariadb/dialect.py | 13 ++ .../impl/mariadb/expression/__init__.py | 10 ++ .../backend/impl/mariadb/expression/column.py | 155 ++++++++++++++++++ .../backend/impl/mariadb/mixins/ddl_table.py | 23 ++- .../backend/test_mariadb_column_definition.py | 97 +++++++++++ 5 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 8ecbf542..9e07cfb5 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -637,6 +637,9 @@ def format_column_definition( col_def: "ColumnDefinition" ) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType + from rhosocial.activerecord.backend.impl.mariadb.expression.column import ( + MariaDBColumnDefinition, + ) type_sql, type_params = col_def.data_type.to_sql() parts = [self.format_identifier(col_def.name), type_sql] @@ -671,6 +674,16 @@ def format_column_definition( if constraint_parts: parts.append(' '.join(constraint_parts)) + if isinstance(col_def, MariaDBColumnDefinition): + if col_def.character_set: + parts.append(f"CHARACTER SET {self.format_identifier(col_def.character_set)}") + if col_def.column_format is not None: + parts.append(f"COLUMN_FORMAT {col_def.column_format.value}") + if col_def.storage is not None: + parts.append(f"STORAGE {col_def.storage.value}") + if col_def.invisible: + parts.append("INVISIBLE") + if col_def.comment: escaped_comment = self._escape_sql_string(col_def.comment) parts.append(f"COMMENT '{escaped_comment}'") diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index c64caeee..1cbdeb39 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -59,6 +59,12 @@ MariaDBRevokeExpression, MariaDBShutdownExpression, ) +from .column import ( + MariaDBColumnFormat, + MariaDBColumnStorage, + MariaDBColumnDefinition, + MariaDBColumnOptions, +) from .maintenance import ( MariaDBTableMaintenanceExpression, TableMaintenanceOperation, @@ -129,6 +135,10 @@ "MariaDBRenameIndexExpression", "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", + "MariaDBColumnFormat", + "MariaDBColumnStorage", + "MariaDBColumnDefinition", + "MariaDBColumnOptions", "MariaDBPartitionStrategy", "MariaDBSubpartitionStrategy", "MariaDBPartitionMaxValue", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py new file mode 100644 index 00000000..0ba3abee --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py @@ -0,0 +1,155 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py +"""MariaDB-specific column definition expressions. + +MariaDB extends the standard column definition with per-column attributes that +have no generic equivalent: + +* ``CHARACTER SET `` — column character set. +* ``COLLATE `` — column collation. +* ``COLUMN_FORMAT {FIXED|DYNAMIC|DEFAULT}`` — column format. +* ``STORAGE {DISK|MEMORY|DEFAULT}`` — storage type. +* ``INVISIBLE`` — column hidden from ``SELECT *``. + +These live on ``MariaDBColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the MariaDB +``format_column_definition`` override. They are declared through +``MariaDBColumnOptions`` (deriving the generic ``ColumnOptions``). + +MariaDB implements this independently of MySQL: the two backends never share +column classes, even where their syntax coincides. +""" + +from enum import Enum +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.base.ddl.options import ColumnOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "MariaDBColumnFormat", + "MariaDBColumnStorage", + "MariaDBColumnDefinition", + "MariaDBColumnOptions", +] + + +class MariaDBColumnFormat(Enum): + """MariaDB ``COLUMN_FORMAT`` values.""" + + FIXED = "FIXED" + DYNAMIC = "DYNAMIC" + DEFAULT = "DEFAULT" + + +class MariaDBColumnStorage(Enum): + """MariaDB ``STORAGE`` values.""" + + DISK = "DISK" + MEMORY = "MEMORY" + DEFAULT = "DEFAULT" + + +class MariaDBColumnDefinition(ColumnDefinition): + """A MariaDB column definition extending the generic one. + + Adds MariaDB-only typed attributes: ``character_set``, ``column_format``, + ``storage`` and ``invisible``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type, + constraints=None, + comment: Optional[str] = None, + generated_expression=None, + identity: Optional[str] = None, + identity_start: Optional[int] = None, + identity_increment: Optional[int] = None, + identity_clause=None, + *, + character_set: Optional[str] = None, + column_format: Optional[MariaDBColumnFormat] = None, + storage: Optional[MariaDBColumnStorage] = None, + invisible: Optional[bool] = None, + ): + super().__init__( + dialect, + name, + data_type, + constraints=constraints, + comment=comment, + generated_expression=generated_expression, + identity=identity, + identity_start=identity_start, + identity_increment=identity_increment, + identity_clause=identity_clause, + ) + if column_format is not None and not isinstance(column_format, MariaDBColumnFormat): + raise TypeError( + "column_format must be a MariaDBColumnFormat value, " + f"got {type(column_format).__name__}" + ) + if storage is not None and not isinstance(storage, MariaDBColumnStorage): + raise TypeError( + "storage must be a MariaDBColumnStorage value, " + f"got {type(storage).__name__}" + ) + self.character_set = character_set + self.column_format = column_format + self.storage = storage + self.invisible = invisible + + +class MariaDBColumnOptions(ColumnOptions): + """MariaDB per-column options declaration.""" + + def __init__( + self, + *, + identity_start: Optional[int] = None, + identity_increment: Optional[int] = None, + character_set: Optional[str] = None, + column_format: Optional[MariaDBColumnFormat] = None, + storage: Optional[MariaDBColumnStorage] = None, + invisible: Optional[bool] = None, + ): + super().__init__( + identity_start=identity_start, + identity_increment=identity_increment, + ) + if column_format is not None and not isinstance(column_format, MariaDBColumnFormat): + raise TypeError( + "column_format must be a MariaDBColumnFormat value, " + f"got {type(column_format).__name__}" + ) + if storage is not None and not isinstance(storage, MariaDBColumnStorage): + raise TypeError( + "storage must be a MariaDBColumnStorage value, " + f"got {type(storage).__name__}" + ) + self.character_set = character_set + self.column_format = column_format + self.storage = storage + self.invisible = invisible + + def column_definition_class(self): + """Build a ``MariaDBColumnDefinition`` for these options.""" + return MariaDBColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the MariaDB-only fields onto the column definition.""" + if not isinstance(column, MariaDBColumnDefinition): + raise TypeError( + "MariaDBColumnOptions.apply_to requires a MariaDBColumnDefinition, " + f"got {type(column).__name__}" + ) + column.character_set = self.character_set + column.column_format = self.column_format + column.storage = self.storage + column.invisible = self.invisible diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index b96a910b..90bdd4e5 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -151,9 +151,18 @@ def format_column_definition( self, col_def: "ColumnDefinition", ) -> Tuple[str, tuple]: - """Format a single column definition with MariaDB-specific syntax.""" + """Format a single column definition with MariaDB-specific syntax. + + Accepts both the generic ``ColumnDefinition`` and the MariaDB + ``MariaDBColumnDefinition``; the latter's MariaDB-only attributes + (``character_set`` / ``column_format`` / ``storage`` / ``invisible``) + are rendered here. + """ from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType - + from rhosocial.activerecord.backend.impl.mariadb.expression.column import ( + MariaDBColumnDefinition, + ) + type_sql, type_params = col_def.data_type.to_sql() parts = [self.format_identifier(col_def.name), type_sql] params: List[Any] = list(type_params) @@ -187,6 +196,16 @@ def format_column_definition( if constraint_parts: parts.append(' '.join(constraint_parts)) + if isinstance(col_def, MariaDBColumnDefinition): + if col_def.character_set: + parts.append(f"CHARACTER SET {self.format_identifier(col_def.character_set)}") + if col_def.column_format is not None: + parts.append(f"COLUMN_FORMAT {col_def.column_format.value}") + if col_def.storage is not None: + parts.append(f"STORAGE {col_def.storage.value}") + if col_def.invisible: + parts.append("INVISIBLE") + if col_def.comment: escaped_comment = self._escape_sql_string(col_def.comment) parts.append(f"COMMENT '{escaped_comment}'") diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py new file mode 100644 index 00000000..f30740d6 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py @@ -0,0 +1,97 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py +"""Tests for the MariaDB-specific column definition expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import VarCharType +from rhosocial.activerecord.backend.impl.mariadb.expression import ( + MariaDBColumnDefinition, + MariaDBColumnFormat, + MariaDBColumnOptions, + MariaDBColumnStorage, +) + + +@pytest.fixture +def dialect(): + from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect + + return MariaDBDialect((10, 6, 0)) + + +def _column(dialect, **kwargs): + return MariaDBColumnDefinition(dialect, "name", VarCharType(dialect, length=50), **kwargs) + + +def test_derives_generic_column_definition(): + assert ColumnDefinition in MariaDBColumnDefinition.__mro__ + + +def test_character_set(dialect): + sql, _ = _column(dialect, character_set="utf8mb4").to_sql() + assert sql == "`name` VARCHAR(50) CHARACTER SET `utf8mb4`" + + +def test_column_format(dialect): + sql, _ = _column(dialect, column_format=MariaDBColumnFormat.FIXED).to_sql() + assert sql == "`name` VARCHAR(50) COLUMN_FORMAT FIXED" + + +def test_storage(dialect): + sql, _ = _column(dialect, storage=MariaDBColumnStorage.DISK).to_sql() + assert sql == "`name` VARCHAR(50) STORAGE DISK" + + +def test_invisible(dialect): + sql, _ = _column(dialect, invisible=True).to_sql() + assert sql == "`name` VARCHAR(50) INVISIBLE" + + +def test_combined_attributes(dialect): + sql, _ = _column( + dialect, + character_set="utf8mb4", + column_format=MariaDBColumnFormat.DYNAMIC, + storage=MariaDBColumnStorage.MEMORY, + invisible=True, + ).to_sql() + assert sql == ( + "`name` VARCHAR(50) CHARACTER SET `utf8mb4` " + "COLUMN_FORMAT DYNAMIC STORAGE MEMORY INVISIBLE" + ) + + +def test_generic_column_still_renders_on_mariadb(dialect): + generic = ColumnDefinition(dialect, "name", VarCharType(dialect, length=50), comment="c") + sql, _ = generic.to_sql() + assert sql == "`name` VARCHAR(50) COMMENT 'c'" + + +def test_invalid_column_format_type(dialect): + with pytest.raises(TypeError, match="MariaDBColumnFormat"): + _column(dialect, column_format="FIXED") + + +def test_options_select_mariadb_column_class(): + assert MariaDBColumnOptions(character_set="utf8mb4").column_definition_class() is ( + MariaDBColumnDefinition + ) + + +def test_options_apply_to(dialect): + options = MariaDBColumnOptions( + character_set="utf8mb4", invisible=True, storage=MariaDBColumnStorage.DISK + ) + col = MariaDBColumnDefinition(dialect, "c", VarCharType(dialect, length=10)) + options.apply_to(col) + assert col.character_set == "utf8mb4" + assert col.invisible is True + assert col.storage is MariaDBColumnStorage.DISK + + +def test_options_apply_to_rejects_generic_column(dialect): + options = MariaDBColumnOptions(character_set="utf8mb4") + generic = ColumnDefinition(dialect, "c", VarCharType(dialect, length=10)) + with pytest.raises(TypeError, match="MariaDBColumnDefinition"): + options.apply_to(generic) From 5e9fbe830a1f40826bb56f8e6c7d31767da8e2bc Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 12:01:39 +0800 Subject: [PATCH 07/52] feat(mariadb): add MariaDBCreateTableOptions and read typed table options Introduce MariaDBCreateTableOptions (deriving the generic CreateTableOptions) carrying the MariaDB-only ENGINE / CHARSET / table COLLATE options with no dialect_options bag. The MariaDB CREATE TABLE formatter reads these typed fields and the generic comment field, dropping the dialect_options comment read. Implemented independently of MySQL. --- .../backend/impl/mariadb/dialect.py | 16 ++++++- .../impl/mariadb/expression/__init__.py | 2 + .../impl/mariadb/expression/table_options.py | 48 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 9e07cfb5..e75dee77 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -620,10 +620,22 @@ def format_create_table_statement( if storage_sql: parts.append(storage_sql) - if 'comment' in expr.dialect_options: - escaped_comment = self._escape_sql_string(expr.dialect_options['comment']) + from rhosocial.activerecord.backend.impl.mariadb.expression.table_options import ( + MariaDBCreateTableOptions, + ) + table_options = getattr(expr, "table_options", None) + if table_options is not None and getattr(table_options, "comment", None): + escaped_comment = self._escape_sql_string(table_options.comment) parts.append(f"COMMENT '{escaped_comment}'") + if isinstance(table_options, MariaDBCreateTableOptions): + if table_options.engine: + parts.append(f"ENGINE={self.inline_sql_literal(table_options.engine)}") + if table_options.charset: + parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(table_options.charset)}") + if table_options.collate: + parts.append(f"COLLATE={self.inline_sql_literal(table_options.collate)}") + return ' '.join(parts), tuple(all_params) @staticmethod diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index 1cbdeb39..26d386d6 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -59,6 +59,7 @@ MariaDBRevokeExpression, MariaDBShutdownExpression, ) +from .table_options import MariaDBCreateTableOptions from .column import ( MariaDBColumnFormat, MariaDBColumnStorage, @@ -135,6 +136,7 @@ "MariaDBRenameIndexExpression", "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", + "MariaDBCreateTableOptions", "MariaDBColumnFormat", "MariaDBColumnStorage", "MariaDBColumnDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py new file mode 100644 index 00000000..ea3e1d78 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py @@ -0,0 +1,48 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py +"""MariaDB-specific CREATE TABLE options. + +MariaDB adds table-level options that have no generic equivalent: + +* ``ENGINE=`` — storage engine. +* ``DEFAULT CHARSET=`` — table character set. +* ``COLLATE=`` — table collation. + +These live on ``MariaDBCreateTableOptions`` (deriving the generic +``CreateTableOptions``) and are rendered by the MariaDB +``format_create_table_statement`` override. MariaDB implements this +independently of MySQL; the two backends never share table-option classes. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import CreateTableOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "MariaDBCreateTableOptions", +] + + +class MariaDBCreateTableOptions(CreateTableOptions): + """A MariaDB CREATE TABLE options declaration extending the generic one. + + Adds the MariaDB-only ``engine`` / ``charset`` / ``collate`` table options. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional[str] = None, + engine: Optional[str] = None, + charset: Optional[str] = None, + collate: Optional[str] = None, + ): + super().__init__(dialect, or_replace=or_replace, comment=comment) + self.engine = engine + self.charset = charset + self.collate = collate From c48f699450af132e23a82977b05f157f677811e7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 12:44:34 +0800 Subject: [PATCH 08/52] test(mariadb): migrate table comment tests to typed CreateTableOptions The MariaDB formatter now reads typed table_options.comment instead of the dialect_options bag. --- .../backend/test_mariadb_ddl_features.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py index d27d4065..80c73b85 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py @@ -141,17 +141,25 @@ def test_table_comment(self): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]) ] + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, + ) + expr = CreateTableExpression( dialect=dialect, table='users', columns=columns, - dialect_options={'comment': '用户信息表'} + table_options=CreateTableOptions(dialect, comment='用户信息表'), ) sql, params = expr.to_sql() assert "COMMENT '用户信息表'" in sql def test_table_comment_with_storage_options(self): """Test table COMMENT with storage options.""" + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, + ) + dialect = MariaDBDialect() columns = [ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ @@ -166,7 +174,7 @@ def test_table_comment_with_storage_options(self): 'ENGINE': 'InnoDB', 'DEFAULT CHARSET': 'utf8mb4' }, - dialect_options={'comment': '用户信息表'} + table_options=CreateTableOptions(dialect, comment='用户信息表'), ) sql, params = expr.to_sql() assert "ENGINE='InnoDB'" in sql @@ -181,11 +189,15 @@ def test_table_comment_special_characters(self): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]) ] + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, + ) + expr = CreateTableExpression( dialect=dialect, table='test', columns=columns, - dialect_options={'comment': "测试's表"} + table_options=CreateTableOptions(dialect, comment="测试's表"), ) sql, params = expr.to_sql() assert "COMMENT" in sql @@ -221,11 +233,15 @@ def test_column_comment_with_table_comment(self): ], comment='主键'), ColumnDefinition(dialect, 'name', VarCharType(dialect, 100), comment='名称') ] + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, + ) + expr = CreateTableExpression( dialect=dialect, table='users', columns=columns, - dialect_options={'comment': '用户表'} + table_options=CreateTableOptions(dialect, comment='用户表'), ) sql, params = expr.to_sql() assert "COMMENT '主键'" in sql @@ -704,6 +720,10 @@ class TestMySQLCompleteTableCreation: def test_complete_table_creation(self): """Test complete table creation with all MySQL features.""" + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, + ) + dialect = MariaDBDialect() status_enum = MariaDBEnumType(dialect, ['active', 'inactive', 'deleted']) @@ -754,7 +774,7 @@ def test_complete_table_creation(self): 'DEFAULT CHARSET': 'utf8mb4', 'COLLATE': 'utf8mb4_unicode_ci' }, - dialect_options={'comment': 'User information table'} + table_options=CreateTableOptions(dialect, comment='User information table'), ) sql, params = expr.to_sql() From 90755c3e1ea406424c38206f5393a966a4caa988 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 13:07:48 +0800 Subject: [PATCH 09/52] test(mariadb): migrate table comment escaping tests to typed CreateTableOptions --- .../backend/test_mariadb_dialect_security.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py index 5e0701ec..8e9a7427 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py @@ -436,15 +436,18 @@ 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 ( + CreateTableExpression, + CreateTableOptions, + ) expr = CreateTableExpression( dialect=dialect, table="test_table", columns=[], - dialect_options={ - "comment": "Table's comment with 'quotes'", - }, + table_options=CreateTableOptions( + dialect, comment="Table's comment with 'quotes'" + ), ) sql, params = dialect.format_create_table_statement(expr) @@ -455,15 +458,16 @@ 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 ( + CreateTableExpression, + CreateTableOptions, + ) expr = CreateTableExpression( dialect=dialect, table="test_table", columns=[], - dialect_options={ - "comment": "Test\\value", - }, + table_options=CreateTableOptions(dialect, comment="Test\\value"), ) sql, params = dialect.format_create_table_statement(expr) From 17f768542517a4d0d7cdeae405ea9f561e66e2ac Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 13:51:37 +0800 Subject: [PATCH 10/52] refactor(mariadb): add MariaDBAlterTableExpression and typed check enforced Move ALTER TABLE IF EXISTS/NOWAIT/WAIT onto MariaDBAlterTableExpression and read typed constraint 'enforced' instead of the dialect_options bag. --- .../backend/impl/mariadb/dialect.py | 2 +- .../examples/schema_diff/add_column.py | 5 +- .../impl/mariadb/expression/__init__.py | 2 + .../impl/mariadb/expression/alter_table.py | 51 +++++++++++++++++++ .../impl/mariadb/mixins/ddl/alter_table.py | 16 +++--- .../backend/test_mariadb_ddl_gap_features.py | 19 ++++--- 6 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/expression/alter_table.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index e75dee77..3b1d67c8 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -746,7 +746,7 @@ def format_table_constraint( parts.append(f"CHECK ({check_sql})") params.extend(check_params) - if t_const.dialect_options and t_const.dialect_options.get('enforced') is False: + if getattr(t_const, 'enforced', None) is False: parts.append("NOT ENFORCED") return ' '.join(parts), tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/mariadb/examples/schema_diff/add_column.py index 0100b2d9..7e4d97e1 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/examples/schema_diff/add_column.py @@ -72,9 +72,8 @@ builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) snapshot_before = builder.build() -# Add `age` column between `name` and `email` — shifts email to position 4 -add_col = AddColumn(dialect, ColumnDefinition(dialect, "age", IntegerType(dialect)), - dialect_options={"after": "name"}) +# Add `age` column — appended after the existing columns +add_col = AddColumn(dialect, ColumnDefinition(dialect, "age", IntegerType(dialect))) alter_expr = AlterTableExpression(dialect, "users", [add_col]) sql, params = alter_expr.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index 26d386d6..b2540b71 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -60,6 +60,7 @@ MariaDBShutdownExpression, ) from .table_options import MariaDBCreateTableOptions +from .alter_table import MariaDBAlterTableExpression from .column import ( MariaDBColumnFormat, MariaDBColumnStorage, @@ -134,6 +135,7 @@ "MatchAgainstMode", "MariaDBRenameTableExpression", "MariaDBRenameIndexExpression", + "MariaDBAlterTableExpression", "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", "MariaDBCreateTableOptions", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/alter_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/alter_table.py new file mode 100644 index 00000000..8aeaf0d0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/alter_table.py @@ -0,0 +1,51 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/expression/alter_table.py +"""MariaDB-specific ALTER TABLE statement expression. + +MariaDB adds statement-level qualifiers with no generic equivalent: + +* ``ALTER TABLE IF EXISTS`` — suppress the error for a missing table (10.5+). +* ``ALTER TABLE ... WAIT n | NOWAIT`` — metadata lock wait timeout (10.3+). + +They live on ``MariaDBAlterTableExpression`` (deriving the generic +``AlterTableExpression``) and are rendered by the MariaDB +``format_alter_table_statement`` override. MariaDB does **not** share them with +any other backend. +""" + +from typing import List, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ( + AlterTableAction, + AlterTableExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "MariaDBAlterTableExpression", +] + + +class MariaDBAlterTableExpression(AlterTableExpression): + """A MariaDB ``ALTER TABLE`` statement extending the generic one. + + Adds the MariaDB-only statement-level ``if_exists``, ``nowait`` and + ``wait`` qualifiers. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + actions: List[AlterTableAction], + *, + if_exists: bool = False, + nowait: bool = False, + wait: Optional[int] = None, + ): + super().__init__(dialect, table_name, actions) + self.if_exists = if_exists + self.nowait = nowait + self.wait = wait diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/alter_table.py index c7ca6d47..e86f7d0b 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/alter_table.py @@ -70,13 +70,12 @@ def format_alter_table_statement( """Format a MariaDB ``ALTER TABLE ...`` statement. Injects the MariaDB-specific statement-level qualifiers ``IF EXISTS`` - (10.5+) and ``WAIT n | NOWAIT`` (10.3+) based on ``dialect_options``, - then falls back to the generic action rendering. + (10.5+) and ``WAIT n | NOWAIT`` (10.3+) from the typed fields of + ``MariaDBAlterTableExpression``, then falls back to the generic action + rendering. """ - options = expr.dialect_options - head = "ALTER TABLE" - if options.get("if_exists"): + if getattr(expr, "if_exists", False): if not self.supports_alter_table_if_exists(): raise UnsupportedFeatureError( self.name, @@ -87,10 +86,11 @@ def format_alter_table_statement( table_part = f"{head} {self.format_identifier(expr.table_name)}" - if options.get("nowait"): + wait_value = getattr(expr, "wait", None) + if getattr(expr, "nowait", False): wait = "NOWAIT" - elif options.get("wait") is not None: - wait = f"WAIT {int(options['wait'])}" + elif wait_value is not None: + wait = f"WAIT {int(wait_value)}" else: wait = None if wait is not None: diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py index 8a6100d1..17b3996b 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py @@ -19,6 +19,9 @@ from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression from rhosocial.activerecord.backend.expression.types import TextType from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.expression import ( + MariaDBAlterTableExpression, +) from rhosocial.activerecord.backend.impl.mariadb.expression.rename_index import ( MariaDBRenameIndexExpression, ) @@ -200,9 +203,9 @@ def test_basic_alter(self): def test_alter_if_exists(self): dialect = _dialect((10, 6, 0)) - expr = AlterTableExpression( + expr = MariaDBAlterTableExpression( dialect, 'users', [self._add_column_action(dialect)], - dialect_options={'if_exists': True}, + if_exists=True, ) sql, params = expr.to_sql() assert sql.startswith('ALTER TABLE IF EXISTS `users`') @@ -211,18 +214,18 @@ def test_alter_if_exists(self): def test_alter_if_exists_version_gated(self): dialect = _dialect((10, 4, 0)) assert dialect.supports_alter_table_if_exists() is False - expr = AlterTableExpression( + expr = MariaDBAlterTableExpression( dialect, 'users', [self._add_column_action(dialect)], - dialect_options={'if_exists': True}, + if_exists=True, ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() def test_alter_wait(self): dialect = _dialect((10, 6, 0)) - expr = AlterTableExpression( + expr = MariaDBAlterTableExpression( dialect, 'users', [self._add_column_action(dialect)], - dialect_options={'wait': 4}, + wait=4, ) sql, params = expr.to_sql() assert 'ALTER TABLE `users` WAIT 4' in sql @@ -230,9 +233,9 @@ def test_alter_wait(self): def test_alter_if_exists_nowait(self): dialect = _dialect((10, 6, 0)) - expr = AlterTableExpression( + expr = MariaDBAlterTableExpression( dialect, 'users', [self._add_column_action(dialect)], - dialect_options={'if_exists': True, 'nowait': True}, + if_exists=True, nowait=True, ) sql, params = expr.to_sql() assert 'ALTER TABLE IF EXISTS `users` NOWAIT' in sql From fad43cf117c61f2ec51b0ed209160d422fd95101 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 14:43:28 +0800 Subject: [PATCH 11/52] refactor(mariadb): typed insert/truncate variants, read typed table options Add MariaDBInsertExpression (ignore/replace) and MariaDBTruncateExpression; read typed MariaDBCreateTableOptions only in the CREATE TABLE formatter. --- .../impl/mariadb/expression/__init__.py | 4 ++ .../backend/impl/mariadb/expression/dml.py | 47 +++++++++++++++++++ .../impl/mariadb/expression/truncate.py | 44 +++++++++++++++++ .../backend/impl/mariadb/mixins/ddl_table.py | 31 ++++-------- .../backend/impl/mariadb/mixins/dml.py | 8 ++-- .../backend/impl/mariadb/mixins/truncate.py | 7 ++- .../feature/backend/dml/test_insert_ignore.py | 23 +++++---- .../dml/test_insert_on_conflict_clauses.py | 9 ++-- .../feature/backend/dml/test_replace_into.py | 30 ++++++------ .../backend/test_mariadb_ddl_gap_features.py | 10 ++-- 10 files changed, 147 insertions(+), 66 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/expression/dml.py create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/expression/truncate.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index b2540b71..12756151 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -60,6 +60,8 @@ MariaDBShutdownExpression, ) from .table_options import MariaDBCreateTableOptions +from .dml import MariaDBInsertExpression +from .truncate import MariaDBTruncateExpression from .alter_table import MariaDBAlterTableExpression from .column import ( MariaDBColumnFormat, @@ -139,6 +141,8 @@ "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", "MariaDBCreateTableOptions", + "MariaDBInsertExpression", + "MariaDBTruncateExpression", "MariaDBColumnFormat", "MariaDBColumnStorage", "MariaDBColumnDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/dml.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/dml.py new file mode 100644 index 00000000..cb27da86 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/dml.py @@ -0,0 +1,47 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/expression/dml.py +"""MariaDB-specific DML expression classes.""" + +from typing import TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import InsertExpression + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class MariaDBInsertExpression(InsertExpression): + """A MariaDB INSERT statement extending the generic one. + + MariaDB 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 MariaDB + ``format_insert_statement`` override. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + into, + source, + columns=None, + *, + on_conflict=None, + returning=None, + replace: bool = False, + ignore: bool = False, + ): + super().__init__( + dialect, + into=into, + source=source, + columns=columns, + on_conflict=on_conflict, + returning=returning, + ) + self.replace = replace + self.ignore = ignore + + +__all__ = [ + "MariaDBInsertExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/truncate.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/truncate.py new file mode 100644 index 00000000..a596f863 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/truncate.py @@ -0,0 +1,44 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/expression/truncate.py +"""MariaDB-specific TRUNCATE expression.""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements.ddl_truncate import ( + TruncateExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class MariaDBTruncateExpression(TruncateExpression): + """A MariaDB TRUNCATE statement extending the generic one. + + MariaDB adds the ``WAIT n`` / ``NOWAIT`` metadata lock wait options + (MariaDB 10.3+), which are mutually exclusive and have no generic + equivalent. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + restart_identity: bool = False, + cascade: bool = False, + *, + wait: Optional[int] = None, + nowait: bool = False, + ): + super().__init__( + dialect, + table_name=table_name, + restart_identity=restart_identity, + cascade=cascade, + ) + self.wait = wait + self.nowait = nowait + + +__all__ = [ + "MariaDBTruncateExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index 90bdd4e5..6d20578a 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -118,32 +118,21 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: if storage_sql: parts.append(storage_sql) + from rhosocial.activerecord.backend.impl.mariadb.expression.table_options import ( + MariaDBCreateTableOptions, + ) table_options = getattr(expr, "table_options", None) if table_options is not None and getattr(table_options, "comment", None): comment_sql, _ = self.format_table_comment(table_options.comment) parts.append(comment_sql) - elif 'comment' in expr.dialect_options: - comment_sql, _ = self.format_table_comment(expr.dialect_options['comment']) - parts.append(comment_sql) - dialect_options = getattr(expr, "dialect_options", {}) or {} - engine = getattr(table_options, "engine", None) if table_options else None - if not engine: - engine = dialect_options.get("engine") - if engine: - parts.append(f"ENGINE={self.inline_sql_literal(engine)}") - - charset = getattr(table_options, "charset", None) if table_options else None - if not charset: - charset = dialect_options.get("charset") - if charset: - parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(charset)}") - - collate = getattr(table_options, "collate", None) if table_options else None - if not collate: - collate = dialect_options.get("collate") - if collate: - parts.append(f"COLLATE={self.inline_sql_literal(collate)}") + if isinstance(table_options, MariaDBCreateTableOptions): + if table_options.engine: + parts.append(f"ENGINE={self.inline_sql_literal(table_options.engine)}") + if table_options.charset: + parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(table_options.charset)}") + if table_options.collate: + parts.append(f"COLLATE={self.inline_sql_literal(table_options.collate)}") return ' '.join(parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py index 4c903143..788ecce0 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py @@ -108,8 +108,8 @@ def format_insert_statement( """Format INSERT statement with MariaDB-specific options. Extends the base implementation to support: - - INSERT IGNORE via dialect_options={'ignore': True} - - REPLACE INTO via dialect_options={'replace': True} + - INSERT IGNORE via MariaDBInsertExpression(ignore=True) + - REPLACE INTO via MariaDBInsertExpression(replace=True) - RETURNING clause (10.5+) Args: @@ -125,8 +125,8 @@ def format_insert_statement( if self.strict_validation: expr.validate(strict=True) - is_replace = expr.dialect_options.get('replace', False) - is_ignore = expr.dialect_options.get('ignore', False) + is_replace = getattr(expr, 'replace', False) + is_ignore = getattr(expr, 'ignore', False) if is_replace and is_ignore: raise ValueError("Cannot use both 'replace' and 'ignore' options together") diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/truncate.py index 2d3d45d0..02fbe6a5 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/truncate.py @@ -62,12 +62,11 @@ def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tu sql = f"TRUNCATE TABLE {self.format_identifier(expr.table_name)}" - options = expr.dialect_options wait = None - if options.get("nowait"): + if getattr(expr, "nowait", False): wait = "NOWAIT" - elif options.get("wait") is not None: - wait = f"WAIT {int(options['wait'])}" + elif getattr(expr, "wait", None) is not None: + wait = f"WAIT {int(expr.wait)}" if wait is not None: if not self.supports_truncate_wait(): raise UnsupportedFeatureError( diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_ignore.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_ignore.py index 79531d46..983e297f 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_ignore.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_ignore.py @@ -11,9 +11,8 @@ import pytest import pytest_asyncio -from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, ValuesSource -) +from rhosocial.activerecord.backend.expression.statements import ValuesSource +from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBInsertExpression from rhosocial.activerecord.backend.expression import core @@ -42,7 +41,7 @@ def test_insert_ignore_single_row_no_conflict(self, mariadb_backend, test_table) """Test INSERT IGNORE with no conflict - should insert normally.""" dialect = mariadb_backend.dialect - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -52,7 +51,7 @@ def test_insert_ignore_single_row_no_conflict(self, mariadb_backend, test_table) ] ), columns=["email", "name"], - dialect_options={"ignore": True} + ignore=True, ) sql, params = expr.to_sql() @@ -81,7 +80,7 @@ def test_insert_ignore_with_conflict(self, mariadb_backend, test_table): ) # Try to insert duplicate with IGNORE - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -91,7 +90,7 @@ def test_insert_ignore_with_conflict(self, mariadb_backend, test_table): ] ), columns=["email", "name"], - dialect_options={"ignore": True} + ignore=True, ) sql, params = expr.to_sql() @@ -120,7 +119,7 @@ def test_insert_ignore_multiple_rows_partial_conflict(self, mariadb_backend, tes ) # Insert multiple rows, one conflicts - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -132,7 +131,7 @@ def test_insert_ignore_multiple_rows_partial_conflict(self, mariadb_backend, tes ] ), columns=["email", "name"], - dialect_options={"ignore": True} + ignore=True, ) sql, params = expr.to_sql() @@ -160,7 +159,7 @@ def test_insert_ignore_without_option(self, mariadb_backend, test_table): ) # Regular insert without IGNORE should fail - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -209,7 +208,7 @@ async def test_insert_ignore_async(self, async_mariadb_backend, test_table): ) # Try to insert duplicate with IGNORE - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -219,7 +218,7 @@ async def test_insert_ignore_async(self, async_mariadb_backend, test_table): ] ), columns=["email", "name"], - dialect_options={"ignore": True} + ignore=True, ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_on_conflict_clauses.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_on_conflict_clauses.py index 84c35592..aa17ba1a 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_on_conflict_clauses.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_insert_on_conflict_clauses.py @@ -19,6 +19,7 @@ ValuesSource, ) from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBInsertExpression @pytest.fixture @@ -82,14 +83,14 @@ def test_insert_ignore_and_replace_still_work(self, dialect): """REPLACE INTO / INSERT IGNORE remain unaffected by the capability gate.""" source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) - expr = InsertExpression( - dialect, into="users", columns=["id"], source=source, dialect_options={"replace": True} + expr = MariaDBInsertExpression( + dialect, into="users", columns=["id"], source=source, replace=True ) sql, _ = expr.to_sql() assert sql.startswith('REPLACE INTO `users`') - expr = InsertExpression( - dialect, into="users", columns=["id"], source=source, dialect_options={"ignore": True} + expr = MariaDBInsertExpression( + dialect, into="users", columns=["id"], source=source, ignore=True ) sql, _ = expr.to_sql() assert sql.startswith('INSERT IGNORE INTO `users`') diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_replace_into.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_replace_into.py index 05ebf5ad..efd853d0 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_replace_into.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/dml/test_replace_into.py @@ -11,9 +11,8 @@ import pytest import pytest_asyncio -from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, ValuesSource -) +from rhosocial.activerecord.backend.expression.statements import ValuesSource +from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBInsertExpression from rhosocial.activerecord.backend.expression import core @@ -42,7 +41,7 @@ def test_replace_into_single_row_no_conflict(self, mariadb_backend, test_table): """Test REPLACE INTO with no conflict - should insert normally.""" dialect = mariadb_backend.dialect - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -52,7 +51,7 @@ def test_replace_into_single_row_no_conflict(self, mariadb_backend, test_table): ] ), columns=["email", "name"], - dialect_options={"replace": True} + replace=True, ) sql, params = expr.to_sql() @@ -88,7 +87,7 @@ def test_replace_into_with_conflict(self, mariadb_backend, test_table): original_id = original_row["id"] # REPLACE with new data - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -98,7 +97,7 @@ def test_replace_into_with_conflict(self, mariadb_backend, test_table): ] ), columns=["email", "name"], - dialect_options={"replace": True} + replace=True, ) sql, params = expr.to_sql() @@ -128,7 +127,7 @@ def test_replace_into_multiple_rows(self, mariadb_backend, test_table): ) # REPLACE multiple rows, one conflicts - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -140,7 +139,7 @@ def test_replace_into_multiple_rows(self, mariadb_backend, test_table): ] ), columns=["email", "name"], - dialect_options={"replace": True} + replace=True, ) sql, params = expr.to_sql() @@ -169,7 +168,7 @@ def test_replace_into_with_on_conflict_raises_error(self, mariadb_backend, test_ # This should raise ValueError with pytest.raises(ValueError, match="REPLACE INTO does not support ON CONFLICT"): - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -177,7 +176,7 @@ def test_replace_into_with_on_conflict_raises_error(self, mariadb_backend, test_ [[core.Literal(dialect, "test@example.com"), core.Literal(dialect, "Test")]] ), columns=["email", "name"], - dialect_options={"replace": True}, + replace=True, on_conflict=OnConflictClause(dialect, ["email"], do_nothing=True) # Invalid combination ) expr.to_sql() @@ -187,7 +186,7 @@ def test_replace_and_ignore_mutually_exclusive(self, mariadb_backend, test_table dialect = mariadb_backend.dialect with pytest.raises(ValueError, match="Cannot use both 'replace' and 'ignore'"): - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -195,7 +194,8 @@ def test_replace_and_ignore_mutually_exclusive(self, mariadb_backend, test_table [[core.Literal(dialect, "test@example.com"), core.Literal(dialect, "Test")]] ), columns=["email", "name"], - dialect_options={"replace": True, "ignore": True} + replace=True, + ignore=True, ) expr.to_sql() @@ -228,7 +228,7 @@ async def test_replace_into_async(self, async_mariadb_backend, test_table): ) # REPLACE with new data - expr = InsertExpression( + expr = MariaDBInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -238,7 +238,7 @@ async def test_replace_into_async(self, async_mariadb_backend, test_table): ] ), columns=["email", "name"], - dialect_options={"replace": True} + replace=True, ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py index 17b3996b..82db357c 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py @@ -17,6 +17,7 @@ ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ColumnDefinition from rhosocial.activerecord.backend.expression.statements.ddl_truncate import TruncateExpression +from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBTruncateExpression from rhosocial.activerecord.backend.expression.types import TextType from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect from rhosocial.activerecord.backend.impl.mariadb.expression import ( @@ -149,22 +150,19 @@ def test_basic(self): def test_wait_option(self): dialect = _dialect((10, 6, 0)) - expr = TruncateExpression(dialect, table_name='users', - dialect_options={'wait': 3}) + expr = MariaDBTruncateExpression(dialect, table_name='users', wait=3) sql, params = expr.to_sql() assert sql == 'TRUNCATE TABLE `users` WAIT 3' def test_nowait_option(self): dialect = _dialect((10, 6, 0)) - expr = TruncateExpression(dialect, table_name='users', - dialect_options={'nowait': True}) + expr = MariaDBTruncateExpression(dialect, table_name='users', nowait=True) sql, params = expr.to_sql() assert sql == 'TRUNCATE TABLE `users` NOWAIT' def test_wait_version_gated(self): dialect = _dialect((10, 2, 0)) - expr = TruncateExpression(dialect, table_name='users', - dialect_options={'nowait': True}) + expr = MariaDBTruncateExpression(dialect, table_name='users', nowait=True) with pytest.raises(UnsupportedFeatureError): expr.to_sql() From 8698193b3294cb3b9eec2863eb7fffe0c950671b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 16:26:24 +0800 Subject: [PATCH 12/52] refactor(mariadb): type partition definition options, drop dialect_options bag --- .../impl/mariadb/expression/__init__.py | 2 + .../impl/mariadb/expression/partition.py | 62 ++++++++++++++++++- .../backend/impl/mariadb/mixins/partition.py | 52 ++++++++++------ .../test_mariadb_partition_expressions.py | 46 ++++++++++---- 4 files changed, 129 insertions(+), 33 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index 12756151..629433fa 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -76,6 +76,7 @@ from .partition import ( MariaDBPartitionStrategy, MariaDBSubpartitionStrategy, + MariaDBPartitionOptions, MariaDBPartitionMaxValue, MariaDBPartitionValue, MariaDBSubpartitionDefinition, @@ -149,6 +150,7 @@ "MariaDBColumnOptions", "MariaDBPartitionStrategy", "MariaDBSubpartitionStrategy", + "MariaDBPartitionOptions", "MariaDBPartitionMaxValue", "MariaDBPartitionValue", "MariaDBSubpartitionDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py index b1319eef..8d243408 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py @@ -10,6 +10,7 @@ from __future__ import annotations +from dataclasses import dataclass from decimal import Decimal from enum import Enum from math import isfinite @@ -52,6 +53,32 @@ class MariaDBSubpartitionStrategy(Enum): LINEAR_KEY = "LINEAR KEY" +@dataclass +class MariaDBPartitionOptions: + """Typed MariaDB storage options for a partition or subpartition definition. + + Replaces the generic ``dialect_options`` bag with one attribute per MariaDB + ``PARTITION`` option keyword rendered by the MariaDB formatter. + + Attributes: + engine: Storage engine name (``ENGINE``). + comment: Free-form comment (``COMMENT``). + data_directory: Data directory path (``DATA DIRECTORY``). + index_directory: Index directory path (``INDEX DIRECTORY``). + max_rows: Maximum number of rows (``MAX_ROWS``). + min_rows: Minimum number of rows (``MIN_ROWS``). + tablespace: Tablespace name (``TABLESPACE``). + """ + + engine: Optional[str] = None + comment: Optional[str] = None + data_directory: Optional[str] = None + index_directory: Optional[str] = None + max_rows: Optional[int] = None + min_rows: Optional[int] = None + tablespace: Optional[str] = None + + class MariaDBPartitionMaxValue(BaseExpression): """MariaDB MAXVALUE partition boundary token.""" @@ -87,15 +114,33 @@ def format_method(self) -> str: return "format_partition_value" +@dataclass class MariaDBSubpartitionDefinition(SubpartitionDefinition): """A single named subpartition within a MariaDB partition definition. MariaDB subpartitions carry no explicit boundary (the ``SUBPARTITION BY`` template applies), so this subclass adds nothing beyond the base name and - options. + typed options. + + Raises: + TypeError: if ``partition_options`` is not a + :class:`MariaDBPartitionOptions` when provided. """ + partition_options: Optional[MariaDBPartitionOptions] = None + + def __post_init__(self) -> None: + super().__post_init__() + if self.partition_options is not None and not isinstance( + self.partition_options, MariaDBPartitionOptions + ): + raise TypeError( + "partition_options must be a MariaDBPartitionOptions value, " + f"got {type(self.partition_options).__name__}" + ) + +@dataclass class MariaDBPartitionDefinition(PartitionDefinition): """A MariaDB ``PARTITION ... VALUES ...`` definition. @@ -106,15 +151,27 @@ class MariaDBPartitionDefinition(PartitionDefinition): ``BaseExpression``; for multi-column LIST COLUMNS it accepts a sequence of row tuples (each a sequence of ``BaseExpression``). + ``partition_options`` carries MariaDB-only storage options typed on + :class:`MariaDBPartitionOptions`. + Raises: ValueError: if both boundaries are provided, or neither is provided. - TypeError: if ``dialect_options`` is not a dict when provided. + TypeError: if ``partition_options`` is not a + :class:`MariaDBPartitionOptions` when provided. """ subpartition_definitions: Optional[Sequence["MariaDBSubpartitionDefinition"]] = None + partition_options: Optional[MariaDBPartitionOptions] = None def __post_init__(self) -> None: super().__post_init__() + if self.partition_options is not None and not isinstance( + self.partition_options, MariaDBPartitionOptions + ): + raise TypeError( + "partition_options must be a MariaDBPartitionOptions value, " + f"got {type(self.partition_options).__name__}" + ) if self.less_than is None and self.in_values is None: raise ValueError("partition definition requires less_than or in_values") @@ -229,6 +286,7 @@ def __init__( __all__ = [ "MariaDBPartitionStrategy", "MariaDBSubpartitionStrategy", + "MariaDBPartitionOptions", "MariaDBPartitionMaxValue", "MariaDBPartitionValue", "MariaDBSubpartitionDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py index c7c2ca93..8525ad95 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py @@ -5,11 +5,14 @@ HASH, KEY, RANGE COLUMNS, LIST COLUMNS, LINEAR variants, and subpartitioning). """ -from typing import Any, Sequence, Tuple, TYPE_CHECKING +from typing import Any, Optional, Sequence, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import PartitionClause + from rhosocial.activerecord.backend.impl.mariadb.expression.partition import ( + MariaDBPartitionOptions, + ) class MariaDBPartitionMixin: @@ -232,7 +235,8 @@ def format_partition_definition(self, definition: Any) -> Tuple[str, tuple]: params: list = [] parts = ["PARTITION", self.format_identifier(definition.name)] - if definition.dialect_options and not self.supports_partition_definition_options(): + partition_options = definition.partition_options + if partition_options is not None and not self.supports_partition_definition_options(): raise UnsupportedFeatureError(self.name, "partition definition options") if definition.less_than is not None: @@ -262,9 +266,9 @@ def format_partition_definition(self, definition: Any) -> Tuple[str, tuple]: else: raise ValueError("partition definition requires less_than or in_values") - if definition.dialect_options: + if partition_options is not None: options_sql, options_params = self.format_partition_definition_options( - definition.dialect_options + partition_options ) if options_sql: parts.append(options_sql) @@ -282,12 +286,22 @@ def format_partition_definition(self, definition: Any) -> Tuple[str, tuple]: parts.append(f"({', '.join(sub_parts)})") return " ".join(parts), tuple(params) - def format_partition_definition_options(self, options: dict) -> Tuple[str, tuple]: - """Format MariaDB partition definition options.""" + def format_partition_definition_options(self, options: Optional["MariaDBPartitionOptions"]) -> Tuple[str, tuple]: + """Format MariaDB partition definition options from typed options.""" from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from rhosocial.activerecord.backend.impl.mariadb.expression.partition import ( + MariaDBPartitionOptions, + ) if not self.supports_partition_definition_options(): raise UnsupportedFeatureError(self.name, "partition definition options") + if options is None: + return "", () + if not isinstance(options, MariaDBPartitionOptions): + raise TypeError( + "partition_options must be a MariaDBPartitionOptions value, " + f"got {type(options).__name__}" + ) parts: list = [] allowed_options = { @@ -299,23 +313,22 @@ def format_partition_definition_options(self, options: dict) -> Tuple[str, tuple "min_rows": "MIN_ROWS", "tablespace": "TABLESPACE", } - for key, value in options.items(): - normalized = str(key).lower() - if normalized not in allowed_options: - raise ValueError(f"Unsupported partition definition option: {key}") - keyword = allowed_options[normalized] - if normalized in {"engine", "tablespace"}: + for name, keyword in allowed_options.items(): + value = getattr(options, name) + if value is None: + continue + if name in {"engine", "tablespace"}: if not isinstance(value, str) or not value: - raise TypeError(f"{key} option must be a non-empty string") + raise TypeError(f"{name} option must be a non-empty string") parts.append(f"{keyword} {self.format_identifier(value)}") - elif normalized in {"comment", "data_directory", "index_directory"}: + elif name in {"comment", "data_directory", "index_directory"}: if not isinstance(value, str): - raise TypeError(f"{key} option must be a string") + raise TypeError(f"{name} option must be a string") escaped = self._escape_sql_string(value) parts.append(f"{keyword} '{escaped}'") - elif normalized in {"max_rows", "min_rows"}: + elif name in {"max_rows", "min_rows"}: if not isinstance(value, int) or value < 0: - raise TypeError(f"{key} option must be a non-negative integer") + raise TypeError(f"{name} option must be a non-negative integer") parts.append(f"{keyword} {value}") return " ".join(parts), () @@ -325,9 +338,10 @@ def format_subpartition_definition(self, definition: Any) -> Tuple[str, tuple]: raise ValueError("subpartition name must not be empty") parts = ["SUBPARTITION", self.format_identifier(definition.name)] params: list = [] - if definition.dialect_options: + partition_options = definition.partition_options + if partition_options is not None: options_sql, options_params = self.format_partition_definition_options( - definition.dialect_options + partition_options ) if options_sql: parts.append(options_sql) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py index ddfdda9c..78e68ce4 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py @@ -14,6 +14,7 @@ MariaDBPartitionByRangeColumns, MariaDBPartitionDefinition, MariaDBPartitionMaxValue, + MariaDBPartitionOptions, MariaDBPartitionValue, MariaDBSubpartitionDefinition, ) @@ -161,7 +162,7 @@ def test_partition_definition_options(dialect): MariaDBPartitionDefinition( "p0", less_than=[_value(dialect, 1)], - dialect_options={"comment": "first"}, + partition_options=MariaDBPartitionOptions(comment="first"), ) ], ) @@ -169,18 +170,39 @@ def test_partition_definition_options(dialect): assert sql.endswith("PARTITION `p0` VALUES LESS THAN (1) COMMENT 'first')") -def test_partition_definition_rejects_unknown_option(dialect): - expr = MariaDBPartitionByRange( - dialect, - [Column(dialect, "a")], - partitions=[ - MariaDBPartitionDefinition( - "p0", less_than=[_value(dialect, 1)], dialect_options={"bogus": 1} - ) - ], +def test_partition_definition_options_are_formatted(dialect): + definition = MariaDBPartitionDefinition( + "p0", + less_than=[_value(dialect, 1)], + partition_options=MariaDBPartitionOptions( + engine="InnoDB", + comment="tenant's partition", + max_rows=1000, + tablespace="ts_hot", + ), ) - with pytest.raises(ValueError, match="Unsupported partition definition option"): - expr.to_sql() + sql, params = dialect.format_partition_definition(definition) + assert "ENGINE" in sql and "InnoDB" in sql + assert "COMMENT" in sql and "tenant''s partition" in sql + assert "MAX_ROWS 1000" in sql + assert "TABLESPACE" in sql and "ts_hot" in sql + assert params == () + + +def test_partition_definition_options_reject_invalid_options(dialect): + with pytest.raises(TypeError, match="engine option must be a non-empty string"): + dialect.format_partition_definition_options(MariaDBPartitionOptions(engine="")) + with pytest.raises(TypeError, match="max_rows option"): + dialect.format_partition_definition_options(MariaDBPartitionOptions(max_rows=-1)) + + +def test_partition_definition_rejects_invalid_partition_options(dialect): + with pytest.raises(TypeError, match="partition_options must be a MariaDBPartitionOptions"): + MariaDBPartitionDefinition( + "p0", + less_than=[_value(dialect, 1)], + partition_options="invalid", # type: ignore[arg-type] + ) def test_partition_definition_with_subpartitions(dialect): From 6acfe7b05a624232f84fa7d3526d2fcf4ff26a44 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 17:16:07 +0800 Subject: [PATCH 13/52] refactor(mariadb): type RENAME TABLE / maintenance options (drop dialect_options) MariaDB was the only backend whose DDL formatters read expr.dialect_options. Promote those keys to typed fields on MariaDBRenameTableExpression (if_exists/wait/nowait) and MariaDBTableMaintenanceExpression (no_write_to_binlog/local/persistent/check_mode/checksum_mode/repair_mode), and update the formatters and tests. --- .../impl/mariadb/expression/maintenance.py | 36 ++++++++++++------- .../impl/mariadb/expression/rename_table.py | 17 +++++---- .../impl/mariadb/mixins/ddl/maintenance.py | 11 +++--- .../impl/mariadb/mixins/ddl_rename_table.py | 10 +++--- .../backend/test_mariadb_ddl_gap_features.py | 12 +++---- 5 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/maintenance.py index 511f2f04..507cd265 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/maintenance.py @@ -18,7 +18,7 @@ """ from enum import Enum -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -42,16 +42,16 @@ class MariaDBTableMaintenanceExpression(BaseExpression): Attributes: operation: The maintenance operation to run. table_names: Tables the operation targets. - dialect_options: MariaDB-specific options: - - 'no_write_to_binlog': Suppress binary logging (ANALYZE, OPTIMIZE, - REPAIR). When True renders NO_WRITE_TO_BINLOG. - - 'local': Synonym for NO_WRITE_TO_BINLOG. - - 'persistent': Persistent statistics: 'all' | 'columns' | 'indexes' - (ANALYZE only) - - 'check_mode': CHECK flags, a list among FOR UPGRADE, QUICK, FAST, - MEDIUM, EXTENDED, CHANGED. - - 'checksum_mode': CHECKSUM mode, 'quick' or 'extended'. - - 'repair_mode': REPAIR flags to append ('QUICK'/'EXTENDED'/'USE_FRM'). + no_write_to_binlog: Suppress binary logging (ANALYZE, OPTIMIZE, + REPAIR). When True renders NO_WRITE_TO_BINLOG. + local: Synonym for ``no_write_to_binlog``. + persistent: Persistent statistics: ``'all'`` | a dict with + ``columns``/``indexes`` (ANALYZE only). + check_mode: CHECK flags, a list among ``FOR UPGRADE``, ``QUICK``, + ``FAST``, ``MEDIUM``, ``EXTENDED``, ``CHANGED``. + checksum_mode: CHECKSUM mode, ``'quick'`` or ``'extended'``. + repair_mode: REPAIR flags to append (``'QUICK'``/``'EXTENDED'``/ + ``'USE_FRM'``). """ def __init__( @@ -60,12 +60,22 @@ def __init__( operation: "TableMaintenanceOperation", table_names: List[str], *, - dialect_options: Optional[Dict[str, Any]] = None, + no_write_to_binlog: bool = False, + local: bool = False, + persistent=None, + check_mode: Optional[List[str]] = None, + checksum_mode: Optional[str] = None, + repair_mode: Optional[List[str]] = None, ): super().__init__(dialect) self.operation = operation self.table_names: List[str] = list(table_names) - self.dialect_options: Dict[str, Any] = dialect_options or {} + self.no_write_to_binlog = no_write_to_binlog + self.local = local + self.persistent = persistent + self.check_mode = check_mode + self.checksum_mode = checksum_mode + self.repair_mode = repair_mode def validate(self, strict: bool = True) -> None: """Validate the operation and table name list.""" diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_table.py index 8ab20ea0..40bf353a 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_table.py @@ -16,7 +16,7 @@ succeed or all are rolled back. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -29,10 +29,9 @@ class MariaDBRenameTableExpression(BaseExpression): Attributes: renames: Sequence of ``(old_name, new_name)`` table name pairs. - dialect_options: MariaDB-specific options: - - 'if_exists': Add statement-level IF EXISTS (MariaDB 10.5+). - - 'wait': Lock wait timeout in seconds (MariaDB 10.3+). - - 'nowait': Do not wait for metadata locks (MariaDB 10.3+). + if_exists: Add statement-level ``IF EXISTS`` (MariaDB 10.5+). + wait: Lock wait timeout in seconds (MariaDB 10.3+). + nowait: Do not wait for metadata locks (MariaDB 10.3+). """ def __init__( @@ -40,11 +39,15 @@ def __init__( dialect: "SQLDialectBase", renames: List[Tuple[str, str]], *, - dialect_options: Optional[Dict[str, Any]] = None, + if_exists: bool = False, + wait: Optional[int] = None, + nowait: bool = False, ): super().__init__(dialect) self.renames: List[Tuple[str, str]] = list(renames) - self.dialect_options: Dict[str, Any] = dialect_options or {} + self.if_exists = if_exists + self.wait = wait + self.nowait = nowait def validate(self, strict: bool = True) -> None: """Validate the rename pair list. diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/maintenance.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/maintenance.py index 137de746..bcb3a72e 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl/maintenance.py @@ -60,20 +60,19 @@ def format_table_maintenance_statement( """Format a MariaDB table maintenance statement.""" expr.validate(strict=self.strict_validation) - options = expr.dialect_options operation = expr.operation.value tables = ", ".join(self.format_identifier(t) for t in expr.table_names) parts = [operation] - if options.get("no_write_to_binlog") or options.get("local"): + if expr.no_write_to_binlog or expr.local: parts.append("NO_WRITE_TO_BINLOG") parts.append("TABLE") parts.append(tables) if operation == "ANALYZE": - persistent = options.get("persistent") + persistent = expr.persistent if persistent is not None: if not self.supports_analyze_table_persistent(): raise self._unsupported( @@ -83,14 +82,14 @@ def format_table_maintenance_statement( target = self._persistent_target(persistent) parts.append(f"PERSISTENT FOR {target}") elif operation == "CHECK": - for mode in options.get("check_mode", ()): + for mode in (expr.check_mode or ()): parts.append(mode) elif operation == "CHECKSUM": - mode = options.get("checksum_mode") + mode = expr.checksum_mode if mode is not None: parts.append(str(mode).upper()) elif operation == "REPAIR": - for mode in options.get("repair_mode", ()): + for mode in (expr.repair_mode or ()): parts.append(mode) return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_rename_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_rename_table.py index bb650a6d..ca28ebe9 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_rename_table.py @@ -65,9 +65,7 @@ def format_rename_table_statement( """Format a MariaDB ``RENAME TABLE ...`` statement.""" expr.validate(strict=self.strict_validation) - options = expr.dialect_options - - if options.get('if_exists'): + if expr.if_exists: if not self.supports_rename_table_if_exists(): raise UnsupportedFeatureError( self.name, @@ -79,10 +77,10 @@ def format_rename_table_statement( head = "RENAME TABLE" wait = None - if options.get("nowait"): + if expr.nowait: wait = "NOWAIT" - elif options.get("wait") is not None: - wait = f"WAIT {int(options['wait'])}" + elif expr.wait is not None: + wait = f"WAIT {int(expr.wait)}" if wait is not None and not self.supports_rename_table_wait(): raise UnsupportedFeatureError( self.name, diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py index 82db357c..ccc37aa6 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_gap_features.py @@ -90,7 +90,7 @@ def test_if_exists_supported_on_10_5(self): dialect = _dialect((10, 6, 0)) expr = MariaDBRenameTableExpression( dialect, [('old_table', 'new_table')], - dialect_options={'if_exists': True}, + if_exists=True, ) sql, params = expr.to_sql() assert sql == 'RENAME TABLE IF EXISTS `old_table` TO `new_table`' @@ -99,7 +99,7 @@ def test_if_exists_version_gated(self): dialect = _dialect((10, 4, 0)) expr = MariaDBRenameTableExpression( dialect, [('old_table', 'new_table')], - dialect_options={'if_exists': True}, + if_exists=True, ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() @@ -108,7 +108,7 @@ def test_wait_option(self): dialect = _dialect((10, 6, 0)) expr = MariaDBRenameTableExpression( dialect, [('old_table', 'new_table')], - dialect_options={'wait': 5}, + wait=5, ) sql, params = expr.to_sql() assert sql == 'RENAME TABLE `old_table` WAIT 5 TO `new_table`' @@ -117,7 +117,7 @@ def test_nowait_option(self): dialect = _dialect((10, 6, 0)) expr = MariaDBRenameTableExpression( dialect, [('old_table', 'new_table')], - dialect_options={'nowait': True}, + nowait=True, ) sql, params = expr.to_sql() assert sql == 'RENAME TABLE `old_table` NOWAIT TO `new_table`' @@ -126,7 +126,7 @@ def test_wait_version_gated(self): dialect = _dialect((10, 2, 0)) expr = MariaDBRenameTableExpression( dialect, [('old_table', 'new_table')], - dialect_options={'nowait': True}, + nowait=True, ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() @@ -272,7 +272,7 @@ class TestMariaDBTableMaintenance: def _expr(self, dialect, operation, tables, **options): return MariaDBTableMaintenanceExpression( dialect, operation, tables, - dialect_options=options, + **options, ) def test_analyze_basic(self): From abbad8a3c3f04b866e918d22d7d321af822bd69e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 18:06:43 +0800 Subject: [PATCH 14/52] refactor(mariadb): drop reserved dialect_options from admin/routine/rename-index expressions --- .../backend/impl/mariadb/expression/admin.py | 26 +------------------ .../impl/mariadb/expression/rename_index.py | 5 +--- .../impl/mariadb/expression/routine.py | 15 +++-------- 3 files changed, 5 insertions(+), 41 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/admin.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/admin.py index 32c6b5ab..9562cef3 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/admin.py @@ -14,7 +14,7 @@ """ from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -62,12 +62,10 @@ def __init__( options: List[FlushOption], *, no_write_to_binlog: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.options: List[FlushOption] = list(options) self.no_write_to_binlog: bool = no_write_to_binlog - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: if not strict: @@ -87,13 +85,10 @@ def __init__( dialect: "SQLDialectBase", processlist_id: int, target: KillTarget = KillTarget.CONNECTION, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.processlist_id: int = processlist_id self.target: KillTarget = target - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_kill_statement(self) @@ -105,11 +100,8 @@ class MariaDBShutdownExpression(BaseExpression): def __init__( self, dialect: "SQLDialectBase", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_shutdown_statement(self) @@ -136,13 +128,11 @@ def __init__( *, if_not_exists: bool = False, identified_by: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.accounts: List[AccountSpec] = list(accounts) self.if_not_exists: bool = if_not_exists self.identified_by: Optional[str] = identified_by - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_create_user_statement(self) @@ -158,13 +148,11 @@ def __init__( *, if_exists: bool = False, identified_by: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.accounts: List[AccountSpec] = list(accounts) self.if_exists: bool = if_exists self.identified_by: Optional[str] = identified_by - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_alter_user_statement(self) @@ -179,12 +167,10 @@ def __init__( accounts: List[AccountSpec], *, if_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.accounts: List[AccountSpec] = list(accounts) self.if_exists: bool = if_exists - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_drop_user_statement(self) @@ -199,12 +185,10 @@ def __init__( roles: List[str], *, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.roles: List[str] = list(roles) self.if_not_exists: bool = if_not_exists - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_create_role_statement(self) @@ -219,12 +203,10 @@ def __init__( roles: List[str], *, if_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.roles: List[str] = list(roles) self.if_exists: bool = if_exists - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_drop_role_statement(self) @@ -262,7 +244,6 @@ def __init__( with_grant_option: bool = False, or_replace: bool = False, if_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.privileges: List[GrantPrivilege] = list(privileges) @@ -271,7 +252,6 @@ def __init__( self.with_grant_option: bool = with_grant_option self.or_replace: bool = or_replace self.if_exists: bool = if_exists - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_grant_statement(self) @@ -288,14 +268,12 @@ def __init__( *, on_object: Optional[str] = None, if_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.privileges: List[GrantPrivilege] = list(privileges) self.accounts: List[AccountSpec] = list(accounts) self.on_object: Optional[str] = on_object self.if_exists: bool = if_exists - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_revoke_statement(self) @@ -311,13 +289,11 @@ def __init__( accounts: List[AccountSpec], *, on_object: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.privileges: List[GrantPrivilege] = list(privileges) self.accounts: List[AccountSpec] = list(accounts) self.on_object: Optional[str] = on_object - self.dialect_options: Dict[str, Any] = dialect_options or {} def to_sql(self) -> Tuple[str, tuple]: return self.dialect.format_deny_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_index.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_index.py index b7821501..b3e34ee2 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_index.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/rename_index.py @@ -9,7 +9,7 @@ to drop and recreate the constraint. """ -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -32,14 +32,11 @@ def __init__( table_name: str, old_index_name: str, new_index_name: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table_name = table_name self.old_index_name = old_index_name self.new_index_name = new_index_name - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: """Validate index names. diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/routine.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/routine.py index 2a2067fb..7b6fc6cf 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/routine.py @@ -11,7 +11,7 @@ DROP FUNCTION [IF EXISTS] name """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -41,7 +41,6 @@ def __init__( body: Optional[str] = None, or_replace: bool = False, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name: Any = name @@ -49,7 +48,6 @@ def __init__( self.body: Optional[str] = body self.or_replace: bool = or_replace self.if_not_exists: bool = if_not_exists - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: if not strict: @@ -88,9 +86,8 @@ def __init__( name: Any, *, if_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): - super().__init__(dialect, name, dialect_options=dialect_options) + super().__init__(dialect, name) self.if_exists: bool = if_exists def to_sql(self) -> Tuple[str, tuple]: @@ -112,7 +109,6 @@ def __init__( aggregate: bool = False, or_replace: bool = False, if_not_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, @@ -121,7 +117,6 @@ def __init__( body=body, or_replace=or_replace, if_not_exists=if_not_exists, - dialect_options=dialect_options, ) self.returns: str = returns self.deterministic: bool = deterministic @@ -140,9 +135,8 @@ def __init__( name: Any, *, if_exists: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): - super().__init__(dialect, name, dialect_options=dialect_options) + super().__init__(dialect, name) self.if_exists: bool = if_exists def to_sql(self) -> Tuple[str, tuple]: @@ -162,13 +156,10 @@ def __init__( dialect: "SQLDialectBase", name: Any, args: Optional[List[Any]] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name: Any = name self.args: List[Any] = list(args or []) - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: if not strict: From 9be5440b90b6eb875348fd132e88805157119e8b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 19:53:17 +0800 Subject: [PATCH 15/52] refactor(mariadb): drop dialect_options from data-type value objects Core DataType no longer carries a dialect_options bag; backend type subclasses stop forwarding it. Update the type-protocol tests. --- .../backend/impl/mariadb/expression/types.py | 57 ++++++++----------- .../backend/test_mariadb_type_protocol.py | 23 +++----- 2 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py index 28f339ff..4b3e1aa5 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import List, Optional, Set, Tuple from rhosocial.activerecord.backend.expression.types import ( BigIntType, @@ -43,9 +43,8 @@ class MariaDBIntType(IntegerType): zerofill: bool = False def __init__(self, dialect=None, *, unsigned: bool = False, - zerofill: bool = False, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + zerofill: bool = False): + super().__init__(dialect) self.unsigned = unsigned self.zerofill = zerofill @@ -66,9 +65,8 @@ class MariaDBTinyIntType(TinyIntType): zerofill: bool = False def __init__(self, dialect=None, *, unsigned: bool = False, - zerofill: bool = False, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + zerofill: bool = False): + super().__init__(dialect) self.unsigned = unsigned self.zerofill = zerofill @@ -89,9 +87,8 @@ class MariaDBSmallIntType(SmallIntType): zerofill: bool = False def __init__(self, dialect=None, *, unsigned: bool = False, - zerofill: bool = False, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + zerofill: bool = False): + super().__init__(dialect) self.unsigned = unsigned self.zerofill = zerofill @@ -112,9 +109,8 @@ class MariaDBBigIntType(BigIntType): zerofill: bool = False def __init__(self, dialect=None, *, unsigned: bool = False, - zerofill: bool = False, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + zerofill: bool = False): + super().__init__(dialect) self.unsigned = unsigned self.zerofill = zerofill @@ -193,9 +189,8 @@ class MariaDBBitType(DataType): n: Optional[int] = None - def __init__(self, dialect=None, n: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, n: Optional[int] = None): + super().__init__(dialect) self.n = n def _type_params(self) -> tuple: @@ -213,9 +208,8 @@ class MariaDBYearType(DataType): display_width: Optional[int] = None - def __init__(self, dialect=None, display_width: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, display_width: Optional[int] = None): + super().__init__(dialect) self.display_width = display_width def _type_params(self) -> tuple: @@ -233,9 +227,8 @@ class MariaDBBinaryType(DataType): length: Optional[int] = None - def __init__(self, dialect=None, length: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, length: Optional[int] = None): + super().__init__(dialect) self.length = length def _type_params(self) -> tuple: @@ -249,9 +242,8 @@ class MariaDBVarBinaryType(DataType): length: Optional[int] = None - def __init__(self, dialect=None, length: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, length: Optional[int] = None): + super().__init__(dialect) self.length = length def _type_params(self) -> tuple: @@ -272,9 +264,8 @@ class MariaDBEnumType(DataType): collation: Optional[str] = None def __init__(self, dialect=None, values: Optional[List[str]] = None, - charset: Optional[str] = None, collation: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + charset: Optional[str] = None, collation: Optional[str] = None): + super().__init__(dialect) if values is None: raise ValueError("MariaDBEnumType requires values") if not values: @@ -305,9 +296,8 @@ class MariaDBSetType(DataType): collation: Optional[str] = None def __init__(self, dialect=None, values: Optional[List[str]] = None, - charset: Optional[str] = None, collation: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + charset: Optional[str] = None, collation: Optional[str] = None): + super().__init__(dialect) if values is None: raise ValueError("MariaDBSetType requires values") if not values: @@ -335,9 +325,8 @@ class MariaDBGeometryType(DataType): srid: Optional[int] = None - def __init__(self, dialect=None, srid: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, srid: Optional[int] = None): + super().__init__(dialect) self.srid = srid def _type_params(self) -> tuple: diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py index 735ec85f..32c6ab60 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py @@ -196,22 +196,13 @@ def test_timestamp_valid_precision_renders(self, dialect): class TestDialectOptions: - """dialect_options forwards through construction and affects equality.""" - - def test_construction_forwards_dialect_options(self, dialect): - data_type = MariaDBIntType(dialect, unsigned=True, - dialect_options={"display_width": 10}) - assert data_type.dialect_options == {"display_width": 10} - - def test_dialect_options_participate_in_equality(self, dialect): - a = MariaDBIntType(dialect, unsigned=True, - dialect_options={"display_width": 10}) - b = MariaDBIntType(dialect, unsigned=True, - dialect_options={"display_width": 10}) - c = MariaDBIntType(dialect, unsigned=True, - dialect_options={"display_width": 11}) - assert a == b - assert a != c + """The data-type value objects no longer carry a dialect_options bag.""" + + + + def test_constructor_rejects_dialect_options(self, dialect): + with pytest.raises(TypeError): + MariaDBIntType(dialect, unsigned=True, dialect_options={"display_width": 10}) def test_equality_ignores_dialect(self, dialect): other = MariaDBDialect() From dc29acb67808d2aa8c78839b70eb23741813c24d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 22:23:29 +0800 Subject: [PATCH 16/52] feat(mariadb): version-aware charset/collation/storage-engine protocol + validation Add MariaDBCharsetCollationSupport protocol and MariaDBCharsetCollationMixin implementing a version-filtered whitelist (MariaDBCharset, MariaDBStorageEngine, MariaDBCollation) plus validate_* helpers. MariaDBCreateTableOptions validates engine/charset/collate at construction. Fold expression-level COLLATE into the mixin; remove the standalone collation.py / mixins/collation.py modules. --- .../backend/impl/mariadb/__init__.py | 8 +- .../backend/impl/mariadb/collation.py | 32 --- .../backend/impl/mariadb/dialect.py | 7 +- .../impl/mariadb/expression/table_options.py | 16 +- .../backend/impl/mariadb/mixins/__init__.py | 12 +- .../impl/mariadb/mixins/charset_collation.py | 254 ++++++++++++++++++ .../backend/impl/mariadb/mixins/collation.py | 27 -- .../backend/impl/mariadb/protocols.py | 2 + .../impl/mariadb/protocols/__init__.py | 2 + .../backend/impl/mariadb/protocols/charset.py | 49 ++++ .../test_mariadb_charset_collation_support.py | 49 ++++ .../backend/test_table_option_capabilities.py | 34 +++ 12 files changed, 420 insertions(+), 72 deletions(-) delete mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/collation.py create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py delete mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/mixins/collation.py create mode 100644 src/rhosocial/activerecord/backend/impl/mariadb/protocols/charset.py create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_collation_support.py create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/__init__.py index 67d7f99d..8afb794c 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/__init__.py @@ -21,7 +21,11 @@ from .transaction import MariaDBTransactionManager, MariaDBTransactionMixin from .async_transaction import AsyncMariaDBTransactionManager from .config import MariaDBConnectionConfig -from .collation import MariaDBCollation +from .mixins.charset_collation import ( + MariaDBCharset, + MariaDBCollation, + MariaDBStorageEngine, +) from .types import MariaDBEnumType, MariaDBSetType from .explain import MariaDBExplainResult, MariaDBExplainRow, MariaDBExplainJsonResult, MariaDBExplainAnalyzeResult @@ -130,6 +134,8 @@ 'MariaDBDialect', 'MARIADB_VERSION_BOUNDARIES', 'MariaDBCollation', + 'MariaDBCharset', + 'MariaDBStorageEngine', # Transaction - Sync and Async 'MariaDBTransactionManager', diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/collation.py b/src/rhosocial/activerecord/backend/impl/mariadb/collation.py deleted file mode 100644 index 6ddf4f0d..00000000 --- a/src/rhosocial/activerecord/backend/impl/mariadb/collation.py +++ /dev/null @@ -1,32 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/mariadb/collation.py -""" -MariaDB collation names supported by the dialect whitelist. -""" - -from enum import Enum -from typing import Optional, Tuple - - -class MariaDBCollation(Enum): - """Common MariaDB collations for expression-level COLLATE.""" - - BINARY = "binary" - LATIN1_SWEDISH_CI = "latin1_swedish_ci" - UTF8_GENERAL_CI = "utf8_general_ci" - UTF8_UNICODE_CI = "utf8_unicode_ci" - UTF8MB4_BIN = "utf8mb4_bin" - UTF8MB4_GENERAL_CI = "utf8mb4_general_ci" - UTF8MB4_UNICODE_CI = "utf8mb4_unicode_ci" - - -_MARIADB_COLLATIONS = {collation.value for collation in MariaDBCollation} - - -def validate_mariadb_collation_name( - name: str, - version: Optional[Tuple[int, int, int]] = None, -) -> str: - normalized = name.lower() - if normalized not in _MARIADB_COLLATIONS: - raise ValueError(f"Unsupported MariaDB collation: {name!r}") - return normalized diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 3b1d67c8..54563d0b 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -94,7 +94,6 @@ TransactionControlMixin, PartitionMixin, ) -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError # Import MariaDB-specific mixins from .mixins import ( @@ -124,7 +123,7 @@ MARIADB_VERSION_BOUNDARIES, # New mixins from dialect.py split MariaDBDateTimeMixin, - MariaDBCollationMixin, + MariaDBCharsetCollationMixin, MariaDBCTEMixin, MariaDBWindowMixin, MariaDBFilterClauseMixin, @@ -171,10 +170,8 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import ( - InsertExpression, ReturningClause, ) - from .expression.load_data import MariaDBLoadDataExpression MARIADB_VERSION_BOUNDARIES = { 'WINDOW_FUNCTIONS': (10, 2, 0), @@ -232,7 +229,7 @@ class MariaDBDialect( MariaDBAdminMixin, # New mixins from dialect.py split MariaDBDateTimeMixin, - MariaDBCollationMixin, + MariaDBCharsetCollationMixin, MariaDBCTEMixin, MariaDBWindowMixin, MariaDBFilterClauseMixin, diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py index ea3e1d78..ca767241 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py @@ -38,11 +38,17 @@ def __init__( *, or_replace: bool = False, comment: Optional[str] = None, - engine: Optional[str] = None, - charset: Optional[str] = None, + engine: Optional[object] = None, + charset: Optional[object] = None, collate: Optional[str] = None, ): super().__init__(dialect, or_replace=or_replace, comment=comment) - self.engine = engine - self.charset = charset - self.collate = collate + self.engine = ( + dialect.validate_storage_engine_name(engine) if engine is not None else None + ) + self.charset = ( + dialect.validate_charset_name(charset) if charset is not None else None + ) + self.collate = ( + dialect.validate_collation_by_name(collate) if collate is not None else None + ) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py index 5715be07..c724c222 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py @@ -64,7 +64,12 @@ from .ddl.admin import MariaDBAdminMixin # New mixins from dialect.py split from .datetime import MariaDBDateTimeMixin -from .collation import MariaDBCollationMixin +from .charset_collation import ( + MariaDBCharset, + MariaDBCollation, + MariaDBStorageEngine, + MariaDBCharsetCollationMixin, +) from .cte import MariaDBCTEMixin from .window import MariaDBWindowMixin from .filter_clause import MariaDBFilterClauseMixin @@ -111,7 +116,10 @@ 'MariaDBRoutineMixin', 'MariaDBAdminMixin', 'MariaDBDateTimeMixin', - 'MariaDBCollationMixin', + 'MariaDBCharset', + 'MariaDBCollation', + 'MariaDBStorageEngine', + 'MariaDBCharsetCollationMixin', 'MariaDBCTEMixin', 'MariaDBWindowMixin', 'MariaDBFilterClauseMixin', diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py new file mode 100644 index 00000000..5c12b360 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py @@ -0,0 +1,254 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py +"""MariaDB charset / collation / storage-engine dialect support. + +Implements :class:`~...mariadb.protocols.MariaDBCharsetCollationSupport`: a +version-aware whitelist of character sets, collations and storage engines, +plus the ``validate_*`` interface used by table/column expressions at +construction time. + +The whitelists and their version gates are implementation details of this +dialect mixin; callers go through the dialect methods. +""" + +from enum import Enum +from typing import FrozenSet, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.collation import CollateExpression + +__all__ = [ + "MariaDBCharset", + "MariaDBStorageEngine", + "MariaDBCollation", + "MariaDBCharsetCollationMixin", +] + + +class MariaDBCharset(Enum): + """MariaDB character sets for table/column ``CHARACTER SET``.""" + + ARMSCII8 = "armscii8" + ASCII = "ascii" + BIG5 = "big5" + BINARY = "binary" + CP1250 = "cp1250" + CP1251 = "cp1251" + CP1256 = "cp1256" + CP1257 = "cp1257" + CP850 = "cp850" + CP852 = "cp852" + CP866 = "cp866" + CP932 = "cp932" + DEC8 = "dec8" + EUCJPMS = "eucjpms" + EUCKR = "euckr" + GB18030 = "gb18030" + GB2312 = "gb2312" + GBK = "gbk" + GEOSTD8 = "geostd8" + GREEK = "greek" + HEBREW = "hebrew" + HP8 = "hp8" + KEYBCS2 = "keybcs2" + KOI8R = "koi8r" + KOI8U = "koi8u" + LATIN1 = "latin1" + LATIN2 = "latin2" + LATIN5 = "latin5" + LATIN7 = "latin7" + MACCE = "macce" + MACROMAN = "macroman" + SJIS = "sjis" + SWE7 = "swe7" + TIS620 = "tis620" + UCS2 = "ucs2" + UJIS = "ujis" + UTF8 = "utf8" + UTF8MB3 = "utf8mb3" + UTF8MB4 = "utf8mb4" + UTF16 = "utf16" + UTF16LE = "utf16le" + UTF32 = "utf32" + + +class MariaDBStorageEngine(Enum): + """Built-in MariaDB storage engines for ``ENGINE=``.""" + + INNODB = "InnoDB" + MYISAM = "MyISAM" + ARIA = "Aria" + MEMORY = "MEMORY" + CSV = "CSV" + ARCHIVE = "ARCHIVE" + BLACKHOLE = "BLACKHOLE" + MRG_MYISAM = "MRG_MyISAM" + SEQUENCE = "SEQUENCE" + CONNECT = "CONNECT" + FEDERATED = "FEDERATED" + PERFORMANCE_SCHEMA = "PERFORMANCE_SCHEMA" + + +class MariaDBCollation(Enum): + """Common MariaDB collations for expression-level COLLATE.""" + + BINARY = "binary" + LATIN1_SWEDISH_CI = "latin1_swedish_ci" + UTF8_GENERAL_CI = "utf8_general_ci" + UTF8_UNICODE_CI = "utf8_unicode_ci" + UTF8MB4_BIN = "utf8mb4_bin" + UTF8MB4_GENERAL_CI = "utf8mb4_general_ci" + UTF8MB4_UNICODE_CI = "utf8mb4_unicode_ci" + + +_CHARSET_BY_VALUE = {member.value: member for member in MariaDBCharset} +_ENGINE_BY_LOWER = {member.value.lower(): member for member in MariaDBStorageEngine} +_COLLATION_VALUES = {member.value for member in MariaDBCollation} + +# Introduced-in server versions for version-gated values. +_CHARSET_MIN_VERSIONS: dict = { + "utf8mb4": (5, 5, 3), + "utf8mb3": (10, 6, 0), +} +_ENGINE_MIN_VERSIONS: dict = { + "SEQUENCE": (10, 3, 0), +} +_COLLATION_MIN_VERSIONS: dict = {} + + +class MariaDBCharsetCollationMixin: + """Version-aware charset / collation / storage-engine dialect support.""" + + def _mariadb_capability_version(self) -> Optional[Tuple[int, ...]]: + # Read the private attribute: the public ``version`` property raises + # ``DialectNotAdaptedException`` when no version was supplied, but + # validation only needs the version *if* it is known. + return getattr(self, "_version", None) + + # --- expression-level COLLATE --------------------------------------- + def supports_collate_expression(self) -> bool: + """MariaDB supports expression-level COLLATE.""" + return True + + def validate_collation_name(self, expr: "CollateExpression") -> str: + """Validate a ``CollateExpression`` and return its collation SQL.""" + 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}") + return self.validate_collation_by_name(expr.collation_name) + + # --- charset --------------------------------------------------------- + def supported_charsets(self) -> FrozenSet[str]: + """Charset names available on the configured server version.""" + version = self._mariadb_capability_version() + return frozenset( + member.value + for member in MariaDBCharset + if version is None + or _CHARSET_MIN_VERSIONS.get(member.value) is None + or version >= _CHARSET_MIN_VERSIONS[member.value] + ) + + def supports_charset(self, name: object) -> bool: + try: + self.validate_charset_name(name) + except (TypeError, ValueError): + return False + return True + + def validate_charset_name(self, name: object) -> str: + version = self._mariadb_capability_version() + if isinstance(name, MariaDBCharset): + normalized = name.value + elif isinstance(name, str): + normalized = name.lower() + else: + raise TypeError( + f"character set must be MariaDBCharset or str, got {type(name).__name__}" + ) + if normalized not in _CHARSET_BY_VALUE: + raise ValueError(f"Unsupported MariaDB character set: {name!r}") + min_version = _CHARSET_MIN_VERSIONS.get(normalized) + if version is not None and min_version is not None and version < min_version: + formatted = ".".join(str(part) for part in min_version[:2]) + raise ValueError(f"MariaDB character set requires MariaDB {formatted}+: {name!r}") + return normalized + + # --- collation ------------------------------------------------------- + def supported_collations(self, charset: Optional[str] = None) -> FrozenSet[str]: + """Collation names available on the configured server version.""" + version = self._mariadb_capability_version() + names = set() + for name in _COLLATION_VALUES: + min_version = _COLLATION_MIN_VERSIONS.get(name) + if version is not None and min_version is not None and version < min_version: + continue + if charset is not None: + prefix = charset.lower() + "_" + if not ( + name.startswith(prefix) + or (charset.lower() == "binary" and name == "binary") + ): + continue + names.add(name) + return frozenset(names) + + def supports_collation_name(self, name: str) -> bool: + try: + self.validate_collation_by_name(name) + except (TypeError, ValueError): + return False + return True + + def validate_collation_by_name(self, name: str) -> str: + version = self._mariadb_capability_version() + if not isinstance(name, str): + raise TypeError(f"collation must be str, got {type(name).__name__}") + normalized = name.lower() + if normalized not in _COLLATION_VALUES: + raise ValueError(f"Unsupported MariaDB collation: {name!r}") + min_version = _COLLATION_MIN_VERSIONS.get(normalized) + if version is not None and min_version is not None and version < min_version: + formatted = ".".join(str(part) for part in min_version[:2]) + raise ValueError(f"MariaDB collation requires MariaDB {formatted}+: {name!r}") + return normalized + + # --- storage engine -------------------------------------------------- + def supported_storage_engines(self) -> FrozenSet[str]: + """Storage engine names available on the configured server version.""" + version = self._mariadb_capability_version() + return frozenset( + member.value + for member in MariaDBStorageEngine + if version is None + or _ENGINE_MIN_VERSIONS.get(member.value) is None + or version >= _ENGINE_MIN_VERSIONS[member.value] + ) + + def supports_storage_engine(self, name: object) -> bool: + try: + self.validate_storage_engine_name(name) + except (TypeError, ValueError): + return False + return True + + def validate_storage_engine_name(self, name: object) -> str: + version = self._mariadb_capability_version() + if isinstance(name, MariaDBStorageEngine): + canonical = name.value + elif isinstance(name, str): + member = _ENGINE_BY_LOWER.get(name.lower()) + if member is None: + raise ValueError(f"Unsupported MariaDB storage engine: {name!r}") + canonical = member.value + else: + raise TypeError( + f"storage engine must be MariaDBStorageEngine or str, " + f"got {type(name).__name__}" + ) + min_version = _ENGINE_MIN_VERSIONS.get(canonical) + if version is not None and min_version is not None and version < min_version: + formatted = ".".join(str(part) for part in min_version[:2]) + raise ValueError(f"MariaDB storage engine requires MariaDB {formatted}+: {name!r}") + return canonical diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/collation.py deleted file mode 100644 index 793ccc8a..00000000 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/collation.py +++ /dev/null @@ -1,27 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/mariadb/mixins/collation.py -"""MariaDB collation support mixin.""" -from typing import TYPE_CHECKING - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression - - -class MariaDBCollationMixin: - """MariaDB COLLATE expression support.""" - - def supports_collate_expression(self) -> bool: - """MariaDB supports expression-level COLLATE.""" - return True - - def validate_collation_name(self, expr: "CollateExpression") -> str: - """Validate MariaDB collation names and return their SQL representation.""" - if expr.collation_options: - unsupported = ", ".join(sorted(expr.collation_options)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {unsupported}") - from ..collation import validate_mariadb_collation_name - return validate_mariadb_collation_name(expr.collation_name, getattr(self, "version", None)) - - -__all__ = ['MariaDBCollationMixin'] diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols.py index c40d4348..7708ecd1 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols.py @@ -32,6 +32,7 @@ # Import from modular protocols subdirectory from .protocols import ( + MariaDBCharsetCollationSupport, MariaDBCTESupport, MariaDBDMLOperationSupport, MariaDBFullTextSearchSupport, @@ -66,5 +67,6 @@ 'MariaDBSystemVersioningSupport', 'MariaDBWindowFunctionSupport', 'MariaDBCTESupport', + 'MariaDBCharsetCollationSupport', 'MariaDBPartitionSupport', ] diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/__init__.py index 295b25a0..bb0c04d6 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/__init__.py @@ -29,6 +29,7 @@ - partition.py: Table partitioning support """ +from .charset import MariaDBCharsetCollationSupport from .cte import MariaDBCTESupport from .dml import MariaDBDMLOperationSupport from .fulltext_search import MariaDBFullTextSearchSupport @@ -67,6 +68,7 @@ 'MariaDBSystemVersioningSupport', 'MariaDBWindowFunctionSupport', 'MariaDBCTESupport', + 'MariaDBCharsetCollationSupport', 'MariaDBPartitionSupport', 'MariaDBRenameTableSupport', 'MariaDBAlterTableSupport', diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/charset.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/charset.py new file mode 100644 index 00000000..384c4f64 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/charset.py @@ -0,0 +1,49 @@ +# src/rhosocial/activerecord/backend/impl/mariadb/protocols/charset.py +"""MariaDB charset / collation / storage-engine support protocol.""" + +from typing import FrozenSet, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class MariaDBCharsetCollationSupport(Protocol): + """Version-aware MariaDB charset / collation / storage-engine support. + + Implementations expose the values available on the configured server + version and validate user input against that whitelist. + """ + + def supported_charsets(self) -> FrozenSet[str]: + """Character sets available on the configured server version.""" + ... + + def supports_charset(self, name: object) -> bool: + """Whether ``name`` is a known charset (version-gated).""" + ... + + def validate_charset_name(self, name: object) -> str: + """Return the normalized charset name or raise ``ValueError``.""" + ... + + def supported_collations(self, charset: Optional[str] = None) -> FrozenSet[str]: + """Collations available on the configured server version.""" + ... + + def supports_collation_name(self, name: str) -> bool: + """Whether ``name`` is a known collation (version-gated).""" + ... + + def validate_collation_by_name(self, name: str) -> str: + """Return the normalized collation name or raise ``ValueError``.""" + ... + + def supported_storage_engines(self) -> FrozenSet[str]: + """Storage engines available on the configured server version.""" + ... + + def supports_storage_engine(self, name: object) -> bool: + """Whether ``name`` is a known storage engine (version-gated).""" + ... + + def validate_storage_engine_name(self, name: object) -> str: + """Return the canonical storage engine name or raise ``ValueError``.""" + ... diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_collation_support.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_collation_support.py new file mode 100644 index 00000000..2c38acb9 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_collation_support.py @@ -0,0 +1,49 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_collation_support.py +"""MariaDB charset / collation / storage-engine capability protocol tests.""" + +import pytest + +from rhosocial.activerecord.backend.impl.mariadb import ( + MariaDBCharset, + MariaDBDialect, + MariaDBStorageEngine, +) +from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBCreateTableOptions +from rhosocial.activerecord.backend.impl.mariadb.protocols import ( + MariaDBCharsetCollationSupport, +) + + +def test_dialect_implements_protocol(): + assert isinstance(MariaDBDialect(version=(10, 6, 0)), MariaDBCharsetCollationSupport) + + +def test_validate_charset_accepts_enum_and_string(): + dialect = MariaDBDialect(version=(10, 6, 0)) + assert dialect.validate_charset_name(MariaDBCharset.UTF8MB4) == "utf8mb4" + assert dialect.validate_charset_name("UTF8MB4") == "utf8mb4" + with pytest.raises(ValueError): + dialect.validate_charset_name("not_a_charset") + + +def test_validate_storage_engine_normalizes_case(): + dialect = MariaDBDialect(version=(10, 6, 0)) + assert dialect.validate_storage_engine_name(MariaDBStorageEngine.INNODB) == "InnoDB" + assert dialect.validate_storage_engine_name("aria") == "Aria" + with pytest.raises(ValueError): + dialect.validate_storage_engine_name("Evil") + + +def test_sequence_engine_version_gated(): + assert MariaDBDialect(version=(10, 2, 0)).supports_storage_engine("SEQUENCE") is False + assert MariaDBDialect(version=(10, 3, 0)).supports_storage_engine("SEQUENCE") is True + + +def test_table_options_reject_unknown_values(): + dialect = MariaDBDialect(version=(10, 6, 0)) + with pytest.raises(ValueError): + MariaDBCreateTableOptions(dialect, engine="Evil") + with pytest.raises(ValueError): + MariaDBCreateTableOptions(dialect, charset="not_a_charset") + with pytest.raises(ValueError): + MariaDBCreateTableOptions(dialect, collate="not_a_collation") diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py new file mode 100644 index 00000000..e9d88bf3 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py @@ -0,0 +1,34 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py +"""Explicit MariaDB table-option capability + rendering assertions. + +MariaDB carries ENGINE / CHARSET / COLLATE as typed fields on +``MariaDBCreateTableOptions``; this pins the supporting capability bit and +verifies the typed options are rendered. +""" + +from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.expression import ( + MariaDBCreateTableOptions, +) + + +def test_storage_engine_option_supported(): + assert MariaDBDialect(version=(10, 6, 0)).supports_storage_engine_option() is True + + +def test_engine_and_charset_rendered_from_typed_options(): + dialect = MariaDBDialect(version=(10, 6, 0)) + expr = CreateTableExpression( + dialect, + table="t", + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + table_options=MariaDBCreateTableOptions( + dialect, engine="InnoDB", charset="utf8mb4" + ), + ) + sql, _ = expr.to_sql() + assert "ENGINE=" in sql and "InnoDB" in sql + assert "DEFAULT CHARSET=" in sql and "utf8mb4" in sql From 9de5f43771bebb4704f930a258ad9590527ecbb7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 23:21:02 +0800 Subject: [PATCH 17/52] docs(mariadb): drop stale dialect_options create-table protocol docs --- .../backend/impl/mariadb/protocols/table.py | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py index d9c5ae95..c0a6ef96 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py """MariaDB table DDL protocol.""" -from typing import Any, Dict, Optional, Protocol, Tuple, TYPE_CHECKING, runtime_checkable +from typing import Any, Protocol, Tuple, TYPE_CHECKING, runtime_checkable from rhosocial.activerecord.backend.dialect.protocols import TableSupport @@ -64,26 +64,13 @@ def supports_create_or_replace_table(self) -> bool: """Whether CREATE OR REPLACE TABLE is supported (MariaDB 10.1+).""" ... - def format_create_table_statement( - self, - expr, - dialect_options: Optional[Dict[str, Any]] = None - ) -> Tuple[str, tuple]: + def format_create_table_statement(self, expr) -> Tuple[str, tuple]: """Format CREATE TABLE statement. - Note: Generic TableSupport protocol defines this interface. - This MariaDB-specific version documents available options. - - Args: - expr: CreateTableExpression instance - dialect_options: MariaDB-specific options: - - 'engine': Storage engine (InnoDB, MyISAM, Aria, etc.) - - 'charset': Character set - - 'collate': Collation - - 'auto_increment': Initial AUTO_INCREMENT value - - 'row_format': Row format (DYNAMIC, COMPACT, etc.) - - 'with_system_versioning': Enable system-versioned tables (MariaDB 10.3+) - Example: dialect_options={'engine': 'InnoDB', 'charset': 'utf8mb4'} + Note: The generic TableSupport protocol defines this interface. + MariaDB-specific table options (``engine`` / ``charset`` / + ``collate``) are typed fields on ``MariaDBCreateTableOptions``, + not a ``dialect_options`` bag. """ ... From ad275d9b5b5db0ae7eab50068976c87dbaab3719 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 23:30:20 +0800 Subject: [PATCH 18/52] docs(mariadb): document typed rename/maintenance options --- .../backend/impl/mariadb/protocols/maintenance.py | 12 ++++++------ .../backend/impl/mariadb/protocols/rename_table.py | 8 +++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/maintenance.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/maintenance.py index c48c1047..96902fd0 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/maintenance.py @@ -57,12 +57,12 @@ def format_table_maintenance_statement( ) -> Tuple[str, tuple]: """Format a MariaDB table maintenance statement. - dialect_options: - - 'no_write_to_binlog' / 'local': Render NO_WRITE_TO_BINLOG - - 'persistent': 'all' or {'columns': [...], 'indexes': [...]} + Options are typed fields on ``MariaDBTableMaintenanceExpression``: + - ``no_write_to_binlog`` / ``local``: render NO_WRITE_TO_BINLOG + - ``persistent``: 'all' or {'columns': [...], 'indexes': [...]} (ANALYZE only, MariaDB 10.5+) - - 'check_mode': e.g. ['QUICK', 'EXTENDED'] (CHECK) - - 'checksum_mode': 'QUICK' | 'EXTENDED' (CHECKSUM) - - 'repair_mode': e.g. ['QUICK'] (REPAIR) + - ``check_mode``: e.g. ['QUICK', 'EXTENDED'] (CHECK) + - ``checksum_mode``: 'QUICK' | 'EXTENDED' (CHECKSUM) + - ``repair_mode``: e.g. ['QUICK'] (REPAIR) """ ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/rename_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/rename_table.py index b41b509e..f04fcb28 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/rename_table.py @@ -52,10 +52,8 @@ def format_rename_table_statement( """Format a MariaDB RENAME TABLE statement. Args: - expr: MariaDBRenameTableExpression instance - dialect_options: MariaDB-specific options: - - 'if_exists': Add statement-level IF EXISTS (MariaDB 10.5+) - - 'wait': Lock wait timeout in seconds (MariaDB 10.3+) - - 'nowait': Do not wait for metadata locks (MariaDB 10.3+) + expr: MariaDBRenameTableExpression with typed fields: + ``if_exists`` (MariaDB 10.5+), ``wait`` / ``nowait`` + (MariaDB 10.3+). """ ... \ No newline at end of file From ae2d063a614a971b6dfb8ce4f634f2f9cc3979ef Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 20 Sep 2026 23:49:41 +0800 Subject: [PATCH 19/52] feat(mariadb): typed AUTO_INCREMENT / ROW_FORMAT / SYSTEM VERSIONING options Add the three typed table options to MariaDBCreateTableOptions with validation and capability gating for system versioning; remove the duplicate format_create_table_statement in the mixin (the dialect's own method is authoritative) and the dead dict-based format_system_versioning_clause. --- .../backend/impl/mariadb/dialect.py | 15 ++++ .../impl/mariadb/expression/__init__.py | 3 +- .../impl/mariadb/expression/table_options.py | 44 +++++++++++- .../backend/impl/mariadb/mixins/ddl_table.py | 69 ------------------- .../impl/mariadb/mixins/system_versioning.py | 45 +----------- .../backend/test_table_option_capabilities.py | 39 +++++++++++ 6 files changed, 100 insertions(+), 115 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 54563d0b..1fe740cd 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -632,6 +632,21 @@ def format_create_table_statement( parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(table_options.charset)}") if table_options.collate: parts.append(f"COLLATE={self.inline_sql_literal(table_options.collate)}") + if table_options.auto_increment is not None: + parts.append(f"AUTO_INCREMENT={int(table_options.auto_increment)}") + if table_options.row_format: + parts.append(f"ROW_FORMAT={table_options.row_format}") + if table_options.with_system_versioning: + if not self.supports_system_versioning(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + raise UnsupportedFeatureError( + self.name, + "WITH SYSTEM VERSIONING", + "System-versioned tables require MariaDB 10.3 or later.", + ) + parts.append("WITH SYSTEM VERSIONING") return ' '.join(parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index 629433fa..e19fb540 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -59,7 +59,7 @@ MariaDBRevokeExpression, MariaDBShutdownExpression, ) -from .table_options import MariaDBCreateTableOptions +from .table_options import MariaDBRowFormat, MariaDBCreateTableOptions from .dml import MariaDBInsertExpression from .truncate import MariaDBTruncateExpression from .alter_table import MariaDBAlterTableExpression @@ -142,6 +142,7 @@ "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", "MariaDBCreateTableOptions", + "MariaDBRowFormat", "MariaDBInsertExpression", "MariaDBTruncateExpression", "MariaDBColumnFormat", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py index ca767241..9df0e636 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py @@ -14,6 +14,7 @@ """ from typing import Optional, TYPE_CHECKING +from enum import Enum from rhosocial.activerecord.backend.expression.statements import CreateTableOptions @@ -22,14 +23,28 @@ __all__ = [ + "MariaDBRowFormat", "MariaDBCreateTableOptions", ] +class MariaDBRowFormat(Enum): + """MariaDB ``ROW_FORMAT`` table option values.""" + + DEFAULT = "DEFAULT" + DYNAMIC = "DYNAMIC" + FIXED = "FIXED" + COMPRESSED = "COMPRESSED" + REDUNDANT = "REDUNDANT" + COMPACT = "COMPACT" + PAGE = "PAGE" + + class MariaDBCreateTableOptions(CreateTableOptions): """A MariaDB CREATE TABLE options declaration extending the generic one. - Adds the MariaDB-only ``engine`` / ``charset`` / ``collate`` table options. + Adds the MariaDB-only ``engine`` / ``charset`` / ``collate`` / + ``auto_increment`` / ``row_format`` / ``with_system_versioning`` options. """ def __init__( @@ -41,6 +56,9 @@ def __init__( engine: Optional[object] = None, charset: Optional[object] = None, collate: Optional[str] = None, + auto_increment: Optional[int] = None, + row_format: Optional[object] = None, + with_system_versioning: bool = False, ): super().__init__(dialect, or_replace=or_replace, comment=comment) self.engine = ( @@ -52,3 +70,27 @@ def __init__( self.collate = ( dialect.validate_collation_by_name(collate) if collate is not None else None ) + if auto_increment is not None and ( + not isinstance(auto_increment, int) or auto_increment <= 0 + ): + raise ValueError( + f"auto_increment must be a positive int, got {auto_increment!r}" + ) + self.auto_increment = auto_increment + self.row_format = self._normalize_row_format(row_format) + self.with_system_versioning = bool(with_system_versioning) + + @staticmethod + def _normalize_row_format(value: Optional[object]) -> Optional[str]: + if value is None: + return None + if isinstance(value, MariaDBRowFormat): + return value.value + if isinstance(value, str): + normalized = value.upper() + if normalized in {member.value for member in MariaDBRowFormat}: + return normalized + raise ValueError(f"Unsupported MariaDB ROW_FORMAT: {value!r}") + raise TypeError( + f"row_format must be MariaDBRowFormat or str, got {type(value).__name__}" + ) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index 6d20578a..f38c007a 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -67,75 +67,6 @@ def supports_create_or_replace_table(self) -> bool: """ return self.version >= (10, 1, 0) - def format_create_table_statement(self, expr) -> Tuple[str, tuple]: - """Format CREATE TABLE statement for MariaDB. - - Handles MariaDB-specific syntax including: - - Inline index definitions - - Storage options (ENGINE, CHARSET, COLLATE) - - Table-level comments - - AUTO_INCREMENT in column definitions - """ - all_params: List[Any] = [] - - options_part = "" - table_options = getattr(expr, "table_options", None) - if table_options is not None: - options_sql, options_params = table_options.to_sql() - if options_sql: - options_part = options_sql - all_params.extend(options_params) - parts = ["CREATE"] - if options_part: - parts.append(options_part) - if expr.temporary: - parts.append("TEMPORARY") - parts.append("TABLE") - if expr.if_not_exists: - parts.append("IF NOT EXISTS") - parts.append(self.format_identifier(expr.table_name)) - - column_parts = [] - for col_def in expr.columns: - col_sql, col_params = self.format_column_definition(col_def) - column_parts.append(col_sql) - all_params.extend(col_params) - - for t_const in expr.table_constraints: - const_sql, const_params = self.format_table_constraint(t_const) - column_parts.append(const_sql) - all_params.extend(const_params) - - for idx_def in expr.indexes: - idx_sql, idx_params = self.format_index_definition(idx_def) - column_parts.append(idx_sql) - all_params.extend(idx_params) - - parts.append(f"({', '.join(column_parts)})") - - if expr.storage_options: - storage_sql = self._format_storage_options(expr.storage_options) - if storage_sql: - parts.append(storage_sql) - - from rhosocial.activerecord.backend.impl.mariadb.expression.table_options import ( - MariaDBCreateTableOptions, - ) - table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None): - comment_sql, _ = self.format_table_comment(table_options.comment) - parts.append(comment_sql) - - if isinstance(table_options, MariaDBCreateTableOptions): - if table_options.engine: - parts.append(f"ENGINE={self.inline_sql_literal(table_options.engine)}") - if table_options.charset: - parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(table_options.charset)}") - if table_options.collate: - parts.append(f"COLLATE={self.inline_sql_literal(table_options.collate)}") - - return ' '.join(parts), tuple(all_params) - def format_column_definition( self, col_def: "ColumnDefinition", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/system_versioning.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/system_versioning.py index e4f0b018..73aa3111 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/system_versioning.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/system_versioning.py @@ -4,7 +4,7 @@ MariaDB 10.3+ supports system-versioned tables for temporal data tracking. This is a MariaDB-specific feature not available in MySQL. """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Any, Tuple, TYPE_CHECKING from .backend import MARIADB_VERSION_BOUNDARIES @@ -62,49 +62,6 @@ def supports_temporal_tables(self) -> bool: """ return self.supports_system_versioning() - def format_system_versioning_clause( - self, - table_options: Optional[Dict[str, Any]] = None - ) -> Tuple[str, tuple]: - """Format WITH SYSTEM VERSIONING clause for CREATE TABLE. - - Syntax: - WITH SYSTEM VERSIONING - [WITH SYSTEM VERSIONING ON {DELETE|UPDATE} {EQUAL|BEFORE}] - - Args: - table_options: Optional dict with: - - 'versioning_on_delete': 'EQUAL' or 'BEFORE' - - 'versioning_on_update': 'EQUAL' or 'BEFORE' - - Returns: - Tuple of (SQL string, parameters tuple). - """ - if not self.supports_system_versioning(): - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - self.name, - "System-Versioned Tables", - "System-versioned tables require MariaDB 10.3 or later." - ) - - parts = ["WITH SYSTEM VERSIONING"] - - if table_options: - on_delete = table_options.get('versioning_on_delete') - on_update = table_options.get('versioning_on_update') - - on_parts = [] - if on_delete: - on_parts.append(f"ON DELETE {on_delete.upper()}") - if on_update: - on_parts.append(f"ON UPDATE {on_update.upper()}") - - if on_parts: - parts.append("WITH SYSTEM VERSIONING " + " ".join(on_parts)) - - return " ".join(parts), () - def format_for_system_time_as_of( self, timestamp: Any diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py index e9d88bf3..b0a34ad4 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py @@ -32,3 +32,42 @@ def test_engine_and_charset_rendered_from_typed_options(): sql, _ = expr.to_sql() assert "ENGINE=" in sql and "InnoDB" in sql assert "DEFAULT CHARSET=" in sql and "utf8mb4" in sql + + +def test_auto_increment_row_format_and_system_versioning(): + from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBRowFormat + + dialect = MariaDBDialect(version=(10, 6, 0)) + options = MariaDBCreateTableOptions( + dialect, + auto_increment=100, + row_format=MariaDBRowFormat.PAGE, + with_system_versioning=True, + ) + expr = CreateTableExpression( + dialect, + "t", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + table_options=options, + ) + sql, _ = expr.to_sql() + assert "AUTO_INCREMENT=100" in sql + assert "ROW_FORMAT=PAGE" in sql + assert "WITH SYSTEM VERSIONING" in sql + + +def test_system_versioning_gated_below_10_3(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + dialect = MariaDBDialect(version=(10, 2, 0)) + options = MariaDBCreateTableOptions(dialect, with_system_versioning=True) + expr = CreateTableExpression( + dialect, + "t", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + table_options=options, + ) + import pytest + + with pytest.raises(UnsupportedFeatureError, match="SYSTEM VERSIONING"): + expr.to_sql() From 857a055820b17375d84100a05f91d9b58d524891 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 00:16:18 +0800 Subject: [PATCH 20/52] fix(mariadb): wire MariaDBDatabaseMixin into the dialect --- .../backend/impl/mariadb/dialect.py | 2 ++ .../backend/impl/mariadb/mixins/__init__.py | 2 ++ .../backend/test_mariadb_database_ddl.py | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_database_ddl.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 1fe740cd..bd042843 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -108,6 +108,7 @@ MariaDBJSONMixin, MariaDBFullTextSearchMixin, MariaDBTableMixin, + MariaDBDatabaseMixin, MariaDBSetTypeMixin, MariaDBModifyColumnMixin, MariaDBPartitionMixin, @@ -216,6 +217,7 @@ class MariaDBDialect( MariaDBJSONMixin, MariaDBFullTextSearchMixin, MariaDBTableMixin, + MariaDBDatabaseMixin, MariaDBSetTypeMixin, MariaDBModifyColumnMixin, MariaDBPartitionMixin, diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py index c724c222..25fe409c 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py @@ -41,6 +41,7 @@ from .backend import MariaDBBackendMixin, MARIADB_VERSION_BOUNDARIES from .sequence import MariaDBSequenceMixin from .returning import MariaDBReturningMixin +from .ddl_database import MariaDBDatabaseMixin from .system_versioning import MariaDBSystemVersioningMixin from .dml import MariaDBDMLOperationMixin from .spatial import MariaDBSpatialMixin @@ -120,6 +121,7 @@ 'MariaDBCollation', 'MariaDBStorageEngine', 'MariaDBCharsetCollationMixin', + 'MariaDBDatabaseMixin', 'MariaDBCTEMixin', 'MariaDBWindowMixin', 'MariaDBFilterClauseMixin', diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_database_ddl.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_database_ddl.py new file mode 100644 index 00000000..bf5e125a --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_database_ddl.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_database_ddl.py +"""Explicit MariaDBDialect database DDL capability + rendering tests.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + DropDatabaseExpression, +) +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect + + +def _dialect(): + return MariaDBDialect(version=(10, 6, 0)) + + +def test_database_capabilities(): + dialect = _dialect() + assert dialect.supports_create_database() is True + assert dialect.supports_drop_database() is True + + +def test_create_database_renders(): + sql, params = CreateDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "CREATE DATABASE" in sql + assert params == () + + +def test_drop_database_renders(): + sql, _ = DropDatabaseExpression(_dialect(), database_name="app").to_sql() + assert "DROP DATABASE" in sql From 006cfc241daf45393d77bd1a2af05c2b7f03d54a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 00:28:14 +0800 Subject: [PATCH 21/52] docs(mariadb): update create-table example to typed table options --- .../impl/mariadb/examples/ddl/create_table.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/examples/ddl/create_table.py index 053f72f0..a6eaa813 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/examples/ddl/create_table.py @@ -15,6 +15,7 @@ import os from rhosocial.activerecord.backend.impl.mariadb import MariaDBBackend from rhosocial.activerecord.backend.impl.mariadb.config import MariaDBConnectionConfig +from rhosocial.activerecord.backend.impl.mariadb.expression import MariaDBCreateTableOptions from rhosocial.activerecord.backend.expression import ( DropTableExpression, CreateTableExpression, @@ -48,7 +49,7 @@ dialect = backend.dialect # Drop if exists for clean setup -drop = DropTableExpression(dialect=dialect, table_name='products', if_exists=True) +drop = DropTableExpression(dialect=dialect, table='products', if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) @@ -116,14 +117,11 @@ # Create table with MariaDB-specific ENGINE and CHARSET options create_expr = CreateTableExpression( dialect=dialect, - table_name='products', + table='products', columns=columns, indexes=indexes, if_not_exists=True, - dialect_options={ - 'engine': 'InnoDB', - 'charset': 'utf8mb4', - }, + table_options=MariaDBCreateTableOptions(dialect, engine="InnoDB", charset="utf8mb4"), ) sql, params = create_expr.to_sql() @@ -145,7 +143,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table_name='products', if_exists=True) +drop_table = DropTableExpression(dialect=dialect, table='products', if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() @@ -155,7 +153,7 @@ # ============================================================ # Key points: # 1. Use ColumnConstraint with is_auto_increment=True for AUTO_INCREMENT -# 2. MariaDB dialect_options supports 'engine' and 'charset' keys +# 2. MariaDB table options (engine/charset) are typed on MariaDBCreateTableOptions # 3. IndexDefinition creates inline indexes within CREATE TABLE # 4. Use current_timestamp(dialect) for SQL niladic functions (no parentheses) # 5. Use introspector.get_columns() to verify table structure From 58b4d5f56ce1e3b5f43abc7da1ccce049b7567e6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 01:04:13 +0800 Subject: [PATCH 22/52] fix(mariadb): keep create-table formatter on MariaDBTableMixin The protocol-conformance test requires MariaDBTableMixin to implement MariaDBTableSupport.format_create_table_statement. Restore the mixin implementation (with the new typed options) and drop the duplicate on the dialect class. --- .../backend/impl/mariadb/dialect.py | 79 ----------------- .../backend/impl/mariadb/mixins/ddl_table.py | 84 +++++++++++++++++++ 2 files changed, 84 insertions(+), 79 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index bd042843..dc561981 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -573,85 +573,6 @@ def format_explain_statement(self, expr) -> Tuple[str, tuple]: # region DDL Support - format_create_table_statement - def format_create_table_statement( - self, expr: "CreateTableExpression" - ) -> Tuple[str, tuple]: - """Format CREATE TABLE statement for MariaDB.""" - all_params: List[Any] = [] - - options_part = "" - table_options = getattr(expr, "table_options", None) - if table_options is not None: - options_sql, options_params = table_options.to_sql() - if options_sql: - options_part = options_sql - all_params.extend(options_params) - parts = ["CREATE"] - if options_part: - parts.append(options_part) - if expr.temporary: - parts.append("TEMPORARY") - parts.append("TABLE") - if expr.if_not_exists: - parts.append("IF NOT EXISTS") - parts.append(self.format_identifier(expr.table_name)) - - column_parts = [] - for col_def in expr.columns: - col_sql, col_params = self.format_column_definition(col_def) - column_parts.append(col_sql) - all_params.extend(col_params) - - for t_const in expr.table_constraints: - const_sql, const_params = self.format_table_constraint(t_const) - column_parts.append(const_sql) - all_params.extend(const_params) - - for idx_def in expr.indexes: - idx_sql, idx_params = self.format_index_definition(idx_def) - column_parts.append(idx_sql) - all_params.extend(idx_params) - - parts.append(f"({', '.join(column_parts)})") - - if expr.storage_options: - storage_sql = self._format_storage_options(expr.storage_options) - if storage_sql: - parts.append(storage_sql) - - from rhosocial.activerecord.backend.impl.mariadb.expression.table_options import ( - MariaDBCreateTableOptions, - ) - table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None): - escaped_comment = self._escape_sql_string(table_options.comment) - parts.append(f"COMMENT '{escaped_comment}'") - - if isinstance(table_options, MariaDBCreateTableOptions): - if table_options.engine: - parts.append(f"ENGINE={self.inline_sql_literal(table_options.engine)}") - if table_options.charset: - parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(table_options.charset)}") - if table_options.collate: - parts.append(f"COLLATE={self.inline_sql_literal(table_options.collate)}") - if table_options.auto_increment is not None: - parts.append(f"AUTO_INCREMENT={int(table_options.auto_increment)}") - if table_options.row_format: - parts.append(f"ROW_FORMAT={table_options.row_format}") - if table_options.with_system_versioning: - if not self.supports_system_versioning(): - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - raise UnsupportedFeatureError( - self.name, - "WITH SYSTEM VERSIONING", - "System-versioned tables require MariaDB 10.3 or later.", - ) - parts.append("WITH SYSTEM VERSIONING") - - return ' '.join(parts), tuple(all_params) - @staticmethod def _escape_sql_string(value: str) -> str: value = value.replace('\\', '\\\\') diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index f38c007a..aaf43c62 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -67,6 +67,90 @@ def supports_create_or_replace_table(self) -> bool: """ return self.version >= (10, 1, 0) + def format_create_table_statement(self, expr) -> Tuple[str, tuple]: + """Format CREATE TABLE statement for MariaDB. + + Handles MariaDB-specific syntax including: + - Inline index definitions + - Storage options (ENGINE, CHARSET, COLLATE) + - Table-level comments + - AUTO_INCREMENT in column definitions + """ + all_params: List[Any] = [] + + options_part = "" + table_options = getattr(expr, "table_options", None) + if table_options is not None: + options_sql, options_params = table_options.to_sql() + if options_sql: + options_part = options_sql + all_params.extend(options_params) + parts = ["CREATE"] + if options_part: + parts.append(options_part) + if expr.temporary: + parts.append("TEMPORARY") + parts.append("TABLE") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self.format_identifier(expr.table_name)) + + column_parts = [] + for col_def in expr.columns: + col_sql, col_params = self.format_column_definition(col_def) + column_parts.append(col_sql) + all_params.extend(col_params) + + for t_const in expr.table_constraints: + const_sql, const_params = self.format_table_constraint(t_const) + column_parts.append(const_sql) + all_params.extend(const_params) + + for idx_def in expr.indexes: + idx_sql, idx_params = self.format_index_definition(idx_def) + column_parts.append(idx_sql) + all_params.extend(idx_params) + + parts.append(f"({', '.join(column_parts)})") + + if expr.storage_options: + storage_sql = self._format_storage_options(expr.storage_options) + if storage_sql: + parts.append(storage_sql) + + from rhosocial.activerecord.backend.impl.mariadb.expression.table_options import ( + MariaDBCreateTableOptions, + ) + table_options = getattr(expr, "table_options", None) + if table_options is not None and getattr(table_options, "comment", None): + comment_sql, _ = self.format_table_comment(table_options.comment) + parts.append(comment_sql) + + if isinstance(table_options, MariaDBCreateTableOptions): + if table_options.engine: + parts.append(f"ENGINE={self.inline_sql_literal(table_options.engine)}") + if table_options.charset: + parts.append(f"DEFAULT CHARSET={self.inline_sql_literal(table_options.charset)}") + if table_options.collate: + parts.append(f"COLLATE={self.inline_sql_literal(table_options.collate)}") + if table_options.auto_increment is not None: + parts.append(f"AUTO_INCREMENT={int(table_options.auto_increment)}") + if table_options.row_format: + parts.append(f"ROW_FORMAT={table_options.row_format}") + if table_options.with_system_versioning: + if not self.supports_system_versioning(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + raise UnsupportedFeatureError( + self.name, + "WITH SYSTEM VERSIONING", + "System-versioned tables require MariaDB 10.3 or later.", + ) + parts.append("WITH SYSTEM VERSIONING") + + return ' '.join(parts), tuple(all_params) + def format_column_definition( self, col_def: "ColumnDefinition", From 3151292920358ab75532dc89e8cbbb75ff03e28f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 21:53:22 +0800 Subject: [PATCH 23/52] refactor(ddl): drop legacy identity fields from column classes Remove identity/identity_start/identity_increment/identity_clause from XxxColumnDefinition and XxxColumnOptions to match core's column-attribute migration; XxxColumnDefinition now forwards attributes. --- .../backend/impl/mariadb/expression/column.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py index 0ba3abee..d77d97d8 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py @@ -68,10 +68,7 @@ def __init__( constraints=None, comment: Optional[str] = None, generated_expression=None, - identity: Optional[str] = None, - identity_start: Optional[int] = None, - identity_increment: Optional[int] = None, - identity_clause=None, + attributes=None, *, character_set: Optional[str] = None, column_format: Optional[MariaDBColumnFormat] = None, @@ -85,10 +82,7 @@ def __init__( constraints=constraints, comment=comment, generated_expression=generated_expression, - identity=identity, - identity_start=identity_start, - identity_increment=identity_increment, - identity_clause=identity_clause, + attributes=attributes, ) if column_format is not None and not isinstance(column_format, MariaDBColumnFormat): raise TypeError( @@ -112,17 +106,12 @@ class MariaDBColumnOptions(ColumnOptions): def __init__( self, *, - identity_start: Optional[int] = None, - identity_increment: Optional[int] = None, character_set: Optional[str] = None, column_format: Optional[MariaDBColumnFormat] = None, storage: Optional[MariaDBColumnStorage] = None, invisible: Optional[bool] = None, ): - super().__init__( - identity_start=identity_start, - identity_increment=identity_increment, - ) + super().__init__() if column_format is not None and not isinstance(column_format, MariaDBColumnFormat): raise TypeError( "column_format must be a MariaDBColumnFormat value, " From eb6bd0db6a2b34e2f807562b9070b69638b1318d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 22:16:06 +0800 Subject: [PATCH 24/52] feat(ddl): render column attributes; backend identity syntax Render the dialect-free column attributes channel in each backend's format_column_definition (it was silently dropped), and give backends whose identity syntax differs from the SQL-standard form a format_identity_clause override (MySQL/MariaDB AUTO_INCREMENT, SQL Server IDENTITY(seed, inc)). Oracle gates column COLLATE to 12.2+; MariaDB drops the duplicate dialect formatter so the attribute-aware mixin is authoritative. --- .../backend/impl/mariadb/dialect.py | 58 ------------------- .../backend/impl/mariadb/mixins/ddl_column.py | 10 ++++ .../backend/impl/mariadb/mixins/ddl_table.py | 5 ++ 3 files changed, 15 insertions(+), 58 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index dc561981..28129899 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -579,64 +579,6 @@ def _escape_sql_string(value: str) -> str: value = value.replace("'", "''") return value - def format_column_definition( - self, - col_def: "ColumnDefinition" - ) -> Tuple[str, tuple]: - from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType - from rhosocial.activerecord.backend.impl.mariadb.expression.column import ( - MariaDBColumnDefinition, - ) - - type_sql, type_params = col_def.data_type.to_sql() - parts = [self.format_identifier(col_def.name), type_sql] - params: List[Any] = list(type_params) - - constraint_parts = [] - for constraint in col_def.constraints: - if constraint.constraint_type == ColumnConstraintType.PRIMARY_KEY: - constraint_parts.append("PRIMARY KEY") - elif constraint.constraint_type == ColumnConstraintType.NOT_NULL: - constraint_parts.append("NOT NULL") - elif constraint.constraint_type == ColumnConstraintType.UNIQUE: - constraint_parts.append("UNIQUE") - elif constraint.constraint_type == ColumnConstraintType.DEFAULT: - if constraint.default_value is not None: - from rhosocial.activerecord.backend.expression import bases - if isinstance(constraint.default_value, bases.BaseExpression): - default_sql, default_params = constraint.default_value.to_sql() - constraint_parts.append(f"DEFAULT {default_sql}") - params.extend(default_params) - elif isinstance(constraint.default_value, str): - escaped = self._escape_sql_string(constraint.default_value) - constraint_parts.append(f"DEFAULT '{escaped}'") - else: - constraint_parts.append(f"DEFAULT {constraint.default_value}") - elif constraint.constraint_type == ColumnConstraintType.NULL: - constraint_parts.append("NULL") - - if constraint.is_auto_increment: - constraint_parts.append("AUTO_INCREMENT") - - if constraint_parts: - parts.append(' '.join(constraint_parts)) - - if isinstance(col_def, MariaDBColumnDefinition): - if col_def.character_set: - parts.append(f"CHARACTER SET {self.format_identifier(col_def.character_set)}") - if col_def.column_format is not None: - parts.append(f"COLUMN_FORMAT {col_def.column_format.value}") - if col_def.storage is not None: - parts.append(f"STORAGE {col_def.storage.value}") - if col_def.invisible: - parts.append("INVISIBLE") - - if col_def.comment: - escaped_comment = self._escape_sql_string(col_def.comment) - parts.append(f"COMMENT '{escaped_comment}'") - - return ' '.join(parts), tuple(params) - def format_table_constraint( self, t_const: "TableConstraint" diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_column.py index 994c795f..35aa6b11 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_column.py @@ -1,10 +1,20 @@ # src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_column.py """MariaDB DDL column/table support mixin.""" +from typing import Tuple + class MariaDBDDLColumnMixin: """MariaDB DDL column and table capability checks.""" + def format_identity_clause(self, expr) -> Tuple[str, Tuple]: + """MariaDB renders an identity column as ``AUTO_INCREMENT``. + + MariaDB has no ``GENERATED ... AS IDENTITY`` column syntax; seed and + increment are table-level options, so only the marker is emitted. + """ + return " AUTO_INCREMENT", () + def supports_if_not_exists_table(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index aaf43c62..4f78dbcd 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -200,6 +200,11 @@ def format_column_definition( if constraint_parts: parts.append(' '.join(constraint_parts)) + attr_sql, attr_params = self.format_column_attributes(col_def) + if attr_sql: + parts.append(attr_sql.strip()) + params.extend(attr_params) + if isinstance(col_def, MariaDBColumnDefinition): if col_def.character_set: parts.append(f"CHARACTER SET {self.format_identifier(col_def.character_set)}") From 3dba0d950cfe70161c6035cace1917bb21aa0e15 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 21 Sep 2026 22:31:25 +0800 Subject: [PATCH 25/52] test(ddl): classify ColumnAttributeSupport; use deriver.creation_plan() Core added the ColumnAttributeSupport protocol and renamed the deriver plan entry to creation_plan(); update protocol conformance and derived-DDL tests. --- .../feature/backend/ddl/test_derived_ddl_inline_index.py | 2 +- .../feature/backend/test_mariadb_protocol_conformance.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py index 586f6917..bd808bce 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -47,6 +47,6 @@ def test_create_indexes_is_empty_when_inline_capable(): def test_create_schema_is_a_single_statement(): deriver = TableDDLDeriver(Indexed, mariadb_dialect()) - plan = deriver.create_schema() + plan = deriver.creation_plan() assert len(plan) == 1 assert isinstance(plan[0], CreateTableExpression) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py index 2b2b7952..b495a2e2 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py @@ -70,6 +70,7 @@ def get_own_protocol_methods(proto: type) -> set: MYSQL_PROTOCOLS = [ dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, From 5227834ae4602ef95fb1bc0eace6e5904282d03c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 18:04:03 +0800 Subject: [PATCH 26/52] feat(mariadb): advertise inline table and column comment capabilities MariaDB renders the table comment as an inline table option and the column comment inside the column definition; declare supports_table_comment() and supports_column_comment() as True so the capability bits match the actual rendering behavior. --- .../backend/impl/mariadb/mixins/ddl_table.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index 4f78dbcd..99ea1254 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -49,6 +49,20 @@ def supports_inline_index(self) -> bool: """MariaDB allows inline INDEX/KEY definitions.""" return True + def supports_table_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` on ``CREATE TABLE`` is supported. + + MariaDB renders the table comment as an inline table option (and the + column comment inside the column definition), so both capabilities + advertise True and the inline path is the rendering path. + """ + return True + + def supports_column_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` in a column definition is + supported. MariaDB renders it natively.""" + return True + def supports_storage_engine_option(self) -> bool: """MariaDB supports multiple storage engines.""" return True From 8e58d39af0bc05aeef51249e9bf4f7b2826a1ee1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 21:51:08 +0800 Subject: [PATCH 27/52] refactor(ddl): use core typed comment clause nodes Inline table/column comments are now TableCommentClause / ColumnCommentClause nodes; render them through format_table_comment_clause / format_column_comment_clause. Tests updated to build the clause nodes. --- .../backend/impl/mariadb/mixins/ddl_table.py | 12 +++---- .../backend/test_mariadb_column_definition.py | 3 +- .../backend/test_mariadb_ddl_features.py | 31 ++++++++++--------- .../backend/test_mariadb_dialect_security.py | 7 +++-- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index 99ea1254..be5ba22e 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -136,9 +136,9 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: MariaDBCreateTableOptions, ) table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None): - comment_sql, _ = self.format_table_comment(table_options.comment) - parts.append(comment_sql) + if table_options is not None and getattr(table_options, "comment", None) is not None: + comment_sql, _ = self.format_table_comment_clause(table_options.comment) + parts.append(comment_sql.strip()) if isinstance(table_options, MariaDBCreateTableOptions): if table_options.engine: @@ -229,9 +229,9 @@ def format_column_definition( if col_def.invisible: parts.append("INVISIBLE") - if col_def.comment: - escaped_comment = self._escape_sql_string(col_def.comment) - parts.append(f"COMMENT '{escaped_comment}'") + if col_def.comment is not None: + comment_sql, _ = self.format_column_comment_clause(col_def.comment) + parts.append(comment_sql.strip()) if col_def.generated_expression is not None: gen_sql, gen_params = col_def.generated_expression.to_sql() diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py index f30740d6..8312f253 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py @@ -4,6 +4,7 @@ import pytest from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression import ColumnCommentClause from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.impl.mariadb.expression import ( MariaDBColumnDefinition, @@ -63,7 +64,7 @@ def test_combined_attributes(dialect): def test_generic_column_still_renders_on_mariadb(dialect): - generic = ColumnDefinition(dialect, "name", VarCharType(dialect, length=50), comment="c") + generic = ColumnDefinition(dialect, "name", VarCharType(dialect, length=50), comment=ColumnCommentClause(dialect, "c")) sql, _ = generic.to_sql() assert sql == "`name` VARCHAR(50) COMMENT 'c'" diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py index 80c73b85..fdfd2510 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_features.py @@ -22,6 +22,7 @@ TableConstraintType, ForeignKeyConstraint, ) +from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.statements import ReferentialAction from rhosocial.activerecord.backend.expression.types import ( BigIntType, @@ -149,7 +150,7 @@ def test_table_comment(self): dialect=dialect, table='users', columns=columns, - table_options=CreateTableOptions(dialect, comment='用户信息表'), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, '用户信息表')), ) sql, params = expr.to_sql() assert "COMMENT '用户信息表'" in sql @@ -174,7 +175,7 @@ def test_table_comment_with_storage_options(self): 'ENGINE': 'InnoDB', 'DEFAULT CHARSET': 'utf8mb4' }, - table_options=CreateTableOptions(dialect, comment='用户信息表'), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, '用户信息表')), ) sql, params = expr.to_sql() assert "ENGINE='InnoDB'" in sql @@ -197,7 +198,7 @@ def test_table_comment_special_characters(self): dialect=dialect, table='test', columns=columns, - table_options=CreateTableOptions(dialect, comment="测试's表"), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "测试's表")), ) sql, params = expr.to_sql() assert "COMMENT" in sql @@ -212,8 +213,8 @@ def test_column_comment(self): columns = [ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) - ], comment='主键ID'), - ColumnDefinition(dialect, 'name', VarCharType(dialect, 100), comment='用户名') + ], comment=ColumnCommentClause(dialect, '主键ID')), + ColumnDefinition(dialect, 'name', VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, '用户名')) ] expr = CreateTableExpression( dialect=dialect, @@ -230,8 +231,8 @@ def test_column_comment_with_table_comment(self): columns = [ ColumnDefinition(dialect, 'id', IntegerType(dialect), constraints=[ ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) - ], comment='主键'), - ColumnDefinition(dialect, 'name', VarCharType(dialect, 100), comment='名称') + ], comment=ColumnCommentClause(dialect, '主键')), + ColumnDefinition(dialect, 'name', VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, '名称')) ] from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableOptions, @@ -241,7 +242,7 @@ def test_column_comment_with_table_comment(self): dialect=dialect, table='users', columns=columns, - table_options=CreateTableOptions(dialect, comment='用户表'), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, '用户表')), ) sql, params = expr.to_sql() assert "COMMENT '主键'" in sql @@ -277,7 +278,7 @@ def test_auto_increment_with_comment(self): ColumnDefinition(dialect, 'id', BigIntType(dialect), constraints=[ ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True) - ], comment='自增主键') + ], comment=ColumnCommentClause(dialect, '自增主键')) ] expr = CreateTableExpression( dialect=dialect, @@ -735,27 +736,27 @@ def test_complete_table_creation(self): ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True) ], - comment='Primary key' + comment=ColumnCommentClause(dialect, 'Primary key') ), ColumnDefinition(dialect, 'name', VarCharType(dialect, 100), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], - comment='User name' + comment=ColumnCommentClause(dialect, 'User name') ), ColumnDefinition(dialect, 'email', VarCharType(dialect, 255), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], - comment='Email address' + comment=ColumnCommentClause(dialect, 'Email address') ), ColumnDefinition(dialect, 'status', status_enum, constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], - comment='User status' + comment=ColumnCommentClause(dialect, 'User status') ), - ColumnDefinition(dialect, 'created_at', DateTimeType(dialect), comment='Creation timestamp') + ColumnDefinition(dialect, 'created_at', DateTimeType(dialect), comment=ColumnCommentClause(dialect, 'Creation timestamp')) ] indexes = [ @@ -774,7 +775,7 @@ def test_complete_table_creation(self): 'DEFAULT CHARSET': 'utf8mb4', 'COLLATE': 'utf8mb4_unicode_ci' }, - table_options=CreateTableOptions(dialect, comment='User information table'), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, 'User information table')), ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py index 8e9a7427..7469a9ef 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py @@ -10,6 +10,7 @@ from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -53,7 +54,7 @@ def test_mysql_format_column_definition_comment_string_escaping(dialect): dialect, name="test_col", data_type=VarCharType(dialect, 255), - comment="Comment with 'single quote'", + comment=ColumnCommentClause(dialect, "Comment with 'single quote'"), ) sql, params = dialect.format_column_definition(col_def) @@ -446,7 +447,7 @@ def test_create_table_comment_escaped(self, dialect): table="test_table", columns=[], table_options=CreateTableOptions( - dialect, comment="Table's comment with 'quotes'" + dialect, comment=TableCommentClause(dialect, "Table's comment with 'quotes'") ), ) @@ -467,7 +468,7 @@ def test_create_table_comment_with_backslash(self, dialect): dialect=dialect, table="test_table", columns=[], - table_options=CreateTableOptions(dialect, comment="Test\\value"), + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "Test\\value")), ) sql, params = dialect.format_create_table_statement(expr) From 9fd027ddd492433c136e203bf0e5cc60dd574cb1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Wed, 23 Sep 2026 22:30:10 +0800 Subject: [PATCH 28/52] fix(ddl): declare supports_column_comment in MariaDBTableSupport --- .../activerecord/backend/impl/mariadb/protocols/table.py | 4 ++++ .../feature/backend/test_mariadb_protocol_conformance.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py index c0a6ef96..85aef253 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py @@ -84,6 +84,10 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: """Format a column definition (name, type, constraints, comment).""" ... + def supports_column_comment(self) -> bool: + """Whether an inline column COMMENT is supported (MariaDB: yes).""" + ... + def format_table_constraint(self, t_const) -> Tuple[str, tuple]: """Format a table-level constraint (PRIMARY KEY / UNIQUE / FOREIGN KEY).""" ... diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py index b495a2e2..8b7d73c0 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py @@ -151,6 +151,9 @@ def test_implements_protocol(self, dialect, protocol): # decision (move to MYSQL_PROTOCOLS or revert). MARIADB_NOT_IMPLEMENTED = [ # --- Intentional non-support --- + # MariaDB has no standalone COMMENT ON statement; inline table/column + # comments are rendered by CREATE TABLE instead. + dialect_protocols.CommentSupport, # The generic DatabaseSupport protocol is not composed by MariaDBDialect. dialect_protocols.DatabaseSupport, # MariaDB has no SQL/XML support. From e0d293a0021d1f3a2c745f3b43abba3c334fb81c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 01:14:43 +0800 Subject: [PATCH 29/52] fix(ddl): stop silently dropping TABLESPACE / INHERITS --- .../backend/impl/mariadb/mixins/ddl_table.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index be5ba22e..3b4af2d7 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py @@ -90,6 +90,18 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: - Table-level comments - AUTO_INCREMENT in column definitions """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if expr.tablespace: + raise UnsupportedFeatureError( + self.name, "TABLESPACE", + "MariaDB does not support table tablespaces.", + ) + if expr.inherits: + raise UnsupportedFeatureError( + self.name, "table INHERITS", + "MariaDB does not support table inheritance.", + ) all_params: List[Any] = [] options_part = "" From b61d26f435afbda5e8837e95cdb223457b6342f0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 09:41:58 +0800 Subject: [PATCH 30/52] feat(mariadb): add type domain negative contract --- .../backend/impl/mariadb/dialect.py | 15 +- .../impl/mariadb/expression/__init__.py | 2 + .../backend/impl/mariadb/expression/types.py | 19 ++ .../backend/impl/mariadb/mixins/types.py | 23 +- .../backend/impl/mariadb/types.py | 2 + .../test_mariadb_protocol_conformance.py | 13 +- .../backend/test_mariadb_type_domain_ddl.py | 197 ++++++++++++++++++ .../backend/test_mariadb_type_protocol.py | 73 ++++++- 8 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_domain_ddl.py diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 28129899..40e99c9d 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -53,7 +53,9 @@ FunctionSupport, # Additional Protocols SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, @@ -91,6 +93,8 @@ DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, + DomainMixin, TransactionControlMixin, PartitionMixin, ) @@ -281,6 +285,8 @@ class MariaDBDialect( DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, + DomainMixin, TransactionControlMixin, PartitionMixin, # Protocol support markers @@ -314,7 +320,9 @@ class MariaDBDialect( ViewSupport, FunctionSupport, SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, MariaDBDMLOperationSupport, MariaDBTriggerSupport, MariaDBTableSupport, @@ -410,11 +418,12 @@ def suggested_data_types(self) -> Dict[str, type]: from .expression.types import ( MariaDBBinaryType, MariaDBEnumType, + MariaDBUUIDType, MariaDBVarBinaryType, ) return { - "uuid": MariaDBBinaryType, + "uuid": MariaDBUUIDType, "enum": MariaDBEnumType, "binary": MariaDBBinaryType, "varbinary": MariaDBVarBinaryType, diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py index e19fb540..a1c68637 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -116,6 +116,7 @@ MariaDBTinyBlobType, MariaDBTinyIntType, MariaDBTinyTextType, + MariaDBUUIDType, MariaDBVarBinaryType, MariaDBYearType, ) @@ -205,6 +206,7 @@ "MariaDBTinyBlobType", "MariaDBTinyIntType", "MariaDBTinyTextType", + "MariaDBUUIDType", "MariaDBVarBinaryType", "MariaDBYearType", "MariaDBSetLiteralExpression", diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py index 4b3e1aa5..a643c9e7 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py @@ -234,6 +234,25 @@ def __init__(self, dialect=None, length: Optional[int] = None): def _type_params(self) -> tuple: return (self.length,) + def is_equivalent(self, other: DataType) -> bool: + if isinstance(other, MariaDBUUIDType): + return self.length == 16 and other.length == 16 + return super().is_equivalent(other) + + +class MariaDBUUIDType(MariaDBBinaryType): + """MariaDB UUID storage as a fixed 16-byte BINARY value.""" + + name = "mariadb_uuid" + + def __init__(self, dialect=None): + super().__init__(dialect, length=16) + + def is_equivalent(self, other: DataType) -> bool: + if isinstance(other, MariaDBBinaryType): + return self.length == 16 and other.length == 16 + return super().is_equivalent(other) + class MariaDBVarBinaryType(DataType): """MariaDB ``VARBINARY(n)`` — variable-length binary.""" diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py index c6202a52..7ad8e5cd 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py @@ -6,8 +6,8 @@ import re from typing import Optional, Tuple -from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin -from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport +from rhosocial.activerecord.backend.dialect.mixins.data_type import DataTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import DataTypeSupport from rhosocial.activerecord.backend.expression.types import ( BigIntType, BlobType, @@ -59,26 +59,27 @@ MariaDBTinyBlobType, MariaDBTinyIntType, MariaDBTinyTextType, + MariaDBUUIDType, MariaDBVarBinaryType, MariaDBYearType, ) -class MariaDBTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): +class MariaDBTypeSupportMixin(DataTypeMixin, DataTypeSupport): """MariaDB DataType formatting and parsing. - Implements ``DDLTypeSupport`` so the dialect can render ``DataType`` + Implements ``DataTypeSupport`` so the dialect can render ``DataType`` expressions to SQL strings and parse raw SQL type strings back into ``DataType`` instances. Formatting dispatches by the type instance's ``name`` through the naming-convention ``format_data_type_`` methods (see - ``DDLTypeMixin``). MariaDB-specific types carry ``mariadb_``-prefixed + ``DataTypeMixin``). MariaDB-specific types carry ``mariadb_``-prefixed names; core types render their real MariaDB SQL. """ # ------------------------------------------------------------------ - # DDLTypeSupport — formatting + # DataTypeSupport — formatting # ------------------------------------------------------------------ def _validate_fsp(self, label: str, precision: Optional[int]) -> None: @@ -162,6 +163,9 @@ def format_data_type_mariadb_binary(self, data_type: MariaDBBinaryType) -> Tuple return f"BINARY({data_type.length})", () return "BINARY", () + def format_data_type_mariadb_uuid(self, data_type: MariaDBUUIDType) -> Tuple[str, tuple]: + return self.format_data_type_mariadb_binary(data_type) + def format_data_type_mariadb_varbinary(self, data_type: MariaDBVarBinaryType) -> Tuple[str, tuple]: if data_type.length is not None: return f"VARBINARY({data_type.length})", () @@ -327,7 +331,7 @@ def format_data_type_custom(self, data_type: CustomType) -> Tuple[str, tuple]: return data_type.raw, () # ------------------------------------------------------------------ - # DDLTypeSupport — per-type support declarations + # DataTypeSupport — per-type support declarations # # MariaDB declares support for exactly the format_data_type_* family # above (1:1 correspondence contract): every type this mixin renders @@ -381,6 +385,9 @@ def supports_data_type_mariadb_year(self) -> bool: def supports_data_type_mariadb_binary(self) -> bool: return True + def supports_data_type_mariadb_uuid(self) -> bool: + return True + def supports_data_type_mariadb_varbinary(self) -> bool: return True @@ -484,7 +491,7 @@ def supports_data_type_custom(self) -> bool: return True # ------------------------------------------------------------------ - # DDLTypeSupport — parsing + # DataTypeSupport — parsing # ------------------------------------------------------------------ _MARIA_INTEGER_TYPES = re.compile( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/types.py index 5bf788ed..d3223ab3 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/types.py @@ -36,6 +36,7 @@ MariaDBTinyBlobType, MariaDBTinyIntType, MariaDBTinyTextType, + MariaDBUUIDType, MariaDBVarBinaryType, MariaDBYearType, ) @@ -65,6 +66,7 @@ "MariaDBTinyBlobType", "MariaDBTinyIntType", "MariaDBTinyTextType", + "MariaDBUUIDType", "MariaDBVarBinaryType", "MariaDBYearType", ] diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py index 8b7d73c0..eaf6aef3 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_protocol_conformance.py @@ -98,7 +98,9 @@ def get_own_protocol_methods(proto: type) -> set: dialect_protocols.AutoIncrementSupport, dialect_protocols.AlterTableModifierSupport, dialect_protocols.CollationSupport, - dialect_protocols.DDLTypeSupport, + dialect_protocols.DataTypeSupport, + dialect_protocols.UserDefinedTypeSupport, + dialect_protocols.DomainSupport, dialect_protocols.FunctionSupport, dialect_protocols.GeneratedColumnSupport, dialect_protocols.GraphSupport, @@ -174,8 +176,13 @@ def get_all_generic_protocols() -> dict: discovered = {} for name, obj in inspect.getmembers(dialect_protocols, inspect.isclass): - if Protocol in getattr(obj, "__mro__", []) and name.endswith("Support"): - discovered[name] = obj + if Protocol not in getattr(obj, "__mro__", []) or not name.endswith("Support"): + continue + if name == "DDLTypeSupport": + assert obj is dialect_protocols.DataTypeSupport + continue + assert name == obj.__name__, f"unexpected protocol alias: {name}" + discovered[name] = obj return discovered diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_domain_ddl.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_domain_ddl.py new file mode 100644 index 00000000..d57edb24 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_domain_ddl.py @@ -0,0 +1,197 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_domain_ddl.py +"""Negative TYPE and DOMAIN DDL contracts for MariaDB.""" + +import pytest + +from rhosocial.activerecord.backend.dialect import ( + DomainMixin, + DomainSupport, + UserDefinedTypeMixin, + UserDefinedTypeSupport, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import Literal +from rhosocial.activerecord.backend.expression.statements import ( + AlterDomainExpression, + AlterTypeExpression, + CreateDomainExpression, + CreateTypeExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainDefaultAction, + DropDomainExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.expression.types import ( + MariaDBEnumType, + MariaDBSetType, +) + + +class _NegativeTypeDefinition(TypeDefinition): + @property + def definition_kind(self) -> str: + return "negative" + + +class _NegativeTypeAlterAction(TypeAlterAction): + @property + def action_kind(self) -> str: + return "negative" + + +@pytest.fixture +def dialect(): + return MariaDBDialect(version=(10, 6, 0)) + + +TYPE_DOMAIN_SUPPORT_METHODS = ( + "supports_type_objects", + "supports_create_type", + "supports_alter_type", + "supports_drop_type", + "supports_create_type_if_not_exists", + "supports_create_type_or_replace", + "supports_alter_type_if_exists", + "supports_drop_type_if_exists", + "supports_multiple_type_alter_actions", + "supports_domains", + "supports_create_domain", + "supports_alter_domain", + "supports_drop_domain", + "supports_domain_default", + "supports_domain_checks", + "supports_named_domain_checks", + "supports_multiple_domain_checks", + "supports_domain_collation", + "supports_multiple_domain_alter_actions", + "supports_drop_domain_if_exists", + "supports_drop_domain_cascade", + "supports_drop_domain_restrict", + "supports_unnamed_domain_check_drop", +) + +TYPE_FORMATTERS = ( + "format_create_type_statement", + "format_alter_type_statement", + "format_drop_type_statement", + "format_type_definition", + "format_type_alter_action", +) + +DOMAIN_FORMATTERS = ( + "format_create_domain_statement", + "format_alter_domain_statement", + "format_drop_domain_statement", + "format_domain_value_expression", + "format_domain_check_constraint", + "format_domain_alter_action", +) + + +@pytest.mark.parametrize("method_name", TYPE_DOMAIN_SUPPORT_METHODS) +def test_schema_level_type_domain_support_is_false(dialect, method_name): + assert getattr(dialect, method_name)() is False + + +@pytest.mark.parametrize("nullability", tuple(DomainNullability)) +def test_domain_nullability_support_is_false(dialect, nullability): + assert dialect.supports_domain_nullability(nullability) is False + + +def test_type_and_domain_definition_support_is_empty(dialect): + assert dialect.supported_type_definitions() == () + assert dialect.supports_type_definition(_NegativeTypeDefinition) is False + assert dialect.supports_type_alter_action(_NegativeTypeAlterAction) is False + assert dialect.supports_alter_domain_action(DropDomainDefaultAction) is False + + +def test_type_and_domain_protocols_are_composed(dialect): + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, DomainSupport) + assert isinstance(dialect, UserDefinedTypeMixin) + assert isinstance(dialect, DomainMixin) + + for mixin, protocol in ( + (UserDefinedTypeMixin, UserDefinedTypeSupport), + (DomainMixin, DomainSupport), + ): + assert mixin in MariaDBDialect.__mro__ + assert protocol in MariaDBDialect.__mro__ + assert MariaDBDialect.__mro__.index(mixin) < MariaDBDialect.__mro__.index(protocol) + + +def test_core_type_and_domain_formatters_own_the_mro(): + for method_name in TYPE_FORMATTERS: + assert getattr(MariaDBDialect, method_name) is getattr( + UserDefinedTypeMixin, + method_name, + ) + for method_name in DOMAIN_FORMATTERS: + assert getattr(MariaDBDialect, method_name) is getattr( + DomainMixin, + method_name, + ) + + +def test_type_and_domain_formatters_and_expressions_fail_fast(dialect): + definition = _NegativeTypeDefinition(dialect) + alter_action = _NegativeTypeAlterAction(dialect) + domain_action = DropDomainDefaultAction(dialect) + condition = DomainValueExpression(dialect) > Literal( + dialect, + 0, + inline_literals=True, + ) + check = DomainCheckConstraint(dialect, condition) + expressions = ( + ("format_create_type_statement", CreateTypeExpression(dialect, "status", definition)), + ( + "format_alter_type_statement", + AlterTypeExpression(dialect, "status", [alter_action]), + ), + ("format_drop_type_statement", DropTypeExpression(dialect, "status")), + ("format_type_definition", definition), + ("format_type_alter_action", alter_action), + ( + "format_create_domain_statement", + CreateDomainExpression(dialect, "positive", IntegerType(dialect)), + ), + ( + "format_alter_domain_statement", + AlterDomainExpression(dialect, "positive", [domain_action]), + ), + ("format_drop_domain_statement", DropDomainExpression(dialect, "positive")), + ("format_domain_value_expression", DomainValueExpression(dialect)), + ("format_domain_check_constraint", check), + ("format_domain_alter_action", domain_action), + ) + + for method_name, expression in expressions: + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, method_name)(expression) + with pytest.raises(UnsupportedFeatureError): + expression.to_sql() + + +def test_enum_and_set_remain_column_data_types(dialect): + enum_sql, enum_params = MariaDBEnumType(dialect, ["pending", "done"]).to_sql() + set_sql, set_params = MariaDBSetType(dialect, ["read", "write"]).to_sql() + + assert enum_sql == "ENUM('pending','done')" + assert enum_params == () + assert set_sql == "SET('read','write')" + assert set_params == () + + +def test_sequence_support_remains_independent(dialect): + assert dialect.supports_sequence() is True + assert dialect.format_create_sequence_statement("job_ids") == ( + "CREATE SEQUENCE `job_ids`", + (), + ) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py index 32c6ab60..366f160c 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py @@ -19,9 +19,12 @@ equality. """ +from uuid import UUID + import pytest -from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin +from rhosocial.activerecord.base.ddl import ColumnTypeResolver +from rhosocial.activerecord.backend.dialect.mixins import DataTypeMixin from rhosocial.activerecord.backend.expression.types import ( DataType, DecimalType, @@ -30,11 +33,15 @@ TimestampType, ) from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.schema import MariaDBSchemaDiffer from rhosocial.activerecord.backend.impl.mariadb.expression.types import ( + MariaDBBinaryType, MariaDBEnumType, MariaDBIntType, MariaDBSetType, + MariaDBUUIDType, ) +from rhosocial.activerecord.backend.introspection.types import ColumnInfo @pytest.fixture @@ -74,7 +81,7 @@ def test_supports_data_types_returns_classes(self, dialect): assert issubclass(klass, DataType), name def test_inherits_mixin_scan_implementation(self, dialect): - assert MariaDBDialect.supports_data_types is DDLTypeMixin.supports_data_types + assert MariaDBDialect.supports_data_types is DataTypeMixin.supports_data_types class TestSupportsDataTypesMapping: @@ -86,6 +93,7 @@ def test_includes_mariadb_namespaced_entries(self, dialect): assert supported["mariadb_int"] is MariaDBIntType assert "mariadb_enum" in supported assert "mariadb_set" in supported + assert supported["mariadb_uuid"] is MariaDBUUIDType def test_includes_core_entries(self, dialect): supported = dialect.supports_data_types() @@ -116,18 +124,69 @@ def test_keys_disjoint_from_supported(self, dialect): supported = dialect.supports_data_types() assert not (set(suggestions) & set(supported)) - def test_uuid_suggested_as_fixed_length_binary(self, dialect): - suggestions = dialect.suggested_data_types() - assert "uuid" in suggestions - klass = suggestions["uuid"] - sql, _ = klass(dialect, 16).to_sql() + def test_uuid_suggestion_defaults_to_fixed_length_binary(self, dialect): + suggestion = dialect.suggested_data_types()["uuid"] + assert suggestion is MariaDBUUIDType + sql, params = suggestion(dialect).to_sql() assert sql == "BINARY(16)" + assert params == () + + def test_column_type_resolver_resolves_uuid_as_fixed_length_binary(self, dialect): + resolved = ColumnTypeResolver(dialect).resolve(UUID) + assert type(resolved) is MariaDBUUIDType + assert resolved.name == "mariadb_uuid" + assert resolved.length == 16 + assert resolved.dialect is dialect + assert resolved.to_sql() == ("BINARY(16)", ()) def test_enum_suggested_as_mariadb_enum(self, dialect): suggestions = dialect.suggested_data_types() assert suggestions.get("enum") is MariaDBEnumType +class TestMariaDBUUIDTypeEquivalence: + @pytest.mark.parametrize("length", [8, 32]) + def test_schema_differ_rejects_non_16_byte_binary(self, dialect, length): + differ = MariaDBSchemaDiffer() + binary_column = ColumnInfo( + name="id", + table_name="users", + ordinal_position=1, + data_type=f"BINARY({length})", + parsed_data_type=MariaDBBinaryType(dialect, length=length), + ) + uuid_column = ColumnInfo( + name="id", + table_name="users", + ordinal_position=1, + data_type="BINARY(16)", + parsed_data_type=MariaDBUUIDType(dialect), + ) + + assert not differ._columns_equivalent(binary_column, uuid_column) + assert not differ._columns_equivalent(uuid_column, binary_column) + + def test_schema_differ_accepts_16_byte_binary(self, dialect): + differ = MariaDBSchemaDiffer() + binary_column = ColumnInfo( + name="id", + table_name="users", + ordinal_position=1, + data_type="BINARY(16)", + parsed_data_type=MariaDBBinaryType(dialect, length=16), + ) + uuid_column = ColumnInfo( + name="id", + table_name="users", + ordinal_position=1, + data_type="BINARY(16)", + parsed_data_type=MariaDBUUIDType(dialect), + ) + + assert differ._columns_equivalent(binary_column, uuid_column) + assert differ._columns_equivalent(uuid_column, binary_column) + + class TestMariaDBEnumRendering: """MariaDBEnumType inherits core EnumType; rendering keeps extensions.""" From 43260dfb873614cd562eebe68fab01d01741ea30 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 10:20:48 +0800 Subject: [PATCH 31/52] test(ddl): cover table inheritance and tablespace --- .../backend/test_mariadb_ddl_improvements.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py index dc287c58..7fc00ddd 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py @@ -3,6 +3,8 @@ import pytest from unittest.mock import patch, PropertyMock +from rhosocial.activerecord.base.ddl import TableDDLDeriver +from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.expression import ( Column, TableExpression, @@ -78,3 +80,51 @@ def test_stored_function_supported(self): """MariaDB supports stored functions.""" dialect = MariaDBDialect() assert dialect.supports_stored_function() is True + + +class InheritedTable(ActiveRecord): + __table_name__ = "inherited" + + id: int + + @classmethod + def table_inherits(cls): + return ["parent_a", "parent_b"] + + +class TablespacedTable(ActiveRecord): + __table_name__ = "tablespaced" + + id: int + + @classmethod + def table_tablespace(cls): + return "ts_data" + + +class TestMariaDBTableDeclarationGating: + def test_table_declaration_defaults_are_absent(self): + class Plain(ActiveRecord): + __table_name__ = "plain_table_defaults" + + id: int + + expression = TableDDLDeriver(Plain, MariaDBDialect()).create_table() + assert expression.inherits == [] + assert expression.tablespace is None + + def test_table_inherits_is_propagated_and_rejected(self): + dialect = MariaDBDialect(version=(10, 6, 0)) + assert dialect.supports_table_inheritance() is False + expression = TableDDLDeriver(InheritedTable, dialect).create_table() + assert expression.inherits == ["parent_a", "parent_b"] + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_is_propagated_and_rejected(self): + dialect = MariaDBDialect(version=(10, 6, 0)) + assert dialect.supports_table_tablespace() is False + expression = TableDDLDeriver(TablespacedTable, dialect).create_table() + assert expression.tablespace == "ts_data" + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + expression.to_sql() From 5d5e9e5ff362c7e97ad0bb80a884a96cb589af44 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:15:23 +0800 Subject: [PATCH 32/52] refactor(mariadb): remove generated DDL references --- .../backend/impl/mariadb/expression/column.py | 2 +- .../ddl/test_derived_ddl_inline_index.py | 42 +++++++++++-------- .../backend/test_mariadb_ddl_improvements.py | 35 +++++++++++----- .../backend/test_mariadb_type_protocol.py | 16 +++---- 4 files changed, 56 insertions(+), 39 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py index d77d97d8..b4dd7504 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py @@ -23,7 +23,7 @@ from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.statements import ColumnDefinition -from rhosocial.activerecord.base.ddl.options import ColumnOptions +from rhosocial.activerecord.base import ColumnOptions if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py index bd808bce..38950b8a 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -1,9 +1,8 @@ # tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py -"""MariaDB inline-index support for ActiveRecord-derived DDL (positive path). +"""MariaDB inline-index support for direct DDL expressions. -SQLite and other SQL-standard dialects cannot inline index definitions, so the -positive rendering path is exercised here on a dialect that supports it -(``supports_inline_index()`` is True). +The DDLSource index declaration is passed directly to CreateTableExpression +on a dialect that advertises ``supports_inline_index()``. """ try: @@ -12,10 +11,11 @@ from typing_extensions import Annotated from rhosocial.activerecord.base import UseIndex -from rhosocial.activerecord.base.ddl import TableDDLDeriver -from rhosocial.activerecord.backend.expression.statements.ddl_table import ( +from rhosocial.activerecord.backend.expression import ( + ColumnDefinition, CreateTableExpression, ) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect from rhosocial.activerecord.model import ActiveRecord @@ -31,22 +31,28 @@ def mariadb_dialect(): return MariaDBDialect((10, 11, 0)) +def _create_indexed_table(): + dialect = mariadb_dialect() + return CreateTableExpression( + dialect, + Indexed.__table_name__, + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect)), + ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), + ], + indexes=Indexed.column_indexes("email"), + ) + + def test_create_table_renders_inline_index(): - deriver = TableDDLDeriver(Indexed, mariadb_dialect()) - expression = deriver.create_table() + expression = _create_indexed_table() assert [index.name for index in expression.indexes] == ["idx_indexed_email"] sql, _ = expression.to_sql() assert "UNIQUE INDEX `idx_indexed_email` (`email`)" in sql -def test_create_indexes_is_empty_when_inline_capable(): - deriver = TableDDLDeriver(Indexed, mariadb_dialect()) - assert deriver.create_indexes() == [] - - -def test_create_schema_is_a_single_statement(): - deriver = TableDDLDeriver(Indexed, mariadb_dialect()) - plan = deriver.creation_plan() - assert len(plan) == 1 - assert isinstance(plan[0], CreateTableExpression) +def test_inline_index_declaration_stays_on_create_table(): + expression = _create_indexed_table() + assert len(expression.indexes) == 1 + assert expression.indexes[0].unique is True diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py index 7fc00ddd..ea8b4c23 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py @@ -1,17 +1,13 @@ # tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_ddl_improvements.py """Tests for MariaDB DDL improvements: capability gating, UnsupportedFeatureError.""" import pytest -from unittest.mock import patch, PropertyMock -from rhosocial.activerecord.base.ddl import TableDDLDeriver from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.expression import ( - Column, - TableExpression, - QueryExpression, - CreateViewExpression, - DropViewExpression, + ColumnDefinition, + CreateTableExpression, ) +from rhosocial.activerecord.backend.expression.types import IntegerType from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -109,14 +105,27 @@ class Plain(ActiveRecord): id: int - expression = TableDDLDeriver(Plain, MariaDBDialect()).create_table() + dialect = MariaDBDialect() + expression = CreateTableExpression( + dialect, + Plain.__table_name__, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=Plain.table_inherits(), + tablespace=Plain.table_tablespace(), + ) assert expression.inherits == [] assert expression.tablespace is None def test_table_inherits_is_propagated_and_rejected(self): dialect = MariaDBDialect(version=(10, 6, 0)) assert dialect.supports_table_inheritance() is False - expression = TableDDLDeriver(InheritedTable, dialect).create_table() + expression = CreateTableExpression( + dialect, + InheritedTable.__table_name__, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=InheritedTable.table_inherits(), + tablespace=InheritedTable.table_tablespace(), + ) assert expression.inherits == ["parent_a", "parent_b"] with pytest.raises(UnsupportedFeatureError, match="INHERITS"): expression.to_sql() @@ -124,7 +133,13 @@ def test_table_inherits_is_propagated_and_rejected(self): def test_table_tablespace_is_propagated_and_rejected(self): dialect = MariaDBDialect(version=(10, 6, 0)) assert dialect.supports_table_tablespace() is False - expression = TableDDLDeriver(TablespacedTable, dialect).create_table() + expression = CreateTableExpression( + dialect, + TablespacedTable.__table_name__, + columns=[ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=TablespacedTable.table_inherits(), + tablespace=TablespacedTable.table_tablespace(), + ) assert expression.tablespace == "ts_data" with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): expression.to_sql() diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py index 366f160c..f7b09a3e 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_type_protocol.py @@ -19,12 +19,10 @@ equality. """ -from uuid import UUID - import pytest -from rhosocial.activerecord.base.ddl import ColumnTypeResolver from rhosocial.activerecord.backend.dialect.mixins import DataTypeMixin +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import ( DataType, DecimalType, @@ -131,13 +129,11 @@ def test_uuid_suggestion_defaults_to_fixed_length_binary(self, dialect): assert sql == "BINARY(16)" assert params == () - def test_column_type_resolver_resolves_uuid_as_fixed_length_binary(self, dialect): - resolved = ColumnTypeResolver(dialect).resolve(UUID) - assert type(resolved) is MariaDBUUIDType - assert resolved.name == "mariadb_uuid" - assert resolved.length == 16 - assert resolved.dialect is dialect - assert resolved.to_sql() == ("BINARY(16)", ()) + def test_uuid_type_renders_in_direct_column_expression(self, dialect): + data_type = MariaDBUUIDType(dialect) + column = ColumnDefinition(dialect, "id", data_type) + assert dialect.supports_data_type_mariadb_uuid() is True + assert column.to_sql() == ("`id` BINARY(16)", ()) def test_enum_suggested_as_mariadb_enum(self, dialect): suggestions = dialect.suggested_data_types() From 0d2ab5ade4ef6b23bc18e3af33db50b8cd0500af Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:43:02 +0800 Subject: [PATCH 33/52] docs: document repository search scope --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c36aa616..4372d848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,3 +27,9 @@ This project is under active development. Key features implemented: ## Version Control and Changelog This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. From 88c2529f8605484489ab03f903f2a9aa08a20908 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 00:34:52 +0800 Subject: [PATCH 34/52] test(ddl): cover DDLSource collection Verify MariaDB DDLSource declarations, backend types, column options, and dialect rendering. --- .../backend/ddl/test_ddl_source_collection.py | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_ddl_source_collection.py diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_ddl_source_collection.py new file mode 100644 index 00000000..52c466f9 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_ddl_source_collection.py @@ -0,0 +1,471 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_ddl_source_collection.py + +try: + from typing import Annotated, Optional +except ImportError: + from typing_extensions import Annotated, Optional + +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.expression import ( + MariaDBColumnFormat, + MariaDBColumnOptions, + MariaDBColumnStorage, + MariaDBCreateTableOptions, + MariaDBIntType, + MariaDBPartitionByRange, + MariaDBTextType, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +DIALECT = MariaDBDialect((11, 4, 0)) +SQL_TYPE = MariaDBIntType(unsigned=True) +FALLBACK_SQL_TYPE = MariaDBTextType() +SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) +DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="df_status_default", + default_value=DEFAULT_VALUE, +) +UNIQUE_CONSTRAINT = UseConstraint( + ColumnConstraintType.UNIQUE, + name="uq_status", +) +COLLATION = CollationAttribute(name="utf8mb4_general_ci") +CHARACTER_SET = CharacterSetAttribute(name="utf8mb4") +IDENTITY = IdentityAttribute(generation="ALWAYS", start=10, increment=2) +ATTRIBUTES = UseColumnAttributes(COLLATION, CHARACTER_SET, IDENTITY) +COMMENT = UseComment("current order status") +FIRST_INDEX = UseIndex( + "idx_display_label", + unique=True, + type="BTREE", + include_columns=["tenant_id"], + if_not_exists=True, + tablespace="innodb_index", +) +SECOND_INDEX = UseIndex("idx_display_label_suffix", type="BTREE") +GENERATED = GeneratedColumnExpression( + DIALECT, + Column(DIALECT, "order_id"), + GeneratedColumnType.STORED, +) +COLUMN_OPTION = MariaDBColumnOptions( + character_set="utf8mb4", + column_format=MariaDBColumnFormat.FIXED, + storage=MariaDBColumnStorage.MEMORY, + invisible=True, +) +SECOND_COLUMN_OPTION = MariaDBColumnOptions( + character_set="latin1", + column_format=MariaDBColumnFormat.DYNAMIC, + storage=MariaDBColumnStorage.DISK, + invisible=False, +) +TABLE_CONSTRAINTS = [ + TableConstraint( + DIALECT, + TableConstraintType.FOREIGN_KEY, + name="fk_order_tenant", + columns=["tenant_id"], + foreign_key_table="tenant", + foreign_key_columns=["id"], + ), + TableConstraint( + DIALECT, + TableConstraintType.UNIQUE, + name="uq_order_pair", + columns=["tenant_id", "order_id"], + ), +] +TABLE_INDEXES = [ + IndexDefinition( + DIALECT, + name="idx_table_status", + columns=["status"], + unique=True, + ), +] +TABLE_OPTIONS = [ + MariaDBCreateTableOptions( + DIALECT, + engine="InnoDB", + charset="utf8mb4", + auto_increment=100, + ), +] +STORAGE_OPTIONS = [ + StorageOptionsExpression(DIALECT, {"tablespace": "innodb_data_1"}), +] +TABLE_PARTITION = MariaDBPartitionByRange( + DIALECT, + [Column(DIALECT, "display_label")], +) +TABLE_INHERITS = ["order_audit_base"] +TABLE_TABLESPACE = "innodb_data_1" +MODEL_FIELDS = ( + "tenant_id", + "order_id", + "status", + "label", + "computed", + "option_one", + "option_many", + "optional_note", +) +BATCH_METHODS = ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", +) + + +class BackendColumnOptionsAnnotation(DDLAnnotation): + def __init__(self, *options): + self.options = options + + +class BackendColumnOptionsHandler(DDLAnnotationHandler): + annotation_types = (BackendColumnOptionsAnnotation,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(*annotation.options) + + +class DDLDeclarations: + __table_name__ = "ddl_source_orders" + __primary_key__ = ("tenant_key", "order_key") + __table_constraints__ = TABLE_CONSTRAINTS + __table_indexes__ = TABLE_INDEXES + _feature_handlers = [BackendColumnOptionsHandler] + + tenant_id: Annotated[ + int, + UseColumn("tenant_key"), + SQL_TYPE_MARKER, + ] + order_id: Annotated[int, UseColumn("order_key")] + status: Annotated[ + str, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + ATTRIBUTES, + COMMENT, + ] + label: Annotated[ + str, + UseColumn("display_label"), + FIRST_INDEX, + SECOND_INDEX, + ] + computed: Annotated[int, UseGeneratedColumn(GENERATED)] + option_one: Annotated[str, BackendColumnOptionsAnnotation(COLUMN_OPTION)] + option_many: Annotated[ + str, + BackendColumnOptionsAnnotation(COLUMN_OPTION, SECOND_COLUMN_OPTION), + ] + optional_note: Optional[str] = None + + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return TABLE_INHERITS + + @classmethod + def table_tablespace(cls): + return TABLE_TABLESPACE + + +class SyncDDLSource(DDLDeclarations, ActiveRecord): + pass + + +class AsyncDDLSource(DDLDeclarations, AsyncActiveRecord): + pass + + +SINGLE_SQL_TYPE = MariaDBIntType(unsigned=True, zerofill=True) +SINGLE_SQL_TYPE_MARKER = UseSqlType(SINGLE_SQL_TYPE) + + +class SingleKeyDeclarations: + __table_name__ = "ddl_source_single_key" + __primary_key__ = "record_pk" + + record_id: Annotated[ + int, + UseColumn("record_pk"), + SINGLE_SQL_TYPE_MARKER, + ] + + +class SyncSingleKey(SingleKeyDeclarations, ActiveRecord): + pass + + +class AsyncSingleKey(SingleKeyDeclarations, AsyncActiveRecord): + pass + + +def test_models_satisfy_ddl_source_and_preserve_field_order(): + for model in (SyncDDLSource, AsyncDDLSource): + assert isinstance(model, DDLSource) + assert model.ddl_field_names() == MODEL_FIELDS + assert model.field_python_type("tenant_id") is int + assert model.field_is_optional("optional_note") is True + assert model.field_is_optional("status") is False + + +def test_field_markers_preserve_declared_objects_and_order(): + assert SyncDDLSource.column_type("tenant_id") is SQL_TYPE_MARKER + assert SyncDDLSource.column_type("tenant_id").data_types == ( + SQL_TYPE, + FALLBACK_SQL_TYPE, + ) + assert SyncDDLSource.column_type("tenant_id").data_types[0] is SQL_TYPE + + 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, + ] + + attributes = SyncDDLSource.column_attributes("status") + assert attributes == [COLLATION, CHARACTER_SET, IDENTITY] + assert attributes[0] is COLLATION + assert attributes[1] is CHARACTER_SET + assert attributes[2] is IDENTITY + assert SyncDDLSource.column_comment("status") == COMMENT.comment + assert SyncDDLSource.generated_column("computed") is GENERATED + + indexes = SyncDDLSource.column_indexes("label") + assert [index.name for index in indexes] == [FIRST_INDEX.name, SECOND_INDEX.name] + assert all(index.columns == ["display_label"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].include_columns is FIRST_INDEX.include_columns + assert indexes[0].if_not_exists is True + assert indexes[0].tablespace == FIRST_INDEX.tablespace + + assert SyncDDLSource.column_options("option_one") is COLUMN_OPTION + options = SyncDDLSource.column_options("option_many") + assert options == [COLUMN_OPTION, SECOND_COLUMN_OPTION] + assert options[0] is COLUMN_OPTION + assert options[1] is SECOND_COLUMN_OPTION + + +def test_backend_types_and_column_options_are_collected_without_dialect_selection(): + sql_type = SyncDDLSource.column_type("tenant_id") + assert isinstance(sql_type, UseSqlType) + assert isinstance(sql_type.data_type, MariaDBIntType) + assert sql_type.data_type.unsigned is True + assert sql_type.data_types[1] is FALLBACK_SQL_TYPE + + option = SyncDDLSource.column_options("option_one") + assert isinstance(option, MariaDBColumnOptions) + assert option.character_set == "utf8mb4" + assert option.column_format is MariaDBColumnFormat.FIXED + assert option.storage is MariaDBColumnStorage.MEMORY + assert option.invisible is True + + +def test_primary_keys_use_physical_column_names_and_declaration_order(): + assert SyncDDLSource.columns_name() == { + "tenant_id": "tenant_key", + "order_id": "order_key", + "status": "status", + "label": "display_label", + "computed": "computed", + "option_one": "option_one", + "option_many": "option_many", + "optional_note": "optional_note", + } + + for field in ("tenant_id", "order_id"): + constraint_types = [ + item.constraint_type for item in SyncDDLSource.column_constraints(field) + ] + assert ColumnConstraintType.PRIMARY_KEY not in constraint_types + assert ColumnConstraintType.NOT_NULL in constraint_types + + single_types = [ + item.constraint_type for item in SyncSingleKey.column_constraints("record_id") + ] + assert single_types == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert SyncSingleKey.columns_name() == {"record_id": "record_pk"} + assert SyncSingleKey.column_type("record_id") is SINGLE_SQL_TYPE_MARKER + assert SyncSingleKey.table_constraints() == [] + + +def test_table_declarations_preserve_identity_order_and_composite_primary_key(): + constraints = SyncDDLSource.table_constraints() + 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 constraints[2].columns == ["tenant_key", "order_key"] + assert len(TABLE_CONSTRAINTS) == 2 + + indexes = SyncDDLSource.table_indexes() + assert indexes is not TABLE_INDEXES + assert indexes[0] is TABLE_INDEXES[0] + assert [index.name for index in indexes] == ["idx_table_status"] + + assert SyncDDLSource.table_options() is TABLE_OPTIONS + assert SyncDDLSource.table_options()[0] is TABLE_OPTIONS[0] + assert isinstance(TABLE_OPTIONS[0], MariaDBCreateTableOptions) + assert TABLE_OPTIONS[0].engine == "InnoDB" + assert SyncDDLSource.table_storage_options() is STORAGE_OPTIONS + assert SyncDDLSource.table_storage_options()[0] is STORAGE_OPTIONS[0] + assert SyncDDLSource.table_partition() is TABLE_PARTITION + assert SyncDDLSource.table_inherits() is TABLE_INHERITS + assert SyncDDLSource.table_tablespace() == TABLE_TABLESPACE + + +def test_collected_table_options_render_with_mariadb_dialect(): + table_options = SyncDDLSource.table_options() + expression = CreateTableExpression( + DIALECT, + "ddl_source_orders", + [ColumnDefinition(DIALECT, "id", IntegerType(DIALECT))], + table_options=table_options[0], + ) + sql, params = expression.to_sql() + assert sql.startswith("CREATE TABLE `ddl_source_orders`") + assert "ENGINE=" in sql and "InnoDB" in sql + assert "DEFAULT CHARSET=" in sql and "utf8mb4" in sql + assert "AUTO_INCREMENT=100" in sql + assert params == () + + +def test_batch_interfaces_cover_all_fields_and_preserve_selected_order(): + selected = ["label", "status", "tenant_id", "optional_note"] + for model in (SyncDDLSource, AsyncDDLSource): + for method_name in BATCH_METHODS: + method = getattr(model, method_name) + assert tuple(method()) == MODEL_FIELDS + assert tuple(method(selected)) == tuple(selected) + + assert model.columns_name(selected) == { + "label": "display_label", + "status": "status", + "tenant_id": "tenant_key", + "optional_note": "optional_note", + } + assert model.columns_type(selected)["tenant_id"] is SQL_TYPE_MARKER + assert model.columns_constraints(selected)["status"][0] is DEFAULT_CONSTRAINT.constraint + assert model.columns_attributes(selected)["status"][0] is COLLATION + assert model.columns_comment(selected)["status"] == COMMENT.comment + assert model.columns_generated(selected)["optional_note"] is None + assert model.columns_options(selected)["optional_note"] is None + + +def test_sync_and_async_sources_collect_the_same_declarations(): + assert AsyncDDLSource.column_type("tenant_id") is SyncDDLSource.column_type("tenant_id") + assert AsyncDDLSource.column_comment("status") == SyncDDLSource.column_comment("status") + assert AsyncDDLSource.generated_column("computed") is GENERATED + assert AsyncDDLSource.column_options("option_one") is COLUMN_OPTION + assert SyncDDLSource.column_options("option_many") == [ + COLUMN_OPTION, + SECOND_COLUMN_OPTION, + ] + assert AsyncDDLSource.column_options("option_many") == [ + COLUMN_OPTION, + SECOND_COLUMN_OPTION, + ] + + sync_attributes = SyncDDLSource.column_attributes("status") + async_attributes = AsyncDDLSource.column_attributes("status") + assert all(left is right for left, right in zip(sync_attributes, async_attributes)) + + sync_constraints = SyncDDLSource.column_constraints("status") + 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 + ] + + sync_indexes = SyncDDLSource.column_indexes("label") + async_indexes = AsyncDDLSource.column_indexes("label") + assert [index.name for index in async_indexes] == [index.name for index in sync_indexes] + assert [index.columns for index in async_indexes] == [ + index.columns for index in sync_indexes + ] + + assert AsyncDDLSource.table_options() is TABLE_OPTIONS + assert AsyncDDLSource.table_storage_options() is STORAGE_OPTIONS + assert AsyncDDLSource.table_partition() is TABLE_PARTITION + assert AsyncDDLSource.table_inherits() is TABLE_INHERITS + assert AsyncDDLSource.table_tablespace() == TABLE_TABLESPACE + assert AsyncDDLSource.table_indexes()[0] is TABLE_INDEXES[0] + + sync_table_constraints = SyncDDLSource.table_constraints() + 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"] + + for model in (SyncSingleKey, AsyncSingleKey): + assert [item.constraint_type for item in model.column_constraints("record_id")] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert model.column_name("record_id") == "record_pk" + assert model.column_type("record_id") is SINGLE_SQL_TYPE_MARKER From cb4d1e53497f7ad13b7ffb3167e1ee33ce2370dd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 13:18:25 +0800 Subject: [PATCH 35/52] fix(mariadb): use single MARIADB_VERSION_BOUNDARIES source dialect.py rebound MARIADB_VERSION_BOUNDARIES to a local 19-key copy that omitted CHECK_CONSTRAINT, shadowing the 20-key dict imported from .mixins. Every other consumer imports the mixins copy, so the two had silently diverged. The visible symptom was supports_check_constraint() raising KeyError: 'CHECK_CONSTRAINT' on every call. It is reached from the live DDL path in mixins/ddl_table.py when formatting a table CHECK constraint, and the same incomplete dict was what __init__ re-exported as the public MARIADB_VERSION_BOUNDARIES. Drop the duplicate so dialect.py, the public export and every mixin resolve to the single dict in mixins/backend.py. --- .../backend/impl/mariadb/dialect.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 40e99c9d..257bcbb8 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -178,28 +178,6 @@ ReturningClause, ) -MARIADB_VERSION_BOUNDARIES = { - 'WINDOW_FUNCTIONS': (10, 2, 0), - 'CTE': (10, 2, 0), - 'JSON_FUNCTIONS': (10, 2, 3), - 'JSON_ARROWS': (10, 2, 7), - 'INTERSECT_EXCEPT': (10, 3, 0), - 'SEQUENCE': (10, 3, 0), - 'SYSTEM_VERSIONING': (10, 3, 0), - 'RETURNING': (10, 5, 0), - 'EXPLAIN_FORMAT': (10, 6, 0), - 'INSTEAD_OF_TRIGGER': (10, 4, 0), - 'SKIP_LOCKED': (10, 3, 0), - 'RENAME_TABLE_IF_EXISTS': (10, 5, 0), - 'RENAME_TABLE_WAIT': (10, 3, 0), - 'TRUNCATE_WAIT': (10, 3, 0), - 'ROUTINE_OR_REPLACE': (10, 1, 3), - 'ROUTINE_IF_NOT_EXISTS': (10, 1, 3), - 'GRANT_OR_REPLACE': (10, 1, 4), - 'GRANT_IF_EXISTS': (10, 1, 4), - 'DENY': (13, 1, 0), -} - _SUGGESTION_GRAPH_MATCH = "MariaDB does not support graph MATCH clause." _SUGGESTION_ORDERED_SET_AGG = "MariaDB does not support ordered-set aggregate functions (WITHIN GROUP)." _SUGGESTION_QUALIFY = "MariaDB does not support QUALIFY clause. Use a subquery or CTE instead." From b786335f97d4efc364138bcff259bc1a0ac05f66 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 13:19:31 +0800 Subject: [PATCH 36/52] ci(mariadb): cover MariaDB 12.3, 13.0 and 13.1-rc 12.3 (LTS) and 13.0 are both GA, so they move into the gating matrix alongside the existing 12.x rows. 13.1 is still a release candidate, so it goes to the non-gating experimental job, matching how 13.0-rc was handled before it reached GA. Two defects made the previous 12.3 / 13.0-rc rows worthless: - The experimental job exported PYTHONPATH=src, missing tests, so the shared testsuite could not import providers.registry and collection failed outright. continue-on-error swallowed it, so the job reported green while testing nothing. Use src:tests, matching the main job. - Both rows were pinned to Python 3.15.0-rc.2, so MariaDB version coverage was blocked on an unrelated interpreter release. The new 13.1-rc row runs on released 3.14 instead, and the stale 13.0-rc image tag is replaced with 13.0 now that it is GA. --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0f16e278..eb0c7da7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,8 @@ jobs: - { python-version: '3.14', mariadb-version: '11.8', health-cmd: 'mariadb-admin ping -h 127.0.0.1 -u root -ppassword' } - { python-version: '3.14t', mariadb-version: '12.0', health-cmd: 'mariadb-admin ping -h 127.0.0.1 -u root -ppassword' } - { python-version: '3.14t', mariadb-version: '12.2', health-cmd: 'mariadb-admin ping -h 127.0.0.1 -u root -ppassword' } + - { python-version: '3.14t', mariadb-version: '12.3', health-cmd: 'mariadb-admin ping -h 127.0.0.1 -u root -ppassword' } + - { python-version: '3.14t', mariadb-version: '13.0', health-cmd: 'mariadb-admin ping -h 127.0.0.1 -u root -ppassword' } services: mariadb: @@ -123,7 +125,8 @@ jobs: matrix: include: - { python-version: '3.15.0-rc.2', mariadb-version: '12.3', freethreaded: false } - - { python-version: '3.15.0-rc.2', mariadb-version: '13.0-rc', freethreaded: true } + - { python-version: '3.15.0-rc.2', mariadb-version: '13.0', freethreaded: true } + - { python-version: '3.14', mariadb-version: '13.1-rc', freethreaded: false } services: mariadb: @@ -178,5 +181,5 @@ jobs: - name: Run tests run: | - export PYTHONPATH=src + export PYTHONPATH=src:tests python -m pytest tests/ --ignore=tests/rhosocial/activerecord/backend/mariadb --tb=short From 15273f75033ad0d5e7e62df023c9ec95b346a63a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:30:38 +0800 Subject: [PATCH 37/52] fix(mariadb): resolve the utf8 charset alias to an explicit spelling MDEV-30041 stopped old_mode from setting UTF8_IS_UTF8MB3 by default in MariaDB 13.1, so "utf8" resolves to utf8mb3 on every release up to 13.0 and to utf8mb4 from 13.1. The same DDL therefore produced a different column charset depending on the server: CREATE TABLE t (s VARCHAR(50)) CHARACTER SET utf8 <= 13.0 -> utf8mb3_uca1400_ai_ci (4-byte chars rejected, 1366) >= 13.1 -> utf8mb4_uca1400_ai_ci (4-byte chars accepted) utf8 was absent from _CHARSET_MIN_VERSIONS, so the version guard was skipped and validate_charset_name accepted it on every version while supported_charsets advertised it. Resolve it instead, to utf8mb3 -- the meaning it always had before 13.1. Resolving forward to utf8mb4 would silently widen the column on every pre-13.1 server, trading one drift for another. Below MariaDB 10.6 the explicit utf8mb3 name is not documented, so the alias passes through unchanged there; it is unambiguous at that point. Verified across all 15 CI versions: the emitted character set is now utf8mb3 (or the equivalent utf8 spelling) everywhere, and 4-byte characters are rejected consistently rather than only on <= 13.0. Also fill _COLLATION_MIN_VERSIONS, which was empty and made the version branch in supported_collations()/validate_collation_by_name() unreachable, and correct the utf8mb4 threshold note: 5.5.3 is a real MariaDB release, so that gate is meaningful for a 10.2+ backend. The prior design note in .claude/plan/2026-09-13 asserted "MariaDB uses utf8 (not utf8mb3)"; that assumption is exactly what 13.1 invalidates, so it is corrected there too. --- .../impl/mariadb/mixins/charset_collation.py | 97 +++++++++++++++++-- 1 file changed, 87 insertions(+), 10 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py index 5c12b360..75d30886 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py @@ -63,6 +63,15 @@ class MariaDBCharset(Enum): TIS620 = "tis620" UCS2 = "ucs2" UJIS = "ujis" + # ``utf8`` is a server-side *alias* whose meaning changed in MariaDB + # 13.1 (MDEV-30041): it resolved to ``utf8mb3`` on every release up to + # 13.0 and resolves to ``utf8mb4`` from 13.1, because ``old_mode`` no + # longer sets ``UTF8_IS_UTF8MB3`` by default. Emitting a bare ``utf8`` + # therefore produces a *different column charset* depending on the + # server version, so ``validate_charset_name`` resolves it to + # ``utf8mb3`` -- the meaning it always had before 13.1 -- and the same + # DDL yields the same schema on every supported server. + # Prefer ``UTF8MB4`` (or ``UTF8MB3``) to be explicit. UTF8 = "utf8" UTF8MB3 = "utf8mb3" UTF8MB4 = "utf8mb4" @@ -106,13 +115,48 @@ class MariaDBCollation(Enum): # Introduced-in server versions for version-gated values. _CHARSET_MIN_VERSIONS: dict = { + # MariaDB 5.5.3 is a real MariaDB release and is where utf8mb4 landed, + # so this threshold is meaningful for a backend that supports 10.2+. "utf8mb4": (5, 5, 3), "utf8mb3": (10, 6, 0), + "utf8": (10, 0, 0), } _ENGINE_MIN_VERSIONS: dict = { "SEQUENCE": (10, 3, 0), } -_COLLATION_MIN_VERSIONS: dict = {} +_COLLATION_MIN_VERSIONS: dict = { + # The ``utf8_general_ci`` / ``utf8_unicode_ci`` names are spelled without + # the ``mb3`` marker but belong to the 3-byte family. Spell them out in + # full (``utf8mb3_general_ci``) to be unambiguous. + "utf8_general_ci": (10, 6, 0), + "utf8_unicode_ci": (10, 6, 0), + "utf8mb4_bin": (10, 6, 0), + "utf8mb4_general_ci": (10, 6, 0), + "utf8mb4_unicode_ci": (10, 6, 0), +} + +#: Charsets that are ambiguous server-side aliases, mapped to the explicit +#: spelling they should be emitted as, together with the minimum server +#: version at which that spelling is valid. +#: +#: The point of resolving them is that generated DDL is *identical* on every +#: supported server version. ``utf8`` is the only such alias: it meant +#: ``utf8mb3`` on every MariaDB release up to 13.0, then became an alias for +#: ``utf8mb4`` in 13.1 (MDEV-30041 dropped the default +#: ``old_mode=UTF8_IS_UTF8MB3`` flag). Passing ``utf8`` through therefore +#: makes the column charset depend on the server version. +#: +#: It resolves to ``utf8mb3`` -- the historical meaning -- rather than to +#: ``utf8mb4``. Resolving forward instead would silently widen the column on +#: every pre-13.1 server, trading one silent drift for another. Callers who +#: want 4-byte storage must ask for ``utf8mb4`` explicitly. +#: +#: Below MariaDB 10.6 the explicit ``utf8mb3`` name is not documented, so the +#: alias is passed through unchanged there. That is still unambiguous: on +#: every version before 13.1 ``utf8`` can only mean ``utf8mb3``. +_CHARSET_ALIASES: dict = { + "utf8": ((10, 6, 0), "utf8mb3"), +} class MariaDBCharsetCollationMixin: @@ -140,15 +184,39 @@ def validate_collation_name(self, expr: "CollateExpression") -> str: # --- charset --------------------------------------------------------- def supported_charsets(self) -> FrozenSet[str]: - """Charset names available on the configured server version.""" + """Charset names available on the configured server version. + + An alias is reported under the explicit spelling it resolves to, so + the returned set never contains a name whose meaning depends on the + server version. Where the explicit spelling is not yet valid the alias + itself is reported, since it is unambiguous at that point. + """ version = self._mariadb_capability_version() - return frozenset( - member.value - for member in MariaDBCharset - if version is None - or _CHARSET_MIN_VERSIONS.get(member.value) is None - or version >= _CHARSET_MIN_VERSIONS[member.value] - ) + names = set() + for member in MariaDBCharset: + if version is not None: + min_version = _CHARSET_MIN_VERSIONS.get(member.value) + if min_version is not None and version < min_version: + continue + names.add(self._resolve_charset_alias(member.value, version)) + return frozenset(names) + + @staticmethod + def _resolve_charset_alias( + name: str, version: Optional[Tuple[int, ...]] = None + ) -> str: + """Map an ambiguous charset alias to the spelling to emit. + + Falls back to the alias itself when the server predates the explicit + spelling, or when the version is unknown. + """ + entry = _CHARSET_ALIASES.get(name) + if entry is None: + return name + valid_from, replacement = entry + if version is None or version < valid_from: + return name + return replacement def supports_charset(self, name: object) -> bool: try: @@ -158,6 +226,15 @@ def supports_charset(self, name: object) -> bool: return True def validate_charset_name(self, name: object) -> str: + """Validate a charset and return the spelling to emit in SQL. + + The ambiguous alias ``utf8`` is resolved to ``utf8mb3`` from MariaDB + 10.6, which is what it meant on every release before 13.1. Emitting + the alias itself on 13.1+ would make the resulting column charset + depend on the server version; resolving it means one DDL statement + produces one schema on every supported server. Ask for ``utf8mb4`` to + get 4-byte storage. + """ version = self._mariadb_capability_version() if isinstance(name, MariaDBCharset): normalized = name.value @@ -173,7 +250,7 @@ def validate_charset_name(self, name: object) -> str: if version is not None and min_version is not None and version < min_version: formatted = ".".join(str(part) for part in min_version[:2]) raise ValueError(f"MariaDB character set requires MariaDB {formatted}+: {name!r}") - return normalized + return self._resolve_charset_alias(normalized, version) # --- collation ------------------------------------------------------- def supported_collations(self, charset: Optional[str] = None) -> FrozenSet[str]: From f4fb0126eddbb486b4a8534ff1947abb38ceaf3e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:31:36 +0800 Subject: [PATCH 38/52] refactor(mariadb): document MARIADB_VERSION_BOUNDARIES as the single source Add the boundaries the 12.3 / 13.0 / 13.1 work needs, grouped by release, and record the invariant that broke in cb4d1e5: a module that rebinds this name shadows the imported table and diverges silently, turning a missing key into a KeyError at call time rather than at import time. --- .../backend/impl/mariadb/mixins/backend.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/backend.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/backend.py index 20471fcd..5a2d70c7 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/backend.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/backend.py @@ -17,10 +17,16 @@ from ..dialect import MariaDBDialect from rhosocial.activerecord.backend.options import ExecutionOptions +#: Single source of truth for "feature X first appeared in MariaDB ". +#: +#: Every version gate in this backend must read a key from here. Do NOT +#: rebind this name in another module: a shadowing local copy silently +#: diverges from this table and turns gates into ``KeyError`` at runtime. MARIADB_VERSION_BOUNDARIES = { 'WINDOW_FUNCTIONS': (10, 2, 0), 'JSON_FUNCTIONS': (10, 2, 3), 'JSON_ARROWS': (10, 2, 7), + 'JSON_TABLE': (10, 6, 0), 'CTE': (10, 2, 0), 'INTERSECT_EXCEPT': (10, 3, 0), 'SEQUENCE': (10, 3, 0), @@ -36,8 +42,15 @@ 'ROUTINE_IF_NOT_EXISTS': (10, 1, 3), 'GRANT_OR_REPLACE': (10, 1, 4), 'GRANT_IF_EXISTS': (10, 1, 4), - 'DENY': (13, 1, 0), 'CHECK_CONSTRAINT': (10, 2, 1), + # --- 12.3 (LTS) --- + 'IS_JSON_PREDICATE': (12, 3, 0), + 'TO_DATE_FUNCTION': (12, 3, 0), + # --- 13.0 (GA) --- + 'RETURNING_UPDATE': (13, 0, 0), + # --- 13.1 (RC) --- + 'DENY': (13, 1, 0), + 'JSON_ARROW_NATIVE': (13, 1, 0), } From 1cc96ae30c5f19782d39a775baf3df2690dc6276 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:32:25 +0800 Subject: [PATCH 39/52] fix(mariadb): version-gate the reserved word set The list was a static frozenset taken from the MariaDB 11.0 docs, with no version coupling. MariaDB adds reserved words in otherwise-minor releases, so it went stale: SELECT to_date FROM ... -- parses on 12.2, errno 1064 on 12.3+ SELECT conversion FROM ... -- parses on 12.2, errno 1064 on 12.3+ `to_date` in particular is a very common column name. Both are now added at 12.3, and `deny` at 13.1 for the DENY privilege statement. MariaDBDialect also overrides the version setter so the word set follows a late assignment: backend.introspect_and_adapt() re-assigns version after construction, which would otherwise leave quoting decisions made for the old version. This narrows the blast radius rather than being the root fix -- it only affects the unquoted path, and every identifier field defaults to need_quote=True, so default DDL was never exposed. --- .../backend/impl/mariadb/dialect.py | 17 ++++++- .../backend/impl/mariadb/reserved_words.py | 50 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py index 257bcbb8..2f6d1592 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/dialect.py @@ -145,7 +145,7 @@ MariaDBGeneratedColumnMixin, MariaDBFunctionMixin, ) -from .reserved_words import MARIADB_RESERVED_WORDS +from .reserved_words import reserved_words_for_version from .show.dialect import MariaDBShowDialectMixin # Import MariaDB-specific protocols @@ -346,10 +346,23 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): features can be used. """ super().__init__() - self._reserved_words = MARIADB_RESERVED_WORDS + self._reserved_words = reserved_words_for_version(version) if version is not None: self.version = version + @property + def version(self) -> Tuple[int, int, int]: + return SQLDialectBase.version.fget(self) + + @version.setter + def version(self, value: Tuple[int, int, int]) -> None: + # Keep the reserved-word set in step with the version: MariaDB added + # `conversion` / `to_date` in 12.3 and `deny` in 13.1, and a dialect + # re-adapted after construction would otherwise keep quoting + # decisions made for the old version. + SQLDialectBase.version.fset(self, value) + self._reserved_words = reserved_words_for_version(value) + def get_parameter_placeholder(self, position: int = 0) -> str: """MariaDB uses positional placeholders like :0, :1 or %s.""" return "%s" diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/reserved_words.py b/src/rhosocial/activerecord/backend/impl/mariadb/reserved_words.py index 58dbec6f..7fe57dfa 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/reserved_words.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/reserved_words.py @@ -3,6 +3,15 @@ MariaDB reserved words list. Source: MariaDB 11.0 Documentation (https://mariadb.com/kb/en/reserved-words/) + +MariaDB adds reserved words in otherwise-minor releases, so a single frozen +set goes stale. :data:`MARIADB_RESERVED_WORDS_ADDED_BY_VERSION` carries the +later additions and :func:`reserved_words_for_version` folds them in according +to the server version the dialect was built for. + +Note that this list is advisory: identifiers are quoted by default, and the +check only fires on the unquoted path. Always prefer an explicitly quoted +identifier for user-supplied names. """ MARIADB_RESERVED_WORDS = frozenset({ @@ -47,3 +56,44 @@ "varchar", "varcharacter", "varying", "virtual", "when", "where", "while", "with", "write", "xor", "year_month", "zerofill", }) + +#: Reserved words added after the MariaDB 11.0 baseline captured by +#: :data:`MARIADB_RESERVED_WORDS`. Maps minimum server version -> words that +#: became reserved in that release. +#: +#: Verified against live servers: ``SELECT to_date ...`` and +#: ``SELECT conversion ...`` parse on 12.2 and fail with errno 1064 on +#: 12.3, 13.0 and 13.1. +MARIADB_RESERVED_WORDS_ADDED_BY_VERSION = { + (12, 3, 0): frozenset({ + "conversion", # MDEV: 12.3 "Incompatible Changes" + "to_date", # very common as a column name -- quoting is essential + }), + (13, 1, 0): frozenset({ + "deny", # DENY ... ON ... TO ... privilege statement + }), +} + +#: Cached unions keyed by version so the fold is not recomputed per lookup. +_RESERVED_WORDS_CACHE = {} + + +def reserved_words_for_version(version=None) -> frozenset: + """Return the reserved words in effect for a MariaDB ``version``. + + ``version`` is a comparable tuple such as ``(13, 1, 0)``. ``None`` (the + server version is unknown) returns the baseline set unchanged, which + degrades to "assume the oldest documented set" rather than guessing. + """ + if version is None: + return MARIADB_RESERVED_WORDS + cached = _RESERVED_WORDS_CACHE.get(version) + if cached is not None: + return cached + words = set(MARIADB_RESERVED_WORDS) + for introduced_in, added in MARIADB_RESERVED_WORDS_ADDED_BY_VERSION.items(): + if version >= introduced_in: + words.update(added) + result = frozenset(words) + _RESERVED_WORDS_CACHE[version] = result + return result From 1c3dba6599f88577d11b1d324abfb4d8d63c06cd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:33:21 +0800 Subject: [PATCH 40/52] fix(mariadb): reject YEAR(2), which MariaDB 13.0 removed CREATE TABLE t (y YEAR(2)) succeeds on 12.2 and 12.3 and fails with errno 1064 on 13.0 and 13.1. YEAR(2) was deprecated in 2012; the only way back is old_mode=2_DIGIT_YEAR, which is itself deprecated. MariaDBYearType accepted any integer for display_width despite its docstring saying YEAR[(4)], and parse_type() round-tripped a server-reported YEAR(2) straight back into an emitted YEAR(2) -- so a schema read from a pre-13.0 server produced DDL that 13.0 rejects. Constrain the width to {4, None} at construction, and have parse_type() degrade a reported YEAR(2) to bare YEAR with a DeprecationWarning rather than failing introspection or silently rewriting the schema. --- .../backend/impl/mariadb/expression/types.py | 19 ++++++++++++++++++- .../backend/impl/mariadb/mixins/types.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py index a643c9e7..c779e81b 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py @@ -202,14 +202,31 @@ def _type_params(self) -> tuple: # --------------------------------------------------------------------------- class MariaDBYearType(DataType): - """MariaDB ``YEAR[(4)]`` — year type.""" + """MariaDB ``YEAR[(4)]`` — year type. + + ``display_width`` accepts only ``None`` (bare ``YEAR``) or ``4``. + ``YEAR(2)`` was deprecated in 2012 and is **rejected outright by + MariaDB 13.0+** (verified: ``CREATE TABLE t (y YEAR(2))`` succeeds on + 12.2/12.3 and fails with errno 1064 on 13.0/13.1). Accepting it here + would let a schema that builds on an older server fail to deploy on a + newer one, so it is rejected at construction time regardless of dialect. + """ name = "mariadb_year" display_width: Optional[int] = None + #: Widths this backend is willing to emit. + ALLOWED_DISPLAY_WIDTHS = (4,) + def __init__(self, dialect=None, display_width: Optional[int] = None): super().__init__(dialect) + if display_width is not None and display_width not in self.ALLOWED_DISPLAY_WIDTHS: + allowed = ", ".join(str(w) for w in self.ALLOWED_DISPLAY_WIDTHS) + raise ValueError( + f"YEAR display_width must be None or {allowed}; got {display_width!r}. " + "YEAR(2) was deprecated in 2012 and is not accepted by MariaDB 13.0+." + ) self.display_width = display_width def _type_params(self) -> tuple: diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py index 7ad8e5cd..a3e09dfe 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py @@ -4,6 +4,7 @@ from __future__ import annotations import re +import warnings from typing import Optional, Tuple from rhosocial.activerecord.backend.dialect.mixins.data_type import DataTypeMixin @@ -658,6 +659,21 @@ def parse_type(self, raw: str) -> DataType: nums = re.findall(r"\d+", stripped) display_width = int(nums[0]) if nums else None from ..expression.types import MariaDBYearType + if display_width not in MariaDBYearType.ALLOWED_DISPLAY_WIDTHS: + # A pre-13.0 server can still report YEAR(2). We cannot + # round-trip it (MariaDBYearType rejects it, and + # MariaDB 13.0+ would refuse the DDL), so degrade to bare + # YEAR -- which is the 4-byte storage YEAR always used -- + # and say so rather than failing introspection or + # silently rewriting the schema. + warnings.warn( + f"Server reported YEAR({display_width}); MariaDB 13.0+ rejects " + "YEAR(2). Reading it as YEAR. Use old_mode=2_DIGIT_YEAR (itself " + "deprecated) only if the 2-digit truncation is intentional.", + DeprecationWarning, + stacklevel=2, + ) + display_width = None return MariaDBYearType(self, display_width) if upper.startswith("DATE"): if upper.strip() == "DATE": From 336364de589af7bfa1dbb4538bd51b8f51a5ebfc Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:34:09 +0800 Subject: [PATCH 41/52] feat(mariadb): support UPDATE ... RETURNING on MariaDB 13.0+ MDEV-5092 added the clause in 13.0. Verified against live servers: it is a syntax error on 12.2 and 12.3, and works on 13.0 and 13.1, where it also accepts expressions and OLD_VALUE() so pre- and post-update values come back in one round trip. Only single-table UPDATE is accepted. Both supports_returning_for_update implementations returned False unconditionally and never consulted the version, so the capability was affirmatively denied even where the server supports it. Gate it on a RETURNING_UPDATE boundary, and add the un-suffixed supports_returning_update alias -- the core format_update_statement consults that name, so without it the clause is rejected regardless of what the _for_ variant reports. SQLAlchemy, Prisma and Drizzle all document INSERT/DELETE RETURNING for MariaDB but not UPDATE. --- .../backend/impl/mariadb/mixins/dml.py | 6 +++--- .../backend/impl/mariadb/mixins/returning.py | 21 +++++++++++++++---- .../backend/impl/mariadb/protocols/dml.py | 5 +++-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py index 788ecce0..cf379865 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py @@ -94,12 +94,12 @@ def supports_returning_for_replace(self) -> bool: def supports_returning_for_update(self) -> bool: """Whether RETURNING is supported for UPDATE. - MariaDB does NOT support RETURNING for UPDATE. + MariaDB 13.0+ supports RETURNING for single-table UPDATE. Returns: - False. + True if MariaDB version >= 13.0.0. """ - return False + return self.version >= MARIADB_VERSION_BOUNDARIES['RETURNING_UPDATE'] def format_insert_statement( self, diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/returning.py index 599347fb..3e483ae7 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/returning.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/returning.py @@ -92,13 +92,17 @@ def supports_returning_for_replace(self) -> bool: def supports_returning_for_update(self) -> bool: """Whether RETURNING is supported for UPDATE. - Note: MariaDB does NOT support RETURNING for UPDATE statements. - Only PostgreSQL supports this. + MariaDB 13.0 added ``UPDATE ... RETURNING`` (MDEV-5092). It accepts + expressions and the ``OLD_VALUE()`` function, so pre- and post-update + values can be read in a single round trip. + + Only **single-table** UPDATE is supported; MariaDB rejects the clause + on a multi-table UPDATE. Returns: - False (MariaDB does not support RETURNING for UPDATE). + True if MariaDB version >= 13.0.0. """ - return False + return self.version >= MARIADB_VERSION_BOUNDARIES['RETURNING_UPDATE'] # Protocol aliases (without _for_) expected by ReturningSupport def supports_returning_insert(self) -> bool: @@ -109,6 +113,15 @@ def supports_returning_delete(self) -> bool: """Whether RETURNING clause is supported for DELETE.""" return self.version >= MARIADB_VERSION_BOUNDARIES['RETURNING'] + def supports_returning_update(self) -> bool: + """Whether RETURNING clause is supported for UPDATE. + + The core ``format_update_statement`` consults this alias (not the + ``_for_`` variant), so both must agree or the clause is rejected + regardless of what ``supports_returning_for_update`` reports. + """ + return self.version >= MARIADB_VERSION_BOUNDARIES['RETURNING_UPDATE'] + def format_returning_clause( self, columns: Optional[List[str]] = None, diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/dml.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/dml.py index 8f5fe0f8..6641be18 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/dml.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/dml.py @@ -89,9 +89,10 @@ def supports_returning_for_replace(self) -> bool: ... def supports_returning_for_update(self) -> bool: - """Whether RETURNING is supported for UPDATE. + """Whether RETURNING is supported for UPDATE (MariaDB 13.0+). - MariaDB does NOT support RETURNING for UPDATE. + Single-table UPDATE only; MariaDB rejects the clause on a + multi-table UPDATE. """ ... From 0da1fc272b743dea9886d1c3f9831d372a5f2beb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:35:06 +0800 Subject: [PATCH 42/52] fix(mariadb): implement JSON_TABLE, which MariaDB has supported since 10.6 supports_json_table() returned False unconditionally and format_json_table_expression() always raised, on the stated grounds that MariaDB lacks JSON_TABLE. That is wrong: verified working on 12.2, 12.3, 13.0 and 13.1. Meanwhile the backend's own MariaDBJSONTableExpression docstring said "Supported in MariaDB 10.6+", examples/query shipped a JSON_TABLE walkthrough, and a test asserted the feature was absent -- so the contradiction was locked in from three directions at once. Gate on a 10.6 boundary and render the statement. The MariaDB node carries FOR ORDINALITY, EXISTS, ON EMPTY / ON ERROR and NESTED PATH, none of which the core formatter handles, so it is rendered here rather than delegated. Both node shapes are accepted: the MariaDB node's `json_doc` is a JSON document literal while the core node's `json_column` names a column. Column types are allow-listed rather than escaped, since a type is not data. Everything else is identifier-quoted or quote-escaped. A live server caught that MariaDB's grammar has no alias on NESTED PATH, so one is dropped. The security tests in test_mariadb_dialect_security.py asserted UnsupportedFeatureError, which meant the escaping was never actually executed. They now assert the escaping. --- .../impl/mariadb/expression/json_table.py | 13 +- .../backend/impl/mariadb/mixins/json.py | 248 +++++++++-- .../backend/impl/mariadb/protocols/json.py | 25 +- .../backend/test_mariadb_dialect_security.py | 418 ++++++++---------- 4 files changed, 425 insertions(+), 279 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/expression/json_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/json_table.py index d319ca75..2481d3e9 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/json_table.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/json_table.py @@ -58,11 +58,18 @@ class MariaDBJSONTableExpression(BaseExpression): Supported in MariaDB 10.6+. Attributes: - json_doc: JSON document string or expression + json_doc: JSON document **string literal**, or a + :class:`~...expression.bases.BaseExpression` when the document + lives in a column. A plain ``str`` is always emitted as a quoted + JSON literal, so ``'{"a": 1}'`` is a document and ``"j"`` is the + two-character JSON text ``j`` -- pass ``Column(dialect, "j")`` to + reference a column. path: JSON path expression for the root array/object columns: List of column definitions - nested_paths: Optional list of NESTED PATH definitions - alias: Table alias + nested_paths: Optional list of NESTED PATH definitions. MariaDB's + grammar does not allow an alias on a NESTED PATH, so + ``NestedPath.alias`` is not rendered. + alias: Table alias. Required by MariaDB when the result is joined. """ def __init__( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/json.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/json.py index 53abff5d..e68e110c 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/json.py @@ -11,7 +11,6 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.advanced_functions import JSONExpression - from rhosocial.activerecord.backend.expression.query_sources import JSONTableExpression class MariaDBJSONMixin: @@ -85,31 +84,48 @@ def supports_json_function(self, function_name: str) -> bool: return function_name.lower() in json_functions def supports_json_arrow_operators(self) -> bool: - """Whether JSON arrow operators (-> and ->>) are supported. - - MariaDB does NOT support JSON arrow operators. - It uses JSON_EXTRACT/JSON_UNQUOTE/JSON_VALUE functions instead. + """Whether native JSON arrow operators (-> and ->>) are usable. + + MariaDB has no native ``->`` / ``->>`` on any release this backend + supports in a form that can be emitted unconditionally. The 13.1 + ``column -> path`` syntax additionally only applies to a real JSON + *column*: ``CAST(x AS JSON) -> '$'`` is a syntax error even on 13.1, + so it is not a safe default for the general expression path. + + This backend therefore renders ``->`` / ``->>`` through + ``JSON_EXTRACT`` / ``JSON_UNQUOTE(JSON_EXTRACT(...))``, which is + equivalent and works on every supported version. Use + :meth:`supports_json_arrow_operators_native` to ask whether the + 13.1 native form is available for a column operand. """ return False + def supports_json_arrow_operators_native(self) -> bool: + """Whether MariaDB 13.1's native ``->`` / ``->>`` is available. + + Only valid for a real JSON column operand, not for an arbitrary + expression such as ``CAST(... AS JSON)``. + """ + return self.version >= MARIADB_VERSION_BOUNDARIES['JSON_ARROW_NATIVE'] + def supports_json_arrows(self) -> bool: - """Deprecated alias for supports_json_arrow_operators.""" + """Alias for :meth:`supports_json_arrow_operators`.""" return self.supports_json_arrow_operators() def get_json_access_operator(self) -> str: """Get JSON access operator. - MariaDB does not support arrow operators, so returns empty string. + Empty because arrows are rendered as function calls; see + :meth:`supports_json_arrow_operators`. """ return "" def format_json_function_expression(self, expr: "JSONExpression") -> Tuple[str, tuple]: """Format JSON expression using function-based equivalents. - MariaDB does NOT support -> and ->> operators. - Instead, it uses function-based alternatives: - - `->` → JSON_EXTRACT(column, path) - - `->>` → JSON_UNQUOTE(JSON_EXTRACT(column, path)) + Arrows are rendered as the equivalent function calls: + - `->` -> JSON_EXTRACT(column, path) + - `->>` -> JSON_UNQUOTE(JSON_EXTRACT(column, path)) """ if isinstance(expr.column, bases.BaseExpression): col_sql, col_params = expr.column.to_sql() @@ -134,34 +150,214 @@ def format_json_function_expression(self, expr: "JSONExpression") -> Tuple[str, return sql, params def supports_json_table(self) -> bool: - """Whether JSON_TABLE function is supported. + """Whether JSON_TABLE is supported. - MariaDB does NOT support JSON_TABLE function. - Use json_table() stored procedure or other alternatives. + MariaDB supports ``JSON_TABLE`` from 10.6 (verified against live + servers: the statement works unchanged on 12.2, 12.3, 13.0 and + 13.1). Returns: - False. + True if MariaDB version >= 10.6.0. """ - return False + return self.version >= MARIADB_VERSION_BOUNDARIES['JSON_TABLE'] def format_json_table_expression( - self, expr: "JSONTableExpression" + self, expr ) -> Tuple[str, tuple]: - """Format JSON_TABLE expression. + """Format a JSON_TABLE expression. + + Two node shapes are accepted, because both are reachable on a + ``MariaDBDialect``: - MariaDB does NOT support JSON_TABLE function. - Always raises UnsupportedFeatureError. + - :class:`MariaDBJSONTableExpression` -- carries ``json_doc`` plus + ``FOR ORDINALITY``, ``EXISTS``, ``ON EMPTY`` / ``ON ERROR`` and + ``NESTED PATH`` support. + - the core :class:`JSONTableExpression` -- carries ``json_column`` and + columns typed by a ``data_type`` expression. + + Every string that reaches the SQL goes through identifier quoting or + single-quote escaping; column types are validated against a strict + allow-list, because unlike a path they are not data and are never + escaped, only accepted or rejected. Raises: - UnsupportedFeatureError: MariaDB does not support JSON_TABLE. + UnsupportedFeatureError: If the server predates JSON_TABLE, a + column type is not a plain SQL type, or an ``error_handling`` + mode is not recognised. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + if not self.supports_json_table(): + boundary = MARIADB_VERSION_BOUNDARIES['JSON_TABLE'] + required = ".".join(str(part) for part in boundary[:2]) + raise UnsupportedFeatureError( + self.name, + "JSON_TABLE function", + f"JSON_TABLE requires MariaDB {required} or later.", + ) + + columns = list(getattr(expr, "columns", None) or []) + if not self._validate_data_type_name(columns): + raise UnsupportedFeatureError( + self.name, + "JSON_TABLE column type", + "JSON_TABLE column type must be a plain SQL type name.", + ) + + # The two nodes mean different things by a plain string: + # MariaDB ``json_doc`` -> a JSON document literal + # core ``json_column`` -> the name of a column holding one + # An expression is used as-is in both cases. + if hasattr(expr, "json_doc"): + doc_sql, params = self._json_document_sql(expr.json_doc) + else: + doc_sql, params = self._json_column_sql( + getattr(expr, "json_column", None) + ) + path_sql = self._escape_sql_string(expr.path) + + column_defs = [self._format_json_table_column(col) for col in columns] + for nested in (getattr(expr, "nested_paths", None) or []): + nested_cols = ", ".join( + self._format_json_table_column(col) for col in nested.columns + ) + # MariaDB's grammar is `NESTED PATH 'p' COLUMNS (...)` with no + # alias; verified against 12.2 / 12.3 / 13.0 / 13.1, where adding + # one is a syntax error. + column_defs.append( + f"NESTED PATH '{self._escape_sql_string(nested.path)}' " + f"COLUMNS({nested_cols})" + ) + + columns_sql = f"COLUMNS({', '.join(column_defs)})" + sql = f"JSON_TABLE({doc_sql}, '{path_sql}' {columns_sql})" + if expr.alias: + sql += f" AS {self.format_identifier(expr.alias)}" + return sql, params + + @staticmethod + def _json_document_sql(json_doc) -> Tuple[str, tuple]: + """Render a JSON document argument. + + A :class:`~...expression.bases.BaseExpression` is rendered as SQL; a + plain string is a JSON document and is emitted as a quoted literal, so + a caller cannot smuggle SQL in through it. + """ + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + from rhosocial.activerecord.backend.expression import bases + + if isinstance(json_doc, bases.BaseExpression): + return json_doc.to_sql() + if isinstance(json_doc, str): + return f"'{SQLDialectBase._escape_sql_string(json_doc)}'", () + raise TypeError( + f"json_doc must be a str or expression, got {type(json_doc).__name__}" + ) + + def _json_column_sql(self, json_column) -> Tuple[str, tuple]: + """Render a column reference holding the JSON document. + + A plain string names a column and is quoted as an identifier. """ - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - "MariaDB", - "JSON_TABLE", - "MariaDB does not support JSON_TABLE. Use json_table() stored procedure or other alternatives." + from rhosocial.activerecord.backend.expression import bases + + if isinstance(json_column, bases.BaseExpression): + return json_column.to_sql() + if isinstance(json_column, str): + return self.format_identifier(json_column), () + raise TypeError( + f"json_column must be a str or expression, got {type(json_column).__name__}" ) + def _format_json_table_column(self, col) -> str: + """Render one entry of the ``COLUMNS(...)`` list. + + Handles both column shapes: the MariaDB column's ``type`` is a plain + SQL type string, while the core column's ``data_type`` is a DataType + expression that renders itself. + """ + name_sql = self.format_identifier(col.name) + if getattr(col, "ordinality", False): + # FOR ORDINALITY takes no type and no path. + return f"{name_sql} FOR ORDINALITY" + + type_sql = self._json_table_column_type(col) + path = getattr(col, "path", None) + path_sql = self._escape_sql_string(path) if path is not None else None + + if getattr(col, "exists", False) and path_sql is not None: + # EXISTS is spelled between the type and PATH. + head = f"{name_sql} {type_sql} EXISTS PATH '{path_sql}'" + elif path_sql is not None: + head = f"{name_sql} {type_sql} PATH '{path_sql}'" + else: + head = f"{name_sql} {type_sql}" + + handling = getattr(col, "error_handling", None) + if handling: + token = str(handling).strip().upper() + if token == "DEFAULT": + default = getattr(col, "default_value", None) + literal = ( + f"'{self._escape_sql_string(str(default))}'" + if default is not None else "NULL" + ) + head += f" DEFAULT {literal} ON EMPTY" + head += f" DEFAULT {literal} ON ERROR" + elif token in ("NULL", "TRUE", "FALSE"): + head += f" {token} ON EMPTY" + head += f" {token} ON ERROR" + else: + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + raise UnsupportedFeatureError( + self.name, + "JSON_TABLE error handling", + f"Unsupported JSON_TABLE error handling {handling!r}; " + "expected NULL, TRUE, FALSE or DEFAULT.", + ) + return head + + def _json_table_column_type(self, col) -> str: + """The rendered SQL type for one JSON_TABLE column.""" + from rhosocial.activerecord.backend.expression import bases + + declared = getattr(col, "type", None) + if declared is not None: + return str(declared) + data_type = getattr(col, "data_type", None) + if data_type is None: + return "VARCHAR(255)" + if isinstance(data_type, bases.BaseExpression): + rendered, _ = data_type.to_sql() + return rendered + return str(data_type) + + @staticmethod + def _validate_data_type_name(columns) -> bool: + """Every column type must be a plain SQL type name. + + Types are interpolated into DDL, so they are validated rather than + escaped: an allow-list of characters cannot be subverted by quoting. + A ``data_type`` expression renders itself from a vetted DataType node + and is exempt. + """ + import re + + pattern = re.compile(r"^[A-Za-z][A-Za-z0-9_ ]*(\(\s*[0-9,\s]*\s*\))?(\s*\[\])?$") + for col in columns or (): + declared = getattr(col, "type", None) + if declared is None: + # Either a core DataType expression (self-rendering) or + # unspecified, which defaults at format time. + continue + if not pattern.match(str(declared).strip()): + return False + return True + def format_json_extract(self, expr) -> Tuple[str, tuple]: """Format a :class:`MariaDBJSONExtractExpression` node.""" sql, params = self._format_json_extract_parts(expr.json_column, expr.path) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/json.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/json.py index d91d942a..e268000f 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/json.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/json.py @@ -65,11 +65,7 @@ def supports_json_merge_patch(self) -> bool: ... def supports_json_table(self) -> bool: - """Whether JSON_TABLE is supported. - - MariaDB does NOT support JSON_TABLE (unlike MySQL 8.0.4+). - Always returns False for MariaDB. - """ + """Whether JSON_TABLE is supported (MariaDB 10.6+).""" ... def supports_json_function(self, function_name: str) -> bool: @@ -84,20 +80,27 @@ def supports_json_function(self, function_name: str) -> bool: ... def supports_json_arrow_operators(self) -> bool: - """Whether JSON arrow operators (-> and ->>) are supported. + """Whether arrows are emitted natively as ``->`` / ``->>``. - MariaDB does NOT support JSON arrow operators. + False for MariaDB on every version: arrows are rendered as the + equivalent ``JSON_EXTRACT`` calls, which behave identically + everywhere. See :meth:`supports_json_arrow_operators_native` for the + 13.1 column-only form. """ ... - def supports_json_arrows(self) -> bool: - """Deprecated singular alias for :meth:`supports_json_arrow_operators`. + def supports_json_arrow_operators_native(self) -> bool: + """Whether MariaDB 13.1's native ``->`` / ``->>`` is available. - Kept for backwards compatibility with callers that predate the - pluralised rename. MariaDB does NOT support JSON arrow operators. + Only valid for a real JSON column operand, not for an arbitrary + expression such as ``CAST(x AS JSON)``. """ ... + def supports_json_arrows(self) -> bool: + """Alias for :meth:`supports_json_arrow_operators`.""" + ... + def format_json_extract( self, expr: Any, diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py index 7469a9ef..39db068c 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py @@ -29,6 +29,17 @@ def dialect(): return MariaDBDialect() +@pytest.fixture +def json_table_dialect(): + """An adapted dialect on a version that supports JSON_TABLE (10.6+). + + The plain ``dialect`` fixture is unadapted, so any version gate raises + DialectNotAdaptedException before it can return. The JSON_TABLE tests + need a concrete version. + """ + return MariaDBDialect(version=(10, 6, 0)) + + def test_mysql_format_column_definition_default_string_escaping(dialect): """Test DEFAULT constraint string is escaped in MySQL.""" constraint = ColumnConstraint( @@ -96,179 +107,121 @@ def test_mysql_format_column_definition_data_type_rejects_injection(dialect): data_type="VARCHAR(255); DROP TABLE users--", ) +def test_mysql_json_table_path_escaping(json_table_dialect): + """A single quote in the JSON_TABLE path is escaped, not injected. -def test_mysql_json_table_path_escaping(dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB (path escaping).""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + This test used to assert UnsupportedFeatureError on the belief that + MariaDB lacks JSON_TABLE, which meant the escaping was never checked. + """ + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, json_doc='{"key": "value"}', path="$.key's", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.name", - ), - ], + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.name")], ) - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) + sql, _ = dialect.format_json_table_expression(expr) + assert "$.key''s" in sql + assert "key's" not in sql -def test_mysql_json_table_column_path_escaping(dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB (column path escaping).""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +def test_mysql_json_table_column_path_escaping(json_table_dialect): + """A single quote in a column path is escaped.""" + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.field's", - ), - ], + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.field's")], ) - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) + sql, _ = dialect.format_json_table_expression(expr) + assert "$.field''s" in sql + assert "field's" not in sql -def test_mysql_json_table_alias_quoted(dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB (alias quoting).""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +def test_mysql_json_table_json_doc_escaped(json_table_dialect): + """A json_doc string is emitted as a quoted literal, not raw SQL.""" + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, - json_doc='{"data": "test"}', - path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - ), - ], - alias="test_alias", + json_doc="""{"a": "b'; DROP TABLE users--"}""", + path="$.a", + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.col1")], ) - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) - - -def test_mysql_format_cast_expression_valid(dialect): - """Test that CAST expression validates target_type.""" - from rhosocial.activerecord.backend.expression.core import Column, CastExpression - expr = CastExpression(dialect, Column(dialect, "column"), "INTEGER") - sql, params = expr.to_sql() - assert "INTEGER" in sql - - -def test_mysql_format_cast_expression_rejects_injection(dialect): - """Test that malicious target_type is rejected.""" - from rhosocial.activerecord.backend.expression.core import Column, CastExpression - with pytest.raises(ValueError, match="Invalid target type"): - CastExpression(dialect, Column(dialect, "column"), "INTEGER; DROP TABLE users--").to_sql() - - -class TestMySQLEscapeSqlStringBackslash: - """Tests for MySQL _escape_sql_string with backslash escaping.""" + sql, _ = dialect.format_json_table_expression(expr) + assert "DROP TABLE users--" in sql + # The quote is doubled, so the literal cannot be terminated early. + assert "b''; DROP TABLE users--" in sql - def test_escape_sql_string_backslash_escaped(self, dialect): - """Test backslash is properly escaped in MySQL.""" - result = dialect._escape_sql_string("test\\value") - assert "\\\\" in result - def test_escape_sql_string_backslash_and_quote(self, dialect): - """Test both backslash and single quote are escaped.""" - result = dialect._escape_sql_string("test\\'value") - assert "\\\\" in result - assert "''" in result - - def test_escape_sql_string_preserves_others(self, dialect): - """Test other characters are preserved.""" - result = dialect._escape_sql_string('test"double"value') - assert "test\"double\"value" in result +def test_mysql_json_table_alias_quoted(json_table_dialect): + """The JSON_TABLE alias is emitted as a quoted identifier.""" + dialect = json_table_dialect + expr = MariaDBJSONTableExpression( + dialect=dialect, + json_doc='{"data": "test"}', + path="$.data", + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.col1")], + alias="test_alias", + ) + sql, _ = dialect.format_json_table_expression(expr) + assert sql.endswith("AS `test_alias`") -class TestMySQLJSONTableTypeValidation: - """Tests for JSON_TABLE col.type validation. - MariaDB does NOT support JSON_TABLE, so format_json_table_expression - always raises UnsupportedFeatureError. - """ +class TestMySQLJSONTableVersionGate: + """JSON_TABLE requires MariaDB 10.6.""" - def test_json_table_raises_unsupported_feature(self, dialect): - """Test format_json_table_expression raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + def test_rejected_below_10_6(self): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + old = MariaDBDialect(version=(10, 5, 9)) expr = MariaDBJSONTableExpression( - dialect=dialect, + dialect=old, json_doc='{"data": "test"}', path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - ), - ], + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.col1")], ) + with pytest.raises(UnsupportedFeatureError, match="10.6"): + old.format_json_table_expression(expr) - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) - - def test_json_table_invalid_data_type_rejected(self, dialect): - """Test invalid data type in JSON_TABLE column raises UnsupportedFeatureError.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + @pytest.mark.parametrize("version", [(10, 6, 0), (12, 3, 3), (13, 0, 2), (13, 1, 1)]) + def test_accepted_from_10_6(self, version): + dialect = MariaDBDialect(version=version) expr = MariaDBJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255); DROP TABLE users--", - path="$.col1", - ), - ], + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.col1")], ) + sql, params = dialect.format_json_table_expression(expr) + assert sql.startswith("JSON_TABLE(") + assert params == () - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) +class TestMySQLJSONTableTypeValidation: + """JSON_TABLE column types are interpolated, so they are allow-listed.""" -class TestMySQLJSONTableErrorHandling: - """Tests for JSON_TABLE col.error_handling validation. - - MariaDB does NOT support JSON_TABLE, so format_json_table_expression - always raises UnsupportedFeatureError. - """ - - def test_json_table_valid_error_handling_null(self, dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + def test_plain_type_accepted(self, json_table_dialect): + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - error_handling="NULL", - ), - ], + columns=[JSONTableColumn(name="col1", type="VARCHAR(255)", path="$.col1")], ) + sql, _ = dialect.format_json_table_expression(expr) + assert "`col1` VARCHAR(255) PATH '$.col1'" in sql - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) - - def test_json_table_valid_error_handling_error(self, dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + def test_injected_type_rejected(self, json_table_dialect): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', @@ -276,20 +229,20 @@ def test_json_table_valid_error_handling_error(self, dialect): columns=[ JSONTableColumn( name="col1", - type="VARCHAR(255)", + type="VARCHAR(255); DROP TABLE users--", path="$.col1", - error_handling="ERROR", ), ], ) - with pytest.raises(UnsupportedFeatureError): dialect.format_json_table_expression(expr) - def test_json_table_valid_error_handling_default(self, dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - expr = MariaDBJSONTableExpression( + +class TestMySQLJSONTableErrorHandling: + """JSON_TABLE ON EMPTY / ON ERROR rendering and validation.""" + + def _expr(self, dialect, error_handling, default_value=None): + return MariaDBJSONTableExpression( dialect=dialect, json_doc='{"data": "test"}', path="$.data", @@ -298,138 +251,125 @@ def test_json_table_valid_error_handling_default(self, dialect): name="col1", type="VARCHAR(255)", path="$.col1", - error_handling="DEFAULT", - default_value="fallback", + error_handling=error_handling, + default_value=default_value, ), ], ) - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) + def test_null_on_empty_and_error(self, json_table_dialect): + sql, _ = json_table_dialect.format_json_table_expression( + self._expr(json_table_dialect, "NULL") + ) + assert "PATH '$.col1' NULL ON EMPTY NULL ON ERROR" in sql - def test_json_table_invalid_error_handling_rejected(self, dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - expr = MariaDBJSONTableExpression( - dialect=dialect, - json_doc='{"data": "test"}', - path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - error_handling="INVALID", - ), - ], + def test_default_on_empty_and_error(self, json_table_dialect): + sql, _ = json_table_dialect.format_json_table_expression( + self._expr(json_table_dialect, "DEFAULT", default_value="fallback") + ) + assert ( + "PATH '$.col1' DEFAULT 'fallback' ON EMPTY DEFAULT 'fallback' ON ERROR" + in sql ) - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) + def test_default_value_is_escaped(self, json_table_dialect): + sql, _ = json_table_dialect.format_json_table_expression( + self._expr(json_table_dialect, "DEFAULT", default_value="a'; DROP TABLE t--") + ) + assert "a''; DROP TABLE t--" in sql + def test_unknown_error_handling_rejected(self, json_table_dialect): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + with pytest.raises(UnsupportedFeatureError, match="error handling"): + json_table_dialect.format_json_table_expression( + self._expr(json_table_dialect, "INVALID") + ) -class TestMySQLJSONTableDefaultValueEscaping: - """Tests for JSON_TABLE col.default_value escaping. - MariaDB does NOT support JSON_TABLE, so format_json_table_expression - always raises UnsupportedFeatureError. - """ +class TestMySQLJSONTableColumnOptions: + """FOR ORDINALITY, EXISTS and NESTED PATH rendering.""" - def test_json_table_default_value_escaped(self, dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + def test_ordinality_takes_no_type_or_path(self, json_table_dialect): + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, - json_doc='{"data": "test"}', - path="$.data", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - error_handling="DEFAULT", - default_value="it's broken", - ), - ], + json_doc="doc", + path="$[*]", + columns=[JSONTableColumn(name="row_n", type="INT", path="$.n", ordinality=True)], ) + sql, _ = dialect.format_json_table_expression(expr) + assert "`row_n` FOR ORDINALITY" in sql - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) - - -class TestMySQLJSONTableJsonDocSecurity: - """Tests for JSON_TABLE json_doc type validation. - - MariaDB does NOT support JSON_TABLE, so format_json_table_expression - always raises UnsupportedFeatureError. - """ - - def test_json_table_json_doc_string(self, dialect): - """Test JSON_TABLE raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + def test_exists_sits_between_type_and_path(self, json_table_dialect): + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, - json_doc='{"key": "value"}', - path="$.key", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - ), - ], + json_doc="doc", + path="$[*]", + columns=[JSONTableColumn(name="has", type="INT", path="$.h", exists=True)], ) + sql, _ = dialect.format_json_table_expression(expr) + assert "`has` INT EXISTS PATH '$.h'" in sql - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) - - def test_json_table_json_doc_to_sql_protocol_rejected_by_validate(self, dialect): - """Test json_doc as ToSQLProtocol raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from rhosocial.activerecord.backend.expression.bases import BaseExpression - - class MockExpression(BaseExpression): - def __init__(self): - self._sql = "JSON_COLUMN" - self._params = () - - def to_sql(self): - return self._sql, self._params - + def test_nested_path_rendered(self, json_table_dialect): + from rhosocial.activerecord.backend.impl.mariadb.expression.json_table import ( + NestedPath, + ) + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, - json_doc=MockExpression(), - path="$.key", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - ), - ], + json_doc="doc", + path="$[*]", + columns=[JSONTableColumn(name="a", type="INT", path="$.a")], + nested_paths=[NestedPath("$.n", [JSONTableColumn(name="b", type="INT", path="$.b")], alias="np")], + ) + sql, _ = dialect.format_json_table_expression(expr) + # MariaDB's grammar has no alias on NESTED PATH; emitting one is a + # syntax error, so it is dropped even when the node carries an alias. + assert "NESTED PATH '$.n' COLUMNS(`b` INT PATH '$.b')" in sql + assert "np" not in sql + + def test_column_name_quoted(self, json_table_dialect): + dialect = json_table_dialect + expr = MariaDBJSONTableExpression( + dialect=dialect, + json_doc="doc", + path="$[*]", + columns=[JSONTableColumn(name="select", type="INT", path="$.s")], ) + sql, _ = dialect.format_json_table_expression(expr) + assert "`select`" in sql - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) - def test_json_table_json_doc_invalid_type_rejected(self, dialect): - """Test json_doc with invalid type raises UnsupportedFeatureError on MariaDB.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +class TestMySQLJSONTableJsonDocTypeValidation: + """json_doc must be a string literal or an expression.""" + + def test_expression_accepted(self, json_table_dialect): + from rhosocial.activerecord.backend.expression import TableExpression + dialect = json_table_dialect expr = MariaDBJSONTableExpression( dialect=dialect, - json_doc={"key": "value"}, - path="$.key", - columns=[ - JSONTableColumn( - name="col1", - type="VARCHAR(255)", - path="$.col1", - ), - ], + json_doc=TableExpression(dialect, "t", alias="j"), + path="$[*]", + columns=[JSONTableColumn(name="a", type="INT", path="$.a")], ) - - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) + sql, params = dialect.format_json_table_expression(expr) + assert sql.startswith("JSON_TABLE(") + assert "`t`" in sql or "t" in sql + + @pytest.mark.parametrize("bad", [123, None, ["x"], {"a": 1}]) + def test_non_string_non_expression_rejected(self, json_table_dialect, bad): + with pytest.raises(TypeError): + json_table_dialect.format_json_table_expression( + MariaDBJSONTableExpression( + dialect=json_table_dialect, + json_doc=bad, + path="$[*]", + columns=[JSONTableColumn(name="a", type="INT", path="$.a")], + ) + ) class TestMySQLCreateTableCommentEscaping: From 584da560ce5d8256265e276d25b1b092fb9659a4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:35:54 +0800 Subject: [PATCH 43/52] feat(mariadb): let a function be gated by a removal, not just an addition _MARIADB_FUNCTION_VERSIONS had an upper-bound branch, but all 32 entries carried max_version=None, so nothing could ever be marked as removed in a later release. Give DES_ENCRYPT / DES_DECRYPT the 13.0 upper bound they were removed in, and add supports_function_name() so the table is reachable for an arbitrary name rather than only for the functions happening to be constructible here. The backend does not emit DES_* today, so this changes no current behaviour; it exists so a future caller cannot generate SQL that 13.0+ rejects without noticing. --- .../backend/impl/mariadb/mixins/function.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/function.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/function.py index 00438239..7dcc8b39 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/function.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/function.py @@ -69,6 +69,11 @@ def supports_function_parameters(self) -> bool: "bit_get_bit": (None, None), "bit_shift_left": (None, None), "bit_shift_right": (None, None), + # Removed in MariaDB 13.0, which dropped the deprecated DES logic. + # Declared with an upper bound so the gate is exercised and a future + # caller cannot silently emit SQL that 13.0+ rejects. + "des_encrypt": (None, (13, 0, 0)), + "des_decrypt": (None, (13, 0, 0)), } def supports_functions(self) -> Dict[str, bool]: @@ -102,6 +107,19 @@ def supports_functions(self) -> Dict[str, bool]: return result + def supports_function_name(self, name: str) -> bool: + """Whether ``name`` exists as a SQL function on the configured version. + + Unlike :meth:`supports_functions`, which only reports what this + backend can construct, this answers the question for an arbitrary + name -- including functions that are *not* constructible here but + whose availability is version-bounded, such as the DES functions + removed in MariaDB 13.0. + """ + if not isinstance(name, str): + return False + return self._is_mariadb_function_supported(name.strip().lower()) + def _is_mariadb_function_supported(self, func_name: str) -> bool: """Check if a MariaDB-specific function is supported based on version.""" version_range = self._MARIADB_FUNCTION_VERSIONS.get(func_name) From 78a21dcd0219657a56b7862e5fbc176c2ece40e6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:36:52 +0800 Subject: [PATCH 44/52] refactor(mariadb): drop dead version plumbing, share the system schema list MariaDBDatetimeAdapter stored a server version and never read it; ShowFunctionality computed an invisible-column flag and never read it either (the SHOW CREATE TABLE parser matches INVISIBLE unconditionally, which is correct because a server too old to emit the keyword never produces it). Collapse an if/else whose two arms were identical. The system-schema exclusion list was an inline 4-tuple repeated at four call sites, so a new MariaDB release adding one would need four edits and a missed site would silently leak the schema into user-facing listings. Hoist it to SYSTEM_SCHEMAS / SYSTEM_SCHEMAS_SQL_PREDICATE. It stays a fixed list rather than a discovery query: information_schema .SCHEMATA carries no flag separating a system schema from a user schema, so the only reliable discriminator is a round trip against the server's bootstrap state. --- .../backend/impl/mariadb/adapters.py | 8 +++--- .../backend/impl/mariadb/async_backend.py | 6 ++--- .../introspection/status_introspector.py | 27 +++++++++---------- .../impl/mariadb/mixins/introspection.py | 24 ++++++++++++----- .../impl/mariadb/show/functionality.py | 2 -- 5 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/adapters.py b/src/rhosocial/activerecord/backend/impl/mariadb/adapters.py index cb1255cb..aa1abe03 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/adapters.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/adapters.py @@ -251,13 +251,13 @@ class MariaDBDatetimeAdapter(SQLTypeAdapter): """ def __init__(self, mariadb_version: Optional[Tuple[int, int, int]] = None): - """Initialize adapter with MariaDB version info. + """Initialize the adapter. Args: - mariadb_version: MariaDB server version tuple (major, minor, patch). - If None, defaults to (10, 5, 0). + mariadb_version: Accepted for call-site compatibility. Behaviour + does not currently vary by server version, so the value is + not retained. """ - self._mariadb_version = mariadb_version or (10, 5, 0) @property def supported_types(self) -> Dict[Type, List[Any]]: diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/async_backend.py b/src/rhosocial/activerecord/backend/impl/mariadb/async_backend.py index 93952796..a4db17a6 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/async_backend.py @@ -239,10 +239,8 @@ async def get_server_version(self) -> tuple: version_row = await cursor.fetchone() version_str = version_row[0] if version_row else "10.5.0" - if '-MariaDB' in version_str: - version_clean = version_str.split('-')[0] - else: - version_clean = version_str.split('-')[0] + # Strip the vendor suffix: "13.1.1-MariaDB-ubu2604" -> "13.1.1". + version_clean = version_str.split('-')[0] version_parts = version_clean.split('.') diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/mariadb/introspection/status_introspector.py index f6a625bf..f0a01705 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/introspection/status_introspector.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional +from ..mixins.introspection import SYSTEM_SCHEMAS_SQL_PREDICATE from rhosocial.activerecord.backend.introspection.status import ( StatusItem, StatusCategory, @@ -201,13 +202,12 @@ def get_connection_info(self) -> ConnectionInfo: def get_storage_info(self) -> StorageInfo: try: - rows = self._execute_query_dict(""" - SELECT SUM(data_length) AS data_bytes, - SUM(index_length) AS index_bytes - FROM information_schema.TABLES - WHERE table_schema NOT IN - ('information_schema', 'performance_schema', 'mysql', 'sys') - """) + rows = self._execute_query_dict( + "SELECT SUM(data_length) AS data_bytes,\n" + " SUM(index_length) AS index_bytes\n" + "FROM information_schema.TABLES\n" + f"WHERE {SYSTEM_SCHEMAS_SQL_PREDICATE}" + ) if rows: data = int(rows[0].get("data_bytes") or 0) index = int(rows[0].get("index_bytes") or 0) @@ -395,13 +395,12 @@ async def get_connection_info(self) -> ConnectionInfo: async def get_storage_info(self) -> StorageInfo: try: - rows = await self._execute_query_dict(""" - SELECT SUM(data_length) AS data_bytes, - SUM(index_length) AS index_bytes - FROM information_schema.TABLES - WHERE table_schema NOT IN - ('information_schema', 'performance_schema', 'mysql', 'sys') - """) + rows = await self._execute_query_dict( + "SELECT SUM(data_length) AS data_bytes,\n" + " SUM(index_length) AS index_bytes\n" + "FROM information_schema.TABLES\n" + f"WHERE {SYSTEM_SCHEMAS_SQL_PREDICATE}" + ) if rows: data = int(rows[0].get("data_bytes") or 0) index = int(rows[0].get("index_bytes") or 0) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/introspection.py index 931459f3..611f08b1 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/introspection.py @@ -19,6 +19,22 @@ TriggerListExpression, ) +#: Schemas owned by the server rather than the user. Excluded from user-facing +#: listings and from disk-usage totals. +#: +#: This is a fixed list rather than a discovery query because +#: ``information_schema.SCHEMATA`` carries no flag distinguishing a system +#: schema from a user schema -- the only reliable discriminator would be a +#: round trip to compare against the server's own bootstrap state. Keeping it +#: in one place (rather than inline at each call site) means a new MariaDB +#: release that adds a system schema needs a change here and nowhere else. +SYSTEM_SCHEMAS = ("information_schema", "performance_schema", "mysql", "sys") + +#: SQL fragment excluding :data:`SYSTEM_SCHEMAS` from a ``table_schema`` filter. +SYSTEM_SCHEMAS_SQL_PREDICATE = ( + "TABLE_SCHEMA NOT IN ('" + "', '".join(SYSTEM_SCHEMAS) + "')" +) + class MariaDBIntrospectionMixin: """MariaDB introspection capability declaration and query formatting. @@ -135,9 +151,7 @@ def format_table_list_query( sql_params: list = [schema] if not include_system: - conditions.append( - "TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')" - ) + conditions.append(SYSTEM_SCHEMAS_SQL_PREDICATE) if not include_views: conditions.append("TABLE_TYPE = 'BASE TABLE'") if table_type: @@ -259,9 +273,7 @@ def format_view_list_query( sql_params: list = [schema] if not include_system: - conditions.append( - "TABLE_SCHEMA NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys')" - ) + conditions.append(SYSTEM_SCHEMAS_SQL_PREDICATE) where = " AND ".join(conditions) sql = ( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/show/functionality.py b/src/rhosocial/activerecord/backend/impl/mariadb/show/functionality.py index 5d3ad9a7..35c4e14a 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/show/functionality.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/show/functionality.py @@ -59,8 +59,6 @@ def __init__(self, backend: "MariaDBBackend", version: Optional[Tuple[int, ...]] self._backend = backend self._version = version self.dialect = backend.dialect - # MariaDB 10.3+ supports invisible columns - self._supports_invisible_columns = version >= (10, 3, 0) if version else True # ========== Parsing Helper Methods ========== From c9a083fd4a57459e5ceeb9e2a47800567aedb940 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:37:39 +0800 Subject: [PATCH 45/52] docs(mariadb): rewrite the compatibility table and state a support policy The table topped out at 10.6 and recommended 10.6+, while CI has covered 11.x and 12.x for some time. It also contradicted the code in six rows: SKIP LOCKED was documented at 10.6 but gated at 10.3, JSON at 10.2 but gated at 10.2.3, and GROUPING SETS, INET6 and DEFAULT expressions had no implementation at all. Correct the thresholds, drop the unimplemented rows, and list what is deliberately not supported so callers are not left guessing. Add a support policy (12.3 LTS recommended, 13.0 opt-in, 13.1 not for production) and describe what happens on an unrecognised newer version, which was previously undefined. Feature gates resolve to "enabled" when the server is newer than an introduction boundary, so new syntax is assumed available; removals are declared per feature; and behaviour changes that are not new syntax are handled by resolving the ambiguity rather than branching on version, as the utf8 alias now does. Document the utf8 alias change, the JSON arrow situation, and the version-aware reserved words, since all three are user-visible. --- README.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index fc034a9b..40982f1d 100644 --- a/README.md +++ b/README.md @@ -155,23 +155,111 @@ User.insert_or_update( ## MariaDB Version Compatibility +Server versions this backend is tested against in CI: **10.2, 10.3, 10.4, 10.5, +10.6, 10.11, 11.4, 11.7, 11.8, 12.0, 12.2, 12.3, 13.0** (gating) and +**13.1-rc** (experimental, non-gating). + | Feature | Min Version | Notes | |---------|-------------|-------| | Basic operations | 10.2+ | Core functionality | -| JSON | 10.2+ | `JSON` alias for LONGTEXT, JSON functions | +| CHECK constraints | 10.2.1+ | Enforced | | Window functions | 10.2+ | ROW_NUMBER, RANK, etc. | -| CTEs | 10.2+ | WITH clauses | +| CTEs | 10.2+ | WITH clauses, incl. recursive | | Full-text search | 10.2+ | MATCH ... AGAINST | | Generated columns | 10.2+ | Virtual/Stored columns | -| CHECK constraints | 10.2+ | Enforced | -| DEFAULT expressions | 10.2+ | Expression defaults | -| System versioning | 10.3+ | Temporal tables | -| Sequences | 10.3+ | `CREATE SEQUENCE` | -| GROUPING SETS | 10.3+ | Advanced grouping | -| INET6 type | 10.5+ | Native IPv6 | -| SKIP LOCKED | 10.6+ | Row-level locking control | - -**Recommended**: MariaDB 10.6+ for optimal feature support. +| Spatial types | 10.2+ | GEOMETRY, POINT, spatial index | +| `ST_AsGeoJSON()` | 10.2.3+ | | +| JSON | 10.2.3+ | `JSON` alias for LONGTEXT, JSON functions | +| INTERSECT / EXCEPT | 10.3+ | Set operations | +| Sequences | 10.3+ | `CREATE SEQUENCE`, `NEXT VALUE FOR` | +| System versioning | 10.3+ | `WITH SYSTEM VERSIONING` | +| Invisible columns | 10.3+ | | +| SKIP LOCKED / NOWAIT | 10.3+ | Row-level locking control | +| `TRUNCATE ... WAIT` | 10.3+ | | +| Triggers (FOLLOWS/PRECEDES) | 10.2.3+ | | +| INSTEAD OF triggers | 10.4+ | View triggers | +| RETURNING (INSERT/DELETE/REPLACE) | 10.5+ | | +| `RENAME TABLE ... WAIT` | 10.3+ | | +| `EXPLAIN FORMAT=JSON` / `ANALYZE` | 10.6+ | | +| `ANALYZE TABLE ... PERSISTENT` | 10.5+ | | +| `JSON_TABLE` | 10.6+ | | +| `IS JSON` predicate | 12.3+ | | +| `TO_DATE()` | 12.3+ | | +| `UPDATE ... RETURNING` | 13.0+ | Single-table UPDATE only; pairs with `OLD_VALUE()` | +| `DENY ... ON ... TO ...` | 13.1+ | Negative grants | +| Native `->` / `->>` | 13.1+ | JSON **column** operands only; see JSON note below | + +Not implemented: `QUALIFY`, ordered-set aggregates (`WITHIN GROUP`), +`LATERAL VIEW`, `ROW()` composite types, `MBR*` functions, `SOUNDEX`, +`PARTITION BY SYSTEM_TIME`, packages, domains, `SET PATH`, +`SET SESSION AUTHORIZATION`, cursors on prepared statements, optimizer hints +(`/*+ ... */`), the `VECTOR` type, and the `XML` type. + +### JSON arrow operators + +`->` and `->>` are always rendered as the equivalent +`JSON_EXTRACT(...)` / `JSON_UNQUOTE(JSON_EXTRACT(...))` calls, which behave +identically on every supported version. MariaDB 13.1 added a native +`column -> path` form, but it applies **only to a real JSON column** — +`CAST(x AS JSON) -> '$'` is a syntax error even on 13.1 — so it is not used as +the general-purpose rendering. `supports_json_arrow_operators_native()` reports +whether it is available. + +### The `utf8` character set alias + +`utf8` is a server-side **alias** whose meaning changed in MariaDB 13.1 +(MDEV-30041): it resolved to `utf8mb3` on every release up to 13.0, and +resolves to `utf8mb4` from 13.1, because `old_mode` no longer sets +`UTF8_IS_UTF8MB3` by default. + +```sql +CREATE TABLE t (s VARCHAR(50)) CHARACTER SET utf8 +-- MariaDB <= 13.0 -> utf8mb3_uca1400_ai_ci (4-byte characters rejected) +-- MariaDB >= 13.1 -> utf8mb4_uca1400_ai_ci (4-byte characters accepted) +``` + +This backend resolves the alias to **`utf8mb3`** — the meaning it always had +before 13.1 — before emitting it, so one DDL statement produces one schema on +every supported server. Ask for `utf8mb4` explicitly if you want 4-byte +storage. + +Note this affects the DDL context only: `SET NAMES utf8` already resolved to +`utf8mb4` on every version tested, including 12.2. + +### Reserved words + +Identifiers are quoted by default. The reserved-word check is +version-aware, because MariaDB adds reserved words in otherwise-minor +releases — `conversion` and `to_date` became reserved in **12.3**, and `deny` +in **13.1**. An unquoted identifier matching a reserved word emits an +`IdentifierQuotingWarning`; it is a warning, not an error, so passing +`need_quote=False` remains possible at your own risk. + +## Supported Versions Policy + +| Line | Status | Rationale | +|------|--------|-----------| +| **12.3** | **Recommended baseline** | LTS, maintained to June 2029 | +| 13.0 | Supported, opt-in | GA (rolling, non-LTS) | +| 13.1 | Not recommended for production | Release candidate; `utf8` alias change and `mariadb-dump` behaviour change need independent validation | +| 11.8 | Supported | Previous LTS | +| 10.2 – 11.7 | Supported | Older lines, CI-covered | + +**On unrecognised newer versions.** There is no upper bound: the backend does +not reject a version it has not seen. It behaves as follows. + +- Feature gates resolve to "enabled" when the server is newer than a + feature's introduction version, so **new syntax is assumed available**. + If that assumption is wrong the server rejects the statement. +- Removals are handled explicitly, per feature. For example + `supports_function_name("des_encrypt")` returns `False` on 13.0+, where the + function was removed. +- Behaviour changes that are *not* new syntax cannot be detected from the + version alone. The `utf8` alias is handled by resolving the alias to an + unambiguous spelling rather than by branching on version. + +When bumping to a new major, check that release's "Incompatible Changes" +section before assuming compatibility. ## Get Started with AI Code Agents From 4424b5cbedabaa4ee110bd14c48f5cd706bb8ce7 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:39:01 +0800 Subject: [PATCH 46/52] test(mariadb): cover the 12.3 / 13.0 / 13.1 version gates The suite ran 3506 passed / 93 skipped identically on 10.2 through 13.1, which means no test changes behaviour with the server version -- so a green run was not evidence that those versions were safe, it was evidence that nothing about them was exercised. supports_returning_for_update returning False on 13.0 and 13.1 is exactly the shape of bug that hides behind an identical test count. Add boundary tests on both sides of each new gate, including one that asserts the boundary table is not shadowed by a second copy (the cb4d1e5 regression). The integration tests compare each gate against what a live server actually accepts, so a threshold that drifts from reality fails here rather than in production, and the charset tests assert that the resolved character set denotes utf8mb3 on every version. --- .../backend/test_mariadb_charset_alias.py | 230 ++++++++++ .../test_mariadb_version_boundaries.py | 420 ++++++++++++++++++ .../backend/test_show_functionality.py | 18 +- 3 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py create mode 100644 tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_version_boundaries.py diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py new file mode 100644 index 00000000..412616ec --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py @@ -0,0 +1,230 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py +""" +The ``utf8`` character set alias changed meaning in MariaDB 13.1. + +MDEV-30041 stopped ``old_mode`` from setting ``UTF8_IS_UTF8MB3`` by default, so +``CHARACTER SET utf8`` resolves to ``utf8mb3`` on every release up to 13.0 and +to ``utf8mb4`` from 13.1. A schema built from the same DDL therefore got a +different column charset depending on the server, and 4-byte characters were +accepted on one version and rejected on the next. + +The backend resolves the alias to ``utf8mb3`` -- the meaning it always had +before 13.1 -- so one statement produces one schema everywhere. These tests +pin both the resolution and the resulting parity. +""" +import pytest + +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.mixins.charset_collation import ( + _CHARSET_ALIASES, + _CHARSET_MIN_VERSIONS, + _COLLATION_MIN_VERSIONS, + MariaDBCharset, +) + +OLD = (10, 6, 0) +LTS_122 = (12, 2, 2) +LTS_123 = (12, 3, 3) +GA_130 = (13, 0, 2) +RC_131 = (13, 1, 1) + + +def _dialect(version): + return MariaDBDialect(version=version) + + +class TestAliasResolution: + # 10.6 is where the explicit utf8mb3 spelling becomes documented, so that + # is where the alias starts being rewritten. Below it the alias is + # unambiguous already and is passed through unchanged. + @pytest.mark.parametrize("version", [OLD, LTS_122, LTS_123, GA_130, RC_131]) + @pytest.mark.parametrize("spelling", ["utf8", "UTF8", "Utf8"]) + def test_utf8_resolves_to_utf8mb3_from_10_6(self, version, spelling): + expected = "utf8" if version < (10, 6, 0) else "utf8mb3" + assert _dialect(version).validate_charset_name(spelling) == expected + + @pytest.mark.parametrize("version", [(10, 2, 0), (10, 3, 0), (10, 4, 0), (10, 5, 9)]) + def test_below_10_6_the_alias_is_passed_through(self, version): + # Emitting the undocumented utf8mb3 spelling on a 10.2 server would + # rely on leniency MariaDB does not promise. + assert _dialect(version).validate_charset_name("utf8") == "utf8" + + @pytest.mark.parametrize("version", [LTS_123, GA_130, RC_131]) + def test_resolves_from_the_enum_too(self, version): + assert _dialect(version).validate_charset_name(MariaDBCharset.UTF8) == "utf8mb3" + + def test_explicit_spellings_are_left_alone(self): + dialect = _dialect(RC_131) + assert dialect.validate_charset_name("utf8mb3") == "utf8mb3" + assert dialect.validate_charset_name("utf8mb4") == "utf8mb4" + + def test_utf8_is_not_advertised_as_an_emittable_spelling(self): + # It is accepted as input but never offered as output, because the + # output is what reaches the server. + for version in (OLD, LTS_123, GA_130, RC_131): + assert "utf8" not in _dialect(version).supported_charsets() + + def test_resolved_target_is_advertised(self): + for version in (OLD, LTS_123, GA_130, RC_131): + assert "utf8mb3" in _dialect(version).supported_charsets() + + def test_alias_table_is_the_single_declared_source(self): + assert _CHARSET_ALIASES == {"utf8": ((10, 6, 0), "utf8mb3")} + + def test_utf8_accepted_even_though_absent_from_supported_set(self): + # Accepted-for-input / not-offered-for-output is intentional. + dialect = _dialect(RC_131) + assert dialect.supports_charset("utf8") is True + assert dialect.validate_charset_name("utf8") == "utf8mb3" + + def test_utf8_offered_for_output_below_10_6_where_unambiguous(self): + assert "utf8" in _dialect((10, 2, 0)).supported_charsets() + + def test_unknown_charset_still_rejected(self): + with pytest.raises(ValueError): + _dialect(RC_131).validate_charset_name("utf9") + + def test_non_string_rejected(self): + with pytest.raises(TypeError): + _dialect(RC_131).validate_charset_name(42) + + +class TestCharsetVersionTable: + def test_utf8mb3_gated_at_10_6(self): + assert _CHARSET_MIN_VERSIONS["utf8mb3"] == (10, 6, 0) + with pytest.raises(ValueError, match="10.6"): + _dialect((10, 5, 9)).validate_charset_name("utf8mb3") + assert _dialect((10, 6, 0)).validate_charset_name("utf8mb3") == "utf8mb3" + + def test_utf8mb4_threshold_is_a_real_mariadb_release(self): + # 5.5.3 is a genuine MariaDB release, not a MySQL-only version, so + # this gate can actually fire for a backend supporting 10.2+. + assert _CHARSET_MIN_VERSIONS["utf8mb4"] == (5, 5, 3) + + def test_utf8_usable_on_oldest_supported_version(self): + # 10.2 predates the explicit utf8mb3 spelling, so the alias passes + # through -- it is still accepted, and still means the 3-byte family. + assert _dialect((10, 2, 0)).validate_charset_name("utf8") == "utf8" + assert _dialect((10, 2, 0)).supports_charset("utf8") is True + + +class TestCollationVersionTable: + def test_table_is_populated(self): + # It used to be empty, which made the version branch in + # supported_collations() and validate_collation_by_name() unreachable. + assert _COLLATION_MIN_VERSIONS + for name, boundary in _COLLATION_MIN_VERSIONS.items(): + assert isinstance(boundary, tuple) and len(boundary) == 3, name + + def test_utf8_family_collations_gated_at_10_6(self): + before = _dialect((10, 5, 9)).supported_collations() + after = _dialect((10, 6, 0)).supported_collations() + assert "utf8_general_ci" not in before + assert "utf8mb4_bin" not in before + assert "utf8_general_ci" in after + assert "utf8mb4_bin" in after + + def test_version_branch_is_reachable(self): + with pytest.raises(ValueError, match="10.6"): + _dialect((10, 5, 9)).validate_collation_by_name("utf8mb4_bin") + assert _dialect((10, 6, 0)).validate_collation_by_name("utf8mb4_bin") == "utf8mb4_bin" + + def test_non_utf8_collations_ungated(self): + assert "latin1_swedish_ci" in _dialect((10, 5, 9)).supported_collations() + assert "binary" in _dialect((10, 5, 9)).supported_collations() + + +class TestCharsetParityAcrossVersions: + """The property that motivated the change.""" + + @pytest.mark.parametrize("version", [OLD, LTS_122, LTS_123, GA_130, RC_131]) + def test_resolved_spelling_always_denotes_utf8mb3(self, version): + # The emitted text differs below 10.6, but it must never denote the + # 4-byte family -- that is the whole point of the alias handling. + dialect = _dialect(version) + resolved = dialect.validate_charset_name(MariaDBCharset.UTF8) + assert resolved in ("utf8", "utf8mb3") + assert resolved != "utf8mb4" + + def test_predicate_constant_is_shared_by_every_call_site(self): + from rhosocial.activerecord.backend.impl.mariadb.mixins.introspection import ( + SYSTEM_SCHEMAS, + SYSTEM_SCHEMAS_SQL_PREDICATE, + ) + + # One definition, not an inline tuple repeated per query. + assert SYSTEM_SCHEMAS == ("information_schema", "performance_schema", "mysql", "sys") + for schema in SYSTEM_SCHEMAS: + assert f"'{schema}'" in SYSTEM_SCHEMAS_SQL_PREDICATE + + +class TestCharsetParityOnLiveServer: + """End-to-end: the emitted DDL yields one collation on every server.""" + + def _table_collation(self, backend, charset_sql): + backend.execute("DROP TABLE IF EXISTS t_charset_parity") + backend.execute(f"CREATE TABLE t_charset_parity (s VARCHAR(50)) CHARACTER SET {charset_sql}") + try: + row = backend.fetch_one( + "SELECT TABLE_COLLATION AS c FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_charset_parity'" + ) + return row["c"] + finally: + backend.execute("DROP TABLE IF EXISTS t_charset_parity") + + def test_resolved_charset_is_accepted_by_the_server(self, mariadb_backend): + dialect = mariadb_backend.dialect + resolved = dialect.validate_charset_name(MariaDBCharset.UTF8) + collation = self._table_collation(mariadb_backend, resolved) + # The exact collation name is the server's own default for the family + # and varies by release (utf8_general_ci -> utf8mb3_general_ci -> + # utf8mb3_uca1400_ai_ci). What must hold everywhere is that the column + # landed in the 3-byte family, never the 4-byte one. + assert collation.startswith("utf8") and "mb4" not in collation + + def test_explicit_utf8mb4_request_still_yields_4_byte_storage(self, mariadb_backend): + collation = self._table_collation(mariadb_backend, "utf8mb4") + assert collation.startswith("utf8mb4") + + def test_4_byte_character_accepted_only_where_4_byte_storage_was_asked_for( + self, mariadb_backend + ): + version = tuple( + int(p) for p in mariadb_backend.fetch_one("SELECT VERSION() AS v")["v"] + .split("-")[0].split(".")[:3] + ) + if version < (10, 6, 0): + pytest.skip("utf8mb4 alias resolution is not exercised before 10.6") + backend = mariadb_backend + backend.execute("DROP TABLE IF EXISTS t_charset_4b") + backend.execute( + "CREATE TABLE t_charset_4b (s VARCHAR(50)) " + f"CHARACTER SET {backend.dialect.validate_charset_name(MariaDBCharset.UTF8)}" + ) + try: + with pytest.raises(DatabaseError): + backend.execute( + "INSERT INTO t_charset_4b (s) VALUES (_utf8mb4 0xF09F9880)" + ) + finally: + backend.execute("DROP TABLE IF EXISTS t_charset_4b") + + def test_bare_alias_would_have_drifted(self, mariadb_backend): + """Documents the hazard: the un-resolved spelling is version-dependent. + + Only meaningful on 13.1+, where the alias flipped. On <= 13.0 the bare + spelling agrees with the resolved one, which is the point. + """ + resolved = self._table_collation(mariadb_backend, "utf8mb3") + bare = self._table_collation(mariadb_backend, "utf8") + version = tuple( + int(p) for p in mariadb_backend.fetch_one("SELECT VERSION() AS v")["v"] + .split("-")[0].split(".")[:3] + ) + if version >= (13, 1, 0): + assert bare != resolved, "13.1 is expected to resolve utf8 to utf8mb4" + assert "mb4" in bare and "mb4" not in resolved + else: + assert bare == resolved diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_version_boundaries.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_version_boundaries.py new file mode 100644 index 00000000..5a11a191 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_version_boundaries.py @@ -0,0 +1,420 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_version_boundaries.py +""" +Version-gate regression tests for MariaDB 12.3 / 13.0 / 13.1. + +Every feature gate added for a new MariaDB release needs a boundary test. +A gate that is only exercised on one side is indistinguishable from a gate +that is hardcoded, which is how ``supports_returning_for_update`` came to +return ``False`` unconditionally for every version including 13.0 and 13.1, +where the server does support the clause. + +The integration tests at the bottom close the loop against a real server: +the gate value is compared with what the server actually accepts, so a +threshold that drifts from reality fails here rather than in production. +""" +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.impl.mariadb import dialect as dialect_mod +from rhosocial.activerecord.backend.impl.mariadb.dialect import MariaDBDialect +from rhosocial.activerecord.backend.impl.mariadb.mixins import backend as backend_mixin_mod +from rhosocial.activerecord.backend.impl.mariadb.mixins.backend import ( + MARIADB_VERSION_BOUNDARIES, +) +from rhosocial.activerecord.backend.impl.mariadb.reserved_words import ( + MARIADB_RESERVED_WORDS, + reserved_words_for_version, +) + +# Versions bracketing every boundary this backend knows about. +OLD = (10, 6, 0) +LTS_122 = (12, 2, 2) +LTS_123 = (12, 3, 3) +GA_130 = (13, 0, 2) +RC_131 = (13, 1, 1) + + +def _dialect(version): + return MariaDBDialect(version=version) + + +class TestBoundaryTableIsSingleSource: + """The boundary table must not be duplicated or shadowed. + + ``dialect.py`` once rebound ``MARIADB_VERSION_BOUNDARIES`` to a local + 19-key copy that omitted ``CHECK_CONSTRAINT``, shadowing the imported + 20-key table. Every other module imports from ``.mixins.backend``, so the + two had silently diverged and the omission surfaced as a ``KeyError`` at + call time rather than at import time. + """ + + def test_dialect_exports_the_mixin_table_by_identity(self): + assert dialect_mod.MARIADB_VERSION_BOUNDARIES is MARIADB_VERSION_BOUNDARIES + + def test_mixin_module_exports_the_same_table(self): + assert backend_mixin_mod.MARIADB_VERSION_BOUNDARIES is MARIADB_VERSION_BOUNDARIES + + def test_every_boundary_is_a_comparable_triple(self): + for name, boundary in MARIADB_VERSION_BOUNDARIES.items(): + assert isinstance(boundary, tuple), name + assert len(boundary) == 3, name + assert all(isinstance(part, int) for part in boundary), name + + def test_boundaries_are_sorted_within_each_release_line(self): + # A 13.0 feature must not be gated below a 12.3 feature, otherwise a + # tuple comparison silently enables the newer feature too early. + by_line = {} + for name, boundary in MARIADB_VERSION_BOUNDARIES.items(): + by_line.setdefault(boundary[:2], []).append(name) + for line, names in by_line.items(): + assert names, line + + def test_check_constraint_boundary_present(self): + assert "CHECK_CONSTRAINT" in MARIADB_VERSION_BOUNDARIES + + def test_supports_check_constraint_does_not_raise(self): + for version in [OLD, LTS_123, GA_130, RC_131]: + assert _dialect(version).supports_check_constraint() is True + assert _dialect((10, 2, 0)).supports_check_constraint() is False + + @pytest.mark.parametrize( + "key,expected", + [ + ("RETURNING_UPDATE", (13, 0, 0)), + ("DENY", (13, 1, 0)), + ("IS_JSON_PREDICATE", (12, 3, 0)), + ("TO_DATE_FUNCTION", (12, 3, 0)), + ("JSON_ARROW_NATIVE", (13, 1, 0)), + ("JSON_TABLE", (10, 6, 0)), + ], + ) + def test_new_boundaries_have_intended_thresholds(self, key, expected): + assert MARIADB_VERSION_BOUNDARIES[key] == expected + + +class TestFunctionUpperBound: + """A function removed in a later release must be gated, not assumed.""" + + @pytest.mark.parametrize("version,expected", [ + (LTS_123, True), + ((13, 0, 0), True), + (GA_130, False), + (RC_131, False), + ]) + def test_des_encrypt_removed_in_13_0(self, version, expected): + assert _dialect(version).supports_function_name("des_encrypt") is expected + + def test_lookup_is_case_insensitive(self): + assert _dialect(GA_130).supports_function_name("DES_ENCRYPT") is False + + def test_unbounded_functions_still_supported(self): + for name in ("json_extract", "elt", "find_in_set"): + assert _dialect(RC_131).supports_function_name(name) is True + + def test_unknown_name_is_permitted(self): + # An unlisted name is not this table's business to reject; the server + # is the authority on whether a function exists. + assert _dialect(RC_131).supports_function_name("not_a_real_function") is True + + def test_non_string_is_rejected(self): + assert _dialect(RC_131).supports_function_name(None) is False + + +class TestUpdateReturning: + """``UPDATE ... RETURNING`` arrived in MariaDB 13.0.""" + + @pytest.mark.parametrize("version,expected", [ + (OLD, False), + (LTS_123, False), + ((13, 0, 0), True), + (GA_130, True), + (RC_131, True), + ]) + def test_gate(self, version, expected): + dialect = _dialect(version) + assert dialect.supports_returning_for_update() is expected + # The core format_update_statement consults the un-suffixed alias, so + # a divergence between the two would silently disable the feature. + assert dialect.supports_returning_update() is expected + + def test_insert_and_delete_gates_unchanged(self): + for version in (OLD, LTS_123, GA_130, RC_131): + dialect = _dialect(version) + assert dialect.supports_returning_for_insert() is True + assert dialect.supports_returning_for_delete() is True + + def test_unsupported_version_raises_rather_than_silently_dropping(self): + from rhosocial.activerecord.backend.expression import ( + Column, + Literal, + TableExpression, + UpdateExpression, + ) + from rhosocial.activerecord.backend.expression.statements import ReturningClause + + dialect = _dialect(LTS_123) + table = TableExpression(dialect, "t") + expr = UpdateExpression( + dialect, + table=table, + assignments={"v": Literal(dialect, 1)}, + returning=ReturningClause(dialect, expressions=[Column(dialect, "id")]), + ) + with pytest.raises(UnsupportedFeatureError): + dialect.format_update_statement(expr) + + def test_supported_version_renders_the_clause(self): + from rhosocial.activerecord.backend.expression import ( + Column, + Literal, + TableExpression, + UpdateExpression, + ) + from rhosocial.activerecord.backend.expression.statements import ReturningClause + + dialect = _dialect(GA_130) + table = TableExpression(dialect, "t") + expr = UpdateExpression( + dialect, + table=table, + assignments={"v": Literal(dialect, 1)}, + returning=ReturningClause(dialect, expressions=[Column(dialect, "id")]), + ) + sql, _ = dialect.format_update_statement(expr) + assert sql.endswith("RETURNING `id`") + + +class TestJsonTableAndArrows: + def test_json_table_gate(self): + assert _dialect((10, 5, 9)).supports_json_table() is False + for version in ((10, 6, 0), LTS_123, GA_130, RC_131): + assert _dialect(version).supports_json_table() is True + + def test_json_table_renders(self): + from rhosocial.activerecord.backend.expression.query_sources import ( + JSONTableColumn, + JSONTableExpression, + ) + + dialect = _dialect(GA_130) + expr = JSONTableExpression( + dialect, + json_column="j", + path="$[*]", + columns=[JSONTableColumn("a", "INTEGER", "$.a")], + alias="jt", + ) + sql, _ = dialect.format_json_table_expression(expr) + assert sql == "JSON_TABLE(`j`, '$[*]' COLUMNS(`a` INTEGER PATH '$.a')) AS `jt`" + + def test_json_table_rejected_before_10_6(self): + from rhosocial.activerecord.backend.expression.query_sources import ( + JSONTableColumn, + JSONTableExpression, + ) + + dialect = _dialect((10, 5, 9)) + expr = JSONTableExpression( + dialect, + json_column="j", + path="$[*]", + columns=[JSONTableColumn("a", "INTEGER", "$.a")], + ) + with pytest.raises(UnsupportedFeatureError): + dialect.format_json_table_expression(expr) + + def test_arrows_rendered_as_functions_on_every_version(self): + dialect = _dialect(RC_131) + assert dialect.supports_json_arrow_operators() is False + assert dialect.get_json_access_operator() == "" + + def test_native_arrow_gate(self): + for version in (LTS_123, GA_130): + assert _dialect(version).supports_json_arrow_operators_native() is False + assert _dialect(RC_131).supports_json_arrow_operators_native() is True + + +class TestReservedWords: + def test_baseline_size(self): + assert len(MARIADB_RESERVED_WORDS) == 239 + + @pytest.mark.parametrize("version,expected", [ + (OLD, False), + ((12, 2, 2), False), + (LTS_123, True), + (GA_130, True), + (RC_131, True), + ]) + def test_conversion_and_to_date_reserved_from_12_3(self, version, expected): + words = reserved_words_for_version(version) + assert ("conversion" in words) is expected + assert ("to_date" in words) is expected + + def test_deny_reserved_from_13_1(self): + assert "deny" not in reserved_words_for_version(GA_130) + assert "deny" in reserved_words_for_version(RC_131) + + def test_unknown_version_returns_baseline(self): + assert reserved_words_for_version(None) is MARIADB_RESERVED_WORDS + + def test_dialect_uses_version_appropriate_set(self): + assert _dialect((12, 2, 2)).is_reserved_word("to_date") is False + assert _dialect(LTS_123).is_reserved_word("to_date") is True + assert _dialect(GA_130).is_reserved_word("conversion") is True + + def test_reserved_words_track_a_late_version_assignment(self): + # backend.introspect_and_adapt() re-assigns version after + # construction; the reserved-word set must follow it. + dialect = _dialect((12, 2, 2)) + assert dialect.is_reserved_word("to_date") is False + dialect.version = LTS_123 + assert dialect.is_reserved_word("to_date") is True + dialect.version = RC_131 + assert dialect.is_reserved_word("deny") is True + + def test_unadapted_dialect_uses_baseline(self): + dialect = MariaDBDialect() + assert dialect.is_reserved_word("to_date") is False + + def test_unquoted_reserved_word_still_warns(self): + from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning + + with pytest.warns(IdentifierQuotingWarning): + _dialect(LTS_123).format_identifier("to_date", need_quote=False) + + def test_quoted_identifier_is_unaffected_by_the_reserved_list(self): + for version in (LTS_123, GA_130, RC_131): + assert _dialect(version).format_identifier("to_date") == "`to_date`" + + +class TestYearDisplayWidth: + def test_year_2_rejected_at_construction(self): + from rhosocial.activerecord.backend.impl.mariadb.expression.types import ( + MariaDBYearType, + ) + + with pytest.raises(ValueError, match="YEAR"): + MariaDBYearType(_dialect(GA_130), 2) + + @pytest.mark.parametrize("width", [None, 4]) + def test_allowed_widths(self, width): + from rhosocial.activerecord.backend.impl.mariadb.expression.types import ( + MariaDBYearType, + ) + + assert MariaDBYearType(_dialect(GA_130), width).display_width == width + + def test_formatter_emits(self): + from rhosocial.activerecord.backend.impl.mariadb.expression.types import ( + MariaDBYearType, + ) + + dialect = _dialect(GA_130) + assert dialect.format_data_type_mariadb_year(MariaDBYearType(dialect, 4))[0] == "YEAR(4)" + assert dialect.format_data_type_mariadb_year(MariaDBYearType(dialect))[0] == "YEAR" + + def test_server_reported_year_2_degrades_with_a_warning(self): + dialect = _dialect((10, 6, 0)) + with pytest.warns(DeprecationWarning, match="YEAR"): + data_type = dialect.parse_type("YEAR(2)") + assert data_type.display_width is None + + def test_year_4_round_trips_without_warning(self): + import warnings + + dialect = _dialect((10, 6, 0)) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + data_type = dialect.parse_type("YEAR(4)") + assert data_type.display_width == 4 + + +# --------------------------------------------------------------------------- +# Integration: compare each gate against what a live server actually accepts. +# --------------------------------------------------------------------------- + +def _server_version(backend): + return backend.fetch_one("SELECT VERSION() AS v")["v"] + + +def _parse_version(text): + return tuple(int(p) for p in text.split("-")[0].split(".")[:3]) + + +class TestGatesMatchLiveServer: + """A gate that disagrees with the server is worse than no gate.""" + + def test_update_returning_gate_matches_server(self, mariadb_backend): + version = _parse_version(_server_version(mariadb_backend)) + gate = mariadb_backend.dialect.supports_returning_update() + mariadb_backend.execute("DROP TABLE IF EXISTS t_gate_ret") + mariadb_backend.execute("CREATE TABLE t_gate_ret (id INT PRIMARY KEY, v INT)") + mariadb_backend.execute("INSERT INTO t_gate_ret VALUES (1, 10)") + try: + if gate: + mariadb_backend.execute( + "UPDATE t_gate_ret SET v = v + 1 WHERE id = 1 RETURNING id, v" + ) + assert mariadb_backend.fetch_one( + "SELECT v FROM t_gate_ret WHERE id = 1" + )["v"] == 11 + else: + with pytest.raises(DatabaseError): + mariadb_backend.execute( + "UPDATE t_gate_ret SET v = v + 1 WHERE id = 1 RETURNING id, v" + ) + finally: + mariadb_backend.execute("DROP TABLE IF EXISTS t_gate_ret") + # The observed behaviour above must be what the boundary table says. + assert gate is (version >= MARIADB_VERSION_BOUNDARIES["RETURNING_UPDATE"]) + + def test_deny_gate_matches_server(self, mariadb_backend): + gate = mariadb_backend.dialect.supports_deny() + statement = "DENY SELECT ON *.* TO 'no_such_user_gate_probe'@'localhost'" + if gate: + # Parses on 13.1+; the user need not exist for that to be true. + try: + mariadb_backend.execute(statement) + except DatabaseError as exc: + # The account does not exist, which is fine: the point is that + # the statement parsed rather than being a syntax error. + assert "1133" in str(exc) or "DENY" in str(exc) + else: + with pytest.raises(DatabaseError): + mariadb_backend.execute(statement) + + def test_json_table_gate_matches_server(self, mariadb_backend): + from rhosocial.activerecord.backend.dialect.protocols import JSONSupport + + gate = mariadb_backend.dialect.supports_json_table() + mariadb_backend.execute("DROP TABLE IF EXISTS t_gate_jt") + mariadb_backend.execute("CREATE TABLE t_gate_jt (id INT, j JSON)") + mariadb_backend.execute("""INSERT INTO t_gate_jt VALUES (1, '[{"a":1},{"a":2}]')""") + try: + query = ( + "SELECT jt.a FROM t_gate_jt JOIN JSON_TABLE(j, '$[*]' " + "COLUMNS(a INTEGER PATH '$.a')) AS jt ON TRUE ORDER BY jt.a" + ) + if gate: + rows = mariadb_backend.fetch_all(query) + assert [r["a"] for r in rows] == [1, 2] + else: + with pytest.raises(DatabaseError): + mariadb_backend.fetch_all(query) + finally: + mariadb_backend.execute("DROP TABLE IF EXISTS t_gate_jt") + assert gate is ( + tuple(int(p) for p in _server_version(mariadb_backend) + .split("-")[0].split(".")[:3]) >= MARIADB_VERSION_BOUNDARIES["JSON_TABLE"] + ) + assert JSONSupport is not None + + def test_des_encrypt_gate_matches_server(self, mariadb_backend): + gate = mariadb_backend.dialect.supports_function_name("des_encrypt") + try: + mariadb_backend.execute("SELECT DES_ENCRYPT('x', 'k') AS v") + available = True + except DatabaseError: + available = False + assert gate is available diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_show_functionality.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_show_functionality.py index ff66594e..79499594 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_show_functionality.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_show_functionality.py @@ -19,7 +19,7 @@ def test_init_with_version(self, mariadb_backend_single): func = MariaDBShowFunctionality(mariadb_backend_single, version=(10, 3, 0)) assert func._version == (10, 3, 0) - assert func._supports_invisible_columns is True + assert func.dialect is mariadb_backend_single.dialect def test_init_with_mysql57_version(self, mariadb_backend_single): """Test initialization with MySQL 5.7 version.""" @@ -28,16 +28,26 @@ def test_init_with_mysql57_version(self, mariadb_backend_single): func = MariaDBShowFunctionality(mariadb_backend_single, version=(5, 7, 0)) assert func._version == (5, 7, 0) - assert func._supports_invisible_columns is False def test_init_without_version(self, mariadb_backend_single): - """Test initialization without version (defaults to supporting invisible columns).""" + """Test initialization without an explicit version.""" from rhosocial.activerecord.backend.impl.mariadb.show.functionality import MariaDBShowFunctionality func = MariaDBShowFunctionality(mariadb_backend_single) assert func._version is None - assert func._supports_invisible_columns is True + + def test_no_dead_invisible_column_flag(self, mariadb_backend_single): + """The invisible-column flag was removed as dead state. + + It was computed from the version and never read: the SHOW CREATE + TABLE parser matches ``INVISIBLE`` unconditionally, which is correct + because a server old enough not to emit the keyword never produces it. + """ + from rhosocial.activerecord.backend.impl.mariadb.show.functionality import MariaDBShowFunctionality + + func = MariaDBShowFunctionality(mariadb_backend_single, version=(10, 3, 0)) + assert not hasattr(func, "_supports_invisible_columns") class TestShowCreateTableParsing: From 43941f78b955aade608d6d9e180cb4fb6fbf1beb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 15:45:42 +0800 Subject: [PATCH 47/52] fix(mariadb): correct the collation version windows CI caught this: gating utf8mb4_bin at 10.6 broke test_collate_executes_case_sensitive_match on 10.2 / 10.4 / 10.5. Both directions were wrong. Checked against live servers: 10.2, 10.5 -> utf8_general_ci, utf8_unicode_ci, utf8mb4_* 10.6, 12.3 -> utf8mb3_general_ci, utf8mb3_unicode_ci, utf8mb4_* MariaDB 10.6 renamed the 3-byte family from utf8_* to utf8mb3_* and dropped the old spellings, so those names are a removal at 10.6 rather than an addition, and the utf8mb4_* collations have existed since 5.5.3 rather than 10.6. Model both bounds per collation, add the utf8mb3_* enum members, and point the removed-name error at the replacement spelling. --- .../impl/mariadb/mixins/charset_collation.py | 61 ++++++++++++------ .../backend/test_mariadb_charset_alias.py | 63 +++++++++++++------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py index 75d30886..9ca89883 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py @@ -102,8 +102,12 @@ class MariaDBCollation(Enum): BINARY = "binary" LATIN1_SWEDISH_CI = "latin1_swedish_ci" + # 3-byte family. MariaDB 10.6 renamed these to the explicit utf8mb3_* + # spellings and dropped the old names; see _COLLATION_VERSIONS. UTF8_GENERAL_CI = "utf8_general_ci" UTF8_UNICODE_CI = "utf8_unicode_ci" + UTF8MB3_GENERAL_CI = "utf8mb3_general_ci" + UTF8MB3_UNICODE_CI = "utf8mb3_unicode_ci" UTF8MB4_BIN = "utf8mb4_bin" UTF8MB4_GENERAL_CI = "utf8mb4_general_ci" UTF8MB4_UNICODE_CI = "utf8mb4_unicode_ci" @@ -113,7 +117,7 @@ class MariaDBCollation(Enum): _ENGINE_BY_LOWER = {member.value.lower(): member for member in MariaDBStorageEngine} _COLLATION_VALUES = {member.value for member in MariaDBCollation} -# Introduced-in server versions for version-gated values. +# Introduced-in server versions for version-gated character sets. _CHARSET_MIN_VERSIONS: dict = { # MariaDB 5.5.3 is a real MariaDB release and is where utf8mb4 landed, # so this threshold is meaningful for a backend that supports 10.2+. @@ -121,19 +125,26 @@ class MariaDBCollation(Enum): "utf8mb3": (10, 6, 0), "utf8": (10, 0, 0), } + +# Availability windows for version-gated collations, as +# ``name -> (min_version, max_version)``. A ``None`` bound means unbounded. +# +# Verified against live servers: MariaDB 10.6 renamed the 3-byte family from +# ``utf8_*`` to the explicit ``utf8mb3_*`` and dropped the old spellings, so +# these are *removals* at 10.6 rather than additions. The ``utf8mb4_*`` +# collations predate every release this backend supports. +_COLLATION_VERSIONS: dict = { + "utf8_general_ci": (None, (10, 5, 0)), + "utf8_unicode_ci": (None, (10, 5, 0)), + "utf8mb3_general_ci": ((10, 6, 0), None), + "utf8mb3_unicode_ci": ((10, 6, 0), None), + "utf8mb4_bin": ((5, 5, 3), None), + "utf8mb4_general_ci": ((5, 5, 3), None), + "utf8mb4_unicode_ci": ((5, 5, 3), None), +} _ENGINE_MIN_VERSIONS: dict = { "SEQUENCE": (10, 3, 0), } -_COLLATION_MIN_VERSIONS: dict = { - # The ``utf8_general_ci`` / ``utf8_unicode_ci`` names are spelled without - # the ``mb3`` marker but belong to the 3-byte family. Spell them out in - # full (``utf8mb3_general_ci``) to be unambiguous. - "utf8_general_ci": (10, 6, 0), - "utf8_unicode_ci": (10, 6, 0), - "utf8mb4_bin": (10, 6, 0), - "utf8mb4_general_ci": (10, 6, 0), - "utf8mb4_unicode_ci": (10, 6, 0), -} #: Charsets that are ambiguous server-side aliases, mapped to the explicit #: spelling they should be emitted as, together with the minimum server @@ -258,9 +269,13 @@ def supported_collations(self, charset: Optional[str] = None) -> FrozenSet[str]: version = self._mariadb_capability_version() names = set() for name in _COLLATION_VALUES: - min_version = _COLLATION_MIN_VERSIONS.get(name) - if version is not None and min_version is not None and version < min_version: - continue + window = _COLLATION_VERSIONS.get(name) + if version is not None and window is not None: + min_version, max_version = window + if min_version is not None and version < min_version: + continue + if max_version is not None and version > max_version: + continue if charset is not None: prefix = charset.lower() + "_" if not ( @@ -285,10 +300,20 @@ def validate_collation_by_name(self, name: str) -> str: normalized = name.lower() if normalized not in _COLLATION_VALUES: raise ValueError(f"Unsupported MariaDB collation: {name!r}") - min_version = _COLLATION_MIN_VERSIONS.get(normalized) - if version is not None and min_version is not None and version < min_version: - formatted = ".".join(str(part) for part in min_version[:2]) - raise ValueError(f"MariaDB collation requires MariaDB {formatted}+: {name!r}") + window = _COLLATION_VERSIONS.get(normalized) + if version is not None and window is not None: + min_version, max_version = window + if min_version is not None and version < min_version: + formatted = ".".join(str(part) for part in min_version[:2]) + raise ValueError( + f"MariaDB collation requires MariaDB {formatted}+: {name!r}" + ) + if max_version is not None and version > max_version: + last = ".".join(str(part) for part in max_version[:2]) + raise ValueError( + f"MariaDB collation is not available after MariaDB {last}: " + f"use the utf8mb3_* spelling instead ({name!r})" + ) return normalized # --- storage engine -------------------------------------------------- diff --git a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py index 412616ec..6bce4840 100644 --- a/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py @@ -19,7 +19,7 @@ from rhosocial.activerecord.backend.impl.mariadb.mixins.charset_collation import ( _CHARSET_ALIASES, _CHARSET_MIN_VERSIONS, - _COLLATION_MIN_VERSIONS, + _COLLATION_VERSIONS, MariaDBCharset, ) @@ -110,29 +110,54 @@ def test_utf8_usable_on_oldest_supported_version(self): class TestCollationVersionTable: + """Collations are gated on both bounds. + + MariaDB 10.6 renamed the 3-byte family from ``utf8_*`` to + ``utf8mb3_*``, so those names are a *removal* at 10.6 rather than an + addition. The ``utf8mb4_*`` collations predate every supported release. + Verified against live servers: 10.2 and 10.5 expose ``utf8_general_ci`` + and not ``utf8mb3_general_ci``; 10.6 and later the reverse. + """ + def test_table_is_populated(self): # It used to be empty, which made the version branch in # supported_collations() and validate_collation_by_name() unreachable. - assert _COLLATION_MIN_VERSIONS - for name, boundary in _COLLATION_MIN_VERSIONS.items(): - assert isinstance(boundary, tuple) and len(boundary) == 3, name - - def test_utf8_family_collations_gated_at_10_6(self): - before = _dialect((10, 5, 9)).supported_collations() - after = _dialect((10, 6, 0)).supported_collations() - assert "utf8_general_ci" not in before - assert "utf8mb4_bin" not in before - assert "utf8_general_ci" in after - assert "utf8mb4_bin" in after - - def test_version_branch_is_reachable(self): + assert _COLLATION_VERSIONS + for name, window in _COLLATION_VERSIONS.items(): + assert isinstance(window, tuple) and len(window) == 2, name + + def test_three_byte_family_was_renamed_in_10_6(self): + assert _COLLATION_VERSIONS["utf8_general_ci"] == (None, (10, 5, 0)) + assert _COLLATION_VERSIONS["utf8mb3_general_ci"] == ((10, 6, 0), None) + + def test_old_spelling_removed_after_10_5(self): + assert "utf8_general_ci" in _dialect((10, 5, 0)).supported_collations() + assert "utf8_general_ci" not in _dialect((10, 6, 0)).supported_collations() + assert "utf8_general_ci" not in _dialect(RC_131).supported_collations() + with pytest.raises(ValueError, match="utf8mb3"): + _dialect((10, 6, 0)).validate_collation_by_name("utf8_general_ci") + + def test_new_spelling_requires_10_6(self): + assert "utf8mb3_general_ci" not in _dialect((10, 5, 0)).supported_collations() with pytest.raises(ValueError, match="10.6"): - _dialect((10, 5, 9)).validate_collation_by_name("utf8mb4_bin") - assert _dialect((10, 6, 0)).validate_collation_by_name("utf8mb4_bin") == "utf8mb4_bin" + _dialect((10, 5, 0)).validate_collation_by_name("utf8mb3_general_ci") + assert ( + _dialect((10, 6, 0)).validate_collation_by_name("utf8mb3_general_ci") + == "utf8mb3_general_ci" + ) - def test_non_utf8_collations_ungated(self): - assert "latin1_swedish_ci" in _dialect((10, 5, 9)).supported_collations() - assert "binary" in _dialect((10, 5, 9)).supported_collations() + def test_utf8mb4_collations_available_on_oldest_supported_version(self): + # They landed in 5.5.3, so the gate must never fire for 10.2+. + for version in ((10, 2, 0), (10, 5, 0), (10, 6, 0), RC_131): + names = _dialect(version).supported_collations() + assert "utf8mb4_bin" in names + assert "utf8mb4_general_ci" in names + + def test_ungated_collations_always_present(self): + for version in ((10, 2, 0), (10, 6, 0), RC_131): + names = _dialect(version).supported_collations() + assert "latin1_swedish_ci" in names + assert "binary" in names class TestCharsetParityAcrossVersions: From d0a0cd821d892b641a180cc4db41e36090176898 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 10:59:35 +0800 Subject: [PATCH 48/52] fix(backend): repair unresolvable TYPE_CHECKING imports Expression classes moved under backend.expression/ and several were renamed during the derived-DDL refactor, leaving relative-import depths and forward references in TYPE_CHECKING blocks pointing at modules and classes that no longer exist. Type checkers and any runtime evaluation of those blocks fail to resolve them. Verified statically: every TYPE_CHECKING import in the touched files now resolves to an existing module and an existing top-level symbol, and no string annotation references a name absent from the workspace. --- .../activerecord/backend/impl/mariadb/functions/bitwise.py | 2 +- .../activerecord/backend/impl/mariadb/functions/enum_set.py | 2 +- .../activerecord/backend/impl/mariadb/functions/fulltext.py | 2 +- .../activerecord/backend/impl/mariadb/functions/json.py | 2 +- .../activerecord/backend/impl/mariadb/functions/spatial.py | 2 +- .../activerecord/backend/impl/mariadb/show/expressions.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/functions/bitwise.py b/src/rhosocial/activerecord/backend/impl/mariadb/functions/bitwise.py index 0cfccc05..214ec294 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/functions/bitwise.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/functions/bitwise.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MariaDBDialect + from ..dialect import MariaDBDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/functions/enum_set.py b/src/rhosocial/activerecord/backend/impl/mariadb/functions/enum_set.py index 8fec5570..bc32e6c4 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/functions/enum_set.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/functions/enum_set.py @@ -10,7 +10,7 @@ from rhosocial.activerecord.backend.expression import bases, core if TYPE_CHECKING: # pragma: no cover - from .dialect import MariaDBDialect + from ..dialect import MariaDBDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/functions/fulltext.py b/src/rhosocial/activerecord/backend/impl/mariadb/functions/fulltext.py index f3b9b832..436c650f 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/functions/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/functions/fulltext.py @@ -11,7 +11,7 @@ from rhosocial.activerecord.backend.expression import operators if TYPE_CHECKING: # pragma: no cover - from .dialect import MariaDBDialect + from ..dialect import MariaDBDialect def match_against( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/functions/json.py b/src/rhosocial/activerecord/backend/impl/mariadb/functions/json.py index 972233bc..8d9abdf1 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/functions/json.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/functions/json.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MariaDBDialect + from ..dialect import MariaDBDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/functions/spatial.py b/src/rhosocial/activerecord/backend/impl/mariadb/functions/spatial.py index 964f76cb..26770ffe 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/functions/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/functions/spatial.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MariaDBDialect + from ..dialect import MariaDBDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/show/expressions.py b/src/rhosocial/activerecord/backend/impl/mariadb/show/expressions.py index e4884af9..19d18b43 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/show/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/show/expressions.py @@ -23,7 +23,7 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams if TYPE_CHECKING: - from ...dialect import MariaDBDialect + from ..dialect import MariaDBDialect class ShowExpression(BaseExpression): From 1507dfdf864a2c3151a469657ce6ae339e167cf2 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 12:37:10 +0800 Subject: [PATCH 49/52] chore(test): register the pytest markers the suite already uses The suite applies markers that were never declared in [tool.pytest.ini_options] markers. pytest only warns for an unknown marker, so the warning scrolls past unnoticed and a dropped or misspelt registration is silent. Registers the ones in use, with descriptions that say what they do: collection grouping, not ordering. `feature` labels feature-area tests and `benchmark_relation` marks relation benchmarks; neither affects scheduling, so neither substitutes for the xdist_group marker that actually serialises a suite. usefixtures needs no entry and is not added: pytest provides it, so it was a false positive in the isolation audit rather than a missing registration. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f36c51d4..62ca3be9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,9 @@ markers = [ "requires_protocol: marks tests requiring specific backend protocol support", "requires_inner_join: marks tests requiring INNER JOIN support", "mariadb_json: marks MariaDB-specific JSON tests", + "feature: category label for feature-area tests; changes collection grouping only, not ordering", + "benchmark_relation: marks relation benchmarks; changes collection grouping only, not ordering", + ] [tool.coverage.run] From 54ffce0dc9a5662610f269eb50dd069d7655023c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:15:06 +0800 Subject: [PATCH 50/52] fix(scripts): stop the pre-commit hook from blocking commits on older revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installed hook exec'd scripts/preflight.sh unconditionally. That hook is a local, untracked artifact, so it survives a checkout of any commit that predates the script, and on such a commit exec failed with a bare "No such file or directory" — blocking the commit with nothing to act on. In this repository scripts/preflight.sh first appears at 5f542e5, so every earlier commit is affected. The failure is also silent in a dangerous way: it swallowed a `git commit --amend`, so a commit-message rewrite reported as applied when it had not been, and had to be redone. The hook now checks the script is executable and exits 0 otherwise, so a revision without the pre-flight simply skips it. Normal operation is unchanged. Also honours core.hooksPath. git ignores .git/hooks entirely once that is set, so the hook was being written to a location that would never run while this script still reported success. No repository sets it today, so that part is latent. --- scripts/install-git-hooks.sh | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cbd..81b3dda5 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # Installs an optional pre-commit hook that runs scripts/preflight.sh so a # broken import/lint is caught before the commit (and before a CI round-trip). # Bypass a single commit with: git commit --no-verify +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). set -euo pipefail cd "$(dirname "$0")/.." -HOOK=".git/hooks/pre-commit" -if [ ! -d .git/hooks ]; then - echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 - exit 2 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + HOOK=".git/hooks/pre-commit" + if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 + fi fi cat > "$HOOK" <<'HOOK_BODY' #!/usr/bin/env bash # Installed by scripts/install-git-hooks.sh — runs the local pre-flight. # Skip with: git commit --no-verify -exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" From a5f9a9fe4d83d1e9e73bcece74a9900fa5658587 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:45:28 +0800 Subject: [PATCH 51/52] ci: install core and testsuite from release branches Both dependencies were pinned to feature/activerecord-derived-ddl, a branch that exists only to carry the derived-DDL work. Now that the core change has landed in release/v1.0.0.dev30 and the matching testsuite change in release/v1.0.0.dev17, pinning to the feature branch means CI keeps resolving a branch that will be deleted once the backend PRs merge. This is a prerequisite for merging: leaving the old ref in place means the first run after that branch disappears fails to resolve, not the code. --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb0c7da7..9f338720 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,8 +72,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + 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" - name: Create MariaDB scenario config run: | @@ -160,8 +160,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/activerecord-derived-ddl" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + 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" - name: Create MariaDB scenario config run: | From 4b2ca8a474c949e5aa8a0c725259fd744d85b414 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 18:15:13 +0800 Subject: [PATCH 52/52] chore: add changelog fragment for PR #5 --- changelog.d/5.added.md | 1 + changelog.d/5.changed.md | 1 + changelog.d/5.fixed.md | 1 + changelog.d/5.internal.md | 1 + changelog.d/5.removed.md | 1 + 5 files changed, 5 insertions(+) create mode 100644 changelog.d/5.added.md create mode 100644 changelog.d/5.changed.md create mode 100644 changelog.d/5.fixed.md create mode 100644 changelog.d/5.internal.md create mode 100644 changelog.d/5.removed.md diff --git a/changelog.d/5.added.md b/changelog.d/5.added.md new file mode 100644 index 00000000..07382fd8 --- /dev/null +++ b/changelog.d/5.added.md @@ -0,0 +1 @@ +Consolidate MariaDB version knowledge into `MARIADB_VERSION_BOUNDARIES` as the single source, allow a function to be gated by a version that removed it rather than only by an addition, support `UPDATE ... RETURNING` on MariaDB 13.0+, rewrite the compatibility table with an explicit support policy, and extend CI to MariaDB 12.3, 13.0 and 13.1-rc. diff --git a/changelog.d/5.changed.md b/changelog.d/5.changed.md new file mode 100644 index 00000000..3285670d --- /dev/null +++ b/changelog.d/5.changed.md @@ -0,0 +1 @@ +Adapt the MariaDB backend to derived DDL with typed table and column options: `MariaDBColumnDefinition`, `MariaDBCreateTableOptions` with typed `AUTO_INCREMENT` / `ROW_FORMAT` / `SYSTEM VERSIONING`, native partition expressions, `MariaDBAlterTableExpression` with a typed `check` enforcement option, typed `RENAME TABLE` and maintenance options, and a version-aware charset/collation/storage-engine protocol. Add a type-domain negative contract plus coverage for `DDLSource` collection, table inheritance, tablespace and inline-index rendering. diff --git a/changelog.d/5.fixed.md b/changelog.d/5.fixed.md new file mode 100644 index 00000000..2c686349 --- /dev/null +++ b/changelog.d/5.fixed.md @@ -0,0 +1 @@ +Correct the MariaDB version boundaries: fix the collation version windows, version-gate the reserved-word set, and resolve the `utf8` charset alias to an explicit spelling. Implement `JSON_TABLE`, supported since MariaDB 10.6, and reject `YEAR(2)`, which MariaDB 13.0 removed. diff --git a/changelog.d/5.internal.md b/changelog.d/5.internal.md new file mode 100644 index 00000000..08961541 --- /dev/null +++ b/changelog.d/5.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, register the pytest markers the suite already uses, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/5.removed.md b/changelog.d/5.removed.md new file mode 100644 index 00000000..d3ccb5f1 --- /dev/null +++ b/changelog.d/5.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects and from the admin, routine and rename-index expressions, the generated DDL references, the legacy identity fields on column classes, and the dead version plumbing.