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