diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 61d5ce24..9f338720 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: @@ -71,7 +73,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + 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: | @@ -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: @@ -158,7 +161,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" + 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: | @@ -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 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. 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 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. 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] 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" 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/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/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 03028a55..2f6d1592 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,10 +93,11 @@ DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, + DomainMixin, TransactionControlMixin, PartitionMixin, ) -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError # Import MariaDB-specific mixins from .mixins import ( @@ -109,6 +112,7 @@ MariaDBJSONMixin, MariaDBFullTextSearchMixin, MariaDBTableMixin, + MariaDBDatabaseMixin, MariaDBSetTypeMixin, MariaDBModifyColumnMixin, MariaDBPartitionMixin, @@ -124,7 +128,7 @@ MARIADB_VERSION_BOUNDARIES, # New mixins from dialect.py split MariaDBDateTimeMixin, - MariaDBCollationMixin, + MariaDBCharsetCollationMixin, MariaDBCTEMixin, MariaDBWindowMixin, MariaDBFilterClauseMixin, @@ -141,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 @@ -171,32 +175,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), - '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)." @@ -219,6 +199,7 @@ class MariaDBDialect( MariaDBJSONMixin, MariaDBFullTextSearchMixin, MariaDBTableMixin, + MariaDBDatabaseMixin, MariaDBSetTypeMixin, MariaDBModifyColumnMixin, MariaDBPartitionMixin, @@ -232,7 +213,7 @@ class MariaDBDialect( MariaDBAdminMixin, # New mixins from dialect.py split MariaDBDateTimeMixin, - MariaDBCollationMixin, + MariaDBCharsetCollationMixin, MariaDBCTEMixin, MariaDBWindowMixin, MariaDBFilterClauseMixin, @@ -282,6 +263,8 @@ class MariaDBDialect( DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, + DomainMixin, TransactionControlMixin, PartitionMixin, # Protocol support markers @@ -315,7 +298,9 @@ class MariaDBDialect( ViewSupport, FunctionSupport, SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, MariaDBDMLOperationSupport, MariaDBTriggerSupport, MariaDBTableSupport, @@ -361,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" @@ -411,11 +409,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, @@ -574,109 +573,12 @@ 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_inline_index(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) - - if 'comment' in expr.dialect_options: - escaped_comment = self._escape_sql_string(expr.dialect_options['comment']) - parts.append(f"COMMENT '{escaped_comment}'") - - return ' '.join(parts), tuple(all_params) - @staticmethod def _escape_sql_string(value: str) -> str: value = value.replace('\\', '\\\\') value = value.replace("'", "''") return value - def format_column_definition( - self, - col_def: "ColumnDefinition" - ) -> Tuple[str, tuple]: - from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType - - 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 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" @@ -721,7 +623,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/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 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 295f3437..a1c68637 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/__init__.py @@ -59,10 +59,36 @@ MariaDBRevokeExpression, MariaDBShutdownExpression, ) +from .table_options import MariaDBRowFormat, MariaDBCreateTableOptions +from .dml import MariaDBInsertExpression +from .truncate import MariaDBTruncateExpression +from .alter_table import MariaDBAlterTableExpression +from .column import ( + MariaDBColumnFormat, + MariaDBColumnStorage, + MariaDBColumnDefinition, + MariaDBColumnOptions, +) from .maintenance import ( MariaDBTableMaintenanceExpression, TableMaintenanceOperation, ) +from .partition import ( + MariaDBPartitionStrategy, + MariaDBSubpartitionStrategy, + MariaDBPartitionOptions, + MariaDBPartitionMaxValue, + MariaDBPartitionValue, + MariaDBSubpartitionDefinition, + MariaDBPartitionDefinition, + MariaDBPartitionClause, + MariaDBPartitionByRange, + MariaDBPartitionByRangeColumns, + MariaDBPartitionByList, + MariaDBPartitionByListColumns, + MariaDBPartitionByHash, + MariaDBPartitionByKey, +) # DataType subclasses for DDL from .types import ( @@ -90,6 +116,7 @@ MariaDBTinyBlobType, MariaDBTinyIntType, MariaDBTinyTextType, + MariaDBUUIDType, MariaDBVarBinaryType, MariaDBYearType, ) @@ -112,8 +139,31 @@ "MatchAgainstMode", "MariaDBRenameTableExpression", "MariaDBRenameIndexExpression", + "MariaDBAlterTableExpression", "MariaDBTableMaintenanceExpression", "TableMaintenanceOperation", + "MariaDBCreateTableOptions", + "MariaDBRowFormat", + "MariaDBInsertExpression", + "MariaDBTruncateExpression", + "MariaDBColumnFormat", + "MariaDBColumnStorage", + "MariaDBColumnDefinition", + "MariaDBColumnOptions", + "MariaDBPartitionStrategy", + "MariaDBSubpartitionStrategy", + "MariaDBPartitionOptions", + "MariaDBPartitionMaxValue", + "MariaDBPartitionValue", + "MariaDBSubpartitionDefinition", + "MariaDBPartitionDefinition", + "MariaDBPartitionClause", + "MariaDBPartitionByRange", + "MariaDBPartitionByRangeColumns", + "MariaDBPartitionByList", + "MariaDBPartitionByListColumns", + "MariaDBPartitionByHash", + "MariaDBPartitionByKey", "MariaDBCallExpression", "MariaDBCreateFunctionExpression", "MariaDBCreateProcedureExpression", @@ -156,6 +206,7 @@ "MariaDBTinyBlobType", "MariaDBTinyIntType", "MariaDBTinyTextType", + "MariaDBUUIDType", "MariaDBVarBinaryType", "MariaDBYearType", "MariaDBSetLiteralExpression", 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/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/expression/column.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py new file mode 100644 index 00000000..b4dd7504 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/column.py @@ -0,0 +1,144 @@ +# 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 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, + attributes=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, + attributes=attributes, + ) + 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, + *, + character_set: Optional[str] = None, + column_format: Optional[MariaDBColumnFormat] = None, + storage: Optional[MariaDBColumnStorage] = None, + invisible: Optional[bool] = None, + ): + super().__init__() + 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/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/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/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/partition.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py new file mode 100644 index 00000000..8d243408 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/partition.py @@ -0,0 +1,301 @@ +# 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 dataclasses import dataclass +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" + + +@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.""" + + 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" + + +@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 + 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. + + 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``). + + ``partition_options`` carries MariaDB-only storage options typed on + :class:`MariaDBPartitionOptions`. + + Raises: + ValueError: if both boundaries are provided, or neither is 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") + + +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", + "MariaDBPartitionOptions", + "MariaDBPartitionMaxValue", + "MariaDBPartitionValue", + "MariaDBSubpartitionDefinition", + "MariaDBPartitionDefinition", + "MariaDBPartitionClause", + "MariaDBPartitionByRange", + "MariaDBPartitionByRangeColumns", + "MariaDBPartitionByList", + "MariaDBPartitionByListColumns", + "MariaDBPartitionByHash", + "MariaDBPartitionByKey", +] 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/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/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: 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..9df0e636 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/expression/table_options.py @@ -0,0 +1,96 @@ +# 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 enum import Enum + +from rhosocial.activerecord.backend.expression.statements import CreateTableOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__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`` / + ``auto_increment`` / ``row_format`` / ``with_system_versioning`` options. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + *, + or_replace: bool = False, + comment: Optional[str] = None, + 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 = ( + 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 + ) + 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/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/expression/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/expression/types.py index 28f339ff..c779e81b 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: @@ -207,15 +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 - def __init__(self, dialect=None, display_width: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + #: 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: @@ -233,14 +244,32 @@ 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: 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.""" @@ -249,9 +278,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 +300,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 +332,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 +361,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/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/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/__init__.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/__init__.py index 5715be07..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 @@ -64,7 +65,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 +117,11 @@ 'MariaDBRoutineMixin', 'MariaDBAdminMixin', 'MariaDBDateTimeMixin', - 'MariaDBCollationMixin', + 'MariaDBCharset', + 'MariaDBCollation', + 'MariaDBStorageEngine', + 'MariaDBCharsetCollationMixin', + 'MariaDBDatabaseMixin', 'MariaDBCTEMixin', 'MariaDBWindowMixin', 'MariaDBFilterClauseMixin', 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), } 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..9ca89883 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/charset_collation.py @@ -0,0 +1,356 @@ +# 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`` 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" + 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" + # 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" + + +_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 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+. + "utf8mb4": (5, 5, 3), + "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), +} + +#: 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: + """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. + + 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() + 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: + self.validate_charset_name(name) + except (TypeError, ValueError): + return False + 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 + 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 self._resolve_charset_alias(normalized, version) + + # --- 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: + 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 ( + 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}") + 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 -------------------------------------------------- + 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/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/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_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_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/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/ddl_table.py index 0b486d88..3b4af2d7 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 @@ -76,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 = "" @@ -107,7 +133,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) @@ -118,32 +144,36 @@ 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 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: + 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) @@ -151,9 +181,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,9 +226,24 @@ def format_column_definition( if constraint_parts: parts.append(' '.join(constraint_parts)) - if col_def.comment: - escaped_comment = self._escape_sql_string(col_def.comment) - parts.append(f"COMMENT '{escaped_comment}'") + 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)}") + 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 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() @@ -260,7 +314,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/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/dml.py index 4c903143..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, @@ -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/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) 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/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/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/partition.py index 7d792fa4..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: @@ -100,13 +103,294 @@ 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)] + 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: + 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 partition_options is not None: + options_sql, options_params = self.format_partition_definition_options( + partition_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: 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 = { + "engine": "ENGINE", + "comment": "COMMENT", + "data_directory": "DATA DIRECTORY", + "index_directory": "INDEX DIRECTORY", + "max_rows": "MAX_ROWS", + "min_rows": "MIN_ROWS", + "tablespace": "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"{name} option must be a non-empty string") + parts.append(f"{keyword} {self.format_identifier(value)}") + elif name in {"comment", "data_directory", "index_directory"}: + if not isinstance(value, str): + raise TypeError(f"{name} option must be a string") + escaped = self._escape_sql_string(value) + parts.append(f"{keyword} '{escaped}'") + elif name in {"max_rows", "min_rows"}: + if not isinstance(value, int) or value < 0: + raise TypeError(f"{name} 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 = [] + partition_options = definition.partition_options + if partition_options is not None: + options_sql, options_params = self.format_partition_definition_options( + partition_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/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/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/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/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py index c6202a52..a3e09dfe 100644 --- a/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mariadb/mixins/types.py @@ -4,10 +4,11 @@ from __future__ import annotations import re +import warnings 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 +60,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 +164,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 +332,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 +386,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 +492,7 @@ def supports_data_type_custom(self) -> bool: return True # ------------------------------------------------------------------ - # DDLTypeSupport — parsing + # DataTypeSupport — parsing # ------------------------------------------------------------------ _MARIA_INTEGER_TYPES = re.compile( @@ -651,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": 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/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. """ ... 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/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 diff --git a/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py b/src/rhosocial/activerecord/backend/impl/mariadb/protocols/table.py index a19639b2..85aef253 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 @@ -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. @@ -71,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. """ ... @@ -104,10 +84,14 @@ 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).""" ... - def format_inline_index(self, idx_def: Any) -> Tuple[str, tuple]: + def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: """Format an inline index definition inside CREATE TABLE.""" ... 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 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): 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 ========== 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/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 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..38950b8a --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -0,0 +1,58 @@ +# tests/rhosocial/activerecord_mariadb_test/feature/backend/ddl/test_derived_ddl_inline_index.py +"""MariaDB inline-index support for direct DDL expressions. + +The DDLSource index declaration is passed directly to CreateTableExpression +on a dialect that advertises ``supports_inline_index()``. +""" + +try: + from typing import Annotated +except ImportError: # Python 3.8 + from typing_extensions import Annotated + +from rhosocial.activerecord.base import UseIndex +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 + + +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 _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(): + 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_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/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_charset_alias.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py new file mode 100644 index 00000000..6bce4840 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_charset_alias.py @@ -0,0 +1,255 @@ +# 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_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: + """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_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, 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_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: + """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_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_mariadb_column_definition.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py new file mode 100644 index 00000000..8312f253 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_column_definition.py @@ -0,0 +1,98 @@ +# 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 import ColumnCommentClause +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=ColumnCommentClause(dialect, "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) 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 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..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, @@ -141,17 +142,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=TableCommentClause(dialect, '用户信息表')), ) 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 +175,7 @@ def test_table_comment_with_storage_options(self): 'ENGINE': 'InnoDB', 'DEFAULT CHARSET': 'utf8mb4' }, - dialect_options={'comment': '用户信息表'} + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, '用户信息表')), ) sql, params = expr.to_sql() assert "ENGINE='InnoDB'" in sql @@ -181,11 +190,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=TableCommentClause(dialect, "测试's表")), ) sql, params = expr.to_sql() assert "COMMENT" in sql @@ -200,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, @@ -218,14 +231,18 @@ 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, + ) + expr = CreateTableExpression( dialect=dialect, table='users', columns=columns, - dialect_options={'comment': '用户表'} + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, '用户表')), ) sql, params = expr.to_sql() assert "COMMENT '主键'" in sql @@ -261,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, @@ -704,6 +721,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']) @@ -715,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 = [ @@ -754,7 +775,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=TableCommentClause(dialect, 'User information table')), ) 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 8a6100d1..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 @@ -17,8 +17,12 @@ ) 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 ( + MariaDBAlterTableExpression, +) from rhosocial.activerecord.backend.impl.mariadb.expression.rename_index import ( MariaDBRenameIndexExpression, ) @@ -86,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`' @@ -95,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() @@ -104,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`' @@ -113,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`' @@ -122,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() @@ -146,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() @@ -200,9 +201,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 +212,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 +231,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 @@ -271,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): 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..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,15 +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.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 @@ -78,3 +76,70 @@ 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 + + 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 = 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() + + def test_table_tablespace_is_propagated_and_rejected(self): + dialect = MariaDBDialect(version=(10, 6, 0)) + assert dialect.supports_table_tablespace() is False + 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_dialect_security.py b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_dialect_security.py index 5e0701ec..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 @@ -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, @@ -28,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( @@ -53,7 +65,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) @@ -95,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.""" - - 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 + 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_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")], ) - - with pytest.raises(UnsupportedFeatureError): - dialect.format_json_table_expression(expr) + sql, params = dialect.format_json_table_expression(expr) + assert sql.startswith("JSON_TABLE(") + assert params == () -class TestMySQLJSONTableErrorHandling: - """Tests for JSON_TABLE col.error_handling validation. - - MariaDB does NOT support JSON_TABLE, so format_json_table_expression - always raises UnsupportedFeatureError. - """ +class TestMySQLJSONTableTypeValidation: + """JSON_TABLE column types are interpolated, so they are allow-listed.""" - 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"}', @@ -275,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", @@ -297,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: @@ -436,15 +377,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=TableCommentClause(dialect, "Table's comment with 'quotes'") + ), ) sql, params = dialect.format_create_table_statement(expr) @@ -455,15 +399,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=TableCommentClause(dialect, "Test\\value")), ) sql, params = dialect.format_create_table_statement(expr) 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..78e68ce4 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_mariadb_partition_expressions.py @@ -0,0 +1,240 @@ +# 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, + MariaDBPartitionOptions, + 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)], + partition_options=MariaDBPartitionOptions(comment="first"), + ) + ], + ) + sql, _ = expr.to_sql() + assert sql.endswith("PARTITION `p0` VALUES LESS THAN (1) COMMENT 'first')") + + +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", + ), + ) + 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): + 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) 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..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 @@ -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, @@ -97,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, @@ -150,6 +153,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. @@ -170,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 735ec85f..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 @@ -21,7 +21,8 @@ import pytest -from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin +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, @@ -30,11 +31,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 +79,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 +91,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 +122,67 @@ 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_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() 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.""" @@ -196,22 +251,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() 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: 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..b0a34ad4 --- /dev/null +++ b/tests/rhosocial/activerecord_mariadb_test/feature/backend/test_table_option_capabilities.py @@ -0,0 +1,73 @@ +# 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 + + +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()