diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55d27dca..2a5684e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,7 +58,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@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 and test database run: | @@ -249,7 +249,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 and test database run: | @@ -356,7 +356,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 and test database run: | diff --git a/CLAUDE.md b/CLAUDE.md index c1156fa5..755090ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,4 +28,10 @@ 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. \ No newline at end of file +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/changelog.d/56.added.md b/changelog.d/56.added.md new file mode 100644 index 00000000..66e920c7 --- /dev/null +++ b/changelog.d/56.added.md @@ -0,0 +1 @@ +Adapt the MySQL backend to derived DDL with typed table and column options: `MySQLColumnDefinition`, `MySQLCreateTableOptions` with typed `AUTO_INCREMENT` and `ROW_FORMAT`, a partition lifecycle provider with validated subpartition names, a version-aware charset/collation/storage-engine protocol, `MySQLInsertExpression` with typed `IGNORE`/`REPLACE`, and `MySQLAddColumn` with a typed `after` field. Add a type-domain negative contract plus coverage for `DDLSource` collection, table inheritance, tablespace and inline-index rendering. diff --git a/changelog.d/56.internal.md b/changelog.d/56.internal.md new file mode 100644 index 00000000..08961541 --- /dev/null +++ b/changelog.d/56.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/56.removed.md b/changelog.d/56.removed.md new file mode 100644 index 00000000..2f24513f --- /dev/null +++ b/changelog.d/56.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects, DDL and admin expressions, and partition definitions, along with the legacy identity fields on column classes. diff --git a/pyproject.toml b/pyproject.toml index f0da44a7..51389d48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ markers = [ "benchmark_sync: marks sync benchmark tests", "benchmark_write: marks write benchmark tests", "benchmark_read: marks read benchmark 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/mysql/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/__init__.py index 99a4a06d..6c49f3e2 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/__init__.py @@ -21,7 +21,7 @@ from .backend import MySQLBackend from .async_backend import AsyncMySQLBackend from .config import MySQLConnectionConfig -from .collation import MySQLCollation, MySQLCollationValidator +from .mixins.charset_collation import MySQLCharset, MySQLCollation, MySQLStorageEngine from .dialect import MySQLDialect from .transaction import MySQLTransactionManager from .async_transaction import AsyncMySQLTransactionManager @@ -158,7 +158,8 @@ # Dialect related "MySQLDialect", "MySQLCollation", - "MySQLCollationValidator", + "MySQLCharset", + "MySQLStorageEngine", # Transaction - Sync and Async "MySQLTransactionManager", "AsyncMySQLTransactionManager", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py b/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py index db2b41a1..99570050 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/async_transaction.py @@ -18,7 +18,7 @@ from .mixins import MySQLTransactionMixin if TYPE_CHECKING: - from .backend import AsyncMySQLBackend + from .async_backend import AsyncMySQLBackend class AsyncMySQLTransactionManager(MySQLTransactionMixin, AsyncTransactionManager): diff --git a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py index d1e07b4e..b97ec611 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/dialect.py @@ -6,10 +6,9 @@ based on the MySQL version provided at initialization. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, List, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -from rhosocial.activerecord.backend.expression.bases import ToSQLProtocol from rhosocial.activerecord.backend.dialect.protocols import ( CollationSupport, CTESupport, @@ -36,7 +35,9 @@ TruncateSupport, TransactionControlSupport, SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, @@ -70,10 +71,11 @@ DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, + DomainMixin, TransactionControlMixin, SetOperationMixin, ) -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from .protocols import ( MySQLTriggerSupport, MySQLTableSupport, @@ -120,7 +122,7 @@ MySQLLoadXMLLMixin, MySQLAdminCommandMixin, MySQLDateTimeMixin, - MySQLCollationMixin, + MySQLCharsetCollationMixin, MySQLCTEMixin, MySQLWindowMixin, MySQLGroupingMixin, @@ -131,6 +133,7 @@ MySQLDDLColumnMixin, MySQLViewMixin, MySQLSchemaMixin, + MySQLDatabaseMixin, MySQLConstraintMixin, MySQLGeneratedColumnMixin, MySQLFunctionMixin, @@ -139,32 +142,16 @@ from .show.dialect import MySQLShowDialectMixin if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.collation import CollateExpression from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - CreateViewExpression, - DropViewExpression, - ExplainExpression, InsertExpression, ) - from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( - FulltextMatchExpression, - ) - from rhosocial.activerecord.backend.expression.statements.ddl_trigger import ( - CreateTriggerExpression, - DropTriggerExpression, - ) - from rhosocial.activerecord.backend.expression.transaction import ( - SetTransactionExpression, - BeginTransactionExpression, - ) class MySQLDialect( SQLDialectBase, # MySQL-specific mixins (before generic mixins to override methods) MySQLDateTimeMixin, - MySQLCollationMixin, + MySQLCharsetCollationMixin, MySQLCTEMixin, MySQLWindowMixin, MySQLGroupingMixin, @@ -175,6 +162,7 @@ class MySQLDialect( MySQLDDLColumnMixin, MySQLViewMixin, MySQLSchemaMixin, + MySQLDatabaseMixin, MySQLConstraintMixin, MySQLGeneratedColumnMixin, MySQLFunctionMixin, @@ -221,6 +209,8 @@ class MySQLDialect( MySQLModifyColumnMixin, MySQLJsonDualityViewMixin, MySQLTypeSupportMixin, + UserDefinedTypeMixin, + DomainMixin, MySQLOptimizerHintMixin, MySQLTableStatementMixin, MySQLMaintenanceMixin, @@ -281,7 +271,9 @@ class MySQLDialect( MySQLLoadXMLSupport, MySQLAdminCommandSupport, SQLFunctionSupport, - DDLTypeSupport, + DataTypeSupport, + UserDefinedTypeSupport, + DomainSupport, ): """ MySQL dialect implementation that adapts to the MySQL version. @@ -411,9 +403,10 @@ def get_upsert_syntax_type(self) -> str: def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple]: """Format INSERT statement with MySQL-specific options. - Extends the base implementation to support: - - INSERT IGNORE via dialect_options={'ignore': True} - - REPLACE INTO via dialect_options={'replace': True} + Extends the base implementation to support the typed flags on + ``MySQLInsertExpression``: + - ``ignore=True`` → ``INSERT IGNORE`` + - ``replace=True`` → ``REPLACE INTO`` Args: expr: InsertExpression instance @@ -430,8 +423,8 @@ def format_insert_statement(self, expr: "InsertExpression") -> Tuple[str, tuple] expr.validate(strict=True) # Check for conflicting options - 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/mysql/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py index 40205feb..f8730409 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/ddl/create_table.py @@ -15,6 +15,7 @@ import os from rhosocial.activerecord.backend.impl.mysql import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( DropTableExpression, CreateTableExpression, @@ -41,7 +42,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) @@ -102,14 +103,11 @@ # Create table with MySQL-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=MySQLCreateTableOptions(dialect, engine="InnoDB", charset="utf8mb4"), ) sql, params = create_expr.to_sql() @@ -131,7 +129,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() @@ -141,7 +139,7 @@ # ============================================================ # Key points: # 1. Use ColumnConstraint with is_auto_increment=True for AUTO_INCREMENT -# 2. MySQL dialect_options supports 'engine' and 'charset' keys +# 2. MySQL table options (engine/charset) are typed on MySQLCreateTableOptions # 3. IndexDefinition creates inline indexes within CREATE TABLE # 4. Use current_timestamp(dialect) for SQL niladic functions (no parentheses) # 5. Use introspector.get_columns() to verify table structure diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py index e7e306df..308da03d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/schema_diff/column_order.py @@ -67,15 +67,18 @@ MySQLSchemaDiffer, ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( # noqa: E402 - AlterTableExpression, AddColumn, + AlterTableExpression, +) +from rhosocial.activerecord.backend.impl.mysql.expression import ( # noqa: E402 + MySQLAddColumn, ) 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_col = MySQLAddColumn(dialect, ColumnDefinition(dialect, "age", IntegerType(dialect)), + after="name") 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/mysql/examples/transaction/exclusive.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py index 7d67708d..6bd74fc9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/exclusive.py @@ -11,6 +11,7 @@ import os from rhosocial.activerecord.backend.impl.mysql import MySQLBackend from rhosocial.activerecord.backend.impl.mysql.config import MySQLConnectionConfig +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( CreateTableExpression, DropTableExpression, @@ -78,7 +79,7 @@ ), ], if_not_exists=True, - dialect_options={"engine": "InnoDB"}, + table_options=MySQLCreateTableOptions(dialect, engine="InnoDB"), ) sql, params = create_table.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py index b35faff3..bfecaffe 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/examples/transaction/for_update.py @@ -26,6 +26,7 @@ backend.connect() dialect = backend.dialect +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.expression import ( # noqa: E402 CreateTableExpression, InsertExpression, @@ -81,7 +82,7 @@ ), ], if_not_exists=True, - dialect_options={"engine": "InnoDB"}, + table_options=MySQLCreateTableOptions(dialect, engine="InnoDB"), ) sql, params = create_table.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py index 0232f97f..ce9bbe7c 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/__init__.py @@ -19,6 +19,15 @@ - types.py - MySQL-specific DataType subclasses for DDL """ +from .table_options import MySQLRowFormat, MySQLCreateTableOptions +from .dml import MySQLInsertExpression +from .alter_column import MySQLAddColumn +from .column import ( + MySQLColumnFormat, + MySQLColumnStorage, + MySQLColumnDefinition, + MySQLColumnOptions, +) from .load_data import MySQLLoadDataExpression, LoadDataOptions from .json_table import MySQLJSONTableExpression, JSONTableColumn, NestedPath from .json import ( @@ -90,6 +99,7 @@ MySQLPartitionByListColumns, MySQLPartitionByHash, MySQLPartitionByKey, + MySQLPartitionOptions, MySQLAddPartitionExpression, MySQLDropPartitionExpression, MySQLTruncatePartitionExpression, @@ -200,6 +210,14 @@ __all__ = [ "MySQLLoadDataExpression", "LoadDataOptions", + "MySQLCreateTableOptions", + "MySQLRowFormat", + "MySQLInsertExpression", + "MySQLAddColumn", + "MySQLColumnFormat", + "MySQLColumnStorage", + "MySQLColumnDefinition", + "MySQLColumnOptions", "MySQLJSONTableExpression", "JSONTableColumn", "NestedPath", @@ -261,6 +279,7 @@ "MySQLPartitionByListColumns", "MySQLPartitionByHash", "MySQLPartitionByKey", + "MySQLPartitionOptions", "MySQLAddPartitionExpression", "MySQLDropPartitionExpression", "MySQLTruncatePartitionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py index 9bc1330b..d87b6ce5 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/admin.py @@ -87,12 +87,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: @@ -114,12 +112,9 @@ def __init__( self, dialect: "SQLDialectBase", option: ResetOption, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.option: ResetOption = option - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -136,14 +131,11 @@ def __init__( dialect: "SQLDialectBase", cache_entries: List[Dict[str, Any]], key_cache: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) # Each entry: {"table": name, "indexes": [names]} (indexes optional) self.cache_entries: List[Dict[str, Any]] = list(cache_entries) self.key_cache: str = key_cache - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -159,12 +151,9 @@ def __init__( self, dialect: "SQLDialectBase", cache_entries: List[Dict[str, Any]], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.cache_entries: List[Dict[str, Any]] = list(cache_entries) - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -180,12 +169,9 @@ def __init__( self, dialect: "SQLDialectBase", names: List[str], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.names: List[str] = list(names) - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -201,12 +187,9 @@ def __init__( self, dialect: "SQLDialectBase", names: List[str], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.names: List[str] = list(names) - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -223,13 +206,10 @@ def __init__( dialect: "SQLDialectBase", plugin_name: str, soname: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.plugin_name: str = plugin_name self.soname: str = soname - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -245,12 +225,9 @@ def __init__( self, dialect: "SQLDialectBase", plugin_name: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.plugin_name: str = plugin_name - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -279,7 +256,6 @@ def __init__( password: Optional[str] = None, from_data_directory: Optional[str] = None, to_data_directory: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.from_user = from_user @@ -288,7 +264,6 @@ def __init__( self.password = password self.from_data_directory = from_data_directory self.to_data_directory = to_data_directory - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -303,11 +278,8 @@ class MySQLRestartExpression(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 {} @property def format_method(self) -> str: @@ -323,12 +295,9 @@ def __init__( self, dialect: "SQLDialectBase", encoded: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.encoded: str = encoded - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -345,13 +314,10 @@ def __init__( dialect: "SQLDialectBase", table: Any, alias: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table = table self.alias = alias - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -373,7 +339,6 @@ def __init__( key_value: Optional[Any] = None, where: Optional[Any] = None, limit: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table = table @@ -382,7 +347,6 @@ def __init__( self.key_value = key_value self.where = where self.limit = limit - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -398,12 +362,9 @@ def __init__( self, dialect: "SQLDialectBase", table: Any, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.table = table - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -419,12 +380,9 @@ def __init__( self, dialect: "SQLDialectBase", expressions: List[Any], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.expressions: List[Any] = list(expressions) - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -441,13 +399,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 {} @property def format_method(self) -> str: @@ -462,11 +417,8 @@ class MySQLShutdownExpression(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 {} @property def format_method(self) -> str: @@ -482,12 +434,9 @@ def __init__( self, dialect: "SQLDialectBase", topic: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.topic: str = topic - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -517,13 +466,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 {} @property def format_method(self) -> str: @@ -541,12 +488,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 {} @property def format_method(self) -> str: @@ -582,14 +527,12 @@ def __init__( *, on_object: Optional[str] = None, with_grant_option: 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 # default "*.*" self.with_grant_option: bool = with_grant_option - self.dialect_options: Dict[str, Any] = dialect_options or {} @property def format_method(self) -> str: @@ -608,13 +551,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 {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py new file mode 100644 index 00000000..82073fd3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py @@ -0,0 +1,45 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/alter_column.py +"""MySQL-specific ALTER TABLE column actions. + +MySQL's ``ADD COLUMN`` accepts a positional clause that has no generic +equivalent: + +* ``AFTER `` — place the new column immediately after ``column``. + +It lives on ``MySQLAddColumn`` (deriving the generic ``AddColumn``) and is +rendered by the MySQL ``format_add_column_action`` override. MySQL does +**not** share it with any other backend. +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ( + AddColumn, + ColumnDefinition, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "MySQLAddColumn", +] + + +class MySQLAddColumn(AddColumn): + """A MySQL ``ADD COLUMN`` action extending the generic one. + + Adds the MySQL-only ``after`` positional clause. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + column: ColumnDefinition, + *, + if_not_exists: Optional[bool] = None, + after: Optional[str] = None, + ): + super().__init__(dialect, column, if_not_exists=if_not_exists) + self.after = after diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py new file mode 100644 index 00000000..cbc0a901 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/column.py @@ -0,0 +1,146 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/column.py +"""MySQL-specific column definition expressions. + +MySQL extends the standard column definition with per-column attributes that +have no generic equivalent: + +* ``CHARACTER SET `` — column character set. +* ``COLLATE `` — column collation (rendered via the collate clause). +* ``COLUMN_FORMAT {FIXED|DYNAMIC|DEFAULT}`` — NDB column format. +* ``STORAGE {DISK|MEMORY|DEFAULT}`` — NDB storage type. +* ``INVISIBLE`` — column hidden from ``SELECT *``. + +These live on ``MySQLColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the MySQL +``format_column_definition`` override. They are declared through +``MySQLColumnOptions`` (deriving the generic ``ColumnOptions``). +""" + +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__ = [ + "MySQLColumnFormat", + "MySQLColumnStorage", + "MySQLColumnDefinition", + "MySQLColumnOptions", +] + + +class MySQLColumnFormat(Enum): + """MySQL NDB ``COLUMN_FORMAT`` values.""" + + FIXED = "FIXED" + DYNAMIC = "DYNAMIC" + DEFAULT = "DEFAULT" + + +class MySQLColumnStorage(Enum): + """MySQL NDB ``STORAGE`` values.""" + + DISK = "DISK" + MEMORY = "MEMORY" + DEFAULT = "DEFAULT" + + +class MySQLColumnDefinition(ColumnDefinition): + """A MySQL column definition extending the generic one. + + Adds MySQL-only typed attributes: ``character_set``, ``column_format``, + ``storage`` and ``invisible``. The MySQL ``format_column_definition`` + renders these alongside the generic parts. + """ + + 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[MySQLColumnFormat] = None, + storage: Optional[MySQLColumnStorage] = 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, MySQLColumnFormat): + raise TypeError( + "column_format must be a MySQLColumnFormat value, " + f"got {type(column_format).__name__}" + ) + if storage is not None and not isinstance(storage, MySQLColumnStorage): + raise TypeError( + "storage must be a MySQLColumnStorage value, " + f"got {type(storage).__name__}" + ) + self.character_set = character_set + self.column_format = column_format + self.storage = storage + self.invisible = invisible + + +class MySQLColumnOptions(ColumnOptions): + """MySQL per-column options declaration. + + Carries MySQL-only typed attributes; callers apply them to a + ``MySQLColumnDefinition`` via :meth:`apply_to`. + """ + + def __init__( + self, + *, + character_set: Optional[str] = None, + column_format: Optional[MySQLColumnFormat] = None, + storage: Optional[MySQLColumnStorage] = None, + invisible: Optional[bool] = None, + ): + super().__init__() + if column_format is not None and not isinstance(column_format, MySQLColumnFormat): + raise TypeError( + "column_format must be a MySQLColumnFormat value, " + f"got {type(column_format).__name__}" + ) + if storage is not None and not isinstance(storage, MySQLColumnStorage): + raise TypeError( + "storage must be a MySQLColumnStorage 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 ``MySQLColumnDefinition`` for these options.""" + return MySQLColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the MySQL-only fields onto the column definition.""" + if not isinstance(column, MySQLColumnDefinition): + raise TypeError( + "MySQLColumnOptions.apply_to requires a MySQLColumnDefinition, " + 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/mysql/expression/dml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py new file mode 100644 index 00000000..cc6a1bf8 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py @@ -0,0 +1,47 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/dml.py +"""MySQL-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 MySQLInsertExpression(InsertExpression): + """A MySQL INSERT statement extending the generic one. + + MySQL supports ``REPLACE INTO`` and ``INSERT IGNORE`` which have no direct + generic equivalent and different semantics from other backends. The flags + live here as typed fields and are rendered by the MySQL + ``format_insert_statement`` override. + """ + + 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__ = [ + "MySQLInsertExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py index dcef47be..1f4606fc 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/load_xml.py @@ -12,7 +12,7 @@ """ from enum import Enum -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -63,7 +63,6 @@ def __init__( rows_identified_by: Optional[str] = None, ignore_count: Optional[int] = None, ignore_unit: str = "LINES", - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.file_path: str = file_path @@ -75,7 +74,6 @@ def __init__( self.rows_identified_by: Optional[str] = rows_identified_by self.ignore_count: Optional[int] = ignore_count self.ignore_unit: str = ignore_unit - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: """Validate expression parameters. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py index 778f0fb4..023fceec 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/maintenance.py @@ -12,7 +12,7 @@ """ from enum import Enum -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -61,7 +61,6 @@ class MySQLTableMaintenanceExpression(BaseExpression): operation: Statement keyword (ANALYZE / CHECK / CHECKSUM / OPTIMIZE / REPAIR). tables: List of table names (may be schema-qualified tuples). no_write_to_binlog: NO_WRITE_TO_BINLOG / LOCAL selector (where supported). - dialect_options: Additional MySQL-specific options. """ operation: str = "" @@ -72,12 +71,10 @@ def __init__( tables: List[Any], *, no_write_to_binlog: "NoWriteToBinlogOption" = NoWriteToBinlogOption.NONE, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.tables: List[Any] = list(tables) self.no_write_to_binlog: NoWriteToBinlogOption = no_write_to_binlog - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: """Validate table list. @@ -120,13 +117,11 @@ def __init__( tables: List[Any], *, options: Optional[List[CheckTableOption]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, - dialect_options=dialect_options, ) self.options: List[CheckTableOption] = list(options or []) @@ -148,13 +143,11 @@ def __init__( tables: List[Any], *, option: Optional[ChecksumTableOption] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, tables, no_write_to_binlog=NoWriteToBinlogOption.NONE, - dialect_options=dialect_options, ) self.option: Optional[ChecksumTableOption] = option @@ -183,13 +176,11 @@ def __init__( *, no_write_to_binlog: "NoWriteToBinlogOption" = NoWriteToBinlogOption.NONE, options: Optional[List[RepairTableOption]] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, tables, no_write_to_binlog=no_write_to_binlog, - dialect_options=dialect_options, ) self.options: List[RepairTableOption] = list(options or []) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py index 837588d3..38a4f0b7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/partition.py @@ -7,15 +7,15 @@ from decimal import Decimal from enum import Enum from math import isfinite -from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, Union +from typing import Any, List, Optional, Sequence, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLValueExpression -from rhosocial.activerecord.backend.expression.mixins import ( - AliasableMixin, - ComparisonMixin, -) +from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.expression.core import TableExpression -from rhosocial.activerecord.backend.expression.statements import PartitionClause +from rhosocial.activerecord.backend.expression.statements import ( + PartitionClause, + PartitionDefinition, + SubpartitionDefinition, +) class MySQLPartitionStrategy(Enum): @@ -45,19 +45,56 @@ class MySQLSubpartitionStrategy(Enum): @dataclass -class MySQLSubpartitionDefinition: +class MySQLPartitionOptions: + """Typed MySQL storage options for a partition or subpartition definition. + + Replaces the generic ``dialect_options`` bag with one attribute per MySQL + ``PARTITION`` option keyword rendered by the MySQL 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 + + +@dataclass +class MySQLSubpartitionDefinition(SubpartitionDefinition): """A single named subpartition within a partition definition. - Used when individual subpartitions need explicit names or distinct - storage options. When omitted, MySQL applies the template from the - ``SUBPARTITION BY`` clause automatically. + MySQL subpartitions carry no explicit boundary (the ``SUBPARTITION BY`` + template applies), so this subclass adds nothing beyond the base name and + options. It exists as the MySQL-owned type derived from the generic + :class:`~rhosocial.activerecord.backend.expression.statements.SubpartitionDefinition`. Raises: - ValueError: if name is empty or whitespace-only. + TypeError: if ``partition_options`` is not a :class:`MySQLPartitionOptions` + when provided. """ - name: str - dialect_options: Optional[Dict[str, Any]] = None + partition_options: Optional[MySQLPartitionOptions] = None + + def __post_init__(self) -> None: + super().__post_init__() + if self.partition_options is not None and not isinstance( + self.partition_options, MySQLPartitionOptions + ): + raise TypeError( + "partition_options must be a MySQLPartitionOptions value, " + f"got {type(self.partition_options).__name__}" + ) class MySQLSubpartitionClause(BaseExpression): @@ -144,9 +181,13 @@ def format_method(self) -> str: @dataclass -class MySQLPartitionDefinition: +class MySQLPartitionDefinition(PartitionDefinition): """A MySQL ``PARTITION ... VALUES ...`` definition. + Derives from the generic + :class:`~rhosocial.activerecord.backend.expression.statements.PartitionDefinition` + and tightens validation: MySQL requires exactly one boundary form. + For single-column LIST COLUMNS, ``in_values`` accepts a flat sequence of ``BaseExpression`` (e.g. ``[val('a'), val('b')]`` → ``VALUES IN ('a', 'b')``). @@ -157,30 +198,30 @@ class MySQLPartitionDefinition: When subpartitioning is used, ``subpartition_definitions`` optionally overrides the template from the ``SUBPARTITION BY`` clause for this - specific partition. + specific partition. ``partition_options`` carries MySQL-only storage + options typed on :class:`MySQLPartitionOptions`. Raises: ValueError: if both ``less_than`` and ``in_values`` are provided, or if neither is provided. - TypeError: if ``dialect_options`` is not a dict when provided. + TypeError: if ``partition_options`` is not a + :class:`MySQLPartitionOptions` when provided. """ - name: str - less_than: Optional[Sequence[BaseExpression]] = None - in_values: Optional[Sequence[Union[BaseExpression, Sequence[BaseExpression]]]] = None subpartition_definitions: Optional[Sequence["MySQLSubpartitionDefinition"]] = None - dialect_options: Optional[dict] = None + partition_options: Optional[MySQLPartitionOptions] = None def __post_init__(self) -> None: - if self.less_than is not None and self.in_values is not None: - raise ValueError("less_than and in_values are mutually exclusive") - if self.less_than is None and self.in_values is None: - raise ValueError("partition definition requires less_than or in_values") - if self.dialect_options is not None and not isinstance(self.dialect_options, dict): + super().__post_init__() + if self.partition_options is not None and not isinstance( + self.partition_options, MySQLPartitionOptions + ): raise TypeError( - "dialect_options must be dict or None, " - f"got {type(self.dialect_options).__name__}" + "partition_options must be a MySQLPartitionOptions 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 MySQLPartitionClause(PartitionClause): diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py index 304d7509..3de597d7 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/rename_table.py @@ -10,7 +10,7 @@ together. """ -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import List, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -23,19 +23,15 @@ class MySQLRenameTableExpression(BaseExpression): Attributes: renames: Sequence of ``(old_name, new_name)`` table name pairs. - dialect_options: Additional MySQL-specific options. """ def __init__( self, dialect: "SQLDialectBase", renames: List[Tuple[str, str]], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.renames: List[Tuple[str, str]] = list(renames) - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: """Validate the rename pair list. diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py index f8236cac..d8635505 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/routine.py @@ -14,7 +14,7 @@ installation-time administrative action (see admin expressions instead). """ -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -38,13 +38,11 @@ def __init__( *, params: Optional[List[Any]] = None, body: Optional[str] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.name: Any = name self.params: List[Any] = list(params or []) self.body: Optional[str] = body - self.dialect_options: Dict[str, Any] = dialect_options or {} def validate(self, strict: bool = True) -> None: if not strict: @@ -81,9 +79,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 @property @@ -105,14 +102,12 @@ def __init__( params: Optional[List[Any]] = None, body: Optional[str] = None, deterministic: bool = False, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, name, params=params, body=body, - dialect_options=dialect_options, ) self.returns: str = returns self.deterministic: bool = deterministic @@ -133,9 +128,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 @property @@ -158,13 +152,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/mysql/expression/table_options.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py new file mode 100644 index 00000000..3ac11b73 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py @@ -0,0 +1,93 @@ +# src/rhosocial/activerecord/backend/impl/mysql/expression/table_options.py +"""MySQL-specific CREATE TABLE options. + +MySQL adds table-level options that have no generic equivalent: + +* ``ENGINE=`` — storage engine. +* ``DEFAULT CHARSET=`` — table character set. +* ``COLLATE=`` — table collation. + +These live on ``MySQLCreateTableOptions`` (deriving the generic +``CreateTableOptions``) and are rendered by the MySQL +``format_create_table_statement`` override. MySQL does **not** share them with +any other backend. +""" + +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__ = [ + "MySQLRowFormat", + "MySQLCreateTableOptions", +] + + +class MySQLRowFormat(Enum): + """MySQL ``ROW_FORMAT`` table option values.""" + + DEFAULT = "DEFAULT" + DYNAMIC = "DYNAMIC" + FIXED = "FIXED" + COMPRESSED = "COMPRESSED" + REDUNDANT = "REDUNDANT" + COMPACT = "COMPACT" + + +class MySQLCreateTableOptions(CreateTableOptions): + """A MySQL CREATE TABLE options declaration extending the generic one. + + Adds the MySQL-only ``engine`` / ``charset`` / ``collate`` / + ``auto_increment`` / ``row_format`` table 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, + ): + 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) + + @staticmethod + def _normalize_row_format(value: Optional[object]) -> Optional[str]: + if value is None: + return None + if isinstance(value, MySQLRowFormat): + return value.value + if isinstance(value, str): + normalized = value.upper() + if normalized in {member.value for member in MySQLRowFormat}: + return normalized + raise ValueError(f"Unsupported MySQL ROW_FORMAT: {value!r}") + raise TypeError( + f"row_format must be MySQLRowFormat or str, got {type(value).__name__}" + ) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py index c3f628cb..52671ab1 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/table_statement.py @@ -10,7 +10,7 @@ row list be used as a table value constructor. """ -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import Any, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -25,7 +25,6 @@ class MySQLBaseTableStatement(BaseExpression): order_by: Optional list of column names for the ORDER BY clause. limit: Optional LIMIT row count. offset: Optional OFFSET row count. - dialect_options: Additional MySQL-specific options. """ def __init__( @@ -35,13 +34,11 @@ def __init__( order_by: Optional[List[str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.order_by: List[str] = list(order_by or []) self.limit: Optional[int] = limit self.offset: Optional[int] = offset - self.dialect_options: Dict[str, Any] = dialect_options or {} def _validate_common(self) -> None: if self.limit is not None and self.limit < 0: @@ -61,14 +58,12 @@ def __init__( order_by: Optional[List[str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, order_by=order_by, limit=limit, offset=offset, - dialect_options=dialect_options, ) self.table_name: str = table_name @@ -97,14 +92,12 @@ def __init__( order_by: Optional[List[str]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__( dialect, order_by=order_by, limit=limit, offset=offset, - dialect_options=dialect_options, ) self.rows: List[List[Any]] = [list(row) for row in rows] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/expression/types.py b/src/rhosocial/activerecord/backend/impl/mysql/expression/types.py index ab8fe38d..cf1e75d3 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/expression/types.py @@ -26,7 +26,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, @@ -55,9 +55,8 @@ class MySQLIntType(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 @@ -78,9 +77,8 @@ class MySQLTinyIntType(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 @@ -101,9 +99,8 @@ class MySQLSmallIntType(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 @@ -124,9 +121,8 @@ class MySQLBigIntType(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 @@ -205,9 +201,8 @@ class MySQLBitType(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: @@ -225,9 +220,8 @@ class MySQLYearType(DataType): display_width: Optional[int] = None - def __init__(self, dialect=None, display_width: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, display_width: Optional[int] = None): + super().__init__(dialect) self.display_width = display_width def _type_params(self) -> tuple: @@ -243,9 +237,8 @@ class MySQLBinaryType(BinaryType): name = "mysql_binary" - 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 @@ -254,9 +247,8 @@ class MySQLVarBinaryType(VarBinaryType): name = "mysql_varbinary" - 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 @@ -278,13 +270,12 @@ class MySQLEnumType(EnumType): 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): + charset: Optional[str] = None, collation: Optional[str] = None): if values is None: raise ValueError("MySQLEnumType requires values") if not values: raise ValueError("ENUM must have at least one value") - super().__init__(dialect, values=values, dialect_options=dialect_options) + super().__init__(dialect, values=values) self.charset = charset self.collation = collation @@ -315,9 +306,8 @@ class MySQLSetType(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("MySQLSetType requires values") if not values: @@ -345,9 +335,8 @@ class MySQLGeometryType(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: @@ -407,9 +396,8 @@ class MySQLVectorType(DataType): dim: Optional[int] = None - def __init__(self, dialect=None, dim: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, dim: Optional[int] = None): + super().__init__(dialect) if dim is None: raise ValueError("MySQLVectorType requires dim") self.dim = dim diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py index 64750041..8aad7456 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/bitwise.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MySQLDialect + from ..dialect import MySQLDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/enum_set.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/enum_set.py index 44b8e111..6ddc156a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/enum_set.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/enum_set.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MySQLDialect + from ..dialect import MySQLDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/fulltext.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/fulltext.py index f2cd0ea2..c73499df 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/fulltext.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/fulltext.py @@ -10,7 +10,7 @@ from rhosocial.activerecord.backend.expression import bases if TYPE_CHECKING: # pragma: no cover - from .dialect import MySQLDialect + from ..dialect import MySQLDialect def match_against( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/json.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/json.py index cda93e27..d76d0066 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/json.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/json.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MySQLDialect + from ..dialect import MySQLDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/functions/spatial.py b/src/rhosocial/activerecord/backend/impl/mysql/functions/spatial.py index 51f1f136..5876d849 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/functions/spatial.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/functions/spatial.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.dialect import SQLDialectBase - from .dialect import MySQLDialect + from ..dialect import MySQLDialect def _convert_to_expression( diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py index 9ff25de9..64c45451 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/__init__.py @@ -25,7 +25,12 @@ from .load_xml import MySQLLoadXMLLMixin from .admin import MySQLAdminCommandMixin from .datetime import MySQLDateTimeMixin -from .collation import MySQLCollationMixin +from .charset_collation import ( + MySQLCharset, + MySQLCollation, + MySQLStorageEngine, + MySQLCharsetCollationMixin, +) from .cte import MySQLCTEMixin from .window import MySQLWindowMixin from .grouping import MySQLGroupingMixin @@ -36,6 +41,7 @@ from .ddl_column import MySQLDDLColumnMixin from .ddl_view import MySQLViewMixin from .schema import MySQLSchemaMixin +from .ddl_database import MySQLDatabaseMixin from .constraint import MySQLConstraintMixin from .generated_column import MySQLGeneratedColumnMixin from .function import MySQLFunctionMixin @@ -68,7 +74,10 @@ "MySQLLoadXMLLMixin", "MySQLAdminCommandMixin", "MySQLDateTimeMixin", - "MySQLCollationMixin", + "MySQLCharset", + "MySQLCollation", + "MySQLStorageEngine", + "MySQLCharsetCollationMixin", "MySQLCTEMixin", "MySQLWindowMixin", "MySQLGroupingMixin", @@ -79,6 +88,7 @@ "MySQLDDLColumnMixin", "MySQLViewMixin", "MySQLSchemaMixin", + "MySQLDatabaseMixin", "MySQLConstraintMixin", "MySQLGeneratedColumnMixin", "MySQLFunctionMixin", diff --git a/src/rhosocial/activerecord/backend/impl/mysql/collation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py similarity index 59% rename from src/rhosocial/activerecord/backend/impl/mysql/collation.py rename to src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py index 81f6b93f..a2ad3825 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/collation.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py @@ -1,10 +1,92 @@ -# src/rhosocial/activerecord/backend/impl/mysql/collation.py -""" -MySQL collation names supported by the dialect whitelist. +# src/rhosocial/activerecord/backend/impl/mysql/mixins/charset_collation.py +"""MySQL charset / collation / storage-engine dialect support. + +Implements :class:`~...mysql.protocols.MySQLCharsetCollationSupport`: 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 Dict, Optional, Tuple +from typing import FrozenSet, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.collation import CollateExpression + +__all__ = [ + "MySQLCharset", + "MySQLStorageEngine", + "MySQLCollation", + "MySQLCharsetCollationMixin", +] + + +class MySQLCharset(Enum): + """MySQL character sets for table/column ``CHARACTER SET``.""" + + ARMSCII8 = "armscii8" + ASCII = "ascii" + BIG5 = "big5" + BINARY = "binary" + CP1250 = "cp1250" + CP1251 = "cp1251" + CP1256 = "cp1256" + CP1257 = "cp1257" + CP850 = "cp850" + CP852 = "cp852" + CP866 = "cp866" + CP932 = "cp932" + DEC8 = "dec8" + EUCJPMS = "eucjpms" + EUCKR = "euckr" + GB18030 = "gb18030" + GB2312 = "gb2312" + GBK = "gbk" + GEOSTD8 = "geostd8" + GREEK = "greek" + HEBREW = "hebrew" + HP8 = "hp8" + KEYBCS2 = "keybcs2" + KOI8R = "koi8r" + KOI8U = "koi8u" + LATIN1 = "latin1" + LATIN2 = "latin2" + LATIN5 = "latin5" + LATIN7 = "latin7" + MACCE = "macce" + MACROMAN = "macroman" + SJIS = "sjis" + SWE7 = "swe7" + TIS620 = "tis620" + UCS2 = "ucs2" + UJIS = "ujis" + UTF8 = "utf8" + UTF8MB3 = "utf8mb3" + UTF8MB4 = "utf8mb4" + UTF16 = "utf16" + UTF16LE = "utf16le" + UTF32 = "utf32" + + +class MySQLStorageEngine(Enum): + """Built-in MySQL storage engines for ``ENGINE=``.""" + + INNODB = "InnoDB" + MYISAM = "MyISAM" + MEMORY = "MEMORY" + CSV = "CSV" + ARCHIVE = "ARCHIVE" + BLACKHOLE = "BLACKHOLE" + MRG_MYISAM = "MRG_MyISAM" + FEDERATED = "FEDERATED" + NDB = "NDB" + NDBCLUSTER = "NDBCLUSTER" + EXAMPLE = "EXAMPLE" + PERFORMANCE_SCHEMA = "PERFORMANCE_SCHEMA" + TEMPORARY = "TEMPORARY" class MySQLCollation(Enum): @@ -294,51 +376,157 @@ class MySQLCollation(Enum): UTF8MB4_ZH_0900_AS_CS = "utf8mb4_zh_0900_as_cs" -_COLLATION_VALUES = {collation.value for collation in MySQLCollation} -_COLLATION_MIN_VERSIONS: Dict[str, Tuple[int, ...]] = { - collation.value: (8, 0, 0) - for collation in MySQLCollation - if "_0900_" in collation.value or collation.value.endswith("_0900_bin") +_CHARSET_BY_VALUE = {member.value: member for member in MySQLCharset} +_ENGINE_BY_LOWER = {member.value.lower(): member for member in MySQLStorageEngine} +_COLLATION_VALUES = {member.value for member in MySQLCollation} + +# Introduced-in server versions for version-gated values. +_CHARSET_MIN_VERSIONS: dict = { + "utf8mb4": (5, 5, 3), + "utf8mb3": (8, 0, 28), } +_ENGINE_MIN_VERSIONS: dict = {} +_COLLATION_MIN_VERSIONS: dict = { + member.value: (8, 0, 0) + for member in MySQLCollation + if "_0900_" in member.value or member.value.endswith("_0900_bin") +} + +class MySQLCharsetCollationMixin: + """Version-aware charset / collation / storage-engine dialect support.""" -class MySQLCollationValidator: - """Validate MySQL collation names against version-gated known collations.""" + def _mysql_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: + """MySQL supports expression-level COLLATE.""" + return True - @classmethod - def is_supported( - cls, - name: str, - version: Optional[Tuple[int, ...]] = None, - ) -> bool: + def validate_collation_name(self, expr: "CollateExpression") -> str: + """Validate a ``CollateExpression`` and return its collation SQL.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if expr.collation_options: + unsupported = ", ".join(sorted(expr.collation_options)) + raise UnsupportedFeatureError(self.name, f"COLLATE options: {unsupported}") + return self.validate_collation_by_name(expr.collation_name) + + # --- charset --------------------------------------------------------- + def supported_charsets(self) -> FrozenSet[str]: + """Charset names available on the configured server version.""" + version = self._mysql_capability_version() + return frozenset( + member.value + for member in MySQLCharset + if version is None + or _CHARSET_MIN_VERSIONS.get(member.value) is None + or version >= _CHARSET_MIN_VERSIONS[member.value] + ) + + def supports_charset(self, name: object) -> bool: + try: + self.validate_charset_name(name) + except (TypeError, ValueError): + return False + return True + + def validate_charset_name(self, name: object) -> str: + version = self._mysql_capability_version() + if isinstance(name, MySQLCharset): + normalized = name.value + elif isinstance(name, str): + normalized = name.lower() + else: + raise TypeError( + f"character set must be MySQLCharset or str, got {type(name).__name__}" + ) + if normalized not in _CHARSET_BY_VALUE: + raise ValueError(f"Unsupported MySQL 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"MySQL character set requires MySQL {formatted}+: {name!r}") + return normalized + + # --- collation ------------------------------------------------------- + def supported_collations(self, charset: Optional[str] = None) -> FrozenSet[str]: + """Collation names available on the configured server version.""" + version = self._mysql_capability_version() + names = set() + for name in _COLLATION_VALUES: + min_version = _COLLATION_MIN_VERSIONS.get(name) + if version is not None and min_version is not None and version < min_version: + continue + if charset is not None: + prefix = charset.lower() + "_" + if not ( + name.startswith(prefix) + or (charset.lower() == "binary" and name == "binary") + ): + continue + names.add(name) + return frozenset(names) + + def supports_collation_name(self, name: str) -> bool: try: - cls.validate(name, version) - except ValueError: + self.validate_collation_by_name(name) + except (TypeError, ValueError): return False return True - @classmethod - def validate( - cls, - name: str, - version: Optional[Tuple[int, ...]] = None, - ) -> str: + def validate_collation_by_name(self, name: str) -> str: + version = self._mysql_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 MySQL collation: {name!r}") - min_version = _COLLATION_MIN_VERSIONS.get(normalized) if version is not None and min_version is not None and version < min_version: - raise ValueError(f"MySQL collation requires MySQL {cls._format_version(min_version)}+: {name!r}") + formatted = ".".join(str(part) for part in min_version[:2]) + raise ValueError(f"MySQL collation requires MySQL {formatted}+: {name!r}") return normalized - @staticmethod - def _format_version(version: Tuple[int, ...]) -> str: - return ".".join(str(part) for part in version[:2]) + # --- storage engine -------------------------------------------------- + def supported_storage_engines(self) -> FrozenSet[str]: + """Storage engine names available on the configured server version.""" + version = self._mysql_capability_version() + return frozenset( + member.value + for member in MySQLStorageEngine + 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_mysql_collation_name( - name: str, - version: Optional[Tuple[int, ...]] = None, -) -> str: - return MySQLCollationValidator.validate(name, version) + def validate_storage_engine_name(self, name: object) -> str: + version = self._mysql_capability_version() + if isinstance(name, MySQLStorageEngine): + canonical = name.value + elif isinstance(name, str): + member = _ENGINE_BY_LOWER.get(name.lower()) + if member is None: + raise ValueError(f"Unsupported MySQL storage engine: {name!r}") + canonical = member.value + else: + raise TypeError( + f"storage engine must be MySQLStorageEngine 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"MySQL storage engine requires MySQL {formatted}+: {name!r}") + return canonical diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/collation.py deleted file mode 100644 index 192b0006..00000000 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/collation.py +++ /dev/null @@ -1,21 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/mysql/mixins/collation.py -from typing import Tuple - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - -from ..collation import validate_mysql_collation_name - - -class MySQLCollationMixin: - """MySQL collation expression support.""" - - def supports_collate_expression(self) -> bool: - """MySQL supports expression-level COLLATE.""" - return True - - def validate_collation_name(self, expr: "CollateExpression") -> str: - """Validate MySQL 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}") - return validate_mysql_collation_name(expr.collation_name, getattr(self, "version", None)) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py index c57aa502..6d1ab698 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_column.py -from typing import Any, Tuple +from typing import Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -7,6 +7,14 @@ class MySQLDDLColumnMixin: """MySQL DDL column definition and ALTER TABLE column actions.""" + def format_identity_clause(self, expr) -> Tuple[str, Tuple]: + """MySQL renders an identity column as ``AUTO_INCREMENT``. + + MySQL has no ``GENERATED ... AS IDENTITY`` column syntax; seed and + increment are table-level options, so only the marker is emitted. + """ + return " AUTO_INCREMENT", () + def format_column(self, expr) -> Tuple[str, Tuple]: """Format column reference for MySQL. @@ -31,7 +39,7 @@ def format_add_column_action(self, action) -> Tuple[str, tuple]: "Pre-check information_schema.COLUMNS before ALTER." ) column_sql, column_params = self.format_column_definition(action.column) - after = action.dialect_options.get("after") + after = getattr(action, "after", None) if after: return f"ADD COLUMN {column_sql} AFTER {self.format_identifier(after)}", column_params return f"ADD COLUMN {column_sql}", column_params diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py index 7f49e14d..30e06683 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_table.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/table.py -from typing import Any, Dict, List, TYPE_CHECKING, Tuple +from typing import Any, List, TYPE_CHECKING, Tuple import re if TYPE_CHECKING: @@ -34,6 +34,22 @@ def supports_create_table_like(self) -> bool: def supports_inline_index(self) -> bool: return True + def supports_table_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` on ``CREATE TABLE`` is supported. + + MySQL 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. Comment + length limits (table 2048 / column 1024 characters) are a known + boundary and are not enforced. + """ + return True + + def supports_column_comment(self) -> bool: + """Whether inline ``COMMENT 'text'`` in a column definition is + supported. MySQL renders it natively.""" + return True + def supports_storage_engine_option(self) -> bool: return True @@ -50,6 +66,18 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: - AUTO_INCREMENT in column definitions - Partition clause """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if expr.tablespace: + raise UnsupportedFeatureError( + self.name, "TABLESPACE", + "MySQL does not support table tablespaces.", + ) + if expr.inherits: + raise UnsupportedFeatureError( + self.name, "table INHERITS", + "MySQL does not support table inheritance.", + ) all_params: List[Any] = [] options_part = "" @@ -81,8 +109,9 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: all_params.extend(const_params) for idx_def in expr.indexes: - idx_sql = 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) parts.append(f"({', '.join(column_parts)})") @@ -94,32 +123,25 @@ def format_create_table_statement(self, expr) -> Tuple[str, tuple]: parts.append(storage_sql) all_params.extend(storage_params) + from rhosocial.activerecord.backend.impl.mysql.expression.table_options import ( + MySQLCreateTableOptions, + ) table_options = getattr(expr, "table_options", None) - if table_options is not None and getattr(table_options, "comment", None): - 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, MySQLCreateTableOptions): + 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 expr.partition is not None: partition_sql, partition_params = expr.partition.to_sql() @@ -134,7 +156,17 @@ def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") return super().format_create_table_like_statement(expr) def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: - """Format a single column definition with MySQL-specific syntax.""" + """Format a single column definition with MySQL-specific syntax. + + Accepts both the generic ``ColumnDefinition`` and the MySQL + ``MySQLColumnDefinition``; the latter's MySQL-only attributes + (``character_set`` / ``column_format`` / ``storage`` / ``invisible``) + are rendered here. + """ + from rhosocial.activerecord.backend.impl.mysql.expression.column import ( + MySQLColumnDefinition, + ) + 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) @@ -148,9 +180,24 @@ def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tu if constraint.is_auto_increment: parts.append("AUTO_INCREMENT") - 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, MySQLColumnDefinition): + 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() @@ -213,7 +260,7 @@ def format_table_constraint(self, t_const: "TableConstraint") -> Tuple[str, tupl return " ".join(parts), params - def format_inline_index(self, idx_def: "IndexDefinition") -> str: + def format_index_definition(self, idx_def: "IndexDefinition") -> Tuple[str, tuple]: """Format an inline INDEX definition within CREATE TABLE (MySQL-specific).""" parts = [] if idx_def.unique: @@ -224,7 +271,7 @@ def format_inline_index(self, idx_def: "IndexDefinition") -> str: parts.append(f"({cols_str})") if idx_def.type: parts.append(f"USING {idx_def.type}") - return " ".join(parts) + return " ".join(parts), () def format_storage_options(self, expr: "StorageOptionsExpression") -> Tuple[str, tuple]: """Format MySQL table storage options (ENGINE, CHARSET, etc.).""" diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py index 60a40b3e..9b19e9b9 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/ddl_view.py @@ -50,6 +50,12 @@ def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[st parts.append(f"AS {query_sql}") if expr.options and expr.options.check_option: + if not self.supports_view_check_option(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "WITH CHECK OPTION", + f"{self.name} does not support WITH CHECK OPTION.", + ) check_option = expr.options.check_option.value parts.append(f"WITH {check_option} CHECK OPTION") diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py index 73683a1f..ec82cc0b 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py @@ -1,5 +1,5 @@ # src/rhosocial/activerecord/backend/impl/mysql/mixins/partition.py -from typing import Any, List, Sequence, Tuple, Union, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Tuple, Union, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -8,7 +8,6 @@ from rhosocial.activerecord.backend.expression.statements import PartitionClause from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( MySQLAddPartitionExpression, - MySQLAddPartitionHelper, MySQLDropPartitionExpression, MySQLGetPartitionsExpression, MySQLPartitionByHash, @@ -19,6 +18,7 @@ MySQLPartitionByRangeColumns, MySQLPartitionDefinition, MySQLPartitionMaxValue, + MySQLPartitionOptions, MySQLPartitionValue, MySQLExchangePartitionExpression, MySQLReorganizePartitionExpression, @@ -32,6 +32,9 @@ MySQLRepairPartitionExpression, MySQLSubpartitionClause, MySQLSubpartitionDefinition, + ) + from rhosocial.activerecord.backend.impl.mysql.expression.partition_lifecycle import ( + MySQLAddPartitionHelper, MySQLCoalescePartitionHelper, MySQLDropOldestPartitionHelper, MySQLReorganizePartitionHelper, @@ -327,7 +330,8 @@ def format_partition_definition(self, definition: "MySQLPartitionDefinition") -> """Format a MySQL partition definition.""" params: List[Any] = [] parts = ["PARTITION", self.format_identifier(definition.name)] - if definition.dialect_options: + partition_options = definition.partition_options + if partition_options is not None: if not self.supports_partition_definition_options(): raise UnsupportedFeatureError(self.name, "partition definition options") @@ -355,9 +359,9 @@ def format_partition_definition(self, definition: "MySQLPartitionDefinition") -> parts.append(f"VALUES IN ({', '.join(value_sql_parts)})") else: raise ValueError("Partition definition requires less_than or in_values.") - if definition.dialect_options: + if partition_options is not None: options_sql, options_params = self.format_partition_definition_options( - definition.dialect_options + partition_options ) if options_sql: parts.append(options_sql) @@ -374,14 +378,25 @@ def format_partition_definition(self, definition: "MySQLPartitionDefinition") -> parts.append(f"({', '.join(sub_parts)})") return " ".join(parts), tuple(params) - def format_partition_definition_options(self, options: dict) -> Tuple[str, tuple]: - """Format MySQL partition definition options.""" + def format_partition_definition_options( + self, options: Optional["MySQLPartitionOptions"] + ) -> Tuple[str, tuple]: + """Format MySQL partition definition options from typed options.""" + from rhosocial.activerecord.backend.impl.mysql.expression.partition import ( + MySQLPartitionOptions, + ) + if not self.supports_partition_definition_options(): raise UnsupportedFeatureError(self.name, "partition definition options") + if options is None: + return "", () + if not isinstance(options, MySQLPartitionOptions): + raise TypeError( + "partition_options must be a MySQLPartitionOptions value, " + f"got {type(options).__name__}" + ) - parts: List[str] = [] - params: List[Any] = [] - allowed_options = { + keyword_by_option = { "engine": "ENGINE", "comment": "COMMENT", "data_directory": "DATA DIRECTORY", @@ -390,25 +405,25 @@ def format_partition_definition_options(self, options: dict) -> Tuple[str, tuple "min_rows": "MIN_ROWS", "tablespace": "TABLESPACE", } - for key, value in options.items(): - normalized = str(key).lower() - if normalized not in allowed_options: - raise ValueError(f"Unsupported partition definition option: {key}") - keyword = allowed_options[normalized] - if normalized in {"engine", "tablespace"}: + parts: List[str] = [] + for name, keyword in keyword_by_option.items(): + value = getattr(options, name) + if value is None: + continue + if name in {"engine", "tablespace"}: if not isinstance(value, str) or not value: - raise TypeError(f"{key} option must be a non-empty string") + raise TypeError(f"{name} option must be a non-empty string") parts.append(f"{keyword} {self.format_identifier(value)}") - elif normalized in {"comment", "data_directory", "index_directory"}: + elif name in {"comment", "data_directory", "index_directory"}: if not isinstance(value, str): - raise TypeError(f"{key} option must be a string") + raise TypeError(f"{name} option must be a string") escaped = self._escape_sql_string(value) parts.append(f"{keyword} '{escaped}'") - elif normalized in {"max_rows", "min_rows"}: + else: if not isinstance(value, int) or value < 0: - raise TypeError(f"{key} option must be a non-negative integer") + raise TypeError(f"{name} option must be a non-negative integer") parts.append(f"{keyword} {value}") - return " ".join(parts), tuple(params) + return " ".join(parts), () def format_get_partitions_expression(self, expr: "MySQLGetPartitionsExpression") -> Tuple[str, tuple]: """Format a ``SELECT ... FROM information_schema.PARTITIONS`` query. @@ -537,7 +552,7 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio Args: definition: MySQLSubpartitionDefinition with name and optional - dialect_options. + typed partition_options. Returns: Tuple of (SQL string, parameters tuple). @@ -549,9 +564,10 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio raise ValueError("subpartition name must not be empty") parts = ["SUBPARTITION", self.format_identifier(definition.name)] params: List[Any] = [] - if definition.dialect_options: + partition_options = definition.partition_options + if partition_options is not None: options_sql, options_params = self.format_partition_definition_options( - definition.dialect_options + partition_options ) if options_sql: parts.append(options_sql) diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py index 773f100e..7845b13d 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/routine.py @@ -87,9 +87,8 @@ def format_drop_function_statement( ) -> Tuple[str, tuple]: """Format ``DROP FUNCTION [IF EXISTS] name`` (stored function). - Note this formats the stored-function form. The loadable UDF form - ``DROP FUNCTION name`` is identical syntactically and shares this - method when ``dialect_options['udf']`` is set. + The loadable UDF form ``DROP FUNCTION name`` is syntactically + identical and shares this method. """ expr.validate(strict=self.strict_validation) parts = ["DROP FUNCTION"] diff --git a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py index 57144f12..1da67653 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/mixins/types.py @@ -4,10 +4,10 @@ from __future__ import annotations import re -from typing import Optional, Tuple +from typing import Dict, 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, @@ -65,21 +65,21 @@ ) -class MySQLTypeSupportMixin(DDLTypeMixin, DDLTypeSupport): +class MySQLTypeSupportMixin(DataTypeMixin, DataTypeSupport): """MySQL 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``). MySQL-specific types carry ``mysql_``-prefixed + ``DataTypeMixin``). MySQL-specific types carry ``mysql_``-prefixed names; core types render their real MySQL SQL. """ # ------------------------------------------------------------------ - # DDLTypeSupport — formatting + # DataTypeSupport — formatting # ------------------------------------------------------------------ # --- MySQL-specific type formatters (dispatch key = type name) --- @@ -336,7 +336,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 # # MySQL declares support for exactly the format_data_type_* family # above (1:1 correspondence contract): every type this mixin renders @@ -496,7 +496,7 @@ def supports_data_type_custom(self) -> bool: return True # ------------------------------------------------------------------ - # DDLTypeSupport — parsing + # DataTypeSupport — parsing # ------------------------------------------------------------------ _MYSQL_INTEGER_TYPES = re.compile( @@ -747,7 +747,7 @@ def parse_type(self, raw: str) -> DataType: return CustomType(self, stripped) # ------------------------------------------------------------------ - # DDLTypeSupport — cross-backend type suggestions + # DataTypeSupport — cross-backend type suggestions # ------------------------------------------------------------------ def suggested_data_types(self) -> "Dict[str, type]": diff --git a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py index dd5a4b51..24a595b0 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/protocols.py @@ -10,7 +10,7 @@ the MySQL-specific protocol - isinstance checks for the generic protocol will still work. """ -from typing import Protocol, runtime_checkable, Tuple, Any, Dict, Sequence, TYPE_CHECKING +from typing import Protocol, runtime_checkable, Tuple, Any, Sequence, FrozenSet, Optional, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.statements import OnConflictClause @@ -79,6 +79,7 @@ MySQLPartitionByRangeColumns, MySQLPartitionDefinition, MySQLPartitionMaxValue, + MySQLPartitionOptions, MySQLPartitionValue, MySQLRebuildPartitionExpression, MySQLReorganizePartitionExpression, @@ -134,13 +135,14 @@ class MySQLDMLOperationSupport(Protocol): - LOAD DATA INFILE: All MySQL versions Usage: - INSERT IGNORE is supported via dialect_options in InsertExpression: + INSERT IGNORE is carried as a typed field on the backend's + ``MySQLInsertExpression``: ```python - InsertExpression( + MySQLInsertExpression( dialect, into='users', source=ValuesSource(...), - dialect_options={'ignore': True} # Generates INSERT IGNORE + ignore=True, # Generates INSERT IGNORE ) ``` """ @@ -316,13 +318,6 @@ def supports_create_table_like(self) -> bool: """ ... - def supports_inline_index(self) -> bool: - """Whether inline index definitions are supported. - - MySQL allows INDEX/KEY definitions within CREATE TABLE. - """ - ... - def supports_storage_engine_option(self) -> bool: """Whether ENGINE option is supported. @@ -351,12 +346,16 @@ def format_column_definition(self, col_def: Any) -> Tuple[str, tuple]: """Format a column definition with MySQL-specific syntax (AUTO_INCREMENT, etc.).""" ... + def supports_column_comment(self) -> bool: + """Whether an inline column COMMENT is supported (MySQL: yes).""" + ... + def format_table_constraint(self, t_const: Any) -> Tuple[str, tuple]: """Format a table-level constraint.""" ... - def format_inline_index(self, idx_def: Any) -> str: - """Format inline INDEX definition within CREATE TABLE.""" + def format_index_definition(self, idx_def: Any) -> Tuple[str, tuple]: + """Format an inline INDEX definition inside CREATE TABLE.""" ... def format_storage_options(self, expr: Any) -> Tuple[str, tuple]: @@ -402,6 +401,30 @@ def supports_partition_value_maxvalue(self) -> bool: """Whether MAXVALUE partition boundary token is supported.""" ... + def supports_add_partition(self) -> bool: + """Whether ADD PARTITION is supported.""" + ... + + def supports_drop_partition(self) -> bool: + """Whether DROP PARTITION is supported.""" + ... + + def supports_truncate_partition(self) -> bool: + """Whether TRUNCATE PARTITION is supported.""" + ... + + def supports_reorganize_partition(self) -> bool: + """Whether REORGANIZE PARTITION is supported.""" + ... + + def supports_attach_partition(self) -> bool: + """Whether ATTACH PARTITION is supported.""" + ... + + def supports_detach_partition(self) -> bool: + """Whether DETACH PARTITION is supported.""" + ... + def supports_remove_partitioning(self) -> bool: """Whether ALTER TABLE ... REMOVE PARTITIONING is supported.""" ... @@ -447,7 +470,9 @@ def format_partition_definition(self, definition: "MySQLPartitionDefinition") -> """Format a MySQL PARTITION definition.""" ... - def format_partition_definition_options(self, options: dict) -> Tuple[str, tuple]: + def format_partition_definition_options( + self, options: "MySQLPartitionOptions" + ) -> Tuple[str, tuple]: """Format MySQL PARTITION definition options.""" ... @@ -488,8 +513,8 @@ def format_subpartition_definition(self, definition: "MySQLSubpartitionDefinitio """Format a single ``SUBPARTITION name ...`` clause. Args: - definition: MySQLSubpartitionDefinition with name and optional - dialect_options. + definition: MySQLSubpartitionDefinition with name and typed + partition options. Returns: Tuple of (SQL string, parameters tuple). @@ -1659,3 +1684,48 @@ def supports_revoke(self) -> bool: def format_revoke_statement(self, expr: "MySQLRevokeExpression") -> Tuple[str, tuple]: ... + + +@runtime_checkable +class MySQLCharsetCollationSupport(Protocol): + """Version-aware MySQL 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/mysql/show/expressions.py b/src/rhosocial/activerecord/backend/impl/mysql/show/expressions.py index 5e65d628..8c26886a 100644 --- a/src/rhosocial/activerecord/backend/impl/mysql/show/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/mysql/show/expressions.py @@ -21,7 +21,7 @@ from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: - from ...dialect import MySQLDialect + from ..dialect import MySQLDialect class ShowExpression(BaseExpression): diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 99be20d4..c2555b96 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -232,7 +232,6 @@ def _event_model(self, backend): class PartitionEvent(ActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() @@ -249,7 +248,6 @@ def _async_event_model(self, backend): class AsyncPartitionEvent(AsyncActiveRecord): __table_name__ = table_name - __primary_key__ = "id" __backend__ = backend c: ClassVar[FieldProxy] = FieldProxy() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py new file mode 100644 index 00000000..54005ecc --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_ddl_source_collection.py @@ -0,0 +1,471 @@ +# tests/rhosocial/activerecord_mysql_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 ( + ColumnDefinition, + CreateTableExpression, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression import ( + MySQLColumnFormat, + MySQLColumnOptions, + MySQLColumnStorage, + MySQLCreateTableOptions, + MySQLIntType, + MySQLLongTextType, + MySQLPartitionByRange, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +DIALECT = MySQLDialect((8, 0, 36)) +SQL_TYPE = MySQLIntType(unsigned=True) +FALLBACK_SQL_TYPE = MySQLLongTextType() +SQL_TYPE_MARKER = UseSqlType(SQL_TYPE, FALLBACK_SQL_TYPE) +DEFAULT_VALUE = Literal(DIALECT, "new", inline_literals=True) +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_0900_ai_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 = MySQLColumnOptions( + character_set="utf8mb4", + column_format=MySQLColumnFormat.FIXED, + storage=MySQLColumnStorage.MEMORY, + invisible=True, +) +SECOND_COLUMN_OPTION = MySQLColumnOptions( + character_set="latin1", + column_format=MySQLColumnFormat.DYNAMIC, + storage=MySQLColumnStorage.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 = [ + MySQLCreateTableOptions( + DIALECT, + engine="InnoDB", + charset="utf8mb4", + auto_increment=100, + ), +] +STORAGE_OPTIONS = [ + StorageOptionsExpression(DIALECT, {"tablespace": "innodb_data_1"}), +] +TABLE_PARTITION = MySQLPartitionByRange( + 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 = MySQLIntType(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, MySQLIntType) + 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, MySQLColumnOptions) + assert option.character_set == "utf8mb4" + assert option.column_format is MySQLColumnFormat.FIXED + assert option.storage is MySQLColumnStorage.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], MySQLCreateTableOptions) + 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_mysql_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_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py new file mode 100644 index 00000000..2c19e111 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py @@ -0,0 +1,58 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/ddl/test_derived_ddl_inline_index.py +"""MySQL inline-index support for direct DDL expressions. + +The DDLSource index declaration is passed directly to CreateTableExpression +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.mysql.dialect import MySQLDialect +from rhosocial.activerecord.model import ActiveRecord + + +class Indexed(ActiveRecord): + __table_name__ = "indexed" + + id: int + email: Annotated[str, UseIndex("idx_indexed_email", unique=True)] + + +def mysql_dialect(): + return MySQLDialect((8, 0, 0)) + + +def _create_indexed_table(): + dialect = mysql_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_mysql_test/feature/backend/dml/test_insert_ignore.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_ignore.py index 92c9bc64..34b87237 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_ignore.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_ignore.py @@ -12,7 +12,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.mysql.expression import MySQLInsertExpression from rhosocial.activerecord.backend.expression import core @@ -41,14 +42,14 @@ def test_insert_ignore_single_row_no_conflict(self, mysql_backend, test_table): """Test INSERT IGNORE with no conflict - should insert normally.""" dialect = mysql_backend.dialect - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( dialect, [[core.Literal(dialect, "alice@example.com"), core.Literal(dialect, "Alice")]] ), columns=["email", "name"], - dialect_options={"ignore": True}, + ignore=True, ) sql, params = expr.to_sql() @@ -73,12 +74,12 @@ def test_insert_ignore_with_conflict(self, mysql_backend, test_table): ) # Try to insert duplicate with IGNORE - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource(dialect, [[core.Literal(dialect, "bob@example.com"), core.Literal(dialect, "Bob 2")]]), columns=["email", "name"], - dialect_options={"ignore": True}, + ignore=True, ) sql, params = expr.to_sql() @@ -103,7 +104,7 @@ def test_insert_ignore_multiple_rows_partial_conflict(self, mysql_backend, test_ ) # Insert multiple rows, one conflicts - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -115,7 +116,7 @@ def test_insert_ignore_multiple_rows_partial_conflict(self, mysql_backend, test_ ], ), columns=["email", "name"], - dialect_options={"ignore": True}, + ignore=True, ) sql, params = expr.to_sql() @@ -142,7 +143,7 @@ def test_insert_ignore_without_option(self, mysql_backend, test_table): ) # Regular insert without IGNORE should fail - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -187,14 +188,14 @@ async def test_insert_ignore_async(self, async_mysql_backend, test_table): ) # Try to insert duplicate with IGNORE - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( dialect, [[core.Literal(dialect, "async@example.com"), core.Literal(dialect, "Duplicate")]] ), columns=["email", "name"], - dialect_options={"ignore": True}, + ignore=True, ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py index 75d1ac6b..c3c5cbc4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_insert_on_conflict_clauses.py @@ -18,6 +18,7 @@ ValuesSource, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLInsertExpression @pytest.fixture @@ -81,14 +82,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 = MySQLInsertExpression( + 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 = MySQLInsertExpression( + 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_mysql_test/feature/backend/dml/test_replace_into.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_replace_into.py index 826dfacc..f458e9b3 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_replace_into.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/dml/test_replace_into.py @@ -12,7 +12,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.mysql.expression import MySQLInsertExpression from rhosocial.activerecord.backend.expression import core @@ -41,14 +42,14 @@ def test_replace_into_single_row_no_conflict(self, mysql_backend, test_table): """Test REPLACE INTO with no conflict - should insert normally.""" dialect = mysql_backend.dialect - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( dialect, [[core.Literal(dialect, "alice@example.com"), core.Literal(dialect, "Alice")]] ), columns=["email", "name"], - dialect_options={"replace": True}, + replace=True, ) sql, params = expr.to_sql() @@ -77,12 +78,12 @@ def test_replace_into_with_conflict(self, mysql_backend, test_table): original_row["id"] # REPLACE with new data - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource(dialect, [[core.Literal(dialect, "bob@example.com"), core.Literal(dialect, "Bob 2")]]), columns=["email", "name"], - dialect_options={"replace": True}, + replace=True, ) sql, params = expr.to_sql() @@ -108,7 +109,7 @@ def test_replace_into_multiple_rows(self, mysql_backend, test_table): ) # REPLACE multiple rows, one conflicts - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( @@ -120,7 +121,7 @@ def test_replace_into_multiple_rows(self, mysql_backend, test_table): ], ), columns=["email", "name"], - dialect_options={"replace": True}, + replace=True, ) sql, params = expr.to_sql() @@ -149,14 +150,14 @@ def test_replace_into_with_on_conflict_raises_error(self, mysql_backend, test_ta # This should raise ValueError with pytest.raises(ValueError, match="REPLACE INTO does not support ON CONFLICT"): - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( dialect, [[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() @@ -166,14 +167,15 @@ def test_replace_and_ignore_mutually_exclusive(self, mysql_backend, test_table): dialect = mysql_backend.dialect with pytest.raises(ValueError, match="Cannot use both 'replace' and 'ignore'"): - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( dialect, [[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() @@ -205,14 +207,14 @@ async def test_replace_into_async(self, async_mysql_backend, test_table): ) # REPLACE with new data - expr = InsertExpression( + expr = MySQLInsertExpression( dialect=dialect, into=test_table, source=ValuesSource( dialect, [[core.Literal(dialect, "async@example.com"), core.Literal(dialect, "Replaced Async")]] ), columns=["email", "name"], - dialect_options={"replace": True}, + replace=True, ) sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py new file mode 100644 index 00000000..a2783d7d --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py @@ -0,0 +1,92 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_charset_collation_support.py +"""MySQL charset / collation / storage-engine capability protocol tests. + +The dialect exposes a version-aware whitelist and validation interface; table +options validate user input at construction time instead of storing arbitrary +strings. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions +from rhosocial.activerecord.backend.impl.mysql.mixins import ( + MySQLCharset, + MySQLStorageEngine, +) +from rhosocial.activerecord.backend.impl.mysql.protocols import ( + MySQLCharsetCollationSupport, +) + + +def test_dialect_implements_protocol(): + assert isinstance(MySQLDialect(version=(8, 0, 0)), MySQLCharsetCollationSupport) + + +def test_validate_charset_accepts_enum_and_string(): + dialect = MySQLDialect(version=(8, 0, 0)) + assert dialect.validate_charset_name(MySQLCharset.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 = MySQLDialect(version=(8, 0, 0)) + assert dialect.validate_storage_engine_name(MySQLStorageEngine.INNODB) == "InnoDB" + assert dialect.validate_storage_engine_name("innodb") == "InnoDB" + with pytest.raises(ValueError): + dialect.validate_storage_engine_name("Evil") + + +def test_validate_collation_rejects_unknown(): + dialect = MySQLDialect(version=(8, 0, 0)) + assert dialect.validate_collation_by_name("UTF8MB4_UNICODE_CI") == "utf8mb4_unicode_ci" + with pytest.raises(ValueError): + dialect.validate_collation_by_name("not_a_collation") + + +def test_table_options_reject_unknown_values(): + dialect = MySQLDialect(version=(8, 0, 0)) + with pytest.raises(ValueError): + MySQLCreateTableOptions(dialect, engine="Evil") + with pytest.raises(ValueError): + MySQLCreateTableOptions(dialect, charset="not_a_charset") + with pytest.raises(ValueError): + MySQLCreateTableOptions(dialect, collate="not_a_collation") + + +def test_charset_version_gating(): + assert MySQLDialect(version=(5, 5, 3)).supports_charset("utf8mb4") is True + assert MySQLDialect(version=(5, 5, 2)).supports_charset("utf8mb4") is False + + +def test_auto_increment_and_row_format_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.mysql.expression import MySQLRowFormat + + dialect = MySQLDialect(version=(8, 0, 0)) + options = MySQLCreateTableOptions( + dialect, auto_increment=1000, row_format=MySQLRowFormat.DYNAMIC + ) + expr = CreateTableExpression( + dialect, + "t", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + table_options=options, + ) + sql, _ = expr.to_sql() + assert "AUTO_INCREMENT=1000" in sql + assert "ROW_FORMAT=DYNAMIC" in sql + + +def test_invalid_row_format_rejected(): + with pytest.raises(ValueError): + MySQLCreateTableOptions(MySQLDialect(version=(8, 0, 0)), row_format="EVIL") + + +def test_invalid_auto_increment_rejected(): + with pytest.raises(ValueError): + MySQLCreateTableOptions(MySQLDialect(version=(8, 0, 0)), auto_increment=0) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_column_definition.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_column_definition.py new file mode 100644 index 00000000..68da5a34 --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_column_definition.py @@ -0,0 +1,105 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_column_definition.py +"""Tests for the MySQL-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.mysql.expression import ( + MySQLColumnDefinition, + MySQLColumnFormat, + MySQLColumnOptions, + MySQLColumnStorage, +) + + +@pytest.fixture +def dialect(): + from rhosocial.activerecord.backend.impl.mysql import MySQLDialect + + return MySQLDialect((8, 0, 0)) + + +def _column(dialect, **kwargs): + return MySQLColumnDefinition(dialect, "name", VarCharType(dialect, length=50), **kwargs) + + +def test_derives_generic_column_definition(): + col = MySQLColumnDefinition.__mro__ + assert ColumnDefinition in col + assert issubclass(MySQLColumnDefinition, ColumnDefinition) + + +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=MySQLColumnFormat.FIXED).to_sql() + assert sql == "`name` VARCHAR(50) COLUMN_FORMAT FIXED" + + +def test_storage(dialect): + sql, _ = _column(dialect, storage=MySQLColumnStorage.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=MySQLColumnFormat.DYNAMIC, + storage=MySQLColumnStorage.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_mysql(dialect): + """MySQL format_column_definition accepts the generic ColumnDefinition too.""" + 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="MySQLColumnFormat"): + _column(dialect, column_format="FIXED") + + +def test_options_select_mysql_column_class(): + options = MySQLColumnOptions(character_set="utf8mb4") + assert options.column_definition_class() is MySQLColumnDefinition + + +def test_options_apply_to(dialect): + options = MySQLColumnOptions( + character_set="utf8mb4", invisible=True, storage=MySQLColumnStorage.DISK + ) + col = MySQLColumnDefinition(dialect, "c", VarCharType(dialect, length=10)) + options.apply_to(col) + assert col.character_set == "utf8mb4" + assert col.invisible is True + assert col.storage is MySQLColumnStorage.DISK + + +def test_options_apply_to_rejects_generic_column(dialect): + options = MySQLColumnOptions(character_set="utf8mb4") + generic = ColumnDefinition(dialect, "c", VarCharType(dialect, length=10)) + with pytest.raises(TypeError, match="MySQLColumnDefinition"): + options.apply_to(generic) + + +def test_options_invalid_storage_type(): + with pytest.raises(TypeError, match="MySQLColumnStorage"): + MySQLColumnOptions(storage="DISK") diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py new file mode 100644 index 00000000..cf14487b --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py @@ -0,0 +1,29 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_database_ddl.py +"""Explicit MySQLDialect database DDL capability + rendering tests.""" + +from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + DropDatabaseExpression, +) +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect + + +def _dialect(): + return MySQLDialect(version=(8, 0, 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_mysql_test/feature/backend/test_mysql_ddl_features.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py index 6f8b6c7f..71a11df4 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_features.py @@ -23,6 +23,7 @@ TableConstraintType, ForeignKeyConstraint, ) +from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.statements import ReferentialAction from rhosocial.activerecord.backend.expression.statements.ddl_table import CreateTableOptions from rhosocial.activerecord.backend.expression.types import ( @@ -32,6 +33,7 @@ VarCharType, ) from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression import MySQLCreateTableOptions from rhosocial.activerecord.backend.impl.mysql.types import MySQLEnumType, MySQLSetType @@ -107,7 +109,8 @@ def test_table_comment(self): dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] expr = CreateTableExpression( - dialect=dialect, table="users", columns=columns, dialect_options={"comment": "用户信息表"} + dialect=dialect, table="users", columns=columns, + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "用户信息表")), ) sql, params = expr.to_sql() assert "COMMENT '用户信息表'" in sql @@ -121,7 +124,7 @@ def test_table_comment_with_storage_options(self): table="users", columns=columns, storage_options={"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 @@ -133,7 +136,8 @@ def test_table_comment_special_characters(self): dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] expr = CreateTableExpression( - dialect=dialect, table="test", columns=columns, dialect_options={"comment": "测试's表"} + dialect=dialect, table="test", columns=columns, + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "测试's表")), ) sql, params = expr.to_sql() assert "COMMENT" in sql @@ -143,45 +147,20 @@ def test_table_comment_via_table_options(self): from rhosocial.activerecord.backend.expression.statements.ddl_table import CreateTableOptions dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - opts = CreateTableOptions(dialect, comment="typed comment") + opts = CreateTableOptions(dialect, comment=TableCommentClause(dialect, "typed comment")) expr = CreateTableExpression( dialect=dialect, table="users", columns=columns, table_options=opts, ) sql, params = expr.to_sql() assert "COMMENT 'typed comment'" in sql - def test_table_comment_prefer_table_options_over_dialect_options(self): - """Test that typed table_options.comment takes precedence over dialect_options.""" - from rhosocial.activerecord.backend.expression.statements.ddl_table import CreateTableOptions - dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - opts = CreateTableOptions(dialect, comment="typed wins") - expr = CreateTableExpression( - dialect=dialect, table="users", columns=columns, - table_options=opts, dialect_options={"comment": "dialect_options loses"}, - ) - sql, params = expr.to_sql() - assert "COMMENT 'typed wins'" in sql - assert "dialect_options loses" not in sql - - def test_engine_charset_via_dialect_options(self): - """Test ENGINE/CHARSET/COLLATE via dialect_options.""" - dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - expr = CreateTableExpression( - dialect=dialect, table="test", columns=columns, - dialect_options={"engine": "InnoDB", "charset": "utf8mb4", "collate": "utf8mb4_unicode_ci"}, - ) - sql, params = expr.to_sql() - assert "ENGINE=" in sql - assert "DEFAULT CHARSET=" in sql - assert "COLLATE=" in sql - def test_engine_charset_via_table_options(self): - """Test ENGINE/CHARSET/COLLATE via typed CreateTableOptions.""" + """Test ENGINE/CHARSET/COLLATE via typed MySQLCreateTableOptions.""" dialect = MySQLDialect() columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - opts = CreateTableOptions(dialect, engine="InnoDB", charset="utf8mb4", collate="utf8mb4_unicode_ci") + opts = MySQLCreateTableOptions( + dialect, engine="InnoDB", charset="utf8mb4", collate="utf8mb4_unicode_ci" + ) expr = CreateTableExpression( dialect=dialect, table="test", columns=columns, table_options=opts, ) @@ -190,19 +169,6 @@ def test_engine_charset_via_table_options(self): assert "DEFAULT CHARSET=" in sql assert "COLLATE=" in sql - def test_table_options_prefer_over_dialect_options(self): - """Test that typed table_options take precedence over dialect_options.""" - dialect = MySQLDialect() - columns = [ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)])] - opts = CreateTableOptions(dialect, engine="MyISAM", charset="latin1") - expr = CreateTableExpression( - dialect=dialect, table="test", columns=columns, - table_options=opts, dialect_options={"engine": "InnoDB", "charset": "utf8mb4"}, - ) - sql, params = expr.to_sql() - assert "ENGINE='MyISAM'" in sql - assert "DEFAULT CHARSET='latin1'" in sql - class TestMySQLColumnComment: """Tests for MySQL column-level COMMENT.""" @@ -212,9 +178,9 @@ def test_column_comment(self): dialect = MySQLDialect() columns = [ ColumnDefinition(dialect, - "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment="主键ID" + "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment=ColumnCommentClause(dialect, "主键ID") ), - ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment="用户名"), + ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, "用户名")), ] expr = CreateTableExpression(dialect=dialect, table="users", columns=columns) sql, params = expr.to_sql() @@ -226,12 +192,13 @@ def test_column_comment_with_table_comment(self): dialect = MySQLDialect() columns = [ ColumnDefinition(dialect, - "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment="主键" + "id", IntegerType(dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], comment=ColumnCommentClause(dialect, "主键") ), - ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment="名称"), + ColumnDefinition(dialect, "name", VarCharType(dialect, 100), comment=ColumnCommentClause(dialect, "名称")), ] expr = CreateTableExpression( - dialect=dialect, table="users", columns=columns, dialect_options={"comment": "用户表"} + dialect=dialect, table="users", columns=columns, + table_options=CreateTableOptions(dialect, comment=TableCommentClause(dialect, "用户表")), ) sql, params = expr.to_sql() assert "COMMENT '主键'" in sql @@ -271,7 +238,7 @@ def test_auto_increment_with_comment(self): ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True), ], - comment="自增主键", + comment=ColumnCommentClause(dialect, "自增主键"), ) ] expr = CreateTableExpression(dialect=dialect, table="users", columns=columns) @@ -623,27 +590,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 = [IndexDefinition(dialect, "idx_email", ["email"], unique=True), IndexDefinition(dialect, "idx_status", ["status"])] @@ -655,7 +622,7 @@ def test_complete_table_creation(self): indexes=indexes, if_not_exists=True, storage_options={"ENGINE": "InnoDB", "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_mysql_test/feature/backend/test_mysql_ddl_improvements.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py index de094e19..ad6ace0e 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py @@ -1,15 +1,19 @@ # tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_ddl_improvements.py """Tests for MySQL DDL improvements: capability gating, UnsupportedFeatureError.""" import pytest -from unittest.mock import patch, PropertyMock +from unittest.mock import patch +from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.expression import ( Column, + ColumnDefinition, + CreateTableExpression, TableExpression, QueryExpression, CreateViewExpression, - DropViewExpression, ) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -37,6 +41,22 @@ def test_materialized_view_not_supported(self): dialect = MySQLDialect() assert dialect.supports_materialized_view() is False + def test_create_view_check_option_gated(self): + """WITH CHECK OPTION must fail fast when the capability is off.""" + dialect = MySQLDialect() + query = QueryExpression( + dialect, select=[Column(dialect, "id")], from_=TableExpression(dialect, "t") + ) + expr = CreateViewExpression( + dialect, + view_name="v", + query=query, + options=ViewOptions(check_option=ViewCheckOption.CASCADED), + ) + with patch.object(type(dialect), "supports_view_check_option", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="CHECK OPTION"): + expr.to_sql() + class TestMySQLColumnCapabilityGating: """Tests for MySQL COLUMN DDL capability gating.""" @@ -97,3 +117,70 @@ def test_drop_schema_supported(self): """MySQL supports DROP SCHEMA (alias for DROP DATABASE).""" dialect = MySQLDialect() assert dialect.supports_drop_schema() 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 TestMySQLTableDeclarationGating: + def test_table_declaration_defaults_are_absent(self): + class Plain(ActiveRecord): + __table_name__ = "plain_table_defaults" + + id: int + + dialect = MySQLDialect() + 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 = MySQLDialect(version=(8, 0, 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 = MySQLDialect(version=(8, 0, 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_mysql_test/feature/backend/test_mysql_dialect_security.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py index 50e6d87b..272978fe 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_dialect_security.py @@ -15,6 +15,7 @@ ColumnConstraint, ColumnConstraintType, ) +from rhosocial.activerecord.backend.expression import ColumnCommentClause, TableCommentClause from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.impl.mysql.expression.json_table import ( MySQLJSONTableExpression, @@ -44,7 +45,7 @@ def test_mysql_format_column_definition_default_string_escaping(dialect): def test_mysql_format_column_definition_comment_string_escaping(dialect): """Test COMMENT string is escaped in MySQL column definition.""" - col_def = ColumnDefinition(dialect, "test_col", VarCharType(dialect, 255), comment="Comment with 'single quote'") + col_def = ColumnDefinition(dialect, "test_col", VarCharType(dialect, 255), comment=ColumnCommentClause(dialect, "Comment with 'single quote'")) sql, params = dialect.format_column_definition(col_def) assert "Comment with ''single quote''" in sql @@ -434,13 +435,15 @@ 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.ddl_table import ( + 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) @@ -453,13 +456,15 @@ 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.ddl_table import ( + 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_mysql_test/feature/backend/test_mysql_mixin_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py index 4005b2a5..a86c1551 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_mixin_expressions.py @@ -55,6 +55,7 @@ MySQLPartitionByRangeColumns, MySQLPartitionDefinition, MySQLPartitionMaxValue, + MySQLPartitionOptions, MySQLPartitionValue, MySQLReorganizePartitionExpression, MySQLTruncatePartitionExpression, @@ -1051,7 +1052,7 @@ def test_partition_definition_with_comment(self): defn = MySQLPartitionDefinition( name="p_2024", in_values=[MySQLPartitionValue(dialect, "2024-01-01")], - dialect_options={"comment": "Partition for 2024 data"}, + partition_options=MySQLPartitionOptions(comment="Partition for 2024 data"), ) sql, params = mixin.format_partition_definition(defn) assert "COMMENT" in sql diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py index e4344236..43e9de9d 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_partition_expressions.py @@ -31,6 +31,7 @@ MySQLPartitionByRangeColumns, MySQLPartitionDefinition, MySQLPartitionMaxValue, + MySQLPartitionOptions, MySQLPartitionValue, MySQLSubpartitionStrategy, MySQLSubpartitionClause, @@ -38,7 +39,6 @@ MySQLTruncatePartitionExpression, ) - @pytest.fixture def dialect(): """Create a MySQL dialect for expression tests.""" @@ -49,6 +49,14 @@ def _partition_value(dialect, value): return MySQLPartitionValue(dialect, value) +def test_partition_capabilities_and_direct_drop_expression(dialect): + assert dialect.supports_add_partition() is True + assert dialect.supports_drop_partition() is True + expression = MySQLDropPartitionExpression(dialect, "events", ["p0"]) + assert isinstance(expression, MySQLDropPartitionExpression) + assert "DROP PARTITION" in expression.to_sql()[0] + + def test_partition_by_range_expression(dialect): """PARTITION BY RANGE should format expression keys and bounds.""" expr = MySQLPartitionByRange( @@ -244,12 +252,12 @@ def test_partition_definition_options_are_formatted(dialect): definition = MySQLPartitionDefinition( name="p2026", less_than=[_partition_value(dialect, "2027-01-01")], - dialect_options={ - "engine": "InnoDB", - "comment": "tenant's partition", - "max_rows": 1000, - "tablespace": "ts_hot", - }, + partition_options=MySQLPartitionOptions( + engine="InnoDB", + comment="tenant's partition", + max_rows=1000, + tablespace="ts_hot", + ), ) sql, params = dialect.format_partition_definition(definition) @@ -263,11 +271,11 @@ def test_partition_definition_options_are_formatted(dialect): def test_partition_definition_options_reject_invalid_options(dialect): """Unsupported or invalid partition options should fail clearly.""" - with pytest.raises(ValueError, match="Unsupported partition definition option"): - dialect.format_partition_definition_options({"unknown": "value"}) + with pytest.raises(TypeError, match="engine option must be a non-empty string"): + dialect.format_partition_definition_options(MySQLPartitionOptions(engine="")) with pytest.raises(TypeError, match="max_rows option"): - dialect.format_partition_definition_options({"max_rows": -1}) + dialect.format_partition_definition_options(MySQLPartitionOptions(max_rows=-1)) def test_extended_partition_maintenance_expressions(dialect): """MySQL maintenance expressions should delegate to public formatters.""" @@ -441,11 +449,9 @@ def test_subpartition_with_explicit_definitions(dialect): def test_subpartition_definition_rejects_empty_name(dialect): - """Subpartition definition should reject empty names.""" - definition = MySQLSubpartitionDefinition(name="") - - with pytest.raises(ValueError, match="must not be empty"): - dialect.format_subpartition_definition(definition) + """Subpartition definition should reject empty names at construction.""" + with pytest.raises(ValueError, match="non-empty string"): + MySQLSubpartitionDefinition(name="") def test_subpartition_clause_rejects_invalid_strategy(dialect): @@ -465,11 +471,11 @@ def test_subpartition_clause_rejects_invalid_count(dialect): ) -def test_subpartition_preserves_dialect_options_on_subpartition_definitions(dialect): - """Subpartition definitions should accept dialect_options.""" +def test_subpartition_preserves_partition_options_on_subpartition_definitions(dialect): + """Subpartition definitions should accept typed partition_options.""" sub_def = MySQLSubpartitionDefinition( name="sp_active", - dialect_options={"engine": "InnoDB", "comment": "active subpartition"}, + partition_options=MySQLPartitionOptions(engine="InnoDB", comment="active subpartition"), ) sql, params = dialect.format_subpartition_definition(sub_def) @@ -635,13 +641,13 @@ def test_partition_value_with_datetime(dialect): assert params == () -def test_partition_definition_rejects_invalid_dialect_options(dialect): - """MySQLPartitionDefinition should reject non-dict dialect_options.""" - with pytest.raises(TypeError, match="dialect_options must be dict"): +def test_partition_definition_rejects_invalid_partition_options(dialect): + """MySQLPartitionDefinition should reject non-MySQLPartitionOptions values.""" + with pytest.raises(TypeError, match="partition_options must be a MySQLPartitionOptions"): MySQLPartitionDefinition( name="p0", less_than=[MySQLPartitionValue(dialect, 100)], - dialect_options="invalid", # type: ignore[arg-type] + partition_options="invalid", # type: ignore[arg-type] ) diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py index 1ce3690d..97e71fb9 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_protocol_conformance.py @@ -66,6 +66,7 @@ def get_own_protocol_methods(proto: type) -> set: MYSQL_PROTOCOLS = [ dialect_protocols.CollationSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, @@ -95,7 +96,9 @@ def get_own_protocol_methods(proto: type) -> set: dialect_protocols.SQLFunctionSupport, # Generic protocols MySQL also satisfies (previously omitted from this list). dialect_protocols.AlterTableModifierSupport, - dialect_protocols.DDLTypeSupport, + dialect_protocols.DataTypeSupport, + dialect_protocols.UserDefinedTypeSupport, + dialect_protocols.DomainSupport, dialect_protocols.SetOperationSupport, dialect_protocols.TriggerSupport, dialect_protocols.TruncateSupport, @@ -148,6 +151,9 @@ def test_implements_protocol(self, dialect, protocol): # decision (move to MYSQL_PROTOCOLS or revert). MYSQL_NOT_IMPLEMENTED = [ # --- Intentional non-support --- + # MySQL 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 MySQLDialect. dialect_protocols.DatabaseSupport, # MySQL has no SQL/XML support. @@ -173,8 +179,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 @@ -209,6 +220,9 @@ def test_positive_and_negative_lists_partition_all_protocols(self): f"Add each to MYSQL_PROTOCOLS or MYSQL_NOT_IMPLEMENTED." ) + stale = (positive | negative) - all_protos + assert not stale, f"Stale protocol classifications for MySQL: {sorted(stale)}" + class TestProtocolNonOverlap: """Assert protocols do not have overlapping method names.""" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py new file mode 100644 index 00000000..73c2cd6b --- /dev/null +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py @@ -0,0 +1,202 @@ +# tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_domain_negative.py +"""Explicit negative contracts for MySQL TYPE and DOMAIN DDL.""" + +from __future__ import annotations + +from typing import Tuple + +import pytest + +from rhosocial.activerecord.backend.dialect import ( + DataTypeMixin, + DataTypeSupport, + DomainMixin, + DomainSupport, + UserDefinedTypeMixin, + UserDefinedTypeSupport, +) +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import BaseExpression, Literal +from rhosocial.activerecord.backend.expression.statements import ( + AlterDomainExpression, + AlterTypeExpression, + CreateDomainExpression, + CreateTypeExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainDefaultAction, + DropDomainExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) +from rhosocial.activerecord.backend.expression.types import DataType, IntegerType +from rhosocial.activerecord.backend.impl.mysql.dialect import MySQLDialect +from rhosocial.activerecord.backend.impl.mysql.expression.types import MySQLEnumType, MySQLSetType +from rhosocial.activerecord.backend.impl.mysql.mixins import MySQLTypeSupportMixin + + +class _TestTypeDefinition(TypeDefinition): + @property + def definition_kind(self) -> str: + return "negative" + + +class _TestTypeAlterAction(TypeAlterAction): + @property + def action_kind(self) -> str: + return "negative" + + +TYPE_SUPPORT_FLAGS = ( + "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", +) + +DOMAIN_SUPPORT_FLAGS = ( + "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.fixture +def dialect() -> MySQLDialect: + return MySQLDialect(version=(8, 0, 0)) + + +def _type_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: + definition = _TestTypeDefinition(dialect) + action = _TestTypeAlterAction(dialect) + return ( + CreateTypeExpression(dialect, "status", definition), + AlterTypeExpression(dialect, "status", [action]), + DropTypeExpression(dialect, "status"), + definition, + action, + ) + + +def _domain_nodes(dialect: MySQLDialect) -> Tuple[BaseExpression, ...]: + condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) + check = DomainCheckConstraint(dialect, condition) + return ( + CreateDomainExpression(dialect, "positive", IntegerType(dialect)), + AlterDomainExpression(dialect, "positive", [DropDomainDefaultAction(dialect)]), + DropDomainExpression(dialect, "positive"), + DomainValueExpression(dialect), + check, + DropDomainDefaultAction(dialect), + ) + + +def test_type_and_domain_protocols_are_composed(dialect: MySQLDialect) -> None: + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, DomainSupport) + assert isinstance(dialect, UserDefinedTypeMixin) + assert isinstance(dialect, DomainMixin) + assert isinstance(dialect, DataTypeSupport) + + +def test_type_and_domain_mixins_precede_protocols() -> None: + mro = MySQLDialect.__mro__ + assert mro.index(UserDefinedTypeMixin) < mro.index(UserDefinedTypeSupport) + assert mro.index(DomainMixin) < mro.index(DomainSupport) + assert mro.index(DataTypeMixin) < mro.index(DataTypeSupport) + assert mro.index(MySQLTypeSupportMixin) < mro.index(DataTypeSupport) + assert issubclass(MySQLTypeSupportMixin, DataTypeMixin) + assert issubclass(MySQLTypeSupportMixin, DataTypeSupport) + assert not issubclass(MySQLTypeSupportMixin, UserDefinedTypeMixin) + assert not issubclass(MySQLTypeSupportMixin, DomainMixin) + + +def test_core_type_and_domain_formatters_own_the_mro() -> None: + for method_name in TYPE_FORMATTERS: + assert getattr(MySQLDialect, method_name) is getattr( + UserDefinedTypeMixin, + method_name, + ) + for method_name in DOMAIN_FORMATTERS: + assert getattr(MySQLDialect, method_name) is getattr( + DomainMixin, + method_name, + ) + + +def test_schema_level_type_and_domain_support_is_false(dialect: MySQLDialect) -> None: + for name in TYPE_SUPPORT_FLAGS + DOMAIN_SUPPORT_FLAGS: + assert getattr(dialect, name)() is False, name + assert dialect.supports_type_definition(_TestTypeDefinition) is False + assert dialect.supports_type_alter_action(_TestTypeAlterAction) is False + for nullability in DomainNullability: + assert dialect.supports_domain_nullability(nullability) is False + assert dialect.supports_alter_domain_action(DropDomainDefaultAction) is False + assert dialect.supported_type_definitions() == () + + +def test_type_formatters_and_expressions_fail_fast(dialect: MySQLDialect) -> None: + nodes = _type_nodes(dialect) + for formatter_name, node in zip(TYPE_FORMATTERS, nodes): + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, formatter_name)(node) + with pytest.raises(UnsupportedFeatureError): + node.to_sql() + + +def test_domain_formatters_and_expressions_fail_fast(dialect: MySQLDialect) -> None: + nodes = _domain_nodes(dialect) + for formatter_name, node in zip(DOMAIN_FORMATTERS, nodes): + with pytest.raises(UnsupportedFeatureError): + getattr(dialect, formatter_name)(node) + with pytest.raises(UnsupportedFeatureError): + node.to_sql() + + +def test_enum_and_set_remain_data_types(dialect: MySQLDialect) -> None: + enum_type = MySQLEnumType(dialect, ["a", "b"]) + set_type = MySQLSetType(dialect, ["a", "b"]) + + assert isinstance(enum_type, DataType) + assert isinstance(set_type, DataType) + assert not isinstance(enum_type, TypeDefinition) + assert not isinstance(set_type, TypeDefinition) + assert dialect.supports_data_type_mysql_enum() is True + assert dialect.supports_data_type_mysql_set() is True + assert enum_type.to_sql()[0] == "ENUM('a','b')" + assert set_type.to_sql()[0] == "SET('a','b')" diff --git a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py index e96ddc5c..bcbbc8f8 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/backend/test_mysql_type_protocol.py @@ -21,7 +21,7 @@ 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.types import ( DataType, DecimalType, @@ -74,9 +74,9 @@ def test_supports_data_types_returns_classes(self, dialect): assert issubclass(klass, DataType), name def test_inherits_mixin_scan_implementation(self, dialect): - # The merge comes from DDLTypeMixin's scan-based + # The merge comes from DataTypeMixin's scan-based # supports_data_types(); MySQL only declares the per-type pairs. - assert MySQLDialect.supports_data_types is DDLTypeMixin.supports_data_types + assert MySQLDialect.supports_data_types is DataTypeMixin.supports_data_types class TestSupportsDataTypesMapping: @@ -200,22 +200,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 = MySQLIntType(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 = MySQLIntType(dialect, unsigned=True, - dialect_options={"display_width": 10}) - b = MySQLIntType(dialect, unsigned=True, - dialect_options={"display_width": 10}) - c = MySQLIntType(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): + MySQLIntType(dialect, unsigned=True, dialect_options={"display_width": 10}) def test_equality_ignores_dialect(self, dialect): other = MySQLDialect() diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py index b8ab8ef9..ba5e9f0b 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models.py @@ -313,8 +313,6 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class TypeAdapterTest(ActiveRecord): """Model for testing various type adapter scenarios.""" __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' - id: Optional[int] = None name: str @@ -332,7 +330,6 @@ class TypeAdapterTest(ActiveRecord): class AsyncTypeAdapterTest(AsyncActiveRecord): """Model for testing various type adapter scenarios.""" __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -354,7 +351,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): """User model with custom column name mappings for testing in basic feature.""" __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[Optional[int], UseColumn("id")] = None @@ -373,7 +369,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """User model with custom column name mappings for testing in basic feature.""" __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -394,7 +389,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[Optional[int], UseColumn("id")] = None @@ -418,7 +412,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -444,7 +437,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[Optional[int], UseColumn("id")] = None @@ -469,7 +461,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -514,7 +505,6 @@ class ColumnMappingModel(ActiveRecord): including mapping the primary key attribute. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[Optional[int], UseColumn("id")] = Field(default=None) @@ -528,7 +518,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): including mapping the primary key attribute. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -615,7 +604,6 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class MixedAnnotationModel(ActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # Corrected from "item_id" # 1. Standard Python type name: str @@ -639,7 +627,6 @@ class MixedAnnotationModel(ActiveRecord): class AsyncMixedAnnotationModel(AsyncActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # Corrected from "item_id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -737,7 +724,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK — backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -747,7 +733,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py index a6c6df92..900034bb 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py310.py @@ -338,7 +338,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -360,7 +359,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -383,7 +381,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[int | None, UseColumn("id")] = None @@ -404,7 +401,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -426,7 +422,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[int | None, UseColumn("id")] = None @@ -453,7 +448,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -481,7 +475,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[int | None, UseColumn("id")] = None @@ -508,7 +501,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -551,7 +543,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -567,7 +558,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -647,7 +637,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # 1. Standard Python type name: str @@ -674,7 +663,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -750,7 +738,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -760,7 +747,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py index f912576e..c348093f 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py311.py @@ -385,7 +385,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -404,7 +403,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -424,7 +422,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -443,7 +440,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -458,7 +454,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -474,7 +469,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -491,7 +485,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -507,7 +500,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -538,7 +530,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -552,7 +543,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -631,7 +621,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -656,7 +645,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -721,7 +709,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -731,7 +718,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py index c0178057..d0e8ce20 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/basic/fixtures/models_py312.py @@ -372,7 +372,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -391,7 +390,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -411,7 +409,6 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -430,7 +427,6 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -445,7 +441,6 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -461,7 +456,6 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -478,7 +472,6 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -494,7 +487,6 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -525,7 +517,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -539,7 +530,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -618,7 +608,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -640,7 +629,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -755,7 +743,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -765,7 +752,6 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py b/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py index d5e5b2c0..b628f344 100644 --- a/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py +++ b/tests/rhosocial/activerecord_mysql_test/feature/query/mysql/test_collation_expression.py @@ -8,7 +8,6 @@ from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.impl.mysql import ( MySQLCollation, - MySQLCollationValidator, MySQLDialect, ) @@ -35,20 +34,23 @@ def collation_table(mysql_backend): mysql_backend.execute("DROP TABLE IF EXISTS test_collation_expression") -class TestMySQLCollationValidator: +class TestMySQLCollationSupport: def test_supports_known_legacy_collation(self): - assert MySQLCollationValidator.is_supported("utf8mb4_unicode_ci", (5, 7, 0)) + assert MySQLDialect(version=(5, 7, 0)).supports_collation_name("utf8mb4_unicode_ci") def test_rejects_mysql_8_collation_on_older_version(self): - assert not MySQLCollationValidator.is_supported("utf8mb4_0900_ai_ci", (5, 7, 0)) - assert MySQLCollationValidator.is_supported("utf8mb4_0900_ai_ci", (8, 0, 0)) + assert not MySQLDialect(version=(5, 7, 0)).supports_collation_name("utf8mb4_0900_ai_ci") + assert MySQLDialect(version=(8, 0, 0)).supports_collation_name("utf8mb4_0900_ai_ci") def test_validate_normalizes_case(self): - assert MySQLCollationValidator.validate("UTF8MB4_BIN", (5, 7, 0)) == "utf8mb4_bin" + assert ( + MySQLDialect(version=(5, 7, 0)).validate_collation_by_name("UTF8MB4_BIN") + == "utf8mb4_bin" + ) def test_validate_rejects_unknown_collation(self): with pytest.raises(ValueError, match="Unsupported MySQL collation"): - MySQLCollationValidator.validate("unknown_ci", (8, 0, 0)) + MySQLDialect(version=(8, 0, 0)).validate_collation_by_name("unknown_ci") def test_enum_contains_representative_collations(self): values = {collation.value for collation in MySQLCollation}