diff --git a/CLAUDE.md b/CLAUDE.md index 96853fd..1d230d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,3 +110,9 @@ git checkout as a development dependency. `python-activerecord-mysql`, etc.) and their `tests/` mirror trees. 3. For provider interface contracts, browse `src/rhosocial/activerecord/testsuite/feature//interfaces.py`. + +## 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/21.added.md b/changelog.d/21.added.md new file mode 100644 index 0000000..9474eef --- /dev/null +++ b/changelog.d/21.added.md @@ -0,0 +1 @@ +Add cross-backend DDL contracts — a materialized-view contract requiring a dialect that advertises `supports_materialized_view()` to own its CREATE and refresh formatters, and a `DDLSource` collection contract covering retained sources — plus a `requires_cte_order_by` decorator and BMP-only Unicode round-trip tests. diff --git a/changelog.d/21.changed.md b/changelog.d/21.changed.md new file mode 100644 index 0000000..d49fd8b --- /dev/null +++ b/changelog.d/21.changed.md @@ -0,0 +1 @@ +Narrow the materialized-view contract to semantics every MV backend shares, stop treating MV refresh as a SQL-standard statement, and grade contract skips by whether the dialect can resolve a version. Adapt the expression tests to node-based casts and normalise timezone-aware datetimes. diff --git a/changelog.d/21.removed.md b/changelog.d/21.removed.md new file mode 100644 index 0000000..d8e69f8 --- /dev/null +++ b/changelog.d/21.removed.md @@ -0,0 +1 @@ +Remove the deprecated `features.py` and the unused `sqlite_features.py`. diff --git a/src/rhosocial/activerecord/testsuite/feature/backend/sqlite_features.py b/src/rhosocial/activerecord/testsuite/feature/backend/sqlite_features.py deleted file mode 100644 index 682f6bc..0000000 --- a/src/rhosocial/activerecord/testsuite/feature/backend/sqlite_features.py +++ /dev/null @@ -1,85 +0,0 @@ -# src/rhosocial/activerecord/testsuite/feature/backend/sqlite_features.py -"""SQLite backend feature support implementation.""" - -from typing import Tuple -from ..features import FeatureSupport, DatabaseFeature - - -class SQLiteFeatureSupport(FeatureSupport): - """SQLite backend feature support declaration.""" - - def __init__(self, version: Tuple[int, int, int]): - """Initialize with SQLite version. - - Args: - version: SQLite version as (major, minor, patch) tuple - """ - super().__init__() - self.version = version - self._declare_features() - - def _declare_features(self): - """Declare SQLite feature support based on version.""" - # Window functions supported from 3.25.0+ - if self.version >= (3, 25, 0): - self.supports([ - DatabaseFeature.WINDOW_FUNCTIONS - ]) - - # RETURNING clause supported from 3.35.0+ - if self.version >= (3, 35, 0): - self.supports([ - DatabaseFeature.RETURNING_CLAUSE, - DatabaseFeature.RETURNING_EXPRESSIONS, - DatabaseFeature.RETURNING_ALIASES - ]) - - # CTEs supported from 3.8.3+ - if self.version >= (3, 8, 3): - self.supports([ - DatabaseFeature.CTE, - DatabaseFeature.CTE_IN_DML - ]) - - # Recursive CTEs supported from 3.8.3+ - if self.version >= (3, 8, 3): - self.supports([ - DatabaseFeature.RECURSIVE_CTE - ]) - - # Recursive CTEs with compound queries from 3.34.0+ - if self.version >= (3, 34, 0): - self.supports([ - DatabaseFeature.COMPOUND_RECURSIVE_CTE - ]) - - # Materialized CTEs from 3.35.0+ - if self.version >= (3, 35, 0): - self.supports([ - DatabaseFeature.MATERIALIZED_CTE - ]) - - # JSON operations supported from 3.9.0+ - if self.version >= (3, 9, 0): - self.supports([ - DatabaseFeature.JSON_OPERATIONS - ]) - - # JSON arrow operators from 3.38.0+ - if self.version >= (3, 38, 0): - self.supports([ - DatabaseFeature.JSON_ARROWS - ]) - - # Savepoints supported - self.supports([ - DatabaseFeature.SAVEPOINT - ]) - - # Bulk operations (execute_many) supported with fallback - self.supports([ - DatabaseFeature.BULK_OPERATIONS - ]) - - # Note: SQLite does NOT support CUBE, ROLLUP, GROUPING SETS - # These are intentionally NOT declared as supported diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists.py b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists.py index 2801156..714a5cd 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists.py @@ -43,7 +43,7 @@ class TestDdlAddColumn: @requires_protocol(AlterTableModifierSupport, "supports_add_column_if_not_exists") def test_add_column_if_not_exists(self, ddl_dialect): """ALTER TABLE ADD COLUMN IF NOT EXISTS should render the IF NOT EXISTS qualifier.""" - action = AddColumn(ddl_dialect, ColumnDefinition("content", TextType()), if_not_exists=True) + action = AddColumn(ddl_dialect, ColumnDefinition(ddl_dialect, "content", TextType(ddl_dialect)), if_not_exists=True) sql, params = action.to_sql() assert "IF NOT EXISTS" in sql, \ "Expected the rendered SQL to contain the IF NOT EXISTS qualifier" @@ -51,7 +51,7 @@ def test_add_column_if_not_exists(self, ddl_dialect): def test_add_column_plain_form(self, ddl_dialect): """ALTER TABLE ADD COLUMN without if_not_exists should omit the qualifier.""" - action = AddColumn(ddl_dialect, ColumnDefinition("content", TextType())) + action = AddColumn(ddl_dialect, ColumnDefinition(ddl_dialect, "content", TextType(ddl_dialect))) sql, params = action.to_sql() assert "IF NOT EXISTS" not in sql, \ "Expected the rendered SQL to omit the IF NOT EXISTS qualifier" diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists_async.py b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists_async.py index ac83268..c389cef 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_alter_table_if_exists_async.py @@ -34,7 +34,7 @@ class TestAsyncDdlAddColumn: async def test_add_column_if_not_exists(self, async_ddl_dialect): """ALTER TABLE ADD COLUMN IF NOT EXISTS should render the IF NOT EXISTS qualifier.""" action = AddColumn( - async_ddl_dialect, ColumnDefinition("content", TextType()), if_not_exists=True + async_ddl_dialect, ColumnDefinition(async_ddl_dialect, "content", TextType(async_ddl_dialect)), if_not_exists=True ) sql, params = action.to_sql() assert "IF NOT EXISTS" in sql, \ @@ -43,7 +43,7 @@ async def test_add_column_if_not_exists(self, async_ddl_dialect): async def test_add_column_plain_form(self, async_ddl_dialect): """ALTER TABLE ADD COLUMN without if_not_exists should omit the qualifier.""" - action = AddColumn(async_ddl_dialect, ColumnDefinition("content", TextType())) + action = AddColumn(async_ddl_dialect, ColumnDefinition(async_ddl_dialect, "content", TextType(async_ddl_dialect))) sql, params = action.to_sql() assert "IF NOT EXISTS" not in sql, \ "Expected the rendered SQL to omit the IF NOT EXISTS qualifier" diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py new file mode 100644 index 0000000..e16570e --- /dev/null +++ b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py @@ -0,0 +1,266 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py +""" +Cross-backend materialized view contract (sync). + +This is an *expression/dialect* contract: the provider hands over a dialect +instance and every assertion runs purely on SQL rendering, so no live database +connection is involved. + +Two layers are covered: + +**Invariants (never skipped).** A dialect that advertises materialized view +support must actually implement the CREATE rendering. Historically backends +declared ``supports_materialized_view() -> True`` while inheriting the generic +``ViewMixin`` formatter, which produced SQL no target database accepts +(PostgreSQL lost ``WITH (storage_parameter)``; ClickHouse emitted ``WITH DATA``, +a clause it does not have). The probe is the public promise, so probe and +formatter are asserted together. + +CREATE is singled out because its syntax diverges on *every* backend +(PostgreSQL ``WITH (...)``/``TABLESPACE``/``WITH [NO] DATA``, Oracle +``BUILD IMMEDIATE``/``REFRESH``, Snowflake ``CLUSTER BY``/``COMMENT``, +ClickHouse ``ENGINE``/``TO``/``POPULATE``/``REFRESH``). A backend that genuinely +wants the generic form still satisfies the rule by overriding the formatter and +delegating to ``super()`` — one explicit method instead of a silent accident. +DROP is deliberately *not* constrained: ``DROP MATERIALIZED VIEW [IF EXISTS] +[CASCADE]`` is broadly valid, so inheriting it is legitimate. + +REFRESH is constrained only when the backend advertises refresh support, since +most databases have no ``REFRESH MATERIALIZED VIEW`` statement at all. + +**Capability-gated rendering.** Backends that do support materialized views must +render the expected statement shape; backends that do not are skipped +declaratively. + +**Scope rule.** This contract asserts only what *every* MV-capable database +shares. It was written three times against three backends and each round trip +removed an assumption that turned out not to be universal: + +1. ``DROP`` need not be backend-owned — ``DROP MATERIALIZED VIEW [IF EXISTS] + [CASCADE]`` is broadly valid, so inheriting the generic formatter is fine. +2. ``REFRESH`` is not a SQL statement everywhere — Oracle refreshes through a + ``DBMS_MVIEW.REFRESH`` PL/SQL block. +3. Column aliases and ``CASCADE`` are not universal either — BigQuery's + ``CREATE MATERIALIZED VIEW`` takes column names from the query and its + ``DROP`` has no ``CASCADE``. +4. The generic expression is not universal either — ClickHouse requires a ``TO`` + target table or an ``ENGINE``, fields the generic expression does not have, + so it legitimately refuses. Likewise its ``DROP`` is spelled ``DROP VIEW``. + +The rendering group therefore asserts only statement *kind* and object +reference, and skips a backend that requires its own expression. + +Everything else (aliases, CASCADE, TABLESPACE, storage parameters, refresh +schedules) is a per-backend feature, asserted in that backend's own suite. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import ( + DialectNotAdaptedException, + UnsupportedFeatureError, +) +from rhosocial.activerecord.backend.dialect.mixins import ViewMixin +from rhosocial.activerecord.backend.dialect.protocols import ViewSupport +from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + CreateMaterializedViewExpression, + DropMaterializedViewExpression, + RefreshMaterializedViewExpression, +) + + +#: Core ``ViewMixin`` implementations a backend must not resolve to once it +#: advertises the matching capability. +CORE_CREATE_FORMATTER = ViewMixin.format_create_materialized_view_statement +CORE_REFRESH_FORMATTER = ViewMixin.format_refresh_materialized_view_statement + + +def _probe(dialect, method_name, *, required: bool): + """Call a capability probe, skipping when the dialect cannot answer it. + + Version-aware probes raise ``DialectNotAdaptedException`` until the backend + has connected, and version-gated formatters legitimately cannot render + without a resolved version (Oracle gates ``CREATE MATERIALIZED VIEW`` on 9i + and ``IF NOT EXISTS`` on 23ai). Such dialects are skipped rather than + failed: the point of this contract is to catch *lying* probes, not to + require every backend to render DDL without a connection. + """ + probe = getattr(dialect, method_name, None) + if probe is None: + if required: + pytest.fail(f"dialect does not expose {method_name}()") + return False + try: + return bool(probe()) + except DialectNotAdaptedException: + pytest.skip(f"dialect is not adapted; {method_name}() needs a server version") + + +def _mv_supported(dialect): + """Whether the dialect advertises materialized views (skip if unknowable).""" + return _probe(dialect, "supports_materialized_view", required=True) + + +def _mv_refresh_supported(dialect): + """Whether the dialect advertises materialized view refresh.""" + return _probe(dialect, "supports_refresh_materialized_view", required=False) + + +def _render(expression): + """Render an expression, skipping dialects that need a resolved version. + + Version-gated formatters cannot produce SQL before the backend has + connected (Oracle gates ``CREATE MATERIALIZED VIEW`` on 9i and + ``IF NOT EXISTS`` on 23ai), so an unadapted dialect is skipped rather than + failed — the ownership invariants above still run for it. + """ + try: + return expression.to_sql() + except DialectNotAdaptedException: + pytest.skip("dialect is not adapted; version-gated DDL cannot be rendered") + + +def _source_query(dialect): + """A minimal SELECT the MV expressions can embed.""" + return QueryExpression( + dialect=dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "source_table"), + ) + + +def _resolved_formatter(dialect, name): + """Return the plain function object a dialect resolves ``name`` to.""" + bound = getattr(dialect, name, None) + if bound is None: + return None + return getattr(bound, "__func__", bound) + + +class TestMaterializedViewCapabilityInvariants: + """A declared capability must be backed by a real implementation.""" + + def test_declared_support_requires_own_create_formatter(self, ddl_dialect): + """``supports_materialized_view() is True`` implies a backend-owned CREATE. + + Guards the failure mode where a backend flips the probe to ``True`` but + leaves the generic ``ViewMixin`` implementation in place, so callers get + SQL the target database cannot execute. + """ + if not _mv_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view support") + + resolved = _resolved_formatter( + ddl_dialect, "format_create_materialized_view_statement" + ) + assert resolved is not None, ( + "dialect does not implement format_create_materialized_view_statement()" + ) + assert resolved is not CORE_CREATE_FORMATTER, ( + f"{type(ddl_dialect).__name__} advertises materialized view support but " + f"resolves format_create_materialized_view_statement() to the core " + f"ViewMixin implementation; the backend must override it (and be listed " + f"before ViewMixin in the dialect base list, otherwise the override is " + f"dead code). CREATE MATERIALIZED VIEW syntax diverges on every backend, " + f"so a backend that wants the generic form must say so explicitly by " + f"overriding the formatter and delegating to super()." + ) + + def test_declared_refresh_support_requires_own_formatter(self, ddl_dialect): + """The same invariant for the REFRESH path.""" + if not _mv_refresh_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view refresh") + + resolved = _resolved_formatter( + ddl_dialect, "format_refresh_materialized_view_statement" + ) + assert resolved is not None + assert resolved is not CORE_REFRESH_FORMATTER, ( + f"{type(ddl_dialect).__name__} advertises REFRESH MATERIALIZED VIEW but " + f"resolves the formatter to the core ViewMixin implementation" + ) + + +class TestMaterializedViewRendering: + """Rendering checks for backends whose MV DDL the generic expression covers.""" + + def _render_generic_create(self, dialect): + """Render the generic CREATE, skipping backends that need their own. + + The generic ``CreateMaterializedViewExpression`` cannot express every + backend's MV: ClickHouse requires a ``TO`` target table or an ``ENGINE`` + and has no such fields, so it legitimately refuses. Where the generic + path *is* supported, the result must be a real CREATE that binds no + parameters. + """ + expression = CreateMaterializedViewExpression( + dialect=dialect, view_name="mv_contract", query=_source_query(dialect) + ) + try: + return _render(expression) + except UnsupportedFeatureError: + pytest.skip( + "backend needs its own materialized view expression; rendering is " + "covered in that backend's own suite" + ) + + def test_create_renders_query(self, ddl_dialect): + if not _mv_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view support") + sql, params = self._render_generic_create(ddl_dialect) + assert sql.upper().startswith("CREATE MATERIALIZED VIEW"), ( + f"CREATE MATERIALIZED VIEW rendered as {sql!r}" + ) + assert "MV_CONTRACT" in sql.upper() + assert params == (), "materialized view DDL must not bind parameters" + + def test_drop_renders(self, ddl_dialect): + """``DROP`` is rendered either as ``DROP MATERIALIZED VIEW`` or ``DROP VIEW``. + + ClickHouse removes materialized views with ``DROP VIEW``, so only the + statement kind is asserted, not the exact keywords. + """ + if not _mv_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view support") + expression = DropMaterializedViewExpression( + dialect=ddl_dialect, view_name="mv_contract", if_exists=True + ) + sql, params = _render(expression) + assert sql.upper().startswith("DROP "), f"DROP rendered as {sql!r}" + assert "VIEW" in sql.upper() + assert "IF EXISTS" in sql + assert "MV_CONTRACT" in sql.upper() + assert params == () + + def test_refresh_renders_statement(self, ddl_dialect): + """A refreshing backend must render *something* that refreshes the view. + + The leading keyword is deliberately not asserted: not every database + exposes a ``REFRESH MATERIALIZED VIEW`` statement. Oracle refreshes via a + ``DBMS_MVIEW.REFRESH`` PL/SQL block and ClickHouse via + ``SYSTEM REFRESH VIEW``. The contract is that the request renders and + refers to the view; the concrete statement shape is asserted per backend. + """ + if not _mv_refresh_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view refresh") + expression = RefreshMaterializedViewExpression( + dialect=ddl_dialect, view_name="mv_contract" + ) + sql, params = _render(expression) + assert sql.strip(), "REFRESH rendered an empty statement" + # Backends that fold unquoted identifiers may emit the name in upper + # case (Oracle passes a string name to DBMS_MVIEW), so compare case + # insensitively. + assert "MV_CONTRACT" in sql.upper() + assert params == () + + + +class TestMaterializedViewProtocolDeclaration: + """The capability must be reachable through the declared protocol.""" + + def test_probe_is_declared_by_view_support(self, ddl_dialect): + if not _mv_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view support") + assert isinstance(ddl_dialect, ViewSupport) diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models.py b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models.py index 4ee567b..5326db0 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models.py @@ -313,8 +313,6 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class TypeAdapterTest(ActiveRecord): """Model for testing various type adapter scenarios.""" __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' - id: Optional[int] = None name: str @@ -332,7 +330,6 @@ class TypeAdapterTest(ActiveRecord): class AsyncTypeAdapterTest(AsyncActiveRecord): """Model for testing various type adapter scenarios.""" __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -354,7 +351,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """User model with custom column name mappings for testing in basic feature.""" __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[Optional[int], UseColumn("id")] = None @@ -373,7 +369,6 @@ class AsyncMappedUser(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): """User model with custom column name mappings for testing in basic feature.""" __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -394,7 +389,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[Optional[int], UseColumn("id")] = None @@ -418,7 +412,6 @@ class AsyncMappedPost(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -444,7 +437,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[Optional[int], UseColumn("id")] = None @@ -469,7 +461,6 @@ class AsyncMappedComment(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecor """Comment model with custom column name mappings for testing in basic feature.""" __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -514,7 +505,6 @@ class ColumnMappingModel(ActiveRecord): including mapping the primary key attribute. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[Optional[int], UseColumn("id")] = Field(default=None) @@ -528,7 +518,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): including mapping the primary key attribute. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -615,7 +604,6 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class MixedAnnotationModel(ActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # Corrected from "item_id" # 1. Standard Python type name: str @@ -639,7 +627,6 @@ class MixedAnnotationModel(ActiveRecord): class AsyncMixedAnnotationModel(AsyncActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # Corrected from "item_id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -737,7 +724,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Single-column auto-increment PK — backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -747,7 +733,6 @@ class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py310.py b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py310.py index 55ce26f..ba474f8 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py310.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py310.py @@ -338,7 +338,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -360,7 +359,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -383,7 +381,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" # Python field: user_id, Database column: id user_id: Annotated[int | None, UseColumn("id")] = None @@ -404,7 +401,6 @@ class AsyncMappedUser(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -426,7 +422,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" # Python field: post_id, Database column: id post_id: Annotated[int | None, UseColumn("id")] = None @@ -453,7 +448,6 @@ class AsyncMappedPost(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -481,7 +475,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" # Python field: comment_id, Database column: id comment_id: Annotated[int | None, UseColumn("id")] = None @@ -508,7 +501,6 @@ class AsyncMappedComment(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecor Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -551,7 +543,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -567,7 +558,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -647,7 +637,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" # 1. Standard Python type name: str @@ -674,7 +663,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -750,7 +738,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -760,7 +747,6 @@ class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py311.py b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py311.py index 0b39e48..cb5d0e2 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py311.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py311.py @@ -385,7 +385,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -404,7 +403,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -424,7 +422,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -443,7 +440,6 @@ class AsyncMappedUser(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -458,7 +454,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -474,7 +469,6 @@ class AsyncMappedPost(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -491,7 +485,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -507,7 +500,6 @@ class AsyncMappedComment(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecor Python 3.11+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -538,7 +530,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -552,7 +543,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -631,7 +621,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -656,7 +645,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -721,7 +709,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -731,7 +718,6 @@ class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py312.py b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py312.py index 303fad7..c5781e1 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py312.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py312.py @@ -372,7 +372,6 @@ class TypeAdapterTest(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' id: int | None = None name: str @@ -391,7 +390,6 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'type_adapter_tests' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -411,7 +409,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -430,7 +427,6 @@ class AsyncMappedUser(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "users" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -445,7 +441,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -461,7 +456,6 @@ class AsyncMappedPost(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "posts" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -478,7 +472,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -494,7 +487,6 @@ class AsyncMappedComment(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecor Python 3.12+ version using | syntax. """ __table_name__ = "comments" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -525,7 +517,6 @@ class ColumnMappingModel(ActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -539,7 +530,6 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = 'column_mapping_items' - __primary_key__ = 'id' c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -618,7 +608,6 @@ class MixedAnnotationModel(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" name: str item_id: Annotated[int, UseColumn("id")] @@ -640,7 +629,6 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ __table_name__ = "mixed_annotation_items" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -755,7 +743,6 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -765,7 +752,6 @@ class Order(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): """Async variant of Order.""" __table_name__ = "orders" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/conftest.py b/src/rhosocial/activerecord/testsuite/feature/ddl/conftest.py index 4872fa2..8c689be 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/conftest.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/conftest.py @@ -1,138 +1,55 @@ # src/rhosocial/activerecord/testsuite/feature/ddl/conftest.py -""" -Pytest fixtures for the ``feature.ddl`` test group. - -Fixtures provide configured model classes whose declarations are the single -source of truth; every test derives DDL via ``generate_create_table`` and the -provider executes it — verifying the model -> DDL -> database round trip on -each backend scenario. Sync and async variants are parameterized alike. -""" +"""Pytest fixtures for the backend-independent DDLSource contract group.""" + import pytest -from rhosocial.activerecord.testsuite.core.registry import get_provider_registry -PROVIDER_KEY_SYNC = "feature.ddl.IDDLSyncProvider" -PROVIDER_KEY_ASYNC = "feature.ddl.IDDLAsyncProvider" +from .fixtures.models import ( + AsyncBareItem, + AsyncCapabilityPost, + AsyncSpecComment, + AsyncSpecOrder, + BareItem, + CapabilityPost, + SpecComment, + SpecOrder, +) + + +@pytest.fixture +def bare_class(): + return BareItem + + +@pytest.fixture +def spec_order_class(): + return SpecOrder + + +@pytest.fixture +def capability_class(): + return CapabilityPost + + +@pytest.fixture +def spec_comment_class(): + return SpecComment + + +@pytest.fixture +def async_bare_class(): + return AsyncBareItem + +@pytest.fixture +def async_spec_order_class(): + return AsyncSpecOrder -def get_scenarios_sync(): - provider_class = get_provider_registry().get_provider(PROVIDER_KEY_SYNC) - if not provider_class: - return [] - return provider_class().get_test_scenarios() +@pytest.fixture +def async_capability_class(): + return AsyncCapabilityPost -def get_scenarios_async(): - provider_class = get_provider_registry().get_provider(PROVIDER_KEY_ASYNC) - if not provider_class: - return [] - return provider_class().get_test_scenarios() - - -scenarios_sync = get_scenarios_sync() -scenarios_async = get_scenarios_async() -SCENARIO_PARAMS_SYNC = scenarios_sync if scenarios_sync else [ - pytest.param("default", marks=pytest.mark.skip(reason="No sync ddl testsuite scenarios found")) -] -SCENARIO_PARAMS_ASYNC = scenarios_async if scenarios_async else [ - pytest.param("default", marks=pytest.mark.skip(reason="No async ddl testsuite scenarios found")) -] - - -def _new_provider(key): - provider_class = get_provider_registry().get_provider(key) - if not provider_class: - raise RuntimeError(f"Provider '{key}' not registered") - return provider_class() - - -# --- sync fixtures ------------------------------------------------------- -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_SYNC) -def bare_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_SYNC) - model = provider.setup_bare_item_model(scenario) - yield model - provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_SYNC) -def spec_order_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_SYNC) - model = provider.setup_spec_order_model(scenario) - yield model - provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_SYNC) -def capability_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_SYNC) - model = provider.setup_capability_post_model(scenario) - yield model - provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_SYNC) -def spec_comment_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_SYNC) - model = provider.setup_spec_comment_model(scenario) - yield model - provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_SYNC) -def spec_comment_with_parent(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_SYNC) - comment, order = provider.setup_spec_comment_with_parent_models(scenario) - yield comment, order - provider.cleanup_after_test(scenario) - - -# --- async fixtures ------------------------------------------------------ -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) -async def async_bare_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_ASYNC) - model = await provider.setup_bare_item_model(scenario) - yield model - await provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) -async def async_spec_order_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_ASYNC) - model = await provider.setup_spec_order_model(scenario) - yield model - await provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) -async def async_capability_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_ASYNC) - model = await provider.setup_capability_post_model(scenario) - yield model - await provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) -async def async_spec_comment_with_parent(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_ASYNC) - comment, order = await provider.setup_spec_comment_with_parent_models(scenario) - yield comment, order - await provider.cleanup_after_test(scenario) - - -@pytest.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) -async def async_spec_comment_class(request): - scenario = request.param - provider = _new_provider(PROVIDER_KEY_ASYNC) - model = await provider.setup_spec_comment_model(scenario) - yield model - await provider.cleanup_after_test(scenario) +@pytest.fixture +def async_spec_comment_class(): + return AsyncSpecComment diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py b/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py index 4a98f05..568ccaa 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py @@ -1,34 +1,5 @@ # src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py -""" -Fixture model definitions for the ``feature.ddl`` test group. - -The declared constants and field annotations are the single source of truth: -``ModelSchemaGenerator.generate_create_table(dialect)`` derives the DDL from -exactly these declarations. Providers execute the derived statements, so the -tests verify the "model -> DDL -> live database" round trip on every backend. - -Three declaration levels are covered: - -1. **Zero declaration** — ``BareItem``: no DDL Specs at all. Verifies the - default derivation rules (field name = column name, Python type via - ``suggest_column_type``, required -> NOT NULL, PK rules). -2. **Generic Specs** — ``SpecOrder`` (UUID PK) and ``SpecComment`` (explicit - integer PK): the portable, standard-semantics Specs. Primary keys come in - three shapes across the fixtures (auto integer / UUID / explicit integer) - to show that derivation follows the model's PK metadata - (``__primary_key__`` / ``__pk_auto_generated__``) rather than any mixin. -3. **Capability Specs** — ``CapabilityPost``: Specs whose acceptance is gated - by backend capability (partial index, JSON column, generated column). - Backends claim what they support and silently ignore the rest; the tests - assert that derivation and execution succeed either way. - -Sync and async (``Async``-prefixed) variants share identical declarations. - -Python-version notes: this module sticks to Python 3.8-compatible syntax -(``Optional[X]``, ``typing.Annotated`` import guard) so the base fixtures run -everywhere. Python 3.10+ syntax variants (``X | None``) live in -``models_py310.py`` and are selected via ``select_fixture``. -""" +"""DDL derivation fixture models.""" import sys @@ -36,44 +7,33 @@ from typing import Annotated else: from typing_extensions import Annotated -from typing import Optional + +from typing import ClassVar, Optional +from uuid import UUID from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraintType, + ForeignKeyConstraint, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + ReferentialAction, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import JsonType, VarCharType from rhosocial.activerecord.base import ( - UseSqlType, UseConstraint, - UseIndex, - ColumnConstraintType, - CheckSpec, - UniqueSpec, - NotNullSpec, - PrimaryKeySpec, - DefaultSpec, - ForeignKeySpec, - IndexSpec, - PartialIndexSpec, - JsonColumnSpec, - GeneratedColumnSpec, + UseGeneratedColumn, + UseSqlType, ) -from typing import ClassVar - -from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.base.field_proxy import FieldProxy from rhosocial.activerecord.field import UUIDMixin from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord -# --------------------------------------------------------------------------- -# 1. Zero declaration (auto integer PK via the default PK metadata) -# --------------------------------------------------------------------------- class BareItem(ActiveRecord): - """No DDL declarations at all — default derivation rules apply. - - Uses the default PK metadata (``__primary_key__ = "id"`` with - ``__pk_auto_generated__ = True``), so the derived DDL carries an - auto-incrementing integer primary key. - """ - __table_name__ = "ddl_bare_items" c: ClassVar[FieldProxy] = FieldProxy() @@ -85,8 +45,6 @@ class BareItem(ActiveRecord): class AsyncBareItem(AsyncActiveRecord): - """Async twin of :class:`BareItem` (same declarations).""" - __table_name__ = "ddl_bare_items" c: ClassVar[FieldProxy] = FieldProxy() @@ -97,188 +55,179 @@ class AsyncBareItem(AsyncActiveRecord): note: Optional[str] = None -# --------------------------------------------------------------------------- -# 2. Generic Specs — UUID PK -# --------------------------------------------------------------------------- class SpecOrder(UUIDMixin, ActiveRecord): - """Generic DDL Specs on a UUID primary key. - - The PK is application-generated (``UUIDMixin``); the derived DDL renders a - non-auto-increment PK column of the UUID-representing type. - """ - __table_name__ = "ddl_spec_orders" c: ClassVar[FieldProxy] = FieldProxy() - # Field-level annotations code: Annotated[str, UseSqlType(VarCharType(length=32))] - status: Annotated[str, UseConstraint( - ColumnConstraintType.CHECK, - check_condition=lambda d: Column(d, "status").in_(["open", "paid", "shipped"]), - name="ck_spec_orders_status", - )] + status: Annotated[ + str, + UseConstraint( + ColumnConstraintType.CHECK, + check_condition=lambda dialect: Column(dialect, "status").in_( + ["open", "paid", "shipped"] + ), + name="ck_spec_orders_status", + ), + ] quantity: Annotated[int, UseConstraint(ColumnConstraintType.NOT_NULL)] = 1 + total: float = 0.0 - # Table-level generic Specs __table_constraints__ = [ - UniqueSpec(columns=["code", "status"], name="uq_ddl_spec_orders_code_status"), - DefaultSpec(column="quantity", value=1), - IndexSpec(columns=["code"], name="ix_ddl_spec_orders_code"), + TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_ddl_spec_orders_code_status", + columns=["code", "status"], + ) + ] + __table_indexes__ = [ + IndexDefinition(None, name="ix_ddl_spec_orders_code", columns=["code"]) ] - - total: float = 0.0 class AsyncSpecOrder(UUIDMixin, AsyncActiveRecord): - """Async twin of :class:`SpecOrder` (same declarations).""" - __table_name__ = "ddl_spec_orders" c: ClassVar[FieldProxy] = FieldProxy() code: Annotated[str, UseSqlType(VarCharType(length=32))] - status: Annotated[str, UseConstraint( - ColumnConstraintType.CHECK, - check_condition=lambda d: Column(d, "status").in_(["open", "paid", "shipped"]), - name="ck_spec_orders_status", - )] + status: Annotated[ + str, + UseConstraint( + ColumnConstraintType.CHECK, + check_condition=lambda dialect: Column(dialect, "status").in_( + ["open", "paid", "shipped"] + ), + name="ck_spec_orders_status", + ), + ] quantity: Annotated[int, UseConstraint(ColumnConstraintType.NOT_NULL)] = 1 + total: float = 0.0 __table_constraints__ = [ - UniqueSpec(columns=["code", "status"], name="uq_ddl_spec_orders_code_status"), - DefaultSpec(column="quantity", value=1), - IndexSpec(columns=["code"], name="ix_ddl_spec_orders_code"), + TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_ddl_spec_orders_code_status", + columns=["code", "status"], + ) + ] + __table_indexes__ = [ + IndexDefinition(None, name="ix_ddl_spec_orders_code", columns=["code"]) ] - - total: float = 0.0 -# --------------------------------------------------------------------------- -# 3. Capability Specs -# --------------------------------------------------------------------------- class CapabilityPost(ActiveRecord): - """Capability-gated Specs: each backend claims what it supports. - - - ``PartialIndexSpec``: gated by ``supports_partial_index`` - - ``JsonColumnSpec``: portable ``JsonType`` (native JSON or TEXT) - - ``GeneratedColumnSpec``: gated by ``supports_generated_columns`` - """ - __table_name__ = "ddl_capability_posts" c: ClassVar[FieldProxy] = FieldProxy() - __table_constraints__ = [ - JsonColumnSpec("meta"), - GeneratedColumnSpec( - "double_views", - expression=lambda d: Column(d, "views") * 2, - stored=True, - ), - ] - __table_indexes__ = [ - PartialIndexSpec( - columns=["title"], - condition=lambda d: Column(d, "is_published") == 1, - name="ix_ddl_capability_posts_published_title", - ), - ] - id: Optional[int] = None title: str views: int = 0 is_published: int = 0 - meta: object = None - double_views: int = 0 + meta: Annotated[Optional[dict], UseSqlType(JsonType())] = None + double_views: Annotated[ + int, + UseGeneratedColumn( + lambda dialect: GeneratedColumnExpression( + dialect, + expression=Column(dialect, "views") * 2, + storage_type=GeneratedColumnType.STORED, + ) + ), + ] = 0 + __table_indexes__ = [ + IndexDefinition( + None, + name="ix_ddl_capability_posts_title", + columns=["title"], + ) + ] -class AsyncCapabilityPost(AsyncActiveRecord): - """Async twin of :class:`CapabilityPost` (same declarations).""" +class AsyncCapabilityPost(AsyncActiveRecord): __table_name__ = "ddl_capability_posts" c: ClassVar[FieldProxy] = FieldProxy() - __table_constraints__ = [ - JsonColumnSpec("meta"), - GeneratedColumnSpec( - "double_views", - expression=lambda d: Column(d, "views") * 2, - stored=True, - ), - ] - __table_indexes__ = [ - PartialIndexSpec( - columns=["title"], - condition=lambda d: Column(d, "is_published") == 1, - name="ix_ddl_capability_posts_published_title", - ), - ] - id: Optional[int] = None title: str views: int = 0 is_published: int = 0 - meta: object = None - double_views: int = 0 + meta: Annotated[Optional[dict], UseSqlType(JsonType())] = None + double_views: Annotated[ + int, + UseGeneratedColumn( + lambda dialect: GeneratedColumnExpression( + dialect, + expression=Column(dialect, "views") * 2, + storage_type=GeneratedColumnType.STORED, + ) + ), + ] = 0 + + __table_indexes__ = [ + IndexDefinition( + None, + name="ix_ddl_capability_posts_title", + columns=["title"], + ) + ] -# --------------------------------------------------------------------------- -# 4. Generic Specs — explicit integer PK (application-assigned) -# --------------------------------------------------------------------------- class SpecComment(ActiveRecord): - """``PrimaryKeySpec`` / ``ForeignKeySpec`` / ``NotNullSpec`` on an - explicitly-assigned integer PK.""" - __table_name__ = "ddl_spec_comments" c: ClassVar[FieldProxy] = FieldProxy() __primary_key__ = "comment_id" __pk_auto_generated__ = False + comment_id: int + order_id: UUID + body: str + __table_constraints__ = [ - PrimaryKeySpec(columns=["comment_id"]), - ForeignKeySpec( - local_columns=["order_id"], - ref_table="ddl_spec_orders", - ref_columns=["id"], - on_delete="CASCADE", + ForeignKeyConstraint( + None, + columns=["order_id"], + foreign_key_table="ddl_spec_orders", + foreign_key_columns=["id"], + on_delete=ReferentialAction.CASCADE, name="fk_ddl_spec_comments_order", - ), - NotNullSpec(column="body"), + ) ] - comment_id: int - order_id: "object" # UUID of the referenced SpecOrder (UUID-typed PK) - body: str - class AsyncSpecComment(AsyncActiveRecord): - """Async twin of :class:`SpecComment`.""" - __table_name__ = "ddl_spec_comments" c: ClassVar[FieldProxy] = FieldProxy() __primary_key__ = "comment_id" __pk_auto_generated__ = False + comment_id: int + order_id: UUID + body: str + __table_constraints__ = [ - PrimaryKeySpec(columns=["comment_id"]), - ForeignKeySpec( - local_columns=["order_id"], - ref_table="ddl_spec_orders", - ref_columns=["id"], - on_delete="CASCADE", + ForeignKeyConstraint( + None, + columns=["order_id"], + foreign_key_table="ddl_spec_orders", + foreign_key_columns=["id"], + on_delete=ReferentialAction.CASCADE, name="fk_ddl_spec_comments_order", - ), - NotNullSpec(column="body"), + ) ] - comment_id: int - order_id: "object" # UUID of the referenced SpecOrder (UUID-typed PK) - body: str - __all__ = [ - "BareItem", "AsyncBareItem", - "SpecOrder", "AsyncSpecOrder", - "CapabilityPost", "AsyncCapabilityPost", - "SpecComment", "AsyncSpecComment", + "BareItem", + "AsyncBareItem", + "SpecOrder", + "AsyncSpecOrder", + "CapabilityPost", + "AsyncCapabilityPost", + "SpecComment", + "AsyncSpecComment", ] diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/interfaces.py b/src/rhosocial/activerecord/testsuite/feature/ddl/interfaces.py deleted file mode 100644 index ed365c8..0000000 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/interfaces.py +++ /dev/null @@ -1,94 +0,0 @@ -# src/rhosocial/activerecord/testsuite/feature/ddl/interfaces.py -""" -Provider interface for the ``feature.ddl`` test group. - -Providers hand over configured model classes whose DDL declarations are the -single source of truth; the tests derive DDL via -``Model.generate_create_table(dialect)``, execute it through the backend, and -verify the resulting structure round-trips. -""" -from abc import ABC, abstractmethod -from typing import List, Type - -from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord - -from .fixtures.models import ( - AsyncBareItem, - AsyncCapabilityPost, - AsyncSpecComment, - AsyncSpecOrder, - BareItem, - CapabilityPost, - SpecComment, - SpecOrder, -) - - -class DDLProviderBase(ABC): - """Shared, non-I/O surface for ddl-group providers.""" - - @abstractmethod - def get_test_scenarios(self) -> List[str]: - """Scenario names (database configurations) this backend supports.""" - pass - - -class IDDLSyncProvider(DDLProviderBase): - """Sync interface: setup returns configured model classes.""" - - @abstractmethod - def setup_bare_item_model(self, scenario_name: str) -> Type[ActiveRecord]: - pass - - @abstractmethod - def setup_spec_order_model(self, scenario_name: str) -> Type[ActiveRecord]: - pass - - @abstractmethod - def setup_capability_post_model(self, scenario_name: str) -> Type[ActiveRecord]: - pass - - @abstractmethod - def setup_spec_comment_model(self, scenario_name: str) -> Type[ActiveRecord]: - pass - - @abstractmethod - def setup_spec_comment_with_parent_models( - self, scenario_name: str - ) -> "tuple[Type[ActiveRecord], Type[ActiveRecord]]": - """Set up SpecComment and its parent SpecOrder on the same - connection, so the FK target exists where inserts happen.""" - pass - - -class IDDLAsyncProvider(DDLProviderBase): - """Async interface: setup returns configured async model classes.""" - - @abstractmethod - async def setup_bare_item_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - pass - - @abstractmethod - async def setup_spec_order_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - pass - - @abstractmethod - async def setup_capability_post_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - pass - - @abstractmethod - async def setup_spec_comment_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - pass - - @abstractmethod - async def setup_spec_comment_with_parent_models( - self, scenario_name: str - ) -> "tuple[Type[AsyncActiveRecord], Type[AsyncActiveRecord]]": - pass - - -__all__ = [ - "DDLProviderBase", "IDDLSyncProvider", "IDDLAsyncProvider", - "BareItem", "AsyncBareItem", "SpecOrder", "AsyncSpecOrder", - "CapabilityPost", "AsyncCapabilityPost", "SpecComment", "AsyncSpecComment", -] diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/test_ddl_source_contract.py b/src/rhosocial/activerecord/testsuite/feature/ddl/test_ddl_source_contract.py new file mode 100644 index 0000000..b85c325 --- /dev/null +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/test_ddl_source_contract.py @@ -0,0 +1,501 @@ +# src/rhosocial/activerecord/testsuite/feature/ddl/test_ddl_source_contract.py + +from typing import ClassVar, Optional + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + CreateIndexExpression, + CreateTableExpression, + CreateTableOptions, + DropIndexExpression, + DropTableExpression, + IndexDefinition, + PartitionStrategy, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import PartitionClause +from rhosocial.activerecord.backend.expression.types import JsonType, TextType +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + ColumnOptions, + DDLAnnotation, + DDLAnnotationHandler, + DDLSource, + DerivedField, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +class BackendOptionAnnotation(DDLAnnotation): + def __init__(self, option): + self.option = option + + +class BackendOptionHandler(DDLAnnotationHandler): + annotation_types = (BackendOptionAnnotation,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(annotation.option) + + +class BackendOption(ColumnOptions): + pass + + +BACKEND_OPTION = BackendOption() +SQL_TYPE = UseSqlType(JsonType(), JsonType(), TextType()) +PAYLOAD_TYPE = UseSqlType(JsonType(), TextType()) +DEFAULT_CONSTRAINT = UseConstraint(ColumnConstraintType.DEFAULT, default_value=7) +UNIQUE_CONSTRAINT = UseConstraint(ColumnConstraintType.UNIQUE, name="uq_payload") +FIRST_INDEX = UseIndex( + "idx_payload", + unique=True, + type="BTREE", + include_columns=["record_id"], + if_not_exists=True, + tablespace="index_space", + if_exists=True, + concurrent=True, +) +SECOND_INDEX = UseIndex("idx_payload_secondary", type="BTREE") +FIRST_COMMENT = UseComment("first comment") +SECOND_COMMENT = UseComment("second comment") + + +def first_generated(dialect): + return object() + + +def second_generated(dialect): + return object() + + +FIRST_GENERATED = first_generated +SECOND_GENERATED = second_generated +FIRST_GENERATED_MARKER = UseGeneratedColumn(FIRST_GENERATED) +SECOND_GENERATED_MARKER = UseGeneratedColumn(SECOND_GENERATED) +FIRST_ATTRIBUTES = UseColumnAttributes( + CollationAttribute(name="und:ci"), + CollationAttribute(name="und:ci"), +) +SECOND_ATTRIBUTES = UseColumnAttributes(CharacterSetAttribute(name="UTF8")) +TABLE_CONSTRAINT = TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_record_status", + columns=["record_id", "status"], +) +TABLE_INDEX = IndexDefinition( + None, + name="idx_table_record", + columns=["record_id"], + unique=True, +) +TABLE_OPTIONS = CreateTableOptions(None, or_replace=True) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"fillfactor": 80}) +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [Column(None, "record_id")], +) +TABLE_INHERITS = ["parent_records"] +TABLE_TABLESPACE = "record_space" + + +class ContractDeclarations: + __table_name__ = "ddl_contract_records" + __schema_name__ = "app" + __primary_key__ = "record_id" + __table_constraints__ = [TABLE_CONSTRAINT] + __table_indexes__ = [TABLE_INDEX] + _feature_handlers = [DDLAnnotationHandler, BackendOptionHandler] + + record_id: Annotated[int, UseColumn("record_id")] + status: Annotated[ + str, + SQL_TYPE, + DEFAULT_CONSTRAINT, + UNIQUE_CONSTRAINT, + FIRST_INDEX, + SECOND_INDEX, + FIRST_ATTRIBUTES, + SECOND_ATTRIBUTES, + FIRST_COMMENT, + SECOND_COMMENT, + FIRST_GENERATED_MARKER, + SECOND_GENERATED_MARKER, + BackendOptionAnnotation(BACKEND_OPTION), + ] + payload: Annotated[Optional[dict], PAYLOAD_TYPE] = None + note: Optional[str] = None + + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return TABLE_INHERITS + + @classmethod + def table_tablespace(cls): + return TABLE_TABLESPACE + + @classmethod + def create_table_statement_classes(cls): + return CreateTableExpression + + @classmethod + def drop_table_statement_classes(cls): + return DropTableExpression + + @classmethod + def create_index_statement_classes(cls): + return CreateIndexExpression + + @classmethod + def drop_index_statement_classes(cls): + return DropIndexExpression + + +class SyncContractModel(ContractDeclarations, ActiveRecord): + pass + + +class AsyncContractModel(ContractDeclarations, AsyncActiveRecord): + pass + + +class DerivedDeclarations: + __table_name__ = "ddl_contract_derived" + + id: int + value: int + total: ClassVar[Annotated[int, DerivedField(lambda dialect: object())]] + + +class SyncDerivedModel(DerivedDeclarations, ActiveRecord): + pass + + +class AsyncDerivedModel(DerivedDeclarations, AsyncActiveRecord): + pass + + +COMPOSITE_PRIMARY_KEY = TableConstraint( + None, + TableConstraintType.PRIMARY_KEY, + columns=["left_id", "right_id"], +) + + +class CompositeDeclarations: + __table_name__ = "ddl_contract_composite" + __primary_key__ = ("left_id", "right_id") + __table_constraints__ = [COMPOSITE_PRIMARY_KEY] + + left_id: Annotated[int, UseColumn("left_id")] + right_id: Annotated[int, UseColumn("right_id")] + + +class SyncCompositeModel(CompositeDeclarations, ActiveRecord): + pass + + +class AsyncCompositeModel(CompositeDeclarations, AsyncActiveRecord): + pass + + +MISMATCHED_PRIMARY_KEY = TableConstraint( + None, + TableConstraintType.PRIMARY_KEY, + columns=["right_id", "left_id"], +) + + +class MismatchedCompositeDeclarations: + __table_name__ = "ddl_contract_mismatched_composite" + __primary_key__ = ("left_id", "right_id") + __table_constraints__ = [MISMATCHED_PRIMARY_KEY] + + left_id: int + right_id: int + + +class MismatchedCompositeModel(MismatchedCompositeDeclarations, ActiveRecord): + pass + + +class NullablePrimaryKeyDeclarations: + __table_name__ = "ddl_contract_nullable_primary_key" + __primary_key__ = "id" + + id: Annotated[Optional[int], UseConstraint(ColumnConstraintType.NULL)] = None + + +class NullablePrimaryKeyModel(NullablePrimaryKeyDeclarations, ActiveRecord): + pass + + +class NotNullPrimaryKeyDeclarations: + __table_name__ = "ddl_contract_not_null_primary_key" + __primary_key__ = "id" + + id: Annotated[int, UseConstraint(ColumnConstraintType.NOT_NULL)] + + +class NotNullPrimaryKeyModel(NotNullPrimaryKeyDeclarations, ActiveRecord): + pass + + +class PlainModel(ActiveRecord): + __table_name__ = "ddl_contract_plain" + + id: int + note: Optional[str] = None + + +class AsyncPlainModel(AsyncActiveRecord): + __table_name__ = "ddl_contract_plain_async" + + id: int + note: Optional[str] = None + + +class UnhandledAnnotation(DDLAnnotation): + pass + + +class UnhandledModel(ActiveRecord): + __table_name__ = "ddl_contract_unhandled" + + id: int + value: Annotated[str, UnhandledAnnotation()] + + +@pytest.mark.parametrize("model", [SyncContractModel, AsyncContractModel], ids=("sync", "async")) +def test_contract_source_collects_table_and_field_declarations(model): + assert isinstance(model, DDLSource) + assert model.table_name() == "ddl_contract_records" + assert model.schema_name() == "app" + assert model.primary_key_columns() == ("record_id",) + assert model.is_composite_pk() is False + assert model.ddl_field_names() == ("record_id", "status", "payload", "note") + assert model.is_derived_field("status") is False + assert model.field_python_type("payload") is dict + assert model.field_is_optional("payload") is True + assert model.field_python_type("status") is str + assert model.field_is_optional("status") is False + assert model.column_name("record_id") == "record_id" + assert model.column_name("status") == "status" + assert model.column_name("payload") == "payload" + assert model.column_name("note") == "note" + metadata = model.ddl_field_metadata("status") + assert metadata.annotations[0] is SQL_TYPE + assert metadata.annotations[1] is DEFAULT_CONSTRAINT + assert metadata.annotations[2] is UNIQUE_CONSTRAINT + assert metadata.annotations[3] is FIRST_INDEX + assert metadata.annotations[4] is SECOND_INDEX + assert metadata.annotations[5] is FIRST_ATTRIBUTES + assert metadata.annotations[6] is SECOND_ATTRIBUTES + assert metadata.annotations[7] is FIRST_COMMENT + assert metadata.annotations[8] is SECOND_COMMENT + assert metadata.annotations[9] is FIRST_GENERATED_MARKER + assert metadata.annotations[10] is SECOND_GENERATED_MARKER + assert isinstance(metadata.annotations[11], BackendOptionAnnotation) + assert metadata.annotations[11].option is BACKEND_OPTION + + +def test_contract_field_collection_preserves_order_and_first_singular_marker(): + model = SyncContractModel + sql_type = model.column_type("status") + assert sql_type is SQL_TYPE + assert len(sql_type.data_types) == 2 + assert isinstance(sql_type.data_types[0], JsonType) + assert isinstance(sql_type.data_types[1], TextType) + constraints = model.column_constraints("status") + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.DEFAULT, + ColumnConstraintType.UNIQUE, + ColumnConstraintType.NOT_NULL, + ] + assert constraints[0] is DEFAULT_CONSTRAINT.constraint + assert constraints[1] is UNIQUE_CONSTRAINT.constraint + assert model.column_attributes("status") == [ + FIRST_ATTRIBUTES.attributes[0], + SECOND_ATTRIBUTES.attributes[0], + ] + assert model.column_comment("status") == FIRST_COMMENT.comment + assert model.generated_column("status") is FIRST_GENERATED + indexes = model.column_indexes("status") + assert [index.name for index in indexes] == [FIRST_INDEX.name, SECOND_INDEX.name] + assert all(index.columns == ["status"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].include_columns is FIRST_INDEX.include_columns + assert indexes[0].if_exists is True + assert indexes[0].concurrent is True + assert model.column_options("status") is BACKEND_OPTION + assert model.column_type("payload") is PAYLOAD_TYPE + assert len(model.column_type("payload").data_types) == 2 + assert isinstance(model.column_type("payload").data_types[0], JsonType) + assert isinstance(model.column_type("payload").data_types[1], TextType) + assert model.column_type("note") is None + + +def test_contract_table_collection_preserves_overrides_and_copies_sequences(): + model = SyncContractModel + assert model.table_options() is TABLE_OPTIONS + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + assert model.table_inherits() is TABLE_INHERITS + assert model.table_tablespace() == TABLE_TABLESPACE + assert model.create_table_statement_classes() is CreateTableExpression + assert model.drop_table_statement_classes() is DropTableExpression + assert model.create_index_statement_classes() is CreateIndexExpression + assert model.drop_index_statement_classes() is DropIndexExpression + indexes = model.table_indexes() + assert indexes is not model.__table_indexes__ + assert indexes[0] is TABLE_INDEX + constraints = model.table_constraints() + assert constraints[0] is TABLE_CONSTRAINT + assert len(constraints) == 1 + + +def test_contract_batch_interfaces_cover_all_model_fields(): + model = SyncContractModel + assert list(model.columns_name()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_type()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_constraints()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_attributes()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_indexes()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_comment()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_generated()) == ["record_id", "status", "payload", "note"] + assert list(model.columns_options()) == ["record_id", "status", "payload", "note"] + fields = ["status", "record_id", "note"] + for method_name in ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", + ): + assert list(getattr(model, method_name)(fields)) == fields + assert model.columns_name(fields) == { + "status": "status", + "record_id": "record_id", + "note": "note", + } + assert model.columns_options(fields)["status"] is BACKEND_OPTION + + +@pytest.mark.parametrize("model", [SyncDerivedModel, AsyncDerivedModel], ids=("sync", "async")) +def test_contract_ddl_field_names_excludes_derived_fields(model): + assert model.ddl_field_names() == ("id", "value") + assert model.is_derived_field("total") is True + assert model.is_derived_field("value") is False + assert model.columns_name() == {"id": "id", "value": "value"} + + +@pytest.mark.parametrize( + "model", [SyncCompositeModel, AsyncCompositeModel], ids=("sync", "async") +) +def test_contract_composite_primary_key_does_not_duplicate_explicit_constraint(model): + constraints = model.table_constraints() + primary_keys = [ + constraint + for constraint in constraints + if constraint.constraint_type == TableConstraintType.PRIMARY_KEY + ] + assert len(primary_keys) == 1 + assert primary_keys[0].columns == ["left_id", "right_id"] + + +def test_contract_mismatched_composite_primary_key_fails_explicitly(): + with pytest.raises(ValueError, match="does not match __primary_key__"): + MismatchedCompositeModel.table_constraints() + + +def test_contract_primary_key_nullability_overrides_follow_source_rules(): + nullable_types = [ + constraint.constraint_type + for constraint in NullablePrimaryKeyModel.column_constraints("id") + ] + not_null_types = [ + constraint.constraint_type + for constraint in NotNullPrimaryKeyModel.column_constraints("id") + ] + assert nullable_types == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert not_null_types == [ + ColumnConstraintType.NOT_NULL, + ColumnConstraintType.PRIMARY_KEY, + ] + + +@pytest.mark.parametrize("model", [PlainModel, AsyncPlainModel], ids=("sync", "async")) +def test_contract_defaults_are_empty_or_none(model): + assert model.ddl_field_names() == ("id", "note") + assert model.column_type("id") is None + assert model.column_constraints("note") == [] + assert model.column_attributes("note") == [] + assert model.column_indexes("note") == [] + assert model.column_comment("note") is None + assert model.generated_column("note") is None + assert model.column_options("note") is 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.create_table_statement_classes() is None + assert model.drop_table_statement_classes() is None + assert model.create_index_statement_classes() is None + assert model.drop_index_statement_classes() is None + assert model.table_indexes() == [] + assert model.table_constraints() == [] + + +def test_contract_unknown_fields_and_unhandled_annotations_fail_explicitly(): + assert SyncContractModel.column_type("missing") is None + assert SyncContractModel.column_constraints("missing") == [] + assert SyncContractModel.column_attributes("missing") == [] + assert SyncContractModel.column_indexes("missing") == [] + assert SyncContractModel.column_comment("missing") is None + assert SyncContractModel.generated_column("missing") is None + assert SyncContractModel.column_options("missing") is None + with pytest.raises(KeyError): + SyncContractModel.field_python_type("missing") + with pytest.raises(TypeError, match="explicit handler"): + UnhandledModel.ddl_field_names() diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py index aee112d..1ed2e68 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py @@ -1,162 +1,78 @@ # src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py -""" -feature.ddl: derivation round-trip (sync). +"""Backend-independent DDLSource declaration tests.""" -Every test derives DDL from the fixture model declarations via -``generate_create_table``, executes it through the backend under test, and -verifies the live structure round-trips (insert / query / defaults). The -declared constants are the single source of truth — no hand-written schema. -""" -import pytest +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraintType, + ForeignKeyConstraint, + TableConstraintType, +) +from rhosocial.activerecord.base import DDLSource, UseSqlType -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - -def _create_model_table_on(backend, model): - """Derive *model*'s CREATE TABLE and execute it on a given backend.""" - expr = model.generate_create_table(backend.dialect) - backend.execute(*expr.to_sql()) - return expr - - -def _create_model_table(model): - """Derive DDL from the model and execute it (table only). - - Idempotent: drops the leftover table first so a test may derive and - execute repeatedly on the same scenario connection. - """ - from rhosocial.activerecord.backend.expression import ( - DropTableExpression, TableExpression, - ) - - drop = DropTableExpression( - dialect=model.__backend__.dialect, - table=TableExpression(model.__backend__.dialect, model.__table_name__), - if_exists=True, - ) - model.__backend__.execute(*drop.to_sql()) - - expr = model.generate_create_table() - model.__backend__.execute(*expr.to_sql()) - return expr - - -def _create_model_indexes(model, expr): - """Derive and execute each index product.""" - for ix in expr.indexes: - index_expr = ix.to_create_index_expression( - model.__backend__.dialect, expr.table - ) - model.__backend__.execute(*index_expr.to_sql()) +def _constraint_types(model, field): + return {constraint.constraint_type for constraint in model.column_constraints(field)} class TestZeroDeclarationDerivation: - """BareItem: no Specs at all — default derivation rules.""" - - def test_derive_and_execute(self, bare_class): - expr = _create_model_table(bare_class) - sql, _ = expr.to_sql() - # Default rules visible in the derived DDL - assert "CREATE TABLE" in sql - assert "PRIMARY KEY" in sql - - def test_round_trip(self, bare_class): - _create_model_table(bare_class) - item = bare_class(name="a", quantity=2, price=1.5) - item.save() - - found = bare_class.find_one({'id': item.id}) - assert found is not None - assert found.name == "a" - assert found.quantity == 2 - - found.quantity = 5 - found.save() - again = bare_class.find_one({'id': item.id}) - assert again.quantity == 5 - - def test_nullable_column_stays_nullable(self, bare_class): - """Optional fields (with defaults) derive as nullable: omitting note - must not violate the derived schema.""" - _create_model_table(bare_class) - item = bare_class(name="n", quantity=1, price=1.0) - item.save() - assert bare_class.find_one({'id': item.id}).note is None - - -class TestGenericSpecDerivation: - """SpecOrder: generic Specs (UUID PK, CHECK, UNIQUE, DEFAULT, INDEX).""" - - def test_derive_and_execute(self, spec_order_class): - expr = _create_model_table(spec_order_class) - sql, _ = expr.to_sql() - assert "UNIQUE" in sql - assert "CHECK" in sql - - def test_unique_constraint_enforced(self, spec_order_class): - _create_model_table(spec_order_class) - first = spec_order_class(code="A1", status="open") - first.save() - dup = spec_order_class(code="A1", status="open") - with pytest.raises(Exception): - dup.save() - - def test_check_constraint_enforced(self, spec_order_class): - _create_model_table(spec_order_class) - bad = spec_order_class(code="A2", status="bogus") - with pytest.raises(Exception): - bad.save() - - def test_round_trip(self, spec_order_class): - _create_model_table(spec_order_class) - order = spec_order_class(code="B1", status="paid", quantity=3, total=9.9) - order.save() - found = spec_order_class.find_one({'id': order.id}) - assert found is not None - assert found.code == "B1" - assert found.status == "paid" - - -class TestCapabilitySpecDerivation: - """CapabilityPost: capability-gated Specs claim-or-ignore per backend.""" - - def test_derive_and_execute(self, capability_class): - expr = _create_model_table(capability_class) - # Whatever the backend claims (partial index, JSON type, generated - # column), derivation and execution succeed. - assert "CREATE TABLE" in sql_text(expr) - - def test_round_trip(self, capability_class): - _create_model_table(capability_class) - post = capability_class(title="hello", views=2) - post.save() - found = capability_class.find_one({"id": post.id}) - assert found is not None - assert found.title == "hello" + def test_source_declaration(self, bare_class): + assert isinstance(bare_class, DDLSource) + assert bare_class.table_name() == "ddl_bare_items" + assert bare_class.primary_key_columns() == ("id",) + assert bare_class.ddl_field_names() == ( + "id", + "name", + "quantity", + "price", + "note", + ) + def test_primary_key_and_nullability_declarations(self, bare_class): + assert ColumnConstraintType.PRIMARY_KEY in _constraint_types( + bare_class, "id" + ) + assert ColumnConstraintType.NOT_NULL in _constraint_types(bare_class, "name") + assert ColumnConstraintType.NOT_NULL not in _constraint_types( + bare_class, "note" + ) -class TestPKAndFKSpecDerivation: - """SpecComment: explicit integer PK + ForeignKeySpec + NotNullSpec.""" - def test_derive_and_execute(self, spec_comment_class): - expr = _create_model_table(spec_comment_class) - sql, _ = expr.to_sql() - assert "FOREIGN KEY" in sql - assert "REFERENCES" in sql +class TestGenericDeclarationDerivation: + def test_type_and_constraint_declarations(self, spec_order_class): + code_type = spec_order_class.column_type("code") + assert isinstance(code_type, UseSqlType) + assert code_type.data_type.name == "varchar" + assert code_type.data_type.length == 32 + assert ColumnConstraintType.CHECK in _constraint_types( + spec_order_class, "status" + ) - def test_round_trip_with_parent(self, spec_comment_with_parent): - comment_class, order_class = spec_comment_with_parent - _create_model_table(comment_class) # order table already created + unique = next( + constraint + for constraint in spec_order_class.table_constraints() + if constraint.constraint_type == TableConstraintType.UNIQUE + ) + assert unique.columns == ["code", "status"] - order = order_class(code="C1", status="open") - order.save() - comment = comment_class(comment_id=1, order_id=order.id, body="ok") - comment.save() - found = comment_class.find_one({"comment_id": 1}) - assert found is not None - assert found.body == "ok" + def test_table_index_declarations(self, spec_order_class): + index = spec_order_class.table_indexes()[0] + assert index.name == "ix_ddl_spec_orders_code" + assert index.columns == ["code"] -def sql_text(expr): - sql, _ = expr.to_sql() - return sql +class TestCapabilityDeclarationDerivation: + def test_capability_declarations_remain_available(self, capability_class): + assert capability_class.table_indexes()[0].name == ( + "ix_ddl_capability_posts_title" + ) + assert capability_class.generated_column("double_views") is not None + assert capability_class.column_indexes("title") == [] + + +class TestForeignKeyDeclarationDerivation: + def test_foreign_key_declaration(self, spec_comment_class): + constraint = spec_comment_class.table_constraints()[0] + assert isinstance(constraint, ForeignKeyConstraint) + assert constraint.columns == ["order_id"] + assert constraint.foreign_key_table == "ddl_spec_orders" + assert constraint.foreign_key_columns == ["id"] + assert constraint.on_delete.value == "CASCADE" diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py index acbe74e..dcf67c4 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py @@ -1,127 +1,81 @@ # src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py -""" -feature.ddl: derivation round-trip (async) — async twin of test_derivation. -""" -import pytest +"""Backend-independent asynchronous DDLSource declaration tests.""" -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + ColumnConstraintType, + ForeignKeyConstraint, + TableConstraintType, +) +from rhosocial.activerecord.base import DDLSource, UseSqlType -def _create_model_table_on(backend, model): - """Derive *model*'s CREATE TABLE and execute it on a given backend.""" - expr = model.generate_create_table(backend.dialect) - backend.execute(*expr.to_sql()) - return expr - - -def _create_model_table(model): - """Idempotent derive-and-execute (drops leftovers first).""" - from rhosocial.activerecord.backend.expression import ( - DropTableExpression, TableExpression, - ) - - drop = DropTableExpression( - dialect=model.__backend__.dialect, - table=TableExpression(model.__backend__.dialect, model.__table_name__), - if_exists=True, - ) - model.__backend__.execute(*drop.to_sql()) - - expr = model.generate_create_table() - model.__backend__.execute(*expr.to_sql()) - return expr +def _constraint_types(model, field): + return {constraint.constraint_type for constraint in model.column_constraints(field)} class TestZeroDeclarationDerivationAsync: - async def test_derive_and_execute(self, async_bare_class): - expr = _create_model_table(async_bare_class) - sql, _ = expr.to_sql() - assert "CREATE TABLE" in sql - assert "PRIMARY KEY" in sql - - async def test_round_trip(self, async_bare_class): - _create_model_table(async_bare_class) - item = async_bare_class(name="a", quantity=2, price=1.5) - await item.save() - - found = await async_bare_class.find_one({'id': item.id}) - assert found is not None - assert found.name == "a" - - found.quantity = 5 - await found.save() - again = await async_bare_class.find_one({'id': item.id}) - assert again.quantity == 5 - - async def test_nullable_column_stays_nullable(self, async_bare_class): - _create_model_table(async_bare_class) - item = async_bare_class(name="n", quantity=1, price=1.0) - await item.save() - found = await async_bare_class.find_one({'id': item.id}) - assert found.note is None - - -class TestGenericSpecDerivationAsync: - async def test_derive_and_execute(self, async_spec_order_class): - expr = _create_model_table(async_spec_order_class) - sql, _ = expr.to_sql() - assert "UNIQUE" in sql - assert "CHECK" in sql - - async def test_unique_constraint_enforced(self, async_spec_order_class): - _create_model_table(async_spec_order_class) - first = async_spec_order_class(code="A1", status="open") - await first.save() - dup = async_spec_order_class(code="A1", status="open") - with pytest.raises(Exception): - await dup.save() - - async def test_check_constraint_enforced(self, async_spec_order_class): - _create_model_table(async_spec_order_class) - bad = async_spec_order_class(code="A2", status="bogus") - with pytest.raises(Exception): - await bad.save() - - async def test_round_trip(self, async_spec_order_class): - _create_model_table(async_spec_order_class) - order = async_spec_order_class(code="B1", status="paid", quantity=3, total=9.9) - await order.save() - found = await async_spec_order_class.find_one({'id': order.id}) - assert found is not None - assert found.code == "B1" - - -class TestCapabilitySpecDerivationAsync: - async def test_derive_and_execute(self, async_capability_class): - expr = _create_model_table(async_capability_class) - sql, _ = expr.to_sql() - assert "CREATE TABLE" in sql - - async def test_round_trip(self, async_capability_class): - _create_model_table(async_capability_class) - post = async_capability_class(title="hello", views=2) - await post.save() - found = await async_capability_class.find_one({"id": post.id}) - assert found is not None - assert found.title == "hello" - - -class TestPKAndFKSpecDerivationAsync: - async def test_derive_and_execute(self, async_spec_comment_class, async_spec_order_class): - _create_model_table(async_spec_order_class) - expr = _create_model_table(async_spec_comment_class) - sql, _ = expr.to_sql() - assert "FOREIGN KEY" in sql - assert "REFERENCES" in sql - - async def test_round_trip_with_parent(self, async_spec_comment_with_parent): - comment_class, order_class = async_spec_comment_with_parent - _create_model_table(comment_class) # order table already created - - order = order_class(code="C1", status="open") - await order.save() - comment = comment_class(comment_id=1, order_id=order.id, body="ok") - await comment.save() - found = await comment_class.find_one({"comment_id": 1}) - assert found is not None - assert found.body == "ok" + async def test_source_declaration(self, async_bare_class): + assert isinstance(async_bare_class, DDLSource) + assert async_bare_class.table_name() == "ddl_bare_items" + assert async_bare_class.primary_key_columns() == ("id",) + assert async_bare_class.ddl_field_names() == ( + "id", + "name", + "quantity", + "price", + "note", + ) + + async def test_primary_key_and_nullability_declarations(self, async_bare_class): + assert ColumnConstraintType.PRIMARY_KEY in _constraint_types( + async_bare_class, "id" + ) + assert ColumnConstraintType.NOT_NULL in _constraint_types( + async_bare_class, "name" + ) + assert ColumnConstraintType.NOT_NULL not in _constraint_types( + async_bare_class, "note" + ) + + +class TestGenericDeclarationDerivationAsync: + async def test_type_and_constraint_declarations(self, async_spec_order_class): + code_type = async_spec_order_class.column_type("code") + assert isinstance(code_type, UseSqlType) + assert code_type.data_type.name == "varchar" + assert code_type.data_type.length == 32 + assert ColumnConstraintType.CHECK in _constraint_types( + async_spec_order_class, "status" + ) + + unique = next( + constraint + for constraint in async_spec_order_class.table_constraints() + if constraint.constraint_type == TableConstraintType.UNIQUE + ) + assert unique.columns == ["code", "status"] + + async def test_table_index_declarations(self, async_spec_order_class): + index = async_spec_order_class.table_indexes()[0] + assert index.name == "ix_ddl_spec_orders_code" + assert index.columns == ["code"] + + +class TestCapabilityDeclarationDerivationAsync: + async def test_capability_declarations_remain_available( + self, async_capability_class + ): + assert async_capability_class.table_indexes()[0].name == ( + "ix_ddl_capability_posts_title" + ) + assert async_capability_class.generated_column("double_views") is not None + + +class TestForeignKeyDeclarationDerivationAsync: + async def test_foreign_key_declaration(self, async_spec_comment_class): + constraint = async_spec_comment_class.table_constraints()[0] + assert isinstance(constraint, ForeignKeyConstraint) + assert constraint.columns == ["order_id"] + assert constraint.foreign_key_table == "ddl_spec_orders" + assert constraint.foreign_key_columns == ["id"] + assert constraint.on_delete.value == "CASCADE" diff --git a/src/rhosocial/activerecord/testsuite/feature/features.py b/src/rhosocial/activerecord/testsuite/feature/features.py deleted file mode 100644 index 6f8c589..0000000 --- a/src/rhosocial/activerecord/testsuite/feature/features.py +++ /dev/null @@ -1,13 +0,0 @@ -# src/rhosocial/activerecord/testsuite/feature/features.py -"""Database feature constants and support system.""" - -# This file is deprecated in favor of the capability system defined in -# src/rhosocial/activerecord/backend/capabilities.py -# -# The new capability system provides: -# 1. A hierarchical structure for organizing capabilities -# 2. Type-safe enums with bit flags for efficient checking -# 3. A single source of truth in the backend modules -# 4. Direct integration with backend instances -# -# Please use the capability system instead of this feature system. diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query.py b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query.py index f65d31c..6bef608 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query.py @@ -15,6 +15,7 @@ from rhosocial.activerecord.query import CTEQuery, AsyncCTEQuery from rhosocial.activerecord.testsuite.utils import ( requires_cte, + requires_cte_order_by, requires_protocol, requires_recursive_cte, ) @@ -121,9 +122,10 @@ def test_cte_query_with_wrong_paradigm_backend_raises_error(self, order_fixtures # Create a mock async backend to simulate passing an async backend to sync CTEQuery from unittest.mock import Mock from rhosocial.activerecord.backend.base import AsyncStorageBackend + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase mock_async_backend = Mock(spec=AsyncStorageBackend) - mock_async_backend.dialect = Mock() + mock_async_backend.dialect = Mock(spec=SQLDialectBase) # Try to create a CTEQuery with an async backend - should raise TypeError with pytest.raises(TypeError) as exc_info: @@ -182,10 +184,11 @@ def test_cte_query_with_wrong_paradigm_query_raises_error(self, order_fixtures): # before any backend interaction occurs. from unittest.mock import Mock from rhosocial.activerecord.backend.base import AsyncStorageBackend + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.query.active_query import AsyncActiveQuery mock_async_backend = Mock(spec=AsyncStorageBackend) - mock_async_backend.dialect = Mock() + mock_async_backend.dialect = Mock(spec=SQLDialectBase) mock_async_model = Mock() mock_async_model.backend.return_value = mock_async_backend async_query = AsyncActiveQuery(mock_async_model) @@ -294,6 +297,7 @@ def test_cte_with_basic_query_conditions(self, order_fixtures): "Expected second total_amount to be 100.00" @requires_cte() + @requires_cte_order_by() def test_cte_with_range_conditions(self, order_fixtures): """ Test CTE query with range conditions (limit, offset). @@ -340,6 +344,50 @@ def test_cte_with_range_conditions(self, order_fixtures): assert results[1]['total_amount'] == Decimal('200.00'), \ "Expected second total_amount to be 200.00 after offset" + @requires_cte() + def test_cte_range_conditions_without_inner_order(self, order_fixtures): + """ + CTE range conditions with ORDER BY only on the outer query. + + This covers backends that prohibit ORDER BY inside CTE definitions + (e.g. SQL Server). The CTE selects raw data; sorting is applied + externally before LIMIT/OFFSET. + """ + User, Order, OrderItem = order_fixtures + + user = User(username='cte_no_inner_order', email='cte_no_inner_order@example.com', age=30) + user.save() + + order1 = Order(user_id=user.id, order_number='CTE-NIO-001', total_amount=Decimal('100.00'), status='active') + order2 = Order(user_id=user.id, order_number='CTE-NIO-002', total_amount=Decimal('200.00'), status='completed') + order3 = Order(user_id=user.id, order_number='CTE-NIO-003', total_amount=Decimal('300.00'), status='pending') + order4 = Order(user_id=user.id, order_number='CTE-NIO-004', total_amount=Decimal('400.00'), status='active') + order1.save() + order2.save() + order3.save() + order4.save() + + backend = Order.backend() + + # CTE definition has NO ORDER BY — only a plain SELECT + cte_query = CTEQuery(backend) + cte_query.with_cte('nio_cte', (f"SELECT id, status, total_amount FROM {Order.table_name()}", ())) + + # ORDER BY applied on the outer query only + sql_query = cte_query.from_cte('nio_cte').select('id', 'status', 'total_amount').order_by(('total_amount', 'DESC')).limit(2).offset(1) + sql, params = sql_query.to_sql() + + assert 'WITH' in sql.upper(), "Expected WITH clause in generated SQL" + assert 'nio_cte' in sql.lower(), "Expected CTE name in generated SQL" + + results = sql_query.aggregate() + + assert len(results) == 2, "Expected 2 limited CTE results" + assert results[0]['total_amount'] == Decimal('300.00'), \ + "Expected first total_amount to be 300.00 after offset" + assert results[1]['total_amount'] == Decimal('200.00'), \ + "Expected second total_amount to be 200.00 after offset" + @requires_cte() def test_cte_with_joins(self, order_fixtures): """ diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query_async.py b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query_async.py index ef24a7d..c2e366f 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query_async.py @@ -15,6 +15,7 @@ from rhosocial.activerecord.query import CTEQuery, AsyncCTEQuery from rhosocial.activerecord.testsuite.utils import ( requires_cte, + requires_cte_order_by, requires_protocol, requires_recursive_cte, ) @@ -117,10 +118,11 @@ async def test_cte_query_with_wrong_paradigm_backend_raises_error(self, async_or # Get sync backend from model (sync backend for sync model) from rhosocial.activerecord.backend.base import StorageBackend + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from unittest.mock import Mock mock_sync_backend = Mock(spec=StorageBackend) - mock_sync_backend.dialect = Mock() + mock_sync_backend.dialect = Mock(spec=SQLDialectBase) # Try to create an AsyncCTEQuery with a sync backend - should raise TypeError with pytest.raises(TypeError) as exc_info: @@ -162,11 +164,12 @@ async def test_cte_query_with_wrong_paradigm_query_raises_error(self, async_orde AsyncUser, AsyncOrder, AsyncOrderItem = async_order_fixtures from rhosocial.activerecord.backend.base import StorageBackend + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.query.active_query import ActiveQuery from unittest.mock import Mock mock_sync_backend = Mock(spec=StorageBackend) - mock_sync_backend.dialect = Mock() + mock_sync_backend.dialect = Mock(spec=SQLDialectBase) mock_sync_model = Mock() mock_sync_model.backend.return_value = mock_sync_backend sync_query = ActiveQuery(mock_sync_model) @@ -271,6 +274,7 @@ async def test_cte_with_basic_query_conditions(self, async_order_fixtures): "Expected second total_amount to be 100.00" @requires_cte() + @requires_cte_order_by() async def test_cte_with_range_conditions(self, async_order_fixtures): """ Test CTE query with range conditions (limit, offset). @@ -317,6 +321,50 @@ async def test_cte_with_range_conditions(self, async_order_fixtures): assert results[1]['total_amount'] == Decimal('200.00'), \ "Expected second total_amount to be 200.00 after offset" + @requires_cte() + async def test_cte_range_conditions_without_inner_order(self, async_order_fixtures): + """ + CTE range conditions with ORDER BY only on the outer query (async). + + This covers backends that prohibit ORDER BY inside CTE definitions + (e.g. SQL Server). The CTE selects raw data; sorting is applied + externally before LIMIT/OFFSET. + """ + AsyncUser, AsyncOrder, AsyncOrderItem = async_order_fixtures + + user = AsyncUser(username='cte_no_inner_order', email='cte_no_inner_order@example.com', age=30) + await user.save() + + order1 = AsyncOrder(user_id=user.id, order_number='CTE-NIO-001', total_amount=Decimal('100.00'), status='active') + order2 = AsyncOrder(user_id=user.id, order_number='CTE-NIO-002', total_amount=Decimal('200.00'), status='completed') + order3 = AsyncOrder(user_id=user.id, order_number='CTE-NIO-003', total_amount=Decimal('300.00'), status='pending') + order4 = AsyncOrder(user_id=user.id, order_number='CTE-NIO-004', total_amount=Decimal('400.00'), status='active') + await order1.save() + await order2.save() + await order3.save() + await order4.save() + + backend = AsyncOrder.backend() + + # CTE definition has NO ORDER BY — only a plain SELECT + cte_query = AsyncCTEQuery(backend) + cte_query.with_cte('nio_cte', (f"SELECT id, status, total_amount FROM {AsyncOrder.table_name()}", ())) + + # ORDER BY applied on the outer query only + sql_query = cte_query.from_cte('nio_cte').select('id', 'status', 'total_amount').order_by(('total_amount', 'DESC')).limit(2).offset(1) + sql, params = sql_query.to_sql() + + assert 'WITH' in sql.upper(), "Expected WITH clause in generated SQL" + assert 'nio_cte' in sql.lower(), "Expected CTE name in generated SQL" + + results = await sql_query.aggregate() + + assert len(results) == 2, "Expected 2 limited CTE results" + assert results[0]['total_amount'] == Decimal('300.00'), \ + "Expected first total_amount to be 300.00 after offset" + assert results[1]['total_amount'] == Decimal('200.00'), \ + "Expected second total_amount to be 200.00 after offset" + @requires_cte() async def test_cte_with_joins(self, async_order_fixtures): """ diff --git a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models.py b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models.py index 38bd3f1..41eb5ba 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models.py @@ -202,7 +202,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[Optional[int], UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -224,7 +223,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[Optional[int], UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -248,7 +246,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[Optional[int], UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -276,7 +273,6 @@ class AsyncMappedUser(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[Optional[int], UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -298,7 +294,6 @@ class AsyncMappedPost(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[Optional[int], UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -322,7 +317,6 @@ class AsyncMappedComment(IntegerPKMixin, DefaultTimestampMixin, AsyncActiveRecor c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[Optional[int], UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] diff --git a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py310.py b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py310.py index 32800c4..4f037be 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py310.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py310.py @@ -227,7 +227,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -252,7 +251,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -279,7 +277,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] diff --git a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py311.py b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py311.py index 489a407..e12b058 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py311.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py311.py @@ -266,7 +266,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -291,7 +290,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -318,7 +316,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] diff --git a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py312.py b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py312.py index 609c96e..619ceca 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py312.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/models_py312.py @@ -274,7 +274,6 @@ class MappedUser(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "users" - __primary_key__ = "id" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -299,7 +298,6 @@ class MappedPost(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "posts" - __primary_key__ = "id" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -326,7 +324,6 @@ class MappedComment(IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() __table_name__ = "comments" - __primary_key__ = "id" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] diff --git a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/schema_models.py b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/schema_models.py index 3b34ecb..5c4f14b 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/fixtures/schema_models.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/fixtures/schema_models.py @@ -22,7 +22,6 @@ class SchemaCustomer(ActiveRecord): __table_name__ = "customers" __schema_name__ = SCHEMA_A - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -32,7 +31,6 @@ class SchemaCustomer(ActiveRecord): class SchemaOrder(ActiveRecord): __table_name__ = "orders" __schema_name__ = SCHEMA_B - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -43,7 +41,6 @@ class SchemaOrder(ActiveRecord): class AsyncSchemaCustomer(AsyncActiveRecord): __table_name__ = "customers" __schema_name__ = SCHEMA_A - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -53,7 +50,6 @@ class AsyncSchemaCustomer(AsyncActiveRecord): class AsyncSchemaOrder(AsyncActiveRecord): __table_name__ = "orders" __schema_name__ = SCHEMA_B - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization.py b/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization.py index 0dac509..ee310e0 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization.py @@ -55,6 +55,74 @@ def _unicode_sample_contents(): } +# --- BMP-only Unicode fixtures ------------------------------------------------ +# +# These exercise the widest character set that every backend supporting +# Unicode can guarantee to store — the entire Basic Multilingual Plane +# (U+0000..U+FFFF) with no supplementary-plane characters. Backends whose +# charset cannot round-trip supplementary characters (e.g. Oracle 18c's +# AL32UTF8) should skip the full Unicode tests above and run these instead. +# +# The names and docstrings are backend-agnostic; the decision to skip the +# full tests is made by each backend's own conftest.py. + +_BMP_MULTILINGUAL_SCRIPTS = ( + "中文:你好,世界!", + "日本語:こんにちは世界", + "العربية: مرحباً بالعالم", + "עברית: שלום עולם", + "हिन्दी: नमस्ते दुनिया", + "ภาษาไทย: สวัสดีชาวโลก", + "한국어: 안녕하세요 세계", + "Русский: Привет, мир", + "Ελληνικά: Γειά σου Κόσμε", + "Deutsch: Hallo Welt", +) + +_BMP_SYMBOLS = "☺ ☀ ✓ ✔ ✗ ☂ ❤ ♥ ★ ☆ ☯ ☃ ♫ ✂ ☎ ✈ ✉ ☁ ☄ ♜ ♞ ♝" + + +def _bmp_multilingual_content() -> str: + """Compose a BMP-only payload exercising the widest portable charset.""" + return "\n".join(_BMP_MULTILINGUAL_SCRIPTS) + "\n" + _BMP_SYMBOLS + + +def _bmp_json_payload() -> dict: + """Return a JSON-serializable BMP-only payload.""" + return { + "greetings": { + "zh": "你好,世界!", + "ja": "こんにちは世界", + "ar": "مرحباً بالعالم", + "he": "שלום עולם", + "hi": "नमस्ते दुनिया", + "th": "สวัสดีชาวโลก", + "ko": "안녕하세요 세계", + "ru": "Привет, мир", + }, + "symbols": _BMP_SYMBOLS, + "numbers": [1, 2, 3, 42], + } + + +def _assert_bmp_round_trip(Post) -> None: + """Save/load a BMP-only payload and assert verbatim round-trip.""" + content = _bmp_multilingual_content() + post = Post(user_id=1, title="BMP multilingual", content=content, status="published") + post.save() + + results = Post.query().where(Post.c.title == "BMP multilingual").all() + assert len(results) == 1, "query should return exactly one BMP post" + found = results[0] + assert found.content == content, "BMP-only content should round-trip verbatim" + assert len(found.content) == len(content) + + config = _make_logging_config() + summary = config.summarize_data({"content": found.content}) + _assert_truncated(summary["content"], content) + assert found.content == content + + def _make_logging_config() -> LoggingConfig: return LoggingConfig( log_data_mode=LogDataMode.SUMMARY, @@ -365,6 +433,41 @@ def test_unicode_emoji_burst_truncated_in_summary(self, blog_fixtures): user = _create_user(User) _assert_unicode_emoji_round_trip(Post) + def test_bmp_multilingual_content_round_trip(self, blog_fixtures): + """BMP-only multilingual content should round-trip on every backend. + + This complements the full Unicode tests (which contain supplementary-plane + characters). Backends whose charset cannot store supplementary characters + should skip the full tests and run this one instead. + """ + User, Post, _ = blog_fixtures + user = _create_user(User) + _assert_bmp_round_trip(Post) + + def test_bmp_json_field_round_trip(self, json_user_fixture): + """BMP-only unicode JSON field should round-trip on every backend.""" + JsonUser = json_user_fixture + original_data = _bmp_json_payload() + json_content = json.dumps(original_data, ensure_ascii=False) + + user = JsonUser( + username="bmp-json-user", + email="bmp-json@example.com", + age=28, + preferences=json_content, + ) + user.save() + + results = JsonUser.query().where(JsonUser.c.username == "bmp-json-user").all() + assert len(results) == 1, "query should return exactly one BMP JSON user" + found = results[0] + + retrieved = json.loads(_normalize_json_value(found.preferences)) + assert retrieved == original_data, "retrieved JSON should match the original data" + assert retrieved["greetings"]["zh"] == "你好,世界!" + assert retrieved["symbols"] == _BMP_SYMBOLS + assert retrieved["numbers"] == [1, 2, 3, 42] + def test_sql_injection_payloads_as_content_round_trip(self, blog_fixtures): """SQL injection payload text should be preserved verbatim as content.""" User, Post, _ = blog_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization_async.py b/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization_async.py index 17e865e..db42f72 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/logging/test_logging_data_summarization_async.py @@ -54,6 +54,68 @@ def _unicode_sample_contents(): } +# --- BMP-only Unicode fixtures ------------------------------------------------ +# +# See test_logging_data_summarization.py for rationale. These are the async +# mirror of the BMP-only tests — same data, async execution. + +_BMP_MULTILINGUAL_SCRIPTS = ( + "中文:你好,世界!", + "日本語:こんにちは世界", + "العربية: مرحباً بالعالم", + "עברית: שלום עולם", + "हिन्दी: नमस्ते दुनिया", + "ภาษาไทย: สวัสดีชาวโลก", + "한국어: 안녕하세요 세계", + "Русский: Привет, мир", + "Ελληνικά: Γειά σου Κόσμε", + "Deutsch: Hallo Welt", +) + +_BMP_SYMBOLS = "☺ ☀ ✓ ✔ ✗ ☂ ❤ ♥ ★ ☆ ☯ ☃ ♫ ✂ ☎ ✈ ✉ ☁ ☄ ♜ ♞ ♝" + + +def _bmp_multilingual_content() -> str: + """Compose a BMP-only payload exercising the widest portable charset.""" + return "\n".join(_BMP_MULTILINGUAL_SCRIPTS) + "\n" + _BMP_SYMBOLS + + +def _bmp_json_payload() -> dict: + """Return a JSON-serializable BMP-only payload.""" + return { + "greetings": { + "zh": "你好,世界!", + "ja": "こんにちは世界", + "ar": "مرحباً بالعالم", + "he": "שלום עולם", + "hi": "नमस्ते दुनिया", + "th": "สวัสดีชาวโลก", + "ko": "안녕하세요 세계", + "ru": "Привет, мир", + }, + "symbols": _BMP_SYMBOLS, + "numbers": [1, 2, 3, 42], + } + + +async def _async_assert_bmp_round_trip(Post) -> None: + """Save/load a BMP-only payload and assert verbatim round-trip.""" + content = _bmp_multilingual_content() + post = Post(user_id=1, title="BMP multilingual", content=content, status="published") + await post.save() + + results = await Post.query().where(Post.c.title == "BMP multilingual").all() + assert len(results) == 1, "query should return exactly one BMP post" + found = results[0] + assert found.content == content, "BMP-only content should round-trip verbatim" + assert len(found.content) == len(content) + + config = _make_logging_config() + summary = config.summarize_data({"content": found.content}) + _assert_truncated(summary["content"], content) + assert found.content == content + + def _make_logging_config() -> LoggingConfig: return LoggingConfig( log_data_mode=LogDataMode.SUMMARY, @@ -358,6 +420,36 @@ async def test_unicode_emoji_burst_truncated_in_summary(self, async_blog_fixture user = await _async_create_user(User) await _async_assert_emoji_round_trip(Post) + async def test_bmp_multilingual_content_round_trip(self, async_blog_fixtures): + """BMP-only multilingual content should round-trip on every backend.""" + User, Post, _ = async_blog_fixtures + user = await _async_create_user(User) + await _async_assert_bmp_round_trip(Post) + + async def test_bmp_json_field_round_trip(self, async_json_user_fixture): + """BMP-only unicode JSON field should round-trip on every backend.""" + JsonUser = async_json_user_fixture + original_data = _bmp_json_payload() + json_content = json.dumps(original_data, ensure_ascii=False) + + user = JsonUser( + username="bmp-json-user", + email="bmp-json@example.com", + age=28, + preferences=json_content, + ) + await user.save() + + results = await JsonUser.query().where(JsonUser.c.username == "bmp-json-user").all() + assert len(results) == 1, "query should return exactly one BMP JSON user" + found = results[0] + + retrieved = json.loads(_normalize_json_value(found.preferences)) + assert retrieved == original_data, "retrieved JSON should match the original data" + assert retrieved["greetings"]["zh"] == "你好,世界!" + assert retrieved["symbols"] == _BMP_SYMBOLS + assert retrieved["numbers"] == [1, 2, 3, 42] + async def test_sql_injection_payloads_as_content_round_trip(self, async_blog_fixtures): """SQL injection payload text should be preserved verbatim as content.""" User, Post, _ = async_blog_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation.py b/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation.py index 6eeb2b2..a467b95 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation.py @@ -437,6 +437,7 @@ def test_mixed_sync_async_set_operations_should_fail(self, order_fixtures): from rhosocial.activerecord.query import SetOperationQuery, AsyncSetOperationQuery from unittest.mock import Mock from rhosocial.activerecord.backend.base import AsyncStorageBackend + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.query.active_query import AsyncActiveQuery User, Order, OrderItem = order_fixtures @@ -448,7 +449,7 @@ def test_mixed_sync_async_set_operations_should_fail(self, order_fixtures): # model/backend is used only to satisfy construction; the rejection # fires before any backend interaction occurs. mock_async_backend = Mock(spec=AsyncStorageBackend) - mock_async_backend.dialect = Mock() + mock_async_backend.dialect = Mock(spec=SQLDialectBase) mock_async_model = Mock() mock_async_model.backend.return_value = mock_async_backend async_query = AsyncActiveQuery(mock_async_model) diff --git a/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation_async.py b/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation_async.py index 99036ed..73b147d 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/set_operations/test_set_operation_async.py @@ -445,6 +445,7 @@ async def test_mixed_sync_async_set_operations_should_fail(self, async_order_fix from rhosocial.activerecord.query import SetOperationQuery, AsyncSetOperationQuery from unittest.mock import Mock from rhosocial.activerecord.backend.base import StorageBackend + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.query.active_query import ActiveQuery AsyncUser, AsyncOrder, AsyncOrderItem = async_order_fixtures @@ -456,7 +457,7 @@ async def test_mixed_sync_async_set_operations_should_fail(self, async_order_fix # sync model/backend is used only to satisfy construction; the # rejection fires before any backend interaction occurs. mock_sync_backend = Mock(spec=StorageBackend) - mock_sync_backend.dialect = Mock() + mock_sync_backend.dialect = Mock(spec=SQLDialectBase) mock_sync_model = Mock() mock_sync_model.backend.return_value = mock_sync_backend sync_query = ActiveQuery(mock_sync_model) diff --git a/src/rhosocial/activerecord/testsuite/utils/__init__.py b/src/rhosocial/activerecord/testsuite/utils/__init__.py index a10a129..19fc3a5 100644 --- a/src/rhosocial/activerecord/testsuite/utils/__init__.py +++ b/src/rhosocial/activerecord/testsuite/utils/__init__.py @@ -30,6 +30,7 @@ requires_grouping_sets, requires_cte, requires_recursive_cte, + requires_cte_order_by, requires_json_operations, requires_inner_join, requires_left_join, @@ -81,6 +82,7 @@ 'requires_grouping_sets', 'requires_cte', 'requires_recursive_cte', + 'requires_cte_order_by', 'requires_json_operations', 'requires_inner_join', 'requires_left_join', diff --git a/src/rhosocial/activerecord/testsuite/utils/common.py b/src/rhosocial/activerecord/testsuite/utils/common.py index 7cefa03..167f1ff 100644 --- a/src/rhosocial/activerecord/testsuite/utils/common.py +++ b/src/rhosocial/activerecord/testsuite/utils/common.py @@ -10,7 +10,7 @@ All tests across the entire testsuite should import protocol utilities from this module. """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional import pytest @@ -268,6 +268,11 @@ def requires_recursive_cte(): from rhosocial.activerecord.backend.dialect.protocols import CTESupport return requires_protocol(CTESupport, 'supports_recursive_cte') +def requires_cte_order_by(): + """Decorator for tests requiring unconditional ORDER BY inside CTE definitions.""" + from rhosocial.activerecord.backend.dialect.protocols import CTESupport + return requires_protocol(CTESupport, 'supports_unconditional_cte_order_by') + def requires_json_operations(): """ Decorator for tests requiring JSON operations. @@ -405,9 +410,15 @@ def requires_array_operations(): def _normalize_for_compare(value: datetime) -> datetime: - """Strip tzinfo so timezone-aware and tz-naive datetimes can be compared.""" + """Normalize a datetime onto the naive UTC wall clock for comparison. + + Timezone-aware values are converted to UTC and then stripped of their + tzinfo, so two datetimes representing the *same instant* compare equal + regardless of the session time zone they were read back in. Naive values + pass through unchanged (compared as wall-clock values). + """ if value.tzinfo is not None: - return value.replace(tzinfo=None) + return value.astimezone(timezone.utc).replace(tzinfo=None) return value diff --git a/src/rhosocial/activerecord/testsuite/utils/expression.py b/src/rhosocial/activerecord/testsuite/utils/expression.py index ce4692a..9e4fba5 100644 --- a/src/rhosocial/activerecord/testsuite/utils/expression.py +++ b/src/rhosocial/activerecord/testsuite/utils/expression.py @@ -42,6 +42,10 @@ def _placeholder_for(param: inspect.Parameter, dialect: Any = None): return {} if raw is str or "str" in text: return "x" + if "DataType" in text or "Type" in text.split(".")[-1:]: + from rhosocial.activerecord.backend.expression.types import IntegerType + + return IntegerType() if raw is int: return 1 if raw is float: @@ -124,6 +128,7 @@ def special_constructors(): backends may register their own entries for backend-specific expressions. """ return { + "statements.ddl_table.ColumnDefinition": _column_definition, "advanced_functions.JSONExpression": _json_expr, "query_parts.JoinExpression": _join_expr, "statements.ddl_partition.PartitionClause": _partition_clause, @@ -140,6 +145,13 @@ def special_constructors(): } +def _column_definition(dialect): + from rhosocial.activerecord.backend.expression.statements import ColumnDefinition + from rhosocial.activerecord.backend.expression.types import IntegerType + + return ColumnDefinition(dialect, "col", IntegerType()) + + def _json_expr(dialect): from rhosocial.activerecord.backend.expression.advanced_functions import JSONExpression from rhosocial.activerecord.backend.expression.core import Literal