diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bd83f2..7ea8e93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,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: Verify Firebird connection and create test user run: | diff --git a/CLAUDE.md b/CLAUDE.md index 497dc34..46e0c9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,4 +26,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/5.added.md b/changelog.d/5.added.md new file mode 100644 index 0000000..4cd7b0a --- /dev/null +++ b/changelog.d/5.added.md @@ -0,0 +1 @@ +Complete Firebird domain DDL support, including `ALTER DOMAIN` constraint rendering and the DDL type protocol alias, add `FirebirdCreateTableExpression` for `on_commit_delete` and `external_file`, add `FirebirdColumnDefinition` with typed column options, classify `CommentSupport` for conformance, and drop an index by bare name without `ON `. diff --git a/changelog.d/5.fixed.md b/changelog.d/5.fixed.md new file mode 100644 index 0000000..74848fa --- /dev/null +++ b/changelog.d/5.fixed.md @@ -0,0 +1 @@ +Restore Firebird domain DDL rendering, which was broken rather than merely incomplete, and stop silently dropping generated column definitions, constraint options and declared comments — the last of these because the comment mixin was composed after `TableMixin` and so never reached the renderer. Unsupported table declarations are now rejected, and the cascade-view and functional-index capability bits are corrected. diff --git a/changelog.d/5.internal.md b/changelog.d/5.internal.md new file mode 100644 index 0000000..0896154 --- /dev/null +++ b/changelog.d/5.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, register the pytest markers the suite already uses, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/5.removed.md b/changelog.d/5.removed.md new file mode 100644 index 0000000..7a589cc --- /dev/null +++ b/changelog.d/5.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects and the legacy identity fields from column classes in the Firebird backend. diff --git a/pyproject.toml b/pyproject.toml index 3fa767e..8bf0485 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,6 +93,9 @@ markers = [ "query: marks tests for query functionality", "transaction: marks tests for transaction handling", "requires_capability: marks tests requiring specific backend capabilities", + "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 e06f0cb..81b3dda 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/firebird/dialect.py b/src/rhosocial/activerecord/backend/impl/firebird/dialect.py index 8271c61..594fa17 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/dialect.py @@ -13,7 +13,7 @@ - DECFLOAT (FB 4.0+) """ -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.protocols import ( @@ -50,6 +50,7 @@ GeneratedColumnSupport, ViewSupport, FunctionSupport, + UserDefinedTypeSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( CollationMixin, @@ -87,11 +88,10 @@ DQLMixin, DMLMixin, DDLColumnMixin, + UserDefinedTypeMixin, TransactionControlMixin, ) -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -from .collation import validate_firebird_collation_name from .mixins.version_boundaries import _norm_version from .reserved_words import FIREBIRD_RESERVED_WORDS from .mixins import ( @@ -140,6 +140,7 @@ FirebirdLockingSupport, FirebirdTransactionSupport, FirebirdTableSupport, + FirebirdDomainSupport, FirebirdTriggerSupport, FirebirdReturningSupport, FirebirdIntrospectionSupport, @@ -165,10 +166,7 @@ ) if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression import bases - from rhosocial.activerecord.backend.expression.collation import CollateExpression from rhosocial.activerecord.backend.expression.statements import ( - ReturningClause, CreateTableExpression, ColumnDefinition, TableConstraint, @@ -217,10 +215,12 @@ class FirebirdDialect( DQLMixin, FirebirdAlterTableModifierMixin, # Before DDLColumnMixin to override format_*_action DDLColumnMixin, + UserDefinedTypeMixin, TransactionControlMixin, # Firebird-specific overrides (before generic mixins to take precedence) FirebirdDMLOperationMixin, # Must be before DMLMixin FirebirdTableMixin, # Must be before TableMixin + FirebirdCommentMixin, # Must be before TableMixin (supports_comment_on/format_comment_statement) TableMixin, ConstraintMixin, FirebirdTriggerMixin, # Must be before TriggerMixin @@ -236,7 +236,6 @@ class FirebirdDialect( FirebirdExternalFunctionMixin, FirebirdRoleMixin, FirebirdUserMixin, - FirebirdCommentMixin, FirebirdDatabaseMixin, # Core feature mixins (no duplicates) DMLMixin, @@ -303,6 +302,7 @@ class FirebirdDialect( FirebirdLockingSupport, FirebirdTransactionSupport, FirebirdTableSupport, + FirebirdDomainSupport, FirebirdTriggerSupport, FirebirdReturningSupport, FirebirdIntrospectionSupport, @@ -325,6 +325,7 @@ class FirebirdDialect( FirebirdCollationSupport, FirebirdExceptionSupport, FirebirdContextVariableSupport, + UserDefinedTypeSupport, FirebirdPartitionMixin, FirebirdTypeSupportMixin, ): @@ -580,6 +581,9 @@ def supports_create_index(self) -> bool: def supports_drop_index(self) -> bool: return True + def supports_drop_index_on_table(self) -> bool: + return False + def supports_unique_index(self) -> bool: return True @@ -587,7 +591,10 @@ def supports_unique_index(self) -> bool: def supports_functional_index(self) -> bool: - return True + # Expression (computed) indexes need Firebird's ``COMPUTED BY`` syntax, + # which is not implemented yet; declaring support would let the generic + # renderer emit invalid SQL. + return False @@ -616,7 +623,8 @@ def supports_view_check_option(self) -> bool: return True def supports_cascade_view(self) -> bool: - return True + # Firebird's DROP VIEW has no CASCADE clause. + return False def supports_trigger(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/firebird/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/firebird/expression/__init__.py index 364e089..e2aa192 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/expression/__init__.py @@ -1,6 +1,8 @@ # src/rhosocial/activerecord/backend/impl/firebird/expression/__init__.py """Firebird-specific expression types.""" +from .column import FirebirdColumnDefinition, FirebirdColumnOptions +from .create_table import FirebirdCreateTableExpression from .alter_table import ( SetGenerated, RestartIdentity, @@ -31,6 +33,7 @@ FirebirdCreateUserExpression, FirebirdDomainAlterMode, FirebirdDropDomainExpression, + FirebirdSetDomainDataTypeAction, FirebirdDropExceptionExpression, FirebirdDropExternalFunctionExpression, FirebirdDropPackageExpression, @@ -53,13 +56,18 @@ FirebirdFloatType, FirebirdDoubleType, FirebirdBlobSubType, + FirebirdCharType, + FirebirdVarCharType, FirebirdTimeStampTzType, FirebirdTimeTzType, + FirebirdTimeWithoutTimeZoneType, FirebirdDecFloatType, FirebirdInt128Type, ) __all__ = [ + 'FirebirdColumnDefinition', 'FirebirdColumnOptions', + "FirebirdCreateTableExpression", "SetGenerated", "RestartIdentity", "SetIncrement", @@ -71,8 +79,11 @@ "FirebirdFloatType", "FirebirdDoubleType", "FirebirdBlobSubType", + "FirebirdCharType", + "FirebirdVarCharType", "FirebirdTimeStampTzType", "FirebirdTimeTzType", + "FirebirdTimeWithoutTimeZoneType", "FirebirdDecFloatType", "FirebirdInt128Type", "BlobColumnExpression", @@ -84,6 +95,7 @@ "FirebirdAlterDomainExpression", "FirebirdDomainAlterMode", "FirebirdDropDomainExpression", + "FirebirdSetDomainDataTypeAction", "FirebirdCreateExceptionExpression", "FirebirdAlterExceptionExpression", "FirebirdDropExceptionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/firebird/expression/column.py b/src/rhosocial/activerecord/backend/impl/firebird/expression/column.py new file mode 100644 index 0000000..0cd2b50 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/firebird/expression/column.py @@ -0,0 +1,95 @@ +# src/rhosocial/activerecord/backend/impl/firebird/expression/column.py +"""Firebird-specific column definition expressions. + +Firebird extends the standard column definition with per-column clauses that +have no generic equivalent: + +* ``COMPUTED BY ()`` — a computed (stored-expression) column. +* ``CHARACTER SET `` — column character set. +* ``COLLATE `` — column collation (Firebird spelling at column level). + +These live on ``FirebirdColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the Firebird +``format_column_definition`` override; they are declared through +``FirebirdColumnOptions`` (deriving the generic ``ColumnOptions``). +""" + +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__ = [ + "FirebirdColumnDefinition", + "FirebirdColumnOptions", +] + + +class FirebirdColumnDefinition(ColumnDefinition): + """A Firebird column definition extending the generic one. + + Adds Firebird-only typed attributes: ``computed_by``, ``character_set`` + and ``collation``. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type, + constraints=None, + comment: Optional[str] = None, + generated_expression=None, + attributes=None, + *, + computed_by: Optional[str] = None, + character_set: Optional[str] = None, + collation: Optional[str] = None, + ): + super().__init__( + dialect, + name, + data_type, + constraints=constraints, + comment=comment, + generated_expression=generated_expression, + attributes=attributes, + ) + self.computed_by = computed_by + self.character_set = character_set + self.collation = collation + + +class FirebirdColumnOptions(ColumnOptions): + """Firebird per-column options declaration.""" + + def __init__( + self, + *, + computed_by: Optional[str] = None, + character_set: Optional[str] = None, + collation: Optional[str] = None, + ): + super().__init__() + self.computed_by = computed_by + self.character_set = character_set + self.collation = collation + + def column_definition_class(self): + """Build a ``FirebirdColumnDefinition`` for these options.""" + return FirebirdColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the Firebird-only fields onto the column definition.""" + if not isinstance(column, FirebirdColumnDefinition): + raise TypeError( + "FirebirdColumnOptions.apply_to requires a FirebirdColumnDefinition, " + f"got {type(column).__name__}" + ) + column.computed_by = self.computed_by + column.character_set = self.character_set + column.collation = self.collation diff --git a/src/rhosocial/activerecord/backend/impl/firebird/expression/create_table.py b/src/rhosocial/activerecord/backend/impl/firebird/expression/create_table.py new file mode 100644 index 0000000..8b6e8d1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/firebird/expression/create_table.py @@ -0,0 +1,81 @@ +# src/rhosocial/activerecord/backend/impl/firebird/expression/create_table.py +"""Firebird-specific CREATE TABLE expression. + +Firebird adds two table-creation clauses with no generic equivalent: + +* ``on_commit_delete`` -- ``ON COMMIT DELETE ROWS`` (``True``) or + ``ON COMMIT PRESERVE ROWS`` (``False``) for global temporary tables. +* ``external_file`` -- ``EXTERNAL FILE ''`` for external tables. + +They live on ``FirebirdCreateTableExpression`` (deriving the generic +``CreateTableExpression``) and are rendered by the Firebird +``format_create_table_statement`` override, which accepts both the generic and +the Firebird-specific instance. +""" + +from typing import TYPE_CHECKING, Any, List, Optional + +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, +) + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnDefinition, + CreateTableOptions, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + ) + from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + ) + + +class FirebirdCreateTableExpression(CreateTableExpression): + """A Firebird CREATE TABLE statement extending the generic one. + + Adds the Firebird-only ``on_commit_delete`` / ``external_file`` table + clauses. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table: Any, + columns: List["ColumnDefinition"], + indexes: Optional[List["IndexDefinition"]] = None, + table_constraints: Optional[List["TableConstraint"]] = None, + temporary: bool = False, + if_not_exists: bool = False, + inherits: Optional[List[str]] = None, + tablespace: Optional[str] = None, + storage_options: Optional["StorageOptionsExpression"] = None, + *, + partition: Optional["PartitionClause"] = None, + table_options: Optional["CreateTableOptions"] = None, + on_commit_delete: Optional[bool] = None, + external_file: Optional[str] = None, + ): + super().__init__( + dialect, + table, + columns, + indexes=indexes, + table_constraints=table_constraints, + temporary=temporary, + if_not_exists=if_not_exists, + inherits=inherits, + tablespace=tablespace, + storage_options=storage_options, + partition=partition, + table_options=table_options, + ) + self.on_commit_delete = on_commit_delete + self.external_file = external_file + + +__all__ = [ + "FirebirdCreateTableExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/__init__.py index a30223a..137a517 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/__init__.py @@ -6,6 +6,7 @@ FirebirdCreateDomainExpression, FirebirdDomainAlterMode, FirebirdDropDomainExpression, + FirebirdSetDomainDataTypeAction, ) from .exception import ( FirebirdAlterExceptionExpression, @@ -45,6 +46,7 @@ "FirebirdAlterDomainExpression", "FirebirdDomainAlterMode", "FirebirdDropDomainExpression", + "FirebirdSetDomainDataTypeAction", "FirebirdCreateExceptionExpression", "FirebirdAlterExceptionExpression", "FirebirdDropExceptionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/domain.py index 4e063c4..88caf94 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/domain.py @@ -1,24 +1,39 @@ # src/rhosocial/activerecord/backend/impl/firebird/expression/ddl/domain.py -"""Firebird DOMAIN statement expressions. - -DOMAIN is a reusable column type (data type + DEFAULT + NOT NULL + CHECK -constraint) available since Firebird 2.5. Each expression delegates SQL -generation to the dialect's ``format_*_domain_statement`` method, following -the Expression-Dialect separation pattern. -""" +"""Firebird DOMAIN statement expressions.""" from enum import Enum -from typing import TYPE_CHECKING, Any, Optional +from typing import Any, Optional, Sequence, Set, TYPE_CHECKING, Union + +from rhosocial.activerecord.backend.expression.bases import SQLPredicate +from rhosocial.activerecord.backend.expression.operators import RawSQLPredicate +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainAlterAction, + DomainCheckConstraint, + DomainNullability, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from rhosocial.activerecord.backend.expression.types import DataType -from rhosocial.activerecord.backend.expression.bases import BaseExpression +from ..types import ( + _normalize_firebird_data_type_name, + _parse_firebird_data_type_name, +) -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - from rhosocial.activerecord.backend.expression.types import DataType class FirebirdDomainAlterMode(Enum): - """ALTER DOMAIN clause selector.""" + """Legacy ALTER DOMAIN clause selector.""" SET_DEFAULT = "SET DEFAULT" DROP_DEFAULT = "DROP DEFAULT" @@ -29,67 +44,322 @@ class FirebirdDomainAlterMode(Enum): SET_TYPE = "TYPE" -class FirebirdCreateDomainExpression(BaseExpression): - """CREATE DOMAIN name [AS] datatype [DEFAULT ...] [NOT NULL] [CHECK (...)].""" +def _bind_data_type(dialect: Any, data_type: DataType) -> DataType: + params = dict(data_type.get_params()) + params.pop("dialect", None) + clone = type(data_type)(**params) + clone.dialect = dialect + return clone + + +class FirebirdSetDomainDataTypeAction(DomainAlterAction): + """Replace a domain's base data type.""" + + action_kind = "set_data_type" + + def __init__( + self, + dialect: "SQLDialectBase", + data_type: Optional[Union[DataType, str]] = None, + *, + data_type_name: Optional[str] = None, + ) -> None: + super().__init__(dialect) + if isinstance(data_type, str): + if data_type_name is not None: + raise ValueError("data_type and data_type_name are mutually exclusive") + data_type_name = data_type + data_type = None + if data_type is not None and not isinstance(data_type, DataType): + raise TypeError( + f"data_type must be a DataType instance, got {type(data_type).__name__}" + ) + if data_type is None and data_type_name is None: + raise ValueError("data_type or data_type_name is required") + if data_type is not None and data_type_name is not None: + raise ValueError("data_type and data_type_name are mutually exclusive") + self.data_type = data_type + if data_type_name is None: + self.data_type_name = None + else: + normalized_name = _normalize_firebird_data_type_name(data_type_name) + _parse_firebird_data_type_name(normalized_name) + self.data_type_name = normalized_name + + def resolve_data_type(self) -> DataType: + if self.data_type is not None: + return _bind_data_type(self.dialect, self.data_type) + if self.data_type_name is None: + raise ValueError("data_type or data_type_name is required") + parsed = _parse_firebird_data_type_name(self.data_type_name) + return _bind_data_type(self.dialect, parsed) + + +def _normalize_nullability(value: Any) -> DomainNullability: + if isinstance(value, str): + try: + return DomainNullability(value) + except ValueError as exc: + raise ValueError(f"Invalid domain nullability: {value!r}") from exc + if not isinstance(value, DomainNullability): + raise TypeError( + f"nullability must be a DomainNullability instance, got " + f"{type(value).__name__}" + ) + return value + + +def _legacy_check( + dialect: "SQLDialectBase", + check: str, +) -> DomainCheckConstraint: + if not isinstance(check, str): + raise TypeError(f"check must be a string, got {type(check).__name__}") + if not check.strip(): + raise ValueError("check must be a non-empty SQL predicate") + return DomainCheckConstraint(dialect, RawSQLPredicate(dialect, check)) + + +def _coalesce_checks( + dialect: "SQLDialectBase", + check: Optional[str], + checks: Optional[Sequence[DomainCheckConstraint]], +) -> Optional[Sequence[DomainCheckConstraint]]: + if check is None: + return checks + legacy_check = _legacy_check(dialect, check) + if checks is None: + return [legacy_check] + if isinstance(checks, (DomainCheckConstraint, SQLPredicate)): + check_items = [checks] + else: + check_items = list(checks) + if len(check_items) != 1 or not isinstance(check_items[0], DomainCheckConstraint): + raise ValueError("check and checks must describe the same single CHECK") + condition = check_items[0].condition + if ( + check_items[0].name is not None + or not isinstance(condition, RawSQLPredicate) + or condition.expression != check + or condition.params + ): + raise ValueError("check and checks must describe the same single CHECK") + return check_items + + +class FirebirdCreateDomainExpression(CreateDomainExpression): + """Compatibility wrapper for the core CREATE DOMAIN expression.""" def __init__( self, dialect: "SQLDialectBase", domain_name: str, - data_type: "DataType", + data_type: DataType, default: Any = None, not_null: bool = False, check: Optional[str] = None, - ): - super().__init__(dialect) - self.domain_name: str = domain_name - self.data_type: "DataType" = data_type - self.default: Any = default - self.not_null: bool = not_null - self.check: Optional[str] = check + *, + nullability: Optional[Union[DomainNullability, str]] = None, + checks: Optional[Sequence[DomainCheckConstraint]] = None, + collation: Optional[str] = None, + ) -> None: + if not isinstance(not_null, bool): + raise TypeError(f"not_null must be a bool, got {type(not_null).__name__}") + normalized_nullability = ( + None if nullability is None else _normalize_nullability(nullability) + ) + if not_null: + if ( + normalized_nullability is not None + and normalized_nullability is not DomainNullability.NOT_NULL + ): + raise ValueError("not_null conflicts with nullability") + normalized_nullability = DomainNullability.NOT_NULL + elif normalized_nullability is None: + normalized_nullability = DomainNullability.UNSPECIFIED + normalized_checks = _coalesce_checks(dialect, check, checks) + super().__init__( + dialect, + domain_name, + data_type, + default=default, + nullability=normalized_nullability, + checks=normalized_checks, + collation=collation, + ) + self.not_null = not_null + self.check = check - @property - def format_method(self) -> str: - return "format_create_domain_statement" - -class FirebirdAlterDomainExpression(BaseExpression): - """ALTER DOMAIN name { SET DEFAULT | DROP DEFAULT | SET/DROP NOT NULL | - ADD/DROP CONSTRAINT | TYPE }.""" +class FirebirdAlterDomainExpression(AlterDomainExpression): + """Compatibility wrapper accepting either the legacy mode or core actions.""" def __init__( self, dialect: "SQLDialectBase", domain_name: str, - mode: FirebirdDomainAlterMode, + mode: Optional[FirebirdDomainAlterMode] = None, value: Any = None, - data_type: Optional["DataType"] = None, + data_type: Optional[Union[DataType, str]] = None, constraint_name: Optional[str] = None, constraint_sql: Optional[str] = None, - ): - super().__init__(dialect) - self.domain_name: str = domain_name - self.mode: FirebirdDomainAlterMode = mode - self.value: Any = value - self.data_type: Optional["DataType"] = data_type - self.constraint_name: Optional[str] = constraint_name - self.constraint_sql: Optional[str] = constraint_sql + *, + actions: Optional[Sequence[DomainAlterAction]] = None, + data_type_name: Optional[str] = None, + ) -> None: + legacy_fields = { + "value": value, + "data_type": data_type, + "constraint_name": constraint_name, + "constraint_sql": constraint_sql, + "data_type_name": data_type_name, + } + if mode is None: + if any(field is not None for field in legacy_fields.values()): + raise ValueError("legacy ALTER DOMAIN fields require mode") + selected_actions = list(actions) if actions is not None else [] + else: + expected_action = self._legacy_action( + dialect, + mode, + value=value, + data_type=data_type, + constraint_name=constraint_name, + constraint_sql=constraint_sql, + data_type_name=data_type_name, + ) + if actions is None: + selected_actions = [expected_action] + else: + selected_actions = list(actions) + if ( + len(selected_actions) != 1 + or not self._actions_equivalent(expected_action, selected_actions[0]) + ): + raise ValueError("mode and actions describe different ALTER DOMAIN changes") + super().__init__(dialect, domain_name, selected_actions) + self.mode = mode + self.value = value + self.data_type = data_type + self.constraint_name = constraint_name + self.constraint_sql = constraint_sql + self.data_type_name = data_type_name + + @staticmethod + def _reject_unused_fields( + mode: FirebirdDomainAlterMode, + fields: dict[str, Any], + allowed: Set[str], + ) -> None: + unexpected = sorted( + name for name, value in fields.items() if name not in allowed and value is not None + ) + if unexpected: + names = ", ".join(unexpected) + raise ValueError(f"{mode.value} does not accept: {names}") - @property - def format_method(self) -> str: - return "format_alter_domain_statement" + @classmethod + def _legacy_action( + cls, + dialect: "SQLDialectBase", + mode: FirebirdDomainAlterMode, + *, + value: Any, + data_type: Optional[Union[DataType, str]], + constraint_name: Optional[str], + constraint_sql: Optional[str], + data_type_name: Optional[str], + ) -> DomainAlterAction: + if not isinstance(mode, FirebirdDomainAlterMode): + raise TypeError( + f"mode must be a FirebirdDomainAlterMode instance, got " + f"{type(mode).__name__}" + ) + fields = { + "value": value, + "data_type": data_type, + "constraint_name": constraint_name, + "constraint_sql": constraint_sql, + "data_type_name": data_type_name, + } + if mode is FirebirdDomainAlterMode.SET_DEFAULT: + cls._reject_unused_fields(mode, fields, {"value"}) + if value is None: + raise ValueError("SET DEFAULT requires a value") + return SetDomainDefaultAction(dialect, value) + if mode is FirebirdDomainAlterMode.DROP_DEFAULT: + cls._reject_unused_fields(mode, fields, set()) + return DropDomainDefaultAction(dialect) + if mode is FirebirdDomainAlterMode.SET_NOT_NULL: + cls._reject_unused_fields(mode, fields, set()) + return SetDomainNotNullAction(dialect) + if mode is FirebirdDomainAlterMode.DROP_NOT_NULL: + cls._reject_unused_fields(mode, fields, set()) + return DropDomainNotNullAction(dialect) + if mode is FirebirdDomainAlterMode.ADD_CONSTRAINT: + cls._reject_unused_fields(mode, fields, {"constraint_name", "constraint_sql"}) + if constraint_sql is None: + raise ValueError("ADD CONSTRAINT requires constraint_sql") + check = _legacy_check(dialect, constraint_sql) + if constraint_name is not None: + check = DomainCheckConstraint( + dialect, + check.condition, + name=constraint_name, + ) + return AddDomainCheckAction(dialect, check) + if mode is FirebirdDomainAlterMode.DROP_CONSTRAINT: + cls._reject_unused_fields(mode, fields, {"constraint_name"}) + return DropDomainCheckAction(dialect, name=constraint_name) + cls._reject_unused_fields(mode, fields, {"data_type", "data_type_name"}) + if data_type is None and data_type_name is None: + raise ValueError("SET TYPE requires data_type or data_type_name") + return FirebirdSetDomainDataTypeAction( + dialect, + data_type=data_type, + data_type_name=data_type_name, + ) + @staticmethod + def _actions_equivalent(expected: DomainAlterAction, actual: DomainAlterAction) -> bool: + if type(expected) is not type(actual): + return False + if isinstance(expected, SetDomainDefaultAction) and isinstance( + actual, + SetDomainDefaultAction, + ): + return expected.default.to_sql() == actual.default.to_sql() + if isinstance(expected, AddDomainCheckAction) and isinstance( + actual, + AddDomainCheckAction, + ): + return ( + expected.check.name == actual.check.name + and expected.check.condition.to_sql() == actual.check.condition.to_sql() + ) + if isinstance(expected, DropDomainCheckAction) and isinstance( + actual, + DropDomainCheckAction, + ): + return expected.name == actual.name + if isinstance(expected, FirebirdSetDomainDataTypeAction) and isinstance( + actual, + FirebirdSetDomainDataTypeAction, + ): + if expected.data_type is not None or actual.data_type is not None: + return expected.data_type == actual.data_type + return expected.data_type_name == actual.data_type_name + return True -class FirebirdDropDomainExpression(BaseExpression): - """DROP DOMAIN name.""" - def __init__(self, dialect: "SQLDialectBase", domain_name: str): - super().__init__(dialect) - self.domain_name: str = domain_name +class FirebirdDropDomainExpression(DropDomainExpression): + """Firebird-named DROP DOMAIN expression.""" + - @property - def format_method(self) -> str: - return "format_drop_domain_statement" +ExpressionRegistry.register(FirebirdCreateDomainExpression) +ExpressionRegistry.register(FirebirdAlterDomainExpression) +ExpressionRegistry.register(FirebirdDropDomainExpression) +ExpressionRegistry.register(FirebirdSetDomainDataTypeAction) __all__ = [ @@ -97,4 +367,5 @@ def format_method(self) -> str: "FirebirdCreateDomainExpression", "FirebirdAlterDomainExpression", "FirebirdDropDomainExpression", + "FirebirdSetDomainDataTypeAction", ] diff --git a/src/rhosocial/activerecord/backend/impl/firebird/expression/types.py b/src/rhosocial/activerecord/backend/impl/firebird/expression/types.py index ad40792..9c3592e 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/expression/types.py @@ -1,15 +1,30 @@ # src/rhosocial/activerecord/backend/impl/firebird/expression/types.py """Firebird-specific DDL DataType subclasses.""" -from typing import Optional, Tuple +import re +from typing import Tuple +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry from rhosocial.activerecord.backend.expression.types import ( + BigIntType, + BinaryType, + BooleanType, + CharType, + CustomType, DataType, + DateType, DecimalType, + DoubleType, FloatType, IntegerType, + SmallIntType, + TextType, + TimeType, TimeTzType, + TimestampType, TimestampTzType, + VarBinaryType, + VarCharType, ) @@ -33,6 +48,16 @@ class FirebirdBlobSubType(IntegerType): name = "firebird_blob_subtype" +class FirebirdCharType(CharType): + """Firebird CHAR type with an explicit UTF8 character set.""" + name = "firebird_char" + + +class FirebirdVarCharType(VarCharType): + """Firebird VARCHAR type with an explicit UTF8 character set.""" + name = "firebird_varchar" + + class FirebirdTimeStampTzType(TimestampTzType): """Firebird TIMESTAMP WITH TIME ZONE type (Firebird 4.0+).""" name = "firebird_timestamptz" @@ -43,6 +68,11 @@ class FirebirdTimeTzType(TimeTzType): name = "firebird_timetz" +class FirebirdTimeWithoutTimeZoneType(TimeType): + """Firebird TIME WITHOUT TIME ZONE type (Firebird 4.0+).""" + name = "firebird_time_without_time_zone" + + class FirebirdDecFloatType(DataType): """Firebird DECFLOAT(16|34) type (Firebird 4.0+). @@ -52,9 +82,8 @@ class FirebirdDecFloatType(DataType): name = "firebird_decfloat" - def __init__(self, dialect=None, precision: int = 16, - dialect_options=None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, precision: int = 16): + super().__init__(dialect) if precision not in (16, 34): raise ValueError(f"DECFLOAT precision must be 16 or 34, got {precision}") self.precision: int = precision @@ -73,13 +102,199 @@ class FirebirdInt128Type(DataType): name = "firebird_int128" +_DATA_TYPE_TEXT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_$.,() \t]*") +_QUALIFIED_IDENTIFIER_RE = re.compile( + r"[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?" +) +_INTEGER_RE = re.compile(r"INTEGER|INT|BIGINT|SMALLINT", re.IGNORECASE) +_FLOAT_RE = re.compile(r"FLOAT(?:\s*\(\s*(\d+)\s*\))?", re.IGNORECASE) +_REAL_RE = re.compile(r"REAL", re.IGNORECASE) +_DOUBLE_RE = re.compile(r"DOUBLE\s+PRECISION", re.IGNORECASE) +_DECIMAL_RE = re.compile( + r"(?:DECIMAL|NUMERIC)(?:\s*\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?", + re.IGNORECASE, +) +_VARCHAR_RE = re.compile( + r"(?:VARCHAR|CHARACTER\s+VARYING|CHAR\s+VARYING)" + r"(?:\s*\(\s*(\d+)\s*\))?", + re.IGNORECASE, +) +_CHAR_RE = re.compile( + r"(?:CHAR|CHARACTER)(?:\s*\(\s*(\d+)\s*\))?", + re.IGNORECASE, +) +_BINARY_VARCHAR_RE = re.compile( + r"(?:VARCHAR|CHARACTER\s+VARYING|CHAR\s+VARYING)" + r"\s*\(\s*(\d+)\s*\)\s+CHARACTER\s+SET\s+OCTETS", + re.IGNORECASE, +) +_BINARY_CHAR_RE = re.compile( + r"(?:CHAR|CHARACTER)\s*\(\s*(\d+)\s*\)\s+CHARACTER\s+SET\s+OCTETS", + re.IGNORECASE, +) +_UTF8_VARCHAR_RE = re.compile( + r"(?:VARCHAR|CHARACTER\s+VARYING|CHAR\s+VARYING)" + r"\s*\(\s*(\d+)\s*\)\s+CHARACTER\s+SET\s+UTF8", + re.IGNORECASE, +) +_UTF8_CHAR_RE = re.compile( + r"(?:CHAR|CHARACTER)\s*\(\s*(\d+)\s*\)\s+CHARACTER\s+SET\s+UTF8", + re.IGNORECASE, +) +_BLOB_TEXT_RE = re.compile(r"BLOB\s+SUB_TYPE\s+TEXT", re.IGNORECASE) +_BLOB_BINARY_RE = re.compile( + r"BLOB(?:\s+SUB_TYPE\s+BINARY|\s+CHARACTER\s+SET\s+OCTETS)?", + re.IGNORECASE, +) +_DATE_RE = re.compile(r"DATE", re.IGNORECASE) +_TIME_RE = re.compile(r"TIME", re.IGNORECASE) +_TIMESTAMP_RE = re.compile(r"TIMESTAMP", re.IGNORECASE) +_BOOLEAN_RE = re.compile(r"BOOLEAN", re.IGNORECASE) +_DECFLOAT_RE = re.compile( + r"DECFLOAT(?:\s*\(\s*(16|34)\s*\))?", + re.IGNORECASE, +) +_INT128_RE = re.compile(r"INT128", re.IGNORECASE) +_TIMESTAMP_WITH_TIME_ZONE_RE = re.compile( + r"TIMESTAMP(?:\s*\(\s*(\d+)\s*\))?\s+WITH\s+TIME\s+ZONE", + re.IGNORECASE, +) +_TIME_WITH_TIME_ZONE_RE = re.compile( + r"TIME(?:\s*\(\s*(\d+)\s*\))?\s+WITH\s+TIME\s+ZONE", + re.IGNORECASE, +) +_TIME_WITHOUT_TIME_ZONE_RE = re.compile( + r"TIME(?:\s*\(\s*(\d+)\s*\))?\s+WITHOUT\s+TIME\s+ZONE", + re.IGNORECASE, +) +_ACTION_WORDS = { + "ADD", + "ALTER", + "CHECK", + "COLLATE", + "CONSTRAINT", + "CREATE", + "DEFAULT", + "DROP", + "NOT", + "NULL", + "SET", + "TO", + "WITHOUT", +} + + +def _normalize_firebird_data_type_name(value: str) -> str: + if not isinstance(value, str): + raise TypeError(f"data_type_name must be a string, got {type(value).__name__}") + normalized = " ".join(value.strip().split()) + if not normalized or _DATA_TYPE_TEXT_RE.fullmatch(normalized) is None: + raise ValueError("data_type_name must be a valid Firebird data type declaration") + return normalized + + +def _parse_firebird_data_type_name(value: str) -> DataType: + normalized = _normalize_firebird_data_type_name(value) + upper = normalized.upper() + match = _BINARY_VARCHAR_RE.fullmatch(upper) + if match: + return VarBinaryType(length=int(match.group(1))) + match = _BINARY_CHAR_RE.fullmatch(upper) + if match: + return BinaryType(length=int(match.group(1))) + match = _UTF8_VARCHAR_RE.fullmatch(upper) + if match: + return FirebirdVarCharType(length=int(match.group(1))) + match = _UTF8_CHAR_RE.fullmatch(upper) + if match: + return FirebirdCharType(length=int(match.group(1))) + if _BLOB_TEXT_RE.fullmatch(upper): + return TextType() + if _BLOB_BINARY_RE.fullmatch(upper): + return BinaryType() + match = _TIMESTAMP_WITH_TIME_ZONE_RE.fullmatch(upper) + if match: + precision = int(match.group(1)) if match.group(1) else None + return FirebirdTimeStampTzType(precision=precision) + match = _TIME_WITH_TIME_ZONE_RE.fullmatch(upper) + if match: + precision = int(match.group(1)) if match.group(1) else None + return FirebirdTimeTzType(precision=precision) + match = _TIME_WITHOUT_TIME_ZONE_RE.fullmatch(upper) + if match: + precision = int(match.group(1)) if match.group(1) else None + return FirebirdTimeWithoutTimeZoneType(precision=precision) + match = _DECFLOAT_RE.fullmatch(upper) + if match: + precision = int(match.group(1)) if match.group(1) else 16 + return FirebirdDecFloatType(precision=precision) + if upper.startswith("DECFLOAT"): + raise ValueError("DECFLOAT precision must be 16 or 34") + if _INT128_RE.fullmatch(upper): + return FirebirdInt128Type() + if _TIMESTAMP_RE.fullmatch(upper): + return TimestampType() + if _TIME_RE.fullmatch(upper): + return TimeType() + if _DATE_RE.fullmatch(upper): + return DateType() + if _BOOLEAN_RE.fullmatch(upper): + return BooleanType() + if _DOUBLE_RE.fullmatch(upper): + return DoubleType() + if _REAL_RE.fullmatch(upper): + return FloatType() + match = _FLOAT_RE.fullmatch(upper) + if match: + return FloatType(precision=int(match.group(1)) if match.group(1) else None) + match = _DECIMAL_RE.fullmatch(upper) + if match: + return DecimalType( + precision=int(match.group(1)) if match.group(1) else None, + scale=int(match.group(2)) if match.group(2) else None, + ) + match = _VARCHAR_RE.fullmatch(upper) + if match: + return VarCharType(length=int(match.group(1)) if match.group(1) else None) + match = _CHAR_RE.fullmatch(upper) + if match: + return CharType(length=int(match.group(1)) if match.group(1) else None) + if _INTEGER_RE.fullmatch(upper): + if upper == "BIGINT": + return BigIntType() + if upper == "SMALLINT": + return SmallIntType() + return IntegerType() + if _QUALIFIED_IDENTIFIER_RE.fullmatch(normalized): + if normalized.upper() in _ACTION_WORDS: + raise ValueError("data_type_name contains an unsupported action word") + return CustomType(raw=normalized) + raise ValueError("data_type_name contains unsupported Firebird type syntax") + + +ExpressionRegistry.register(FirebirdDecimalType) +ExpressionRegistry.register(FirebirdFloatType) +ExpressionRegistry.register(FirebirdDoubleType) +ExpressionRegistry.register(FirebirdBlobSubType) +ExpressionRegistry.register(FirebirdCharType) +ExpressionRegistry.register(FirebirdVarCharType) +ExpressionRegistry.register(FirebirdTimeStampTzType) +ExpressionRegistry.register(FirebirdTimeTzType) +ExpressionRegistry.register(FirebirdTimeWithoutTimeZoneType) +ExpressionRegistry.register(FirebirdDecFloatType) +ExpressionRegistry.register(FirebirdInt128Type) + + __all__ = [ "FirebirdDecimalType", "FirebirdFloatType", "FirebirdDoubleType", "FirebirdBlobSubType", + "FirebirdCharType", + "FirebirdVarCharType", "FirebirdTimeStampTzType", "FirebirdTimeTzType", + "FirebirdTimeWithoutTimeZoneType", "FirebirdDecFloatType", "FirebirdInt128Type", ] diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/comment.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/comment.py index a7a04b3..1d49024 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/comment.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/comment.py @@ -6,27 +6,28 @@ Firebird 2.5, gated here at ``(2, 5, 0)``. """ -from typing import Tuple, TYPE_CHECKING +from typing import Tuple from .version_boundaries import _norm_version from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from ..expression.comment import FirebirdCommentObjectType -if TYPE_CHECKING: - from ..expression.comment import FirebirdCommentExpression - class FirebirdCommentMixin: def supports_comment_on(self) -> bool: return _norm_version(self.version) >= (2, 5, 0) - def format_comment_statement(self, expr: "FirebirdCommentExpression") -> Tuple[str, tuple]: - """Format COMMENT ON IS 'text' (or IS NULL to remove).""" + def format_comment_statement(self, expr) -> Tuple[str, tuple]: + """Format COMMENT ON IS 'text' (or IS NULL to remove). + + Accepts any comment expression carrying ``object_type``, + ``object_name`` and ``comment``. + """ self._check_comment_version("COMMENT ON") - object_type = expr.object_type.value + object_type = getattr(expr.object_type, "value", expr.object_type) name = self._format_comment_object_name(expr) if expr.comment is None: return f"COMMENT ON {object_type} {name} IS NULL", () @@ -38,9 +39,10 @@ def format_comment_statement(self, expr: "FirebirdCommentExpression") -> Tuple[s def _format_comment_object_name(self, expr) -> str: """Quote a comment target; dotted names (COLUMN relation.field, PARAMETER routine.param) are quoted per part.""" - if expr.object_type in ( - FirebirdCommentObjectType.COLUMN, - FirebirdCommentObjectType.PARAMETER, + object_type = getattr(expr.object_type, "value", expr.object_type) + if object_type in ( + FirebirdCommentObjectType.COLUMN.value, + FirebirdCommentObjectType.PARAMETER.value, ): parts = expr.object_name.split(".") return ".".join(self.format_identifier(part) for part in parts) diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/ddl_table.py index 072d607..d8f7dff 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/ddl_table.py @@ -39,13 +39,37 @@ def supports_drop_table_restrict(self) -> bool: format_alter_table_statement = TableMixin.format_alter_table_statement def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if getattr(expr, "tablespace", None): + raise UnsupportedFeatureError( + self.name, + "TABLESPACE", + "Firebird does not support table tablespaces.", + ) + if getattr(expr, "inherits", None): + raise UnsupportedFeatureError( + self.name, + "table INHERITS", + "Firebird does not support table inheritance.", + ) if getattr(expr, 'partition', None) is not None: - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError raise UnsupportedFeatureError( self.name, "PARTITION BY clause", "Firebird does not support table partitioning.", ) + if getattr(getattr(expr, 'table_options', None), 'comment', None): + # Firebird annotates comments through the standalone + # COMMENT ON statement (no inline table option); a comment on the + # table options is never silently dropped. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, + "TABLE COMMENT", + "Firebird has no inline table comment; use a standalone " + "COMMENT ON TABLE statement.", + ) all_params: List[Any] = [] @@ -96,34 +120,57 @@ def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[ return ' '.join(parts), tuple(all_params) def format_column_definition(self, col_def: "ColumnDefinition") -> Tuple[str, tuple]: + """Format a single column definition with Firebird-specific syntax. + + Accepts both the generic ``ColumnDefinition`` and the Firebird + ``FirebirdColumnDefinition``; the latter's Firebird-only attributes + (``computed_by`` / ``character_set`` / ``collation``) are rendered here. + """ from rhosocial.activerecord.backend.expression.statements import ColumnConstraintType + from rhosocial.activerecord.backend.impl.firebird.expression.column import ( + FirebirdColumnDefinition, + ) + + if getattr(col_def, "comment", None): + # Firebird annotates column comments through the standalone + # COMMENT ON COLUMN statement (no inline column clause); a comment + # on a column definition is never silently dropped. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "COLUMN COMMENT", + "Firebird has no inline column comment; use a standalone " + "COMMENT ON COLUMN statement.", + ) type_sql, _ = col_def.data_type.to_sql() parts = [self.format_identifier(col_def.name), type_sql] params: List[Any] = [] - identity = getattr(col_def, 'identity', None) - if identity: - generated = identity.upper() if isinstance(identity, str) else 'BY DEFAULT' - parts.append(f"GENERATED {generated} AS IDENTITY") - start = getattr(col_def, 'identity_start', None) - increment = getattr(col_def, 'identity_increment', None) - if start is not None or increment is not None: - id_parts = [] - if start is not None: - id_parts.append(f"START WITH {start}") - if increment is not None: - id_parts.append(f"INCREMENT BY {increment}") - parts.append(f"({' '.join(id_parts)})") - else: - for constraint in col_def.constraints: - if getattr(constraint, 'is_auto_increment', False): - parts.append("GENERATED BY DEFAULT AS IDENTITY") - break + for constraint in col_def.constraints: + if getattr(constraint, 'is_auto_increment', False): + parts.append("GENERATED BY DEFAULT AS IDENTITY") + break + + 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, FirebirdColumnDefinition) and col_def.character_set: + parts.append(f"CHARACTER SET {col_def.character_set}") computed_by = getattr(col_def, 'computed_by', None) if computed_by: parts.append(f"COMPUTED BY ({computed_by})") + else: + # A generic GeneratedColumnExpression maps onto Firebird's + # ``COMPUTED BY ()`` form (Firebird has no GENERATED ALWAYS + # syntax); a declared generated column is never silently dropped. + generated = getattr(col_def, 'generated_expression', None) + if generated is not None: + gen_sql, gen_params = generated.expression.to_sql() + parts.append(f"COMPUTED BY ({gen_sql})") + params.extend(gen_params) constraint_parts = [] default_parts: List[str] = [] @@ -167,6 +214,18 @@ def format_table_constraint(self, expr: "TableConstraint") -> Tuple[str, tuple]: ReferentialAction, TableConstraintType, ) + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + if getattr(expr, "deferrable", None) is not None: + raise UnsupportedFeatureError( + self.name, "DEFERRABLE constraint", + "Firebird does not support deferrable constraints.", + ) + if isinstance(expr, ForeignKeyConstraint) and getattr(expr, "match_type", None) is not None: + raise UnsupportedFeatureError( + self.name, "FOREIGN KEY MATCH", + "Firebird does not support FOREIGN KEY MATCH.", + ) parts = [] params: List[Any] = [] diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/domain.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/domain.py index 3b8240b..407f0e1 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/domain.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/domain.py @@ -1,125 +1,266 @@ # src/rhosocial/activerecord/backend/impl/firebird/mixins/domain.py -"""Firebird DOMAIN statement formatting mixin. +"""Firebird DOMAIN statement formatting mixin.""" -DOMAIN is an ancient Firebird feature (available in every supported -version, gated here at ``(2, 5, 0)``) that packages a data type with a -default value, a NOT NULL flag and optional CHECK constraints for reuse -across table columns. -""" +from typing import Optional, Tuple, Type, TYPE_CHECKING -from typing import Tuple, TYPE_CHECKING - -from .version_boundaries import _norm_version from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import DomainMixin +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainAlterAction, + DomainNullability, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) + +from ..expression.ddl.domain import FirebirdSetDomainDataTypeAction, _bind_data_type +from .version_boundaries import FIREBIRD_VERSION_BOUNDARIES, _norm_version -from ..expression.ddl.domain import FirebirdDomainAlterMode -if TYPE_CHECKING: - from ..expression.ddl.domain import ( - FirebirdCreateDomainExpression, - FirebirdAlterDomainExpression, - FirebirdDropDomainExpression, - ) +class FirebirdDomainMixin(DomainMixin): + """Render Firebird DOMAIN DDL and enforce its version boundaries.""" + if TYPE_CHECKING: + version: Optional[Tuple[int, ...]] -class FirebirdDomainMixin: + def supports_domains(self) -> bool: + version = _norm_version(self.version) + return version is not None and version >= FIREBIRD_VERSION_BOUNDARIES["DOMAIN"] def supports_domain(self) -> bool: - return _norm_version(self.version) >= (2, 5, 0) + return self.supports_domains() def supports_create_domain(self) -> bool: - return _norm_version(self.version) >= (2, 5, 0) + return self.supports_domains() def supports_alter_domain(self) -> bool: - return _norm_version(self.version) >= (2, 5, 0) + return self.supports_domains() def supports_drop_domain(self) -> bool: - return _norm_version(self.version) >= (2, 5, 0) + return self.supports_domains() + + def supports_domain_default(self) -> bool: + return self.supports_domains() + + def supports_domain_nullability(self, nullability: DomainNullability) -> bool: + return self.supports_domains() and nullability is DomainNullability.NOT_NULL + + def supports_domain_checks(self) -> bool: + return self.supports_domains() + + def supports_named_domain_checks(self) -> bool: + return False + + def supports_multiple_domain_checks(self) -> bool: + return False + + def supports_domain_collation(self) -> bool: + return self.supports_domains() + + def supports_alter_domain_action( + self, + action_type: Type[DomainAlterAction], + ) -> bool: + if not self.supports_domains(): + return False + try: + if issubclass( + action_type, + (SetDomainNotNullAction, DropDomainNotNullAction), + ): + version = _norm_version(self.version) + return ( + version is not None + and version >= FIREBIRD_VERSION_BOUNDARIES["DOMAIN_NOT_NULL_ACTION"] + ) + return issubclass( + action_type, + ( + SetDomainDefaultAction, + DropDomainDefaultAction, + AddDomainCheckAction, + DropDomainCheckAction, + RenameDomainAction, + FirebirdSetDomainDataTypeAction, + ), + ) + except TypeError: + return False + + def supports_multiple_domain_alter_actions(self) -> bool: + return self.supports_alter_domain() - def format_create_domain_statement(self, expr: "FirebirdCreateDomainExpression") -> Tuple[str, tuple]: - """Format CREATE DOMAIN name [AS] datatype [DEFAULT ...] [NOT NULL] [CHECK (...)].""" - self._check_domain_version("CREATE DOMAIN") + def supports_drop_domain_if_exists(self) -> bool: + return False - type_sql, _ = self.format_data_type(expr.data_type) + def supports_drop_domain_cascade(self) -> bool: + return False + + def supports_drop_domain_restrict(self) -> bool: + return False + + def supports_unnamed_domain_check_drop(self) -> bool: + return self.supports_domains() + + def format_create_domain_statement( + self, + expr: CreateDomainExpression, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_create_domain(): + raise UnsupportedFeatureError(self.name, "CREATE DOMAIN") + data_type = _bind_data_type(self, expr.data_type) + type_sql, type_params = data_type.to_sql() parts = [ "CREATE DOMAIN", self.format_identifier(expr.domain_name), "AS", type_sql, ] - - if getattr(expr, "default", None) is not None: - parts.append(f"DEFAULT {self._format_ddl_literal(expr.default)}") - - if getattr(expr, "not_null", False): - parts.append("NOT NULL") - - if getattr(expr, "check", None): - parts.append(f"CHECK ({expr.check})") - - return " ".join(parts), () - - def format_alter_domain_statement(self, expr: "FirebirdAlterDomainExpression") -> Tuple[str, tuple]: - """Format ALTER DOMAIN name per the requested mode.""" - self._check_domain_version("ALTER DOMAIN") - - domain = self.format_identifier(expr.domain_name) - mode = expr.mode - - if mode == FirebirdDomainAlterMode.SET_DEFAULT: - if expr.value is None: - raise ValueError("SET DEFAULT requires a value") - return f"ALTER DOMAIN {domain} SET DEFAULT {self._format_ddl_literal(expr.value)}", () - if mode == FirebirdDomainAlterMode.DROP_DEFAULT: - return f"ALTER DOMAIN {domain} DROP DEFAULT", () - if mode == FirebirdDomainAlterMode.SET_NOT_NULL: - return f"ALTER DOMAIN {domain} SET NOT NULL", () - if mode == FirebirdDomainAlterMode.DROP_NOT_NULL: - return f"ALTER DOMAIN {domain} DROP NOT NULL", () - if mode == FirebirdDomainAlterMode.ADD_CONSTRAINT: - constraint = f"CONSTRAINT {self.format_identifier(expr.constraint_name)} " if expr.constraint_name else "" - return ( - f"ALTER DOMAIN {domain} ADD {constraint}CHECK ({expr.constraint_sql})", - (), + params = list(type_params) + if expr.default is not None: + if not self.supports_domain_default(): + raise UnsupportedFeatureError(self.name, "DOMAIN DEFAULT") + default_sql, default_params = expr.default.to_sql() + if default_params: + raise ValueError("DOMAIN DEFAULT must render without bind parameters") + parts.append(f"DEFAULT {default_sql}") + if expr.nullability is not DomainNullability.UNSPECIFIED: + if not self.supports_domain_nullability(expr.nullability): + raise UnsupportedFeatureError( + self.name, + f"DOMAIN {expr.nullability.value}", + ) + parts.append(expr.nullability.value) + checks = list(expr.checks) + if checks and not self.supports_domain_checks(): + raise UnsupportedFeatureError(self.name, "DOMAIN CHECK") + if any(check.name is not None for check in checks): + raise UnsupportedFeatureError(self.name, "named DOMAIN CHECK") + if len(checks) > 1: + raise UnsupportedFeatureError( + self.name, + "multiple domain CHECK constraints", ) - if mode == FirebirdDomainAlterMode.DROP_CONSTRAINT: - return ( - f"ALTER DOMAIN {domain} DROP CONSTRAINT " - f"{self.format_identifier(expr.constraint_name)}", - (), + for check in checks: + check_sql, check_params = check.to_sql() + parts.append(check_sql) + params.extend(check_params) + if expr.collation is not None: + if not self.supports_domain_collation(): + raise UnsupportedFeatureError(self.name, "DOMAIN COLLATE") + collation_parts = expr.collation.split(".") + if any(not part.strip() for part in collation_parts): + raise ValueError("collation must contain non-empty identifier segments") + collation_sql = ".".join( + self.format_identifier(part) for part in collation_parts ) - if mode == FirebirdDomainAlterMode.SET_TYPE: - type_sql, _ = self.format_data_type(expr.data_type) - return f"ALTER DOMAIN {domain} TYPE {type_sql}", () - - raise UnsupportedFeatureError( - self.name, - f"ALTER DOMAIN mode {mode}", - "Unsupported ALTER DOMAIN clause.", + parts.append(f"COLLATE {collation_sql}") + return " ".join(parts), tuple(params) + + def format_alter_domain_statement( + self, + expr: AlterDomainExpression, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_alter_domain(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN") + if len(expr.actions) > 1 and not self.supports_multiple_domain_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER DOMAIN actions") + action_groups = { + "rename": "rename", + "type": "TYPE", + "default": "DEFAULT", + "nullability": "NOT NULL", + "check": "CHECK", + } + action_order = { + "rename": 0, + "type": 1, + "default": 2, + "nullability": 3, + "check": 4, + } + action_groups_seen = set() + ordered_actions = [] + for action in expr.actions: + if not self.supports_alter_domain_action(type(action)): + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {action.action_kind}", + ) + group = self._domain_action_group(action) + if group is None: + raise UnsupportedFeatureError( + self.name, + f"ALTER DOMAIN action {action.action_kind}", + ) + if group in action_groups_seen: + raise UnsupportedFeatureError( + self.name, + f"multiple ALTER DOMAIN {action_groups[group]} actions", + ) + action_groups_seen.add(group) + ordered_actions.append((action_order[group], action)) + ordered_actions.sort(key=lambda item: item[0]) + action_parts = [] + action_params = [] + for _, action in ordered_actions: + action_sql, params = action.to_sql() + action_parts.append(action_sql) + action_params.extend(params) + return ( + f'ALTER DOMAIN {self.format_identifier(expr.domain_name)} {" ".join(action_parts)}', + tuple(action_params), ) - def format_drop_domain_statement(self, expr: "FirebirdDropDomainExpression") -> Tuple[str, tuple]: - """Format DROP DOMAIN name.""" - self._check_domain_version("DROP DOMAIN") - return f"DROP DOMAIN {self.format_identifier(expr.domain_name)}", () - - def _format_ddl_literal(self, value) -> str: - """Render a DDL default value as an inline literal.""" - if isinstance(value, str): - return self._quote_literal(value) - if isinstance(value, bool): - return "TRUE" if value else "FALSE" - return str(value) - - def _quote_literal(self, value: str) -> str: - """Inline a string literal with Firebird single-quote escaping.""" - return f"'{value.replace(chr(39), chr(39) * 2)}'" - - def _check_domain_version(self, feature: str) -> None: - version = getattr(self, 'version', (2, 5, 0)) - if _norm_version(version) < (2, 5, 0): + def format_drop_domain_statement( + self, + expr: DropDomainExpression, + ) -> Tuple[str, tuple]: + return super().format_drop_domain_statement(expr) + + def format_domain_alter_action( + self, + expr: DomainAlterAction, + ) -> Tuple[str, tuple]: + if not self.supports_domains() or not self.supports_alter_domain(): + raise UnsupportedFeatureError(self.name, "ALTER DOMAIN action") + if not self.supports_alter_domain_action(type(expr)): raise UnsupportedFeatureError( self.name, - feature, - "Firebird 2.5 or later is required for DOMAIN statements.", + f"ALTER DOMAIN action {expr.action_kind}", ) + if isinstance(expr, FirebirdSetDomainDataTypeAction): + data_type = expr.resolve_data_type() + type_sql, type_params = data_type.to_sql() + return f"TYPE {type_sql}", tuple(type_params) + if isinstance(expr, RenameDomainAction): + return f"TO {self.format_identifier(expr.new_name)}", () + if isinstance(expr, SetDomainNotNullAction): + return "SET NOT NULL", () + if isinstance(expr, DropDomainNotNullAction): + return "DROP NOT NULL", () + return super().format_domain_alter_action(expr) + + @staticmethod + def _domain_action_group(action: DomainAlterAction) -> Optional[str]: + if isinstance(action, RenameDomainAction): + return "rename" + if isinstance(action, FirebirdSetDomainDataTypeAction): + return "type" + if isinstance(action, (SetDomainDefaultAction, DropDomainDefaultAction)): + return "default" + if isinstance(action, (SetDomainNotNullAction, DropDomainNotNullAction)): + return "nullability" + if isinstance(action, (AddDomainCheckAction, DropDomainCheckAction)): + return "check" + return None + + +__all__ = ["FirebirdDomainMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/transaction.py index 1a5303c..10c6887 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/transaction.py @@ -4,7 +4,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.transaction import ( + from rhosocial.activerecord.backend.expression.transaction import ( BeginTransactionExpression, SetTransactionExpression, ) diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/types.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/types.py index 7a6f841..84ab91a 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/types.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/types.py @@ -44,6 +44,7 @@ from .version_boundaries import _norm_version from ..expression.types import ( + FirebirdCharType, FirebirdDecimalType, FirebirdDecFloatType, FirebirdDoubleType, @@ -51,7 +52,9 @@ FirebirdInt128Type, FirebirdBlobSubType, FirebirdTimeStampTzType, + FirebirdTimeWithoutTimeZoneType, FirebirdTimeTzType, + FirebirdVarCharType, ) @@ -128,6 +131,7 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: return "DECIMAL", () def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: + self._check_fb3_type("BOOLEAN") return "BOOLEAN", () def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: @@ -191,15 +195,37 @@ def format_data_type_firebird_double(self, data_type: FirebirdDoubleType) -> Tup def format_data_type_firebird_blob_subtype(self, data_type: FirebirdBlobSubType) -> Tuple[str, tuple]: return "BLOB SUB_TYPE TEXT", () + def format_data_type_firebird_char(self, data_type: FirebirdCharType) -> Tuple[str, tuple]: + length = data_type.length if data_type.length is not None else 1 + return f"CHAR({length}) CHARACTER SET UTF8", () + + def format_data_type_firebird_varchar(self, data_type: FirebirdVarCharType) -> Tuple[str, tuple]: + length = data_type.length if data_type.length is not None else 255 + return f"VARCHAR({length}) CHARACTER SET UTF8", () + def format_data_type_firebird_timestamptz(self, data_type: FirebirdTimeStampTzType) -> Tuple[str, tuple]: """Format TIMESTAMP WITH TIME ZONE (Firebird 4.0+).""" self._check_fb4_type("TIMESTAMP WITH TIME ZONE") - return "TIMESTAMP WITH TIME ZONE", () + base_type = ( + f"TIMESTAMP({data_type.precision})" + if data_type.precision is not None + else "TIMESTAMP" + ) + return f"{base_type} WITH TIME ZONE", () def format_data_type_firebird_timetz(self, data_type: FirebirdTimeTzType) -> Tuple[str, tuple]: """Format TIME WITH TIME ZONE (Firebird 4.0+).""" self._check_fb4_type("TIME WITH TIME ZONE") - return "TIME WITH TIME ZONE", () + base_type = f"TIME({data_type.precision})" if data_type.precision is not None else "TIME" + return f"{base_type} WITH TIME ZONE", () + + def format_data_type_firebird_time_without_time_zone( + self, + data_type: FirebirdTimeWithoutTimeZoneType, + ) -> Tuple[str, tuple]: + self._check_fb4_type("TIME WITHOUT TIME ZONE") + base_type = f"TIME({data_type.precision})" if data_type.precision is not None else "TIME" + return f"{base_type} WITHOUT TIME ZONE", () def format_data_type_firebird_decfloat(self, data_type: FirebirdDecFloatType) -> Tuple[str, tuple]: """Format DECFLOAT(16|34) (Firebird 4.0+).""" @@ -211,6 +237,15 @@ def format_data_type_firebird_int128(self, data_type: FirebirdInt128Type) -> Tup self._check_fb4_type("INT128") return "INT128", () + def _check_fb3_type(self, feature: str) -> None: + version = getattr(self, 'version', (3, 0, 0)) + if _norm_version(version) < (3, 0, 0): + raise UnsupportedFeatureError( + self.name, + feature, + f"Firebird 3.0 or later is required for the {feature} data type.", + ) + def _check_fb4_type(self, feature: str) -> None: """Raise unless the dialect targets Firebird 4.0 or later. @@ -259,7 +294,7 @@ def supports_data_type_decimal(self) -> bool: return True def supports_data_type_boolean(self) -> bool: - return True + return _norm_version(getattr(self, 'version', (3, 0, 0))) >= (3, 0, 0) def supports_data_type_varchar(self) -> bool: return True @@ -315,12 +350,21 @@ def supports_data_type_firebird_double(self) -> bool: def supports_data_type_firebird_blob_subtype(self) -> bool: return True + def supports_data_type_firebird_char(self) -> bool: + return True + + def supports_data_type_firebird_varchar(self) -> bool: + return True + def supports_data_type_firebird_timestamptz(self) -> bool: return _norm_version(getattr(self, 'version', (4, 0, 0))) >= (4, 0, 0) def supports_data_type_firebird_timetz(self) -> bool: return _norm_version(getattr(self, 'version', (4, 0, 0))) >= (4, 0, 0) + def supports_data_type_firebird_time_without_time_zone(self) -> bool: + return _norm_version(getattr(self, 'version', (4, 0, 0))) >= (4, 0, 0) + def supports_data_type_firebird_decfloat(self) -> bool: return _norm_version(getattr(self, 'version', (4, 0, 0))) >= (4, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/version_boundaries.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/version_boundaries.py index 04982d2..d463340 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/version_boundaries.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/version_boundaries.py @@ -28,6 +28,8 @@ 'POSITION_FUNCTION': (2, 5, 0), 'IIF_DECODE': (2, 5, 0), 'COMPUTED_BY': (2, 5, 0), + 'DOMAIN': (2, 5, 0), + 'DOMAIN_NOT_NULL_ACTION': (3, 0, 0), } diff --git a/src/rhosocial/activerecord/backend/impl/firebird/mixins/window.py b/src/rhosocial/activerecord/backend/impl/firebird/mixins/window.py index c270f71..8379f95 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/mixins/window.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/mixins/window.py @@ -4,12 +4,12 @@ from typing import Any, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.advanced_functions import WindowFunctionCallExpression + from rhosocial.activerecord.backend.expression.advanced_functions import WindowFunctionCall class FirebirdWindowFunctionMixin: - def format_window_function_call(self, call: "WindowFunctionCallExpression") -> Tuple[str, tuple]: + def format_window_function_call(self, call: "WindowFunctionCall") -> Tuple[str, tuple]: """Format a window function call, pinning SUM/AVG result types. Mirrors :meth:`format_function_call`: Firebird 5/6-snapshot fails to diff --git a/src/rhosocial/activerecord/backend/impl/firebird/protocols.py b/src/rhosocial/activerecord/backend/impl/firebird/protocols.py index b8d2990..68efbe2 100644 --- a/src/rhosocial/activerecord/backend/impl/firebird/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/firebird/protocols.py @@ -3,6 +3,8 @@ from typing import Any, Protocol, Tuple, runtime_checkable +from rhosocial.activerecord.backend.dialect.protocols import DomainSupport + @runtime_checkable class FirebirdDMLOperationSupport(Protocol): @@ -53,6 +55,11 @@ def supports_identity_columns(self) -> bool: ... def supports_external_file(self) -> bool: ... +@runtime_checkable +class FirebirdDomainSupport(DomainSupport, Protocol): + """Firebird implementation of the core DOMAIN DDL protocol.""" + + @runtime_checkable class FirebirdTriggerSupport(Protocol): def supports_trigger_position(self) -> bool: ... @@ -190,6 +197,7 @@ def supports_context_variables(self) -> bool: ... "FirebirdLockingSupport", "FirebirdTransactionSupport", "FirebirdTableSupport", + "FirebirdDomainSupport", "FirebirdTriggerSupport", "FirebirdReturningSupport", "FirebirdIntrospectionSupport", diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/dialect/test_view_index_capabilities.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/dialect/test_view_index_capabilities.py new file mode 100644 index 0000000..2280b25 --- /dev/null +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/dialect/test_view_index_capabilities.py @@ -0,0 +1,42 @@ +# tests/rhosocial/activerecord_firebird_test/feature/backend/dialect/test_view_index_capabilities.py +"""Firebird capability accuracy: CASCADE view and functional indexes. + +Firebird has no ``DROP VIEW ... CASCADE`` clause and its expression +(computed) indexes use ``COMPUTED BY`` syntax, which the generic index +renderer does not emit. Both capabilities are therefore declared False so +the layer fails fast instead of emitting invalid SQL. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements import DropViewExpression +from rhosocial.activerecord.backend.impl.firebird.dialect import FirebirdDialect + + +@pytest.fixture +def dialect(): + return FirebirdDialect(version=(5, 0, 0)) + + +def test_cascade_view_unsupported(dialect): + assert dialect.supports_cascade_view() is False + expr = DropViewExpression(dialect, view_name="v", cascade=True) + with pytest.raises(UnsupportedFeatureError, match="CASCADE"): + expr.to_sql() + + +def test_functional_index_unsupported(dialect): + assert dialect.supports_functional_index() is False + + +def test_view_check_option_supported(dialect): + assert dialect.supports_view_check_option() is True + + +def test_collation_supported(dialect): + assert dialect.supports_collation() is True + + +def test_unique_index_supported(dialect): + assert dialect.supports_unique_index() is True diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_auto_increment_ddl.py index bd24969..c6734e1 100644 --- a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_auto_increment_ddl.py +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_auto_increment_ddl.py @@ -26,7 +26,7 @@ @pytest.fixture def dialect(): - return FirebirdDialect() + return FirebirdDialect((3, 0, 0)) def _pk(dialect): diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_extensions.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_extensions.py index ddb5bec..17dc3a5 100644 --- a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_extensions.py +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_extensions.py @@ -7,6 +7,11 @@ """ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, +) from rhosocial.activerecord.backend.expression.types.integer import IntegerType from rhosocial.activerecord.backend.expression.types.numeric import DecimalType from rhosocial.activerecord.backend.impl.firebird.dialect import FirebirdDialect @@ -71,9 +76,10 @@ def test_alter_domain_all_modes(self, dialect): def test_alter_domain_add_constraint(self, dialect): sql, _ = FirebirdAlterDomainExpression( dialect, "d", mode=FirebirdDomainAlterMode.ADD_CONSTRAINT, - constraint_name="ck", constraint_sql="VALUE > 0", + constraint_sql="VALUE > 0", ).to_sql() - assert sql == 'ALTER DOMAIN "D" ADD CONSTRAINT "CK" CHECK (VALUE > 0)' + assert sql == 'ALTER DOMAIN "D" ADD CHECK (VALUE > 0)' + def test_drop_domain(self, dialect): sql, _ = FirebirdDropDomainExpression(dialect, "obsolete").to_sql() @@ -100,3 +106,42 @@ def test_drop_package_variants(self, dialect): assert header_sql == 'DROP PACKAGE "PKG_OLD"' body_sql, _ = FirebirdDropPackageExpression(dialect, "pkg_old", body=True).to_sql() assert body_sql == 'DROP PACKAGE BODY "PKG_OLD"' + + +class TestFirebirdTableDDLDeclarations: + def test_table_declaration_defaults_are_absent(self, dialect): + expression = CreateTableExpression( + dialect, + "plain_table_defaults", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + ) + sql, params = expression.to_sql() + assert expression.inherits == [] + assert expression.tablespace is None + assert "plain_table_defaults" in sql.lower() + assert "id" in sql.lower() + assert params == () + + def test_table_inherits_is_carried_and_rejected(self, dialect): + expression = CreateTableExpression( + dialect, + "child", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + inherits=["parent_a", "parent_b"], + ) + assert expression.inherits == ["parent_a", "parent_b"] + assert dialect.supports_table_inheritance() is False + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_is_carried_and_rejected(self, dialect): + expression = CreateTableExpression( + dialect, + "spaced", + [ColumnDefinition(dialect, "id", IntegerType(dialect))], + tablespace="ts_data", + ) + assert expression.tablespace == "ts_data" + assert dialect.supports_table_tablespace() is False + with pytest.raises(UnsupportedFeatureError, match="TABLESPACE"): + expression.to_sql() diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_source_mixin.py new file mode 100644 index 0000000..45ca0ae --- /dev/null +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_source_mixin.py @@ -0,0 +1,530 @@ +# tests/rhosocial/activerecord_firebird_test/feature/backend/test_ddl_source_mixin.py + +try: + from typing import Annotated, ClassVar, Optional +except ImportError: + from typing_extensions import Annotated, ClassVar, Optional + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + CreateTableOptions, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.impl.firebird import FirebirdDialect +from rhosocial.activerecord.backend.impl.firebird.expression.column import ( + FirebirdColumnOptions, +) +from rhosocial.activerecord.backend.impl.firebird.expression.types import ( + FirebirdCharType, + FirebirdVarCharType, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSourceMixin, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +class UseColumnOptions(DDLAnnotation): + def __init__(self, *options): + self.options = options + + +class ColumnOptionsHandler(DDLAnnotationHandler): + annotation_types = (UseColumnOptions,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(*annotation.options) + + +def check_condition(dialect): + return Column(dialect, "value_col") == "active" + + +def index_condition(dialect): + return Column(dialect, "value_col") == "active" + + +PRIMARY_TYPE = FirebirdVarCharType(length=64) +FALLBACK_TYPE = FirebirdCharType(length=8) +TYPE_MARKER = UseSqlType(PRIMARY_TYPE, FALLBACK_TYPE) +DEFAULT_VALUE = Literal(None, "model-default", inline_literals=True) +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="df_value", + default_value=DEFAULT_VALUE, +) +CHECK_CONSTRAINT = UseConstraint( + ColumnConstraintType.CHECK, + name="ck_value", + check_condition=check_condition, +) +INCLUDE_COLUMNS = ["nullable_col"] +INDEX_MARKER = UseIndex( + "ix_value", + unique=True, + type="BTREE", + partial_condition=index_condition, + include_columns=INCLUDE_COLUMNS, + if_not_exists=True, + tablespace="index_space", + if_exists=True, + concurrent=True, +) +SECOND_INDEX_MARKER = UseIndex("ix_value_suffix", type="BTREE") +IDENTITY_ATTRIBUTE = IdentityAttribute(generation="ALWAYS", start=5, increment=2) +COLLATION_ATTRIBUTE = CollationAttribute(name="NOCASE") +CHARACTER_SET_ATTRIBUTE = CharacterSetAttribute(name="utf8mb4") +ATTRIBUTES_MARKER = UseColumnAttributes(IDENTITY_ATTRIBUTE, COLLATION_ATTRIBUTE) +SECOND_ATTRIBUTES_MARKER = UseColumnAttributes(CHARACTER_SET_ATTRIBUTE) +COMMENT_MARKER = UseComment("value comment") +GENERATED_EXPRESSION = GeneratedColumnExpression( + None, + Column(None, "value_col"), + GeneratedColumnType.STORED, +) +GENERATED_MARKER = UseGeneratedColumn(GENERATED_EXPRESSION) +VALUE_OPTIONS = ( + FirebirdColumnOptions(character_set="UTF8", collation="UNICODE"), + FirebirdColumnOptions(computed_by="UPPER(value_col)"), +) +VALUE_OPTIONS_MARKER = UseColumnOptions(*VALUE_OPTIONS) +SINGLE_OPTION = FirebirdColumnOptions(character_set="UTF8") +SINGLE_OPTION_MARKER = UseColumnOptions(SINGLE_OPTION) +NULL_CONSTRAINT = UseConstraint(ColumnConstraintType.NULL) +PK_TYPE = Annotated[int, UseColumn("id")] +NULLABLE_TYPE = Annotated[Optional[str], NULL_CONSTRAINT] +VALUE_TYPE = Annotated[ + str, + UseColumn("value_col"), + TYPE_MARKER, + DEFAULT_CONSTRAINT, + CHECK_CONSTRAINT, + INDEX_MARKER, + SECOND_INDEX_MARKER, + ATTRIBUTES_MARKER, + SECOND_ATTRIBUTES_MARKER, + COMMENT_MARKER, + GENERATED_MARKER, + VALUE_OPTIONS_MARKER, +] + +TABLE_INDEXES = [ + IndexDefinition(None, name="ix_table_first", columns=["value_col"], unique=True), + IndexDefinition(None, name="ix_table_second", columns=["id"]), +] +TABLE_CONSTRAINTS = [ + TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_value", + columns=["value_col"], + ), + TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_value_table", + columns=["value_col"], + check_condition=check_condition, + ), +] +TABLE_OPTIONS = CreateTableOptions(None, or_replace=True) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"fillfactor": 80}) +PARTITION_KEY = Column(None, "id") +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [PARTITION_KEY], +) +INHERITED_TABLES = ["parent_a", "parent_b"] +TABLE_SPACE = "ts_data" +BATCH_METHODS = ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", +) + + +class DeclarationMixin: + __table_indexes__ = TABLE_INDEXES + __table_constraints__ = TABLE_CONSTRAINTS + _feature_handlers: ClassVar[list] = [DDLAnnotationHandler, ColumnOptionsHandler] + + @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 INHERITED_TABLES + + @classmethod + def table_tablespace(cls): + return TABLE_SPACE + + +class SyncModel(DeclarationMixin, ActiveRecord): + __table_name__ = "ddl_source_sync" + __primary_key__ = "id" + + pk_field: PK_TYPE + value_field: VALUE_TYPE = DEFAULT_VALUE + nullable_field: NULLABLE_TYPE = None + optional_field: Annotated[Optional[str], SINGLE_OPTION_MARKER] = None + + +class AsyncModel(DeclarationMixin, AsyncActiveRecord): + __table_name__ = "ddl_source_async" + __primary_key__ = "id" + + pk_field: PK_TYPE + value_field: VALUE_TYPE = DEFAULT_VALUE + nullable_field: NULLABLE_TYPE = None + optional_field: Annotated[Optional[str], SINGLE_OPTION_MARKER] = None + + +class CompositeModel(DeclarationMixin, ActiveRecord): + __table_name__ = "ddl_source_composite" + __primary_key__ = ("left_col", "right_col") + + left_field: Annotated[int, UseColumn("left_col")] + right_field: Annotated[int, UseColumn("right_col")] + value_field: str + + +class AsyncCompositeModel(DeclarationMixin, AsyncActiveRecord): + __table_name__ = "ddl_source_async_composite" + __primary_key__ = ("left_col", "right_col") + + left_field: Annotated[int, UseColumn("left_col")] + right_field: Annotated[int, UseColumn("right_col")] + value_field: str + + +class PlainModel(ActiveRecord): + __table_name__ = "ddl_source_plain" + + id: int + optional: Optional[str] = None + + +class AsyncPlainModel(AsyncActiveRecord): + __table_name__ = "ddl_source_async_plain" + + id: int + optional: Optional[str] = None + + +def _constraint_signature(model, field): + return [ + ( + constraint.constraint_type, + constraint.name, + constraint.default_value, + constraint.check_condition, + ) + for constraint in model.column_constraints(field) + ] + + +def _index_signature(index): + return ( + index.name, + index.columns, + index.unique, + index.type, + index.partial_condition, + index.include_columns, + index.if_not_exists, + index.tablespace, + index.if_exists, + index.concurrent, + ) + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_field_declarations_are_collected_in_declaration_order(model): + assert issubclass(model, DDLSourceMixin) + assert isinstance(model, DDLSource) + assert ColumnOptionsHandler in model.get_feature_handlers() + assert model.ddl_field_names() == ( + "pk_field", + "value_field", + "nullable_field", + "optional_field", + ) + assert model.columns_name() == { + "pk_field": "id", + "value_field": "value_col", + "nullable_field": "nullable_field", + "optional_field": "optional_field", + } + assert model.column_name("pk_field") == "id" + assert model.column_name("value_field") == "value_col" + assert model.column_type("value_field") is TYPE_MARKER + assert model.column_type("value_field").data_types == (PRIMARY_TYPE, FALLBACK_TYPE) + assert model.column_type("value_field").data_types[0] is PRIMARY_TYPE + assert model.column_type("value_field").data_types[1] is FALLBACK_TYPE + assert isinstance(model.column_type("value_field").data_type, FirebirdVarCharType) + assert model.column_type("value_field").data_type.length == 64 + + value_constraints = model.column_constraints("value_field") + assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint + assert value_constraints[1] is CHECK_CONSTRAINT.constraint + assert value_constraints[2].constraint_type is ColumnConstraintType.NOT_NULL + assert value_constraints[0].default_value is DEFAULT_VALUE + assert value_constraints[1].check_condition is check_condition + assert model.column_constraints("nullable_field") == [NULL_CONSTRAINT.constraint] + assert model.column_constraints("optional_field") == [] + + attributes = model.column_attributes("value_field") + assert attributes[0] is IDENTITY_ATTRIBUTE + assert attributes[1] is COLLATION_ATTRIBUTE + assert attributes[2] is CHARACTER_SET_ATTRIBUTE + + indexes = model.column_indexes("value_field") + assert [index.name for index in indexes] == ["ix_value", "ix_value_suffix"] + assert all(index.columns == ["value_col"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].type == "BTREE" + assert indexes[0].partial_condition is index_condition + assert indexes[0].include_columns is INCLUDE_COLUMNS + assert indexes[0].if_not_exists is True + assert indexes[0].tablespace == "index_space" + assert indexes[0].if_exists is True + assert indexes[0].concurrent is True + + assert model.column_comment("value_field") == "value comment" + assert model.generated_column("value_field") is GENERATED_EXPRESSION + options = model.column_options("value_field") + assert options[0] is VALUE_OPTIONS[0] + assert options[1] is VALUE_OPTIONS[1] + assert options[0].character_set == "UTF8" + assert options[0].collation == "UNICODE" + assert options[1].computed_by == "UPPER(value_col)" + assert model.column_options("optional_field") is SINGLE_OPTION + assert model.field_python_type("value_field") is str + assert model.field_is_optional("value_field") is False + assert model.field_is_optional("optional_field") is True + + +def test_collected_backend_data_type_renders_with_dialect(): + dialect = FirebirdDialect((4, 0, 0)) + data_type = SyncModel.column_type("value_field").data_types[0] + assert isinstance(data_type, FirebirdVarCharType) + data_type.dialect = dialect + try: + rendered = ColumnDefinition(dialect, "value_col", data_type).to_sql() + finally: + data_type.dialect = None + assert rendered == ('"VALUE_COL" VARCHAR(64) CHARACTER SET UTF8', ()) + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_table_declarations_are_returned_unchanged(model): + assert model.__table_indexes__ is TABLE_INDEXES + assert model.__table_constraints__ is TABLE_CONSTRAINTS + indexes = model.table_indexes() + assert indexes is not TABLE_INDEXES + assert indexes[0] is TABLE_INDEXES[0] + assert indexes[1] is TABLE_INDEXES[1] + constraints = model.table_constraints() + assert constraints is not TABLE_CONSTRAINTS + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert model.table_options() is TABLE_OPTIONS + assert model.table_options().or_replace is True + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + assert model.table_partition().method == PartitionStrategy.RANGE.value + assert model.table_partition().keys[0] is PARTITION_KEY + assert model.table_inherits() is INHERITED_TABLES + assert model.table_inherits() == ["parent_a", "parent_b"] + assert model.table_tablespace() == TABLE_SPACE + assert model.create_table_statement_classes() is None + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_single_primary_key_is_composed_on_the_physical_column(model): + assert model.primary_key_columns() == ("id",) + assert model.primary_key_field() == "pk_field" + assert model.column_name("pk_field") == "id" + constraints = model.column_constraints("pk_field") + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert all( + item.constraint_type is not TableConstraintType.PRIMARY_KEY + for item in model.table_constraints() + ) + + +@pytest.mark.parametrize( + "model", [CompositeModel, AsyncCompositeModel], ids=("sync", "async") +) +def test_composite_primary_key_is_composed_as_a_table_constraint(model): + assert model.primary_key_columns() == ("left_col", "right_col") + assert model.primary_key_field() == ("left_field", "right_field") + assert model.column_name("left_field") == "left_col" + assert model.column_name("right_field") == "right_col" + assert [item.constraint_type for item in model.column_constraints("left_field")] == [ + ColumnConstraintType.NOT_NULL + ] + assert [item.constraint_type for item in model.column_constraints("right_field")] == [ + ColumnConstraintType.NOT_NULL + ] + constraints = model.table_constraints() + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert constraints[2].constraint_type is TableConstraintType.PRIMARY_KEY + assert constraints[2].columns == ["left_col", "right_col"] + + +@pytest.mark.parametrize("model", [PlainModel, AsyncPlainModel], ids=("sync", "async")) +def test_default_declarations_and_batch_interfaces(model): + assert model.ddl_field_names() == ("id", "optional") + assert model.columns_name() == {"id": "id", "optional": "optional"} + assert model.column_type("id") is None + assert model.column_comment("id") is None + assert model.generated_column("id") is None + assert model.column_options("id") is None + assert model.column_attributes("id") == [] + assert model.column_indexes("id") == [] + assert [item.constraint_type for item in model.column_constraints("id")] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert model.column_constraints("optional") == [] + assert model.columns_type() == {"id": None, "optional": None} + assert model.columns_constraints()["optional"] == [] + assert model.columns_attributes() == {"id": [], "optional": []} + assert model.columns_indexes() == {"id": [], "optional": []} + assert model.columns_comment() == {"id": None, "optional": None} + assert model.columns_generated() == {"id": None, "optional": None} + assert model.columns_options() == {"id": None, "optional": None} + assert model.table_options() is None + assert model.table_storage_options() is None + assert model.table_partition() is None + assert model.table_inherits() is None + assert model.table_tablespace() is None + assert model.table_indexes() == [] + assert model.table_constraints() == [] + assert model.create_table_statement_classes() is None + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_batch_interfaces_preserve_requested_field_order(model): + for method_name in BATCH_METHODS: + method = getattr(model, method_name) + assert tuple(method()) == model.ddl_field_names() + + fields = ["value_field", "pk_field", "optional_field"] + names = model.columns_name(fields) + types = model.columns_type(fields) + constraints = model.columns_constraints(fields) + attributes = model.columns_attributes(fields) + indexes = model.columns_indexes(fields) + comments = model.columns_comment(fields) + generated = model.columns_generated(fields) + options = model.columns_options(fields) + + assert list(names) == fields + assert list(types) == fields + assert list(constraints) == fields + assert list(attributes) == fields + assert list(indexes) == fields + assert list(comments) == fields + assert list(generated) == fields + assert list(options) == fields + assert names == { + "value_field": "value_col", + "pk_field": "id", + "optional_field": "optional_field", + } + assert types["value_field"] is TYPE_MARKER + assert constraints["value_field"][0] is DEFAULT_CONSTRAINT.constraint + assert attributes["value_field"][0] is IDENTITY_ATTRIBUTE + assert indexes["value_field"][0].columns == ["value_col"] + assert comments["value_field"] == "value comment" + assert generated["value_field"] is GENERATED_EXPRESSION + assert options["value_field"][0] is VALUE_OPTIONS[0] + assert options["optional_field"] is SINGLE_OPTION + + +def test_sync_and_async_ddl_sources_collect_the_same_declarations(): + assert SyncModel.columns_name() == AsyncModel.columns_name() + assert SyncModel.columns_type() == AsyncModel.columns_type() + assert SyncModel.columns_attributes() == AsyncModel.columns_attributes() + assert SyncModel.columns_comment() == AsyncModel.columns_comment() + assert SyncModel.columns_generated() == AsyncModel.columns_generated() + assert SyncModel.columns_options() == AsyncModel.columns_options() + for field in ("pk_field", "value_field", "nullable_field", "optional_field"): + assert _constraint_signature(SyncModel, field) == _constraint_signature( + AsyncModel, field + ) + sync_indexes = SyncModel.column_indexes(field) + async_indexes = AsyncModel.column_indexes(field) + assert len(sync_indexes) == len(async_indexes) + for sync_index, async_index in zip(sync_indexes, async_indexes, strict=True): + assert _index_signature(sync_index) == _index_signature(async_index) + assert SyncModel.table_indexes()[0] is AsyncModel.table_indexes()[0] + assert SyncModel.table_indexes()[1] is AsyncModel.table_indexes()[1] + assert SyncModel.table_constraints()[0] is AsyncModel.table_constraints()[0] + assert SyncModel.table_constraints()[1] is AsyncModel.table_constraints()[1] + assert SyncModel.table_options() is AsyncModel.table_options() + assert SyncModel.table_storage_options() is AsyncModel.table_storage_options() + assert SyncModel.table_partition() is AsyncModel.table_partition() + assert SyncModel.table_inherits() is AsyncModel.table_inherits() + assert SyncModel.table_tablespace() == AsyncModel.table_tablespace() + assert _constraint_signature(CompositeModel, "left_field") == _constraint_signature( + AsyncCompositeModel, "left_field" + ) + assert _constraint_signature(CompositeModel, "right_field") == _constraint_signature( + AsyncCompositeModel, "right_field" + ) + assert [ + (item.constraint_type, item.columns) for item in CompositeModel.table_constraints() + ] == [ + (item.constraint_type, item.columns) + for item in AsyncCompositeModel.table_constraints() + ] diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_domain_expression.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_domain_expression.py index 5154d2a..245d303 100644 --- a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_domain_expression.py +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_domain_expression.py @@ -1,139 +1,699 @@ # tests/rhosocial/activerecord_firebird_test/feature/backend/test_domain_expression.py -"""Tests for Firebird DOMAIN statement expressions. +"""SQL unit coverage for Firebird DOMAIN expressions and capabilities.""" -Covers ``CREATE DOMAIN`` (type + DEFAULT + NOT NULL + CHECK), every -``ALTER DOMAIN`` clause (SET/DROP DEFAULT, SET/DROP NOT NULL, ADD/DROP -CONSTRAINT, TYPE) and ``DROP DOMAIN``. All tests are pure construction — -no database connection. -""" - -from rhosocial.activerecord.backend.expression.types import VarCharType +import pytest +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import DomainMixin, UserDefinedTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import DomainSupport, UserDefinedTypeSupport +from rhosocial.activerecord.backend.expression import Literal +from rhosocial.activerecord.backend.expression.serialization import ( + ExpressionRegistry, + deserialize, + deserialize_json, + deserialize_xml, + serialize, + serialize_json, + serialize_xml, +) +from rhosocial.activerecord.backend.expression.statements import ( + AddDomainCheckAction, + AlterDomainExpression, + CreateDomainExpression, + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, + DropDomainCheckAction, + DropDomainDefaultAction, + DropDomainExpression, + DropDomainNotNullAction, + RenameDomainAction, + SetDomainDefaultAction, + SetDomainNotNullAction, +) +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + DecimalType, + IntegerType, + VarCharType, +) from rhosocial.activerecord.backend.impl.firebird.dialect import FirebirdDialect from rhosocial.activerecord.backend.impl.firebird.expression import ( FirebirdAlterDomainExpression, FirebirdCreateDomainExpression, FirebirdDomainAlterMode, FirebirdDropDomainExpression, + FirebirdSetDomainDataTypeAction, +) +from rhosocial.activerecord.backend.impl.firebird.expression.types import ( + FirebirdBlobSubType, + FirebirdCharType, + FirebirdDecFloatType, + FirebirdDecimalType, + FirebirdDoubleType, + FirebirdFloatType, + FirebirdInt128Type, + FirebirdTimeStampTzType, + FirebirdTimeWithoutTimeZoneType, + FirebirdTimeTzType, + FirebirdVarCharType, ) +from rhosocial.activerecord.backend.impl.firebird.mixins.domain import FirebirdDomainMixin +from rhosocial.activerecord.backend.impl.firebird.protocols import FirebirdDomainSupport +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect + + +def _check(dialect, condition, *, name=None): + return DomainCheckConstraint(dialect, condition, name=name) + + +def _positive_check(dialect): + return _check(dialect, DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True)) class TestCreateDomain: - def test_create_domain_default_and_check(self): + def test_legacy_expression_delegates_to_core_model(self): dialect = FirebirdDialect((4, 0, 0)) - expr = FirebirdCreateDomainExpression( + expression = FirebirdCreateDomainExpression( dialect, - "dm_zip", - VarCharType(length=10), - default="00000", - check="VALUE SIMILAR TO '[0-9]{5}'", + "salary_range", + DecimalType(precision=10, scale=2), + default=0, + not_null=True, + check="VALUE >= 0", + collation="UNICODE_FS", ) - sql, params = expr.to_sql() - assert sql == ( - 'CREATE DOMAIN "DM_ZIP" AS VARCHAR(10) ' - "DEFAULT '00000' CHECK (VALUE SIMILAR TO '[0-9]{5}')" + + assert isinstance(expression, CreateDomainExpression) + assert expression.to_sql() == ( + 'CREATE DOMAIN "SALARY_RANGE" AS DECIMAL(10, 2) DEFAULT 0 NOT NULL ' + 'CHECK (VALUE >= 0) COLLATE "UNICODE_FS"', + (), + ) + + def test_core_expression_uses_firebird_clause_order(self): + dialect = FirebirdDialect((5, 0, 0)) + condition = DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) + expression = CreateDomainExpression( + dialect, + "positive_code", + VarCharType(length=8), + default=Literal(dialect, "A", inline_literals=True), + nullability=DomainNullability.NOT_NULL, + checks=[_check(dialect, condition)], + collation="UNICODE_FS", + ) + + assert expression.to_sql() == ( + 'CREATE DOMAIN "POSITIVE_CODE" AS VARCHAR(8) DEFAULT \'A\' NOT NULL ' + 'CHECK (VALUE > 0) COLLATE "UNICODE_FS"', + (), + ) + + def test_minimal_create(self): + dialect = FirebirdDialect((4, 0, 0)) + expression = FirebirdCreateDomainExpression( + dialect, + "amount", + DecimalType(precision=10, scale=2), ) - assert params == () - def test_create_domain_not_null(self): + assert expression.to_sql() == ('CREATE DOMAIN "AMOUNT" AS DECIMAL(10, 2)', ()) + + def test_create_rebinds_cross_dialect_data_type(self): dialect = FirebirdDialect((4, 0, 0)) - expr = FirebirdCreateDomainExpression(dialect, "dm_code", VarCharType(length=8), not_null=True) - sql, params = expr.to_sql() - assert sql == 'CREATE DOMAIN "DM_CODE" AS VARCHAR(8) NOT NULL' - assert params == () + sqlite = SQLiteDialect(version=(3, 45, 0)) + expression = FirebirdCreateDomainExpression( + dialect, + "ratio", + FirebirdDecFloatType(sqlite, precision=16), + ) - def test_create_domain_minimal(self): + assert expression.to_sql() == ('CREATE DOMAIN "RATIO" AS DECFLOAT(16)', ()) + + def test_named_check_fails_fast(self): dialect = FirebirdDialect((4, 0, 0)) - expr = FirebirdCreateDomainExpression(dialect, "dm_amt", VarCharType(length=10)) - sql, params = expr.to_sql() - assert sql == 'CREATE DOMAIN "DM_AMT" AS VARCHAR(10)' - assert params == () + expression = CreateDomainExpression( + dialect, + "positive", + IntegerType(dialect), + checks=[ + _check( + dialect, + DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True), + name="positive_check", + ) + ], + ) + + with pytest.raises(UnsupportedFeatureError, match="named DOMAIN CHECK"): + expression.to_sql() - def test_create_domain_supports_flag(self): + def test_multiple_checks_fail_fast(self): dialect = FirebirdDialect((4, 0, 0)) - assert dialect.supports_domain() is True - assert dialect.supports_create_domain() is True + expression = CreateDomainExpression( + dialect, + "bounded", + IntegerType(dialect), + checks=[_positive_check(dialect), _positive_check(dialect)], + ) + + with pytest.raises(UnsupportedFeatureError, match="multiple domain CHECK"): + expression.to_sql() + + def test_explicit_null_is_not_rendered(self): + dialect = FirebirdDialect((4, 0, 0)) + expression = CreateDomainExpression( + dialect, + "nullable", + IntegerType(dialect), + nullability=DomainNullability.NULLABLE, + ) + + with pytest.raises(UnsupportedFeatureError, match="DOMAIN NULL"): + expression.to_sql() + + def test_empty_legacy_check_is_not_dropped(self): + dialect = FirebirdDialect((4, 0, 0)) + + with pytest.raises(ValueError, match="non-empty SQL predicate"): + FirebirdCreateDomainExpression( + dialect, + "invalid_check", + IntegerType(dialect), + check=" ", + ) class TestAlterDomain: - def _alter(self, dialect, mode, **kwargs): - return FirebirdAlterDomainExpression(dialect, "dm_zip", mode, **kwargs).to_sql() + def test_legacy_modes_render_core_actions(self): + dialect = FirebirdDialect((4, 0, 0)) + cases = ( + (FirebirdDomainAlterMode.SET_DEFAULT, {"value": 7}, 'ALTER DOMAIN "D" SET DEFAULT 7'), + (FirebirdDomainAlterMode.DROP_DEFAULT, {}, 'ALTER DOMAIN "D" DROP DEFAULT'), + (FirebirdDomainAlterMode.SET_NOT_NULL, {}, 'ALTER DOMAIN "D" SET NOT NULL'), + (FirebirdDomainAlterMode.DROP_NOT_NULL, {}, 'ALTER DOMAIN "D" DROP NOT NULL'), + ( + FirebirdDomainAlterMode.ADD_CONSTRAINT, + {"constraint_sql": "VALUE > 0"}, + 'ALTER DOMAIN "D" ADD CHECK (VALUE > 0)', + ), + ( + FirebirdDomainAlterMode.DROP_CONSTRAINT, + {}, + 'ALTER DOMAIN "D" DROP CONSTRAINT', + ), + ( + FirebirdDomainAlterMode.SET_TYPE, + {"data_type": VarCharType(length=12)}, + 'ALTER DOMAIN "D" TYPE VARCHAR(12)', + ), + ) - def test_set_default(self): + for mode, fields, expected in cases: + expression = FirebirdAlterDomainExpression(dialect, "d", mode, **fields) + assert isinstance(expression, AlterDomainExpression) + assert expression.to_sql() == (expected, ()) + + def test_unbound_data_type_is_bound_to_firebird(self): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter(dialect, FirebirdDomainAlterMode.SET_DEFAULT, value="12345") - assert sql == "ALTER DOMAIN \"DM_ZIP\" SET DEFAULT '12345'" - assert params == () + action = FirebirdSetDomainDataTypeAction(dialect, VarCharType(length=12)) + + assert action.to_sql() == ("TYPE VARCHAR(12)", ()) - def test_drop_default(self): + def test_cross_dialect_data_type_is_rebound_to_firebird(self): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter(dialect, FirebirdDomainAlterMode.DROP_DEFAULT) - assert sql == 'ALTER DOMAIN "DM_ZIP" DROP DEFAULT' - assert params == () + sqlite = SQLiteDialect(version=(3, 45, 0)) + action = FirebirdSetDomainDataTypeAction( + dialect, + FirebirdDecFloatType(sqlite, precision=16), + ) - def test_set_not_null(self): + assert action.to_sql() == ("TYPE DECFLOAT(16)", ()) + + def test_unsupported_cross_dialect_data_type_fails(self): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter(dialect, FirebirdDomainAlterMode.SET_NOT_NULL) - assert sql == 'ALTER DOMAIN "DM_ZIP" SET NOT NULL' - assert params == () + sqlite = SQLiteDialect(version=(3, 45, 0)) + action = FirebirdSetDomainDataTypeAction( + dialect, + ArrayType(sqlite, IntegerType(sqlite), dimensions=1), + ) + + with pytest.raises(TypeError, match="generic type 'array'"): + action.to_sql() - def test_drop_not_null(self): + def test_legacy_data_type_name_api_is_preserved(self): + dialect = FirebirdDialect((4, 0, 0)) + action = FirebirdSetDomainDataTypeAction(dialect, "VARCHAR(20)") + expression = FirebirdAlterDomainExpression( + dialect, + "d", + FirebirdDomainAlterMode.SET_TYPE, + data_type_name="DECIMAL(10, 2)", + ) + + assert action.to_sql() == ("TYPE VARCHAR(20)", ()) + assert expression.to_sql() == ('ALTER DOMAIN "D" TYPE DECIMAL(10, 2)', ()) + + @pytest.mark.parametrize( + "data_type_name", + ( + "INTEGER; DROP DOMAIN D", + "INTEGER DROP DOMAIN D", + "VARCHAR(20) EXTRA", + "VARCHAR(20) CHARACTER SET UTF8 DROP DOMAIN D", + "DROP", + ), + ) + def test_data_type_name_rejects_unsafe_syntax(self, data_type_name): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter(dialect, FirebirdDomainAlterMode.DROP_NOT_NULL) - assert sql == 'ALTER DOMAIN "DM_ZIP" DROP NOT NULL' - assert params == () - def test_add_constraint(self): + with pytest.raises(ValueError, match="data_type_name"): + FirebirdSetDomainDataTypeAction( + dialect, + data_type_name=data_type_name, + ) + + @pytest.mark.parametrize( + ("data_type_name", "expected"), + ( + ("INT", "TYPE INTEGER"), + ("NUMERIC(10, 2)", "TYPE DECIMAL(10, 2)"), + ("DOUBLE PRECISION", "TYPE DOUBLE PRECISION"), + ("BLOB SUB_TYPE TEXT", "TYPE BLOB SUB_TYPE TEXT"), + ( + "VARCHAR(40) CHARACTER SET UTF8", + "TYPE VARCHAR(40) CHARACTER SET UTF8", + ), + ("CHAR(5) CHARACTER SET UTF8", "TYPE CHAR(5) CHARACTER SET UTF8"), + ("APP.MY_DOMAIN", "TYPE APP.MY_DOMAIN"), + ), + ) + def test_legal_data_type_names_map_to_firebird_types( + self, + data_type_name, + expected, + ): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter( + action = FirebirdSetDomainDataTypeAction( dialect, - FirebirdDomainAlterMode.ADD_CONSTRAINT, - constraint_name="chk", - constraint_sql="VALUE SIMILAR TO '[0-9]{5}'", + data_type_name=data_type_name, + ) + + assert action.to_sql() == (expected, ()) + + @pytest.mark.parametrize( + ("data_type_name", "expected"), + ( + ("DECFLOAT", "TYPE DECFLOAT(16)"), + ("DECFLOAT(16)", "TYPE DECFLOAT(16)"), + ("INT128", "TYPE INT128"), + ("TIME WITH TIME ZONE", "TYPE TIME WITH TIME ZONE"), + ("TIMESTAMP WITH TIME ZONE", "TYPE TIMESTAMP WITH TIME ZONE"), + ("TIME WITHOUT TIME ZONE", "TYPE TIME WITHOUT TIME ZONE"), + ("TIME(6) WITHOUT TIME ZONE", "TYPE TIME(6) WITHOUT TIME ZONE"), + ( + "TIMESTAMP(6) WITH TIME ZONE", + "TYPE TIMESTAMP(6) WITH TIME ZONE", + ), + ), + ) + def test_firebird_4_data_type_names_are_version_gated( + self, + data_type_name, + expected, + ): + action = FirebirdSetDomainDataTypeAction( + FirebirdDialect((3, 0, 0)), + data_type_name=data_type_name, + ) + + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + firebird_4 = FirebirdSetDomainDataTypeAction( + FirebirdDialect((4, 0, 0)), + data_type_name=data_type_name, + ) + assert firebird_4.to_sql() == (expected, ()) + + def test_boolean_data_type_name_is_version_gated(self): + action = FirebirdSetDomainDataTypeAction( + FirebirdDialect((2, 5, 0)), + data_type_name="BOOLEAN", + ) + + with pytest.raises(UnsupportedFeatureError): + action.to_sql() + + def test_invalid_decfloat_precision_is_rejected(self): + with pytest.raises(ValueError, match="16 or 34"): + FirebirdSetDomainDataTypeAction( + FirebirdDialect((4, 0, 0)), + data_type_name="DECFLOAT(17)", + ) + + def test_multiple_actions_use_firebird_order(self): + dialect = FirebirdDialect((4, 0, 0)) + actions = [ + SetDomainNotNullAction(dialect), + RenameDomainAction(dialect, "d_v2"), + FirebirdSetDomainDataTypeAction(dialect, VarCharType(length=12)), + SetDomainDefaultAction(dialect, 7), + ] + expression = FirebirdAlterDomainExpression(dialect, "d", actions=actions) + + assert expression.to_sql() == ( + 'ALTER DOMAIN "D" TO "D_V2" TYPE VARCHAR(12) ' + 'SET DEFAULT 7 SET NOT NULL', + (), ) - assert sql == ( - 'ALTER DOMAIN "DM_ZIP" ADD CONSTRAINT "CHK" ' - "CHECK (VALUE SIMILAR TO '[0-9]{5}')" + + def test_core_alter_expression_uses_same_formatter(self): + dialect = FirebirdDialect((4, 0, 0)) + expression = AlterDomainExpression( + dialect, + "d", + [ + DropDomainDefaultAction(dialect), + RenameDomainAction(dialect, "d_v2"), + ], ) - assert params == () - def test_drop_constraint(self): + assert expression.to_sql() == ('ALTER DOMAIN "D" TO "D_V2" DROP DEFAULT', ()) + + def test_named_check_actions_fail_fast(self): + dialect = FirebirdDialect((4, 0, 0)) + named_check = _check( + dialect, + DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True), + name="positive_check", + ) + add_expression = AlterDomainExpression( + dialect, + "d", + [AddDomainCheckAction(dialect, named_check)], + ) + drop_expression = AlterDomainExpression( + dialect, + "d", + [DropDomainCheckAction(dialect, name="positive_check")], + ) + + with pytest.raises(UnsupportedFeatureError, match="named DOMAIN CHECK"): + add_expression.to_sql() + with pytest.raises(UnsupportedFeatureError, match="named DOMAIN CHECK drop"): + drop_expression.to_sql() + + def test_legacy_named_check_actions_fail_fast(self): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter( + add_expression = FirebirdAlterDomainExpression( dialect, + "d", + FirebirdDomainAlterMode.ADD_CONSTRAINT, + constraint_name="positive_check", + constraint_sql="VALUE > 0", + ) + drop_expression = FirebirdAlterDomainExpression( + dialect, + "d", FirebirdDomainAlterMode.DROP_CONSTRAINT, - constraint_name="chk", + constraint_name="positive_check", ) - assert sql == 'ALTER DOMAIN "DM_ZIP" DROP CONSTRAINT "CHK"' - assert params == () - def test_set_type(self): + with pytest.raises(UnsupportedFeatureError, match="named DOMAIN CHECK"): + add_expression.to_sql() + with pytest.raises(UnsupportedFeatureError, match="named DOMAIN CHECK drop"): + drop_expression.to_sql() + + def test_only_one_check_action_is_rendered(self): dialect = FirebirdDialect((4, 0, 0)) - sql, params = self._alter( + expression = AlterDomainExpression( dialect, - FirebirdDomainAlterMode.SET_TYPE, - data_type=VarCharType(length=12), + "d", + [ + AddDomainCheckAction(dialect, _positive_check(dialect)), + AddDomainCheckAction(dialect, _positive_check(dialect)), + ], ) - assert sql == 'ALTER DOMAIN "DM_ZIP" TYPE VARCHAR(12)' - assert params == () + with pytest.raises(UnsupportedFeatureError, match="multiple ALTER DOMAIN CHECK"): + expression.to_sql() -class TestDropDomain: - def test_drop_domain(self): + def test_missing_legacy_action_fields_fail_fast(self): dialect = FirebirdDialect((4, 0, 0)) - sql, params = FirebirdDropDomainExpression(dialect, "dm_zip").to_sql() - assert sql == 'DROP DOMAIN "DM_ZIP"' - assert params == () + with pytest.raises(ValueError, match="SET DEFAULT requires a value"): + FirebirdAlterDomainExpression( + dialect, + "d", + FirebirdDomainAlterMode.SET_DEFAULT, + ) + with pytest.raises(ValueError, match="ADD CONSTRAINT requires constraint_sql"): + FirebirdAlterDomainExpression( + dialect, + "d", + FirebirdDomainAlterMode.ADD_CONSTRAINT, + ) + with pytest.raises(ValueError, match="SET TYPE requires data_type"): + FirebirdAlterDomainExpression( + dialect, + "d", + FirebirdDomainAlterMode.SET_TYPE, + ) + + def test_conflicting_mode_and_actions_fail_fast(self): + dialect = FirebirdDialect((4, 0, 0)) + + with pytest.raises(ValueError, match="different ALTER DOMAIN changes"): + FirebirdAlterDomainExpression( + dialect, + "d", + FirebirdDomainAlterMode.SET_DEFAULT, + value=7, + actions=[DropDomainDefaultAction(dialect)], + ) + + +class TestVersionBoundaries: + def test_domain_statements_start_at_firebird_25(self): + dialect = FirebirdDialect((2, 5, 0)) + create = FirebirdCreateDomainExpression( + dialect, + "positive", + IntegerType(dialect), + not_null=True, + ) + alter = AlterDomainExpression( + dialect, + "positive", + [FirebirdSetDomainDataTypeAction(dialect, IntegerType(dialect))], + ) + drop = FirebirdDropDomainExpression(dialect, "positive") + + assert create.to_sql() == ('CREATE DOMAIN "POSITIVE" AS INTEGER NOT NULL', ()) + assert alter.to_sql() == ('ALTER DOMAIN "POSITIVE" TYPE INTEGER', ()) + assert drop.to_sql() == ('DROP DOMAIN "POSITIVE"', ()) + + @pytest.mark.parametrize("action_type", [SetDomainNotNullAction, DropDomainNotNullAction]) + def test_not_null_actions_require_firebird_30(self, action_type): + dialect = FirebirdDialect((2, 5, 0)) + action = action_type(dialect) + expression = AlterDomainExpression(dialect, "positive", [action]) + + assert dialect.supports_alter_domain_action(action_type) is False + with pytest.raises(UnsupportedFeatureError, match="ALTER DOMAIN action"): + expression.to_sql() -class TestDomainDispatch: - def test_expression_to_sql_delegates_to_dialect(self): - from rhosocial.activerecord.backend.impl.firebird.mixins.domain import FirebirdDomainMixin + def test_not_null_actions_render_on_firebird_30(self): + dialect = FirebirdDialect((3, 0, 0)) + assert dialect.supports_alter_domain_action(SetDomainNotNullAction) is True + assert dialect.supports_alter_domain_action(DropDomainNotNullAction) is True + + def test_domain_statements_fail_before_firebird_25(self): + dialect = FirebirdDialect((2, 0, 0)) + + with pytest.raises(UnsupportedFeatureError, match="CREATE DOMAIN"): + FirebirdCreateDomainExpression(dialect, "d", IntegerType(dialect)).to_sql() + with pytest.raises(UnsupportedFeatureError, match="DROP DOMAIN"): + DropDomainExpression(dialect, "d").to_sql() + + +class TestCapabilities: + def test_firebird_domain_capabilities(self): + dialect = FirebirdDialect((4, 0, 0)) + + assert dialect.supports_domain() is True + assert dialect.supports_domains() is True + assert dialect.supports_create_domain() is True + assert dialect.supports_alter_domain() is True + assert dialect.supports_drop_domain() is True + assert dialect.supports_domain_default() is True + assert dialect.supports_domain_nullability(DomainNullability.NOT_NULL) is True + assert dialect.supports_domain_nullability(DomainNullability.NULLABLE) is False + assert dialect.supports_domain_checks() is True + assert dialect.supports_named_domain_checks() is False + assert dialect.supports_multiple_domain_checks() is False + assert dialect.supports_domain_collation() is True + assert dialect.supports_multiple_domain_alter_actions() is True + assert dialect.supports_drop_domain_if_exists() is False + assert dialect.supports_drop_domain_cascade() is False + assert dialect.supports_drop_domain_restrict() is False + assert dialect.supports_unnamed_domain_check_drop() is True + + def test_independent_type_capabilities_are_all_false(self): + dialect = FirebirdDialect((4, 0, 0)) + capability_names = ( + "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", + ) + + for name in capability_names: + assert getattr(dialect, name)() is False + assert dialect.supported_type_definitions() == () + assert dialect.supports_type_definition(IntegerType) is False + assert dialect.supports_type_alter_action(DropDomainDefaultAction) is False + assert FirebirdDialect.supports_type_objects is UserDefinedTypeMixin.supports_type_objects + + def test_domain_protocols_and_mro(self): + dialect = FirebirdDialect((4, 0, 0)) + + assert issubclass(FirebirdDomainSupport, DomainSupport) + assert isinstance(dialect, FirebirdDomainSupport) + assert isinstance(dialect, DomainSupport) + assert isinstance(dialect, DomainMixin) + assert isinstance(dialect, UserDefinedTypeSupport) + + +class TestDispatchAndSerialization: + def test_dispatch_uses_firebird_mixin(self): dialect = FirebirdDialect((4, 0, 0)) - assert type(dialect).format_create_domain_statement == FirebirdDomainMixin.format_create_domain_statement - assert type(dialect).format_alter_domain_statement == FirebirdDomainMixin.format_alter_domain_statement - assert type(dialect).format_drop_domain_statement == FirebirdDomainMixin.format_drop_domain_statement - def test_supports_domain_true_across_supported_versions(self): - assert FirebirdDialect((2, 5, 0)).supports_domain() is True - assert FirebirdDialect((5, 0, 0)).supports_domain() is True + assert type(dialect).format_create_domain_statement is FirebirdDomainMixin.format_create_domain_statement + assert type(dialect).format_alter_domain_statement is FirebirdDomainMixin.format_alter_domain_statement + assert type(dialect).format_drop_domain_statement is FirebirdDomainMixin.format_drop_domain_statement + assert type(dialect).format_domain_alter_action is FirebirdDomainMixin.format_domain_alter_action + + def test_backend_domain_classes_are_registered(self): + classes = ( + FirebirdCreateDomainExpression, + FirebirdAlterDomainExpression, + FirebirdDropDomainExpression, + FirebirdSetDomainDataTypeAction, + ) + + for expression_class in classes: + fqn = f"{expression_class.__module__}.{expression_class.__name__}" + assert ExpressionRegistry.lookup(fqn) is expression_class + + def test_firebird_data_types_are_registered(self): + classes = ( + FirebirdDecimalType, + FirebirdFloatType, + FirebirdDoubleType, + FirebirdBlobSubType, + FirebirdCharType, + FirebirdVarCharType, + FirebirdTimeStampTzType, + FirebirdTimeWithoutTimeZoneType, + FirebirdTimeTzType, + FirebirdDecFloatType, + FirebirdInt128Type, + ) + + for expression_class in classes: + fqn = f"{expression_class.__module__}.{expression_class.__name__}" + assert ExpressionRegistry.lookup(fqn) is expression_class + + def test_firebird_data_types_round_trip_in_all_codecs(self): + dialect = FirebirdDialect((4, 0, 0)) + expressions = ( + FirebirdDecimalType(dialect, precision=10, scale=2), + FirebirdFloatType(dialect), + FirebirdDoubleType(dialect), + FirebirdBlobSubType(dialect), + FirebirdCharType(dialect, length=5), + FirebirdVarCharType(dialect, length=40), + FirebirdTimeStampTzType(dialect, precision=6), + FirebirdTimeWithoutTimeZoneType(dialect, precision=6), + FirebirdTimeTzType(dialect, precision=6), + FirebirdDecFloatType(dialect, precision=16), + FirebirdInt128Type(dialect), + ) + codecs = ( + (serialize, deserialize), + (serialize_json, deserialize_json), + (serialize_xml, deserialize_xml), + ) + + for expression in expressions: + for encoder, decoder in codecs: + payload = encoder(expression) + restored = decoder(payload, dialect) + assert type(restored) is type(expression) + assert restored.to_sql() == expression.to_sql() + assert encoder(restored) == payload + + def test_backend_domain_classes_round_trip_in_all_codecs(self): + dialect = FirebirdDialect((4, 0, 0)) + expressions = ( + FirebirdCreateDomainExpression( + dialect, + "positive", + IntegerType(dialect), + default=0, + not_null=True, + check="VALUE >= 0", + ), + FirebirdAlterDomainExpression( + dialect, + "positive", + FirebirdDomainAlterMode.SET_DEFAULT, + value=1, + ), + FirebirdAlterDomainExpression( + dialect, + "positive", + FirebirdDomainAlterMode.SET_TYPE, + data_type_name="DECIMAL(10, 2)", + ), + FirebirdAlterDomainExpression( + dialect, + "positive", + actions=[ + FirebirdSetDomainDataTypeAction( + dialect, + FirebirdDecFloatType(precision=16), + ) + ], + ), + FirebirdDropDomainExpression(dialect, "positive"), + FirebirdSetDomainDataTypeAction( + dialect, + FirebirdDecFloatType(precision=16), + ), + FirebirdSetDomainDataTypeAction( + dialect, + data_type_name="VARCHAR(20)", + ), + ) + codecs = ( + (serialize, deserialize), + (serialize_json, deserialize_json), + (serialize_xml, deserialize_xml), + ) + + for expression in expressions: + for encoder, decoder in codecs: + payload = encoder(expression) + restored = decoder(payload, dialect) + assert type(restored) is type(expression) + assert restored.to_sql() == expression.to_sql() + assert encoder(restored) == payload diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_column_definition.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_column_definition.py new file mode 100644 index 0000000..7759277 --- /dev/null +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_column_definition.py @@ -0,0 +1,63 @@ +# tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_column_definition.py +"""Tests for the Firebird-specific column definition expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import VarCharType +from rhosocial.activerecord.backend.impl.firebird import FirebirdDialect +from rhosocial.activerecord.backend.impl.firebird.expression import ( + FirebirdColumnDefinition, + FirebirdColumnOptions, +) + + +@pytest.fixture +def dialect(): + return FirebirdDialect((5, 0, 0)) + + +def test_derives_generic_column_definition(): + assert ColumnDefinition in FirebirdColumnDefinition.__mro__ + + +def test_character_set_and_collation(dialect): + sql, _ = FirebirdColumnDefinition( + dialect, "x", VarCharType(dialect, length=20), + character_set="UTF8", collation="UNICODE", + ).to_sql() + assert sql == '"X" VARCHAR(20) CHARACTER SET UTF8 COLLATE UNICODE' + + +def test_computed_by(dialect): + sql, _ = FirebirdColumnDefinition( + dialect, "x", VarCharType(dialect, length=20), computed_by="UPPER(name)" + ).to_sql() + assert "COMPUTED BY (UPPER(name))" in sql + + +def test_generic_column_still_renders_on_firebird(dialect): + generic = ColumnDefinition(dialect, "x", VarCharType(dialect, length=20)) + sql, _ = generic.to_sql() + assert sql == '"X" VARCHAR(20)' + + +def test_options_select_firebird_column_class(): + assert FirebirdColumnOptions(character_set="UTF8").column_definition_class() is ( + FirebirdColumnDefinition + ) + + +def test_options_apply_to(dialect): + options = FirebirdColumnOptions(character_set="UTF8", collation="UNICODE") + col = FirebirdColumnDefinition(dialect, "c", VarCharType(dialect, length=10)) + options.apply_to(col) + assert col.character_set == "UTF8" + assert col.collation == "UNICODE" + + +def test_options_apply_to_rejects_generic_column(dialect): + options = FirebirdColumnOptions(character_set="UTF8") + generic = ColumnDefinition(dialect, "c", VarCharType(dialect, length=10)) + with pytest.raises(TypeError, match="FirebirdColumnDefinition"): + options.apply_to(generic) diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_protocol_conformance.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_protocol_conformance.py index 1af52eb..e5c7496 100644 --- a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_protocol_conformance.py +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_protocol_conformance.py @@ -55,8 +55,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 @@ -67,9 +72,12 @@ def get_all_generic_protocols() -> dict: dialect_protocols.ArraySupport, dialect_protocols.AutoIncrementSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, + dialect_protocols.CommentSupport, dialect_protocols.CollationSupport, dialect_protocols.ConstraintSupport, dialect_protocols.DDLTypeSupport, + dialect_protocols.DomainSupport, dialect_protocols.ExplainSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.FunctionSupport, @@ -97,6 +105,7 @@ def get_all_generic_protocols() -> dict: dialect_protocols.TriggerSupport, dialect_protocols.TruncateSupport, dialect_protocols.UpsertSupport, + dialect_protocols.UserDefinedTypeSupport, dialect_protocols.ViewSupport, dialect_protocols.WildcardSupport, dialect_protocols.WindowFunctionSupport, diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_type_protocol.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_type_protocol.py index 7340ab6..a0abed3 100644 --- a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_type_protocol.py +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_firebird_type_protocol.py @@ -239,30 +239,13 @@ def test_fb4_support_flags_gated(self, dialect, dialect_3): class TestDialectOptions: - """dialect_options forwards through construction and affects equality.""" + """The data-type value objects no longer carry a dialect_options bag.""" - def test_construction_forwards_dialect_options(self, dialect): - data_type = FirebirdDecFloatType( - dialect=dialect, precision=16, - dialect_options={"key": "value"}, - ) - assert data_type.dialect_options == {"key": "value"} - def test_dialect_options_participate_in_equality(self, dialect): - a = FirebirdDecFloatType( - dialect=dialect, precision=16, - dialect_options={"key": "value"}, - ) - b = FirebirdDecFloatType( - dialect=dialect, precision=16, - dialect_options={"key": "value"}, - ) - c = FirebirdDecFloatType( - dialect=dialect, precision=16, - dialect_options={"key": "other"}, - ) - assert a == b - assert a != c + + def test_constructor_rejects_dialect_options(self, dialect): + with pytest.raises(TypeError): + FirebirdDecFloatType(precision=16, dialect_options={"x": 1}) def test_equality_ignores_dialect(self, dialect): other = FirebirdDialect() diff --git a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_types_and_dialect_branches.py b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_types_and_dialect_branches.py index 9bdd315..999f5dc 100644 --- a/tests/rhosocial/activerecord_firebird_test/feature/backend/test_types_and_dialect_branches.py +++ b/tests/rhosocial/activerecord_firebird_test/feature/backend/test_types_and_dialect_branches.py @@ -385,13 +385,23 @@ def test_if_not_exists_raises_when_unsupported(self, dialect): Previously the clause was rendered unconditionally; it must now be rejected through supports_if_not_exists_table(). """ - expr = CreateTableExpression(dialect, "tbl_c", [_column(dialect, "id", IntegerType(dialect))], if_not_exists=True) + expr = CreateTableExpression( + dialect, + "tbl_c", + [_column(dialect, "id", IntegerType(dialect))], + if_not_exists=True, + ) with pytest.raises(UnsupportedFeatureError) as excinfo: expr.to_sql() assert "IF NOT EXISTS" in str(excinfo.value) def test_if_not_exists_renders_when_capability_present(self, dialect): - expr = CreateTableExpression(dialect, "tbl_c", [_column(dialect, "id", IntegerType(dialect))], if_not_exists=True) + expr = CreateTableExpression( + dialect, + "tbl_c", + [_column(dialect, "id", IntegerType(dialect))], + if_not_exists=True, + ) from unittest import mock with mock.patch.object(FirebirdDialect, "supports_if_not_exists_table", return_value=True): sql, _ = expr.to_sql() @@ -412,10 +422,10 @@ def test_computed_by_column(self, dialect): ) def test_identity_with_start_and_increment(self, dialect): + from rhosocial.activerecord.base import IdentityAttribute + col = _column(dialect, "id", IntegerType(dialect)) - col.identity = "ALWAYS" - col.identity_start = 1000 - col.identity_increment = 10 + col.attributes = [IdentityAttribute(generation="ALWAYS", start=1000, increment=10)] assert CreateTableExpression(dialect, "ident_t", [col]).to_sql() == ( 'CREATE TABLE "IDENT_T" ' '("ID" INTEGER GENERATED ALWAYS AS IDENTITY (START WITH 1000 INCREMENT BY 10))', @@ -443,8 +453,14 @@ def test_string_default_escaped_and_ordered_before_not_null(self, dialect): def test_expression_default_contributes_params(self, dialect): col = _column( - dialect, "created_at", DateTimeType(dialect=dialect), - ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=E.Literal(dialect, "CURRENT_TIMESTAMP")), + dialect, + "created_at", + DateTimeType(dialect=dialect), + ColumnConstraint( + dialect, + ColumnConstraintType.DEFAULT, + default_value=E.Literal(dialect, "CURRENT_TIMESTAMP"), + ), ) assert CreateTableExpression(dialect, "t5b", [col]).to_sql() == ( 'CREATE TABLE "T5B" ("CREATED_AT" TIMESTAMP DEFAULT ?)', ("CURRENT_TIMESTAMP",)