From 968d6e3aa30c78d15b07aaee62233264b7656e71 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 11 Sep 2026 01:20:09 +0800 Subject: [PATCH 01/15] fix(testsuite): adapt expression tests to node-based casts and normalize tz-aware datetimes - CastExpression is now an AST node: tests build it explicitly and assert the node wrapping (placeholder for prior _cast_types state). - ColumnDefinition/ColumnConstraint/TableConstraint/ForeignKeyConstraint now require the dialect as the first constructor argument. - Mock async/sync backends supply a SQLDialectBase-specced dialect so the strict dialect binding accepts them. - _normalize_for_compare converts tz-aware datetimes to UTC before stripping tzinfo, so the same instant read back in a different session time zone compares equal. - _placeholder_for/_column_definition support the DataType-family types. (cherry picked from commit 93d6e37b758601147230fac66244f6ef0f69f01b) --- .../feature/basic/ddl/test_alter_table_if_exists.py | 4 ++-- .../basic/ddl/test_alter_table_if_exists_async.py | 4 ++-- .../feature/query/cte/test_cte_query_active_query.py | 6 ++++-- .../query/cte/test_cte_query_active_query_async.py | 6 ++++-- .../query/set_operations/test_set_operation.py | 3 ++- .../query/set_operations/test_set_operation_async.py | 3 ++- src/rhosocial/activerecord/testsuite/utils/common.py | 12 +++++++++--- .../activerecord/testsuite/utils/expression.py | 12 ++++++++++++ 8 files changed, 37 insertions(+), 13 deletions(-) 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/query/cte/test_cte_query_active_query.py b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_active_query.py index f65d31c..b55d890 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 @@ -121,9 +121,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 +183,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) 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..28a780e 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 @@ -117,10 +117,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 +163,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) 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/common.py b/src/rhosocial/activerecord/testsuite/utils/common.py index 7cefa03..8fc9153 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 @@ -405,9 +405,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 From f30c27b2dc659bb574e92b3dcdd02d50617080d2 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 19:20:17 +0800 Subject: [PATCH 02/15] chore: remove dead code - deprecated features.py and unused sqlite_features.py --- .../feature/backend/sqlite_features.py | 85 ------------------- .../testsuite/feature/features.py | 13 --- 2 files changed, 98 deletions(-) delete mode 100644 src/rhosocial/activerecord/testsuite/feature/backend/sqlite_features.py delete mode 100644 src/rhosocial/activerecord/testsuite/feature/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/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. From 51adfbae5d43f0816ffc448c8dc921ae65574613 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 12 Sep 2026 20:11:38 +0800 Subject: [PATCH 03/15] feat(test): add BMP-only Unicode round-trip tests Add test_bmp_multilingual_content_round_trip and test_bmp_json_field_round_trip (sync + async) to the logging test suite. These exercise the entire Basic Multilingual Plane (U+0000..U+FFFF) with no supplementary-plane characters. Every backend can run these tests. Backends whose charset cannot store supplementary characters (e.g. Oracle 18c AL32UTF8) should skip the full-Unicode tests in their own conftest.py and keep these BMP-only tests as their Unicode coverage. (cherry picked from commit ff625bef7c8cc0adec68f7cc527c18f15b9084e4) --- .../test_logging_data_summarization.py | 103 ++++++++++++++++++ .../test_logging_data_summarization_async.py | 92 ++++++++++++++++ 2 files changed, 195 insertions(+) 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 From dc23157f7d7c9fa22ab0e671b6ce4b524ded658c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 10:40:26 +0800 Subject: [PATCH 04/15] feat(protocol): add requires_cte_order_by decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New decorator for tests requiring ORDER BY inside CTE definitions. Used by test_cte_with_range_conditions (sync + async) which generate CTE SQL with ORDER BY — unsupported on SQL Server. (cherry picked from commit cf3315318e4e66e85d6d43c93cf8dd7bcf0cf7a6) --- .../feature/query/cte/test_cte_query_active_query.py | 2 ++ .../feature/query/cte/test_cte_query_active_query_async.py | 2 ++ src/rhosocial/activerecord/testsuite/utils/__init__.py | 2 ++ src/rhosocial/activerecord/testsuite/utils/common.py | 5 +++++ 4 files changed, 11 insertions(+) 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 b55d890..25ba668 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, ) @@ -296,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). 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 28a780e..a325ed1 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, ) @@ -273,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). 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 8fc9153..80f0d54 100644 --- a/src/rhosocial/activerecord/testsuite/utils/common.py +++ b/src/rhosocial/activerecord/testsuite/utils/common.py @@ -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 ORDER BY inside CTE definitions.""" + from rhosocial.activerecord.backend.dialect.protocols import CTESupport + return requires_protocol(CTESupport, 'supports_cte_order_by') + def requires_json_operations(): """ Decorator for tests requiring JSON operations. From 883fa43c100c47bac1d13d5e566433d24d71cb9d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 13 Sep 2026 11:05:32 +0800 Subject: [PATCH 05/15] refactor(protocol): rename requires_cte_order_by to match protocol rename (cherry picked from commit d080b3124387f24dee72dcf77cb01cda33fd5da5) --- .../query/cte/test_cte_query_active_query.py | 44 +++++++++++++++++++ .../cte/test_cte_query_active_query_async.py | 44 +++++++++++++++++++ .../activerecord/testsuite/utils/common.py | 4 +- 3 files changed, 90 insertions(+), 2 deletions(-) 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 25ba668..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 @@ -344,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 a325ed1..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 @@ -321,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/utils/common.py b/src/rhosocial/activerecord/testsuite/utils/common.py index 80f0d54..167f1ff 100644 --- a/src/rhosocial/activerecord/testsuite/utils/common.py +++ b/src/rhosocial/activerecord/testsuite/utils/common.py @@ -269,9 +269,9 @@ def requires_recursive_cte(): return requires_protocol(CTESupport, 'supports_recursive_cte') def requires_cte_order_by(): - """Decorator for tests requiring ORDER BY inside CTE definitions.""" + """Decorator for tests requiring unconditional ORDER BY inside CTE definitions.""" from rhosocial.activerecord.backend.dialect.protocols import CTESupport - return requires_protocol(CTESupport, 'supports_cte_order_by') + return requires_protocol(CTESupport, 'supports_unconditional_cte_order_by') def requires_json_operations(): """ From 657a292e3faf60bee02429cef8ebbbb3728c669e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 24 Sep 2026 00:23:17 +0800 Subject: [PATCH 06/15] test: drop redundant __primary_key__ = "id" from fixtures Model base already defaults to "id"; fixtures should exercise the unspecified-PK default path. (cherry picked from commit 7f180c1572adb8061394fc18a63416ea5dcb68bb) --- .../testsuite/feature/basic/fixtures/models.py | 15 --------------- .../feature/basic/fixtures/models_py310.py | 14 -------------- .../feature/basic/fixtures/models_py311.py | 14 -------------- .../feature/basic/fixtures/models_py312.py | 14 -------------- .../testsuite/feature/query/fixtures/models.py | 6 ------ .../feature/query/fixtures/models_py310.py | 3 --- .../feature/query/fixtures/models_py311.py | 3 --- .../feature/query/fixtures/models_py312.py | 3 --- .../feature/query/fixtures/schema_models.py | 4 ---- 9 files changed, 76 deletions(-) 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/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 From 887de338401363e5f472f685605fe9e81f99124b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:05:54 +0800 Subject: [PATCH 07/15] refactor(ddl): test retained DDLSource contracts (cherry picked from commit 09a1aed4b03a06355e0004e26277f623194aa278) --- .../testsuite/feature/ddl/conftest.py | 177 +++------- .../testsuite/feature/ddl/fixtures/models.py | 301 ++++++++---------- .../testsuite/feature/ddl/interfaces.py | 94 ------ .../testsuite/feature/ddl/test_derivation.py | 212 ++++-------- .../feature/ddl/test_derivation_async.py | 194 +++++------ 5 files changed, 310 insertions(+), 668 deletions(-) delete mode 100644 src/rhosocial/activerecord/testsuite/feature/ddl/interfaces.py 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_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" From eab283755a7131ba58f6006f1f446c0f44291ea8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 25 Sep 2026 18:42:34 +0800 Subject: [PATCH 08/15] docs: document repository search scope (cherry picked from commit a0b1fd5173e551826f59c2787167db2636d0e9f0) --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) 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. From 18ff77c4fb9479bd6c4c9f35f640162be44437ef Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 00:30:55 +0800 Subject: [PATCH 09/15] test(ddl): add DDLSource collection contract Cover declaration ordering, field metadata, table defaults, and sync/async parity for the shared DDLSource contract. (cherry picked from commit f2227e538acd116fd235b8bc613f9c1df6427e0a) --- .../feature/ddl/test_ddl_source_contract.py | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 src/rhosocial/activerecord/testsuite/feature/ddl/test_ddl_source_contract.py 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() From 1a9fb4bbda9332ad7de119f4c24a10f0873b40fb Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 09:19:19 +0800 Subject: [PATCH 10/15] test(ddl): add a cross-backend materialized view contract The postgres materialized view defect (MRO order left the PG mixin dead) and ClickHouse's equivalent (probe returned True while inheriting the core formatter, producing SQL ClickHouse cannot execute) share one root cause: nothing constrained a capability probe to the formatter behind it. The core layer had no cross-backend MV contract at all, so each backend was on its own and a defect could only ever be found inside that one backend. Adds feature/basic/ddl/test_materialized_view_contract.py (expression/dialect layer, no live connection) in two layers: - Invariants (never skipped): declaring supports_materialized_view() implies owning the CREATE formatter; declaring supports_refresh_materialized_view() implies owning the refresh formatter; the generic expressions must render on any backend that claims support (catches formatters written against a private expression surface) - Capability-gated rendering: statement shape for CREATE/DROP/REFRESH, with unsupported backends skipped declaratively CREATE is constrained on its own because its syntax diverges on every backend (PG 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 satisfies the rule by overriding the formatter and delegating to super(), turning a silent accident into an explicit statement. DROP is left unconstrained: DROP MATERIALIZED VIEW [IF EXISTS] [CASCADE] is broadly valid, so inheriting it is legitimate. Measured: postgres 120 passed; ClickHouse 3 failed before its fix (naming the missing override), Snowflake 3 failed before its fix (AttributeError on the generic expression). (cherry picked from commit 951172c071c06b89615726b090fcd88479bc84f4) --- changelog.d/21.added.md | 1 + .../ddl/test_materialized_view_contract.py | 191 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 changelog.d/21.added.md create mode 100644 src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py diff --git a/changelog.d/21.added.md b/changelog.d/21.added.md new file mode 100644 index 0000000..8546c2c --- /dev/null +++ b/changelog.d/21.added.md @@ -0,0 +1 @@ +Add a cross-backend materialized view contract (`feature/basic/ddl/test_materialized_view_contract.py`): a dialect advertising `supports_materialized_view()` must own its CREATE formatter, a dialect advertising refresh must own its refresh formatter, and the generic MV expressions must render on any backend that claims support. Backends without materialized views skip the rendering tests declaratively. 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..c376579 --- /dev/null +++ b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py @@ -0,0 +1,191 @@ +# 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 via ``@requires_protocol``. +""" + +import pytest + +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, +) +from rhosocial.activerecord.testsuite.utils import requires_protocol + + +#: 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 _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_materialized_view_probe_is_callable(self, ddl_dialect): + """Every backend must answer the probe (used for gating).""" + probe = getattr(ddl_dialect, "supports_materialized_view", None) + assert callable(probe), "dialect does not expose supports_materialized_view()" + assert isinstance(probe(), bool) + + 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 ddl_dialect.supports_materialized_view() is not True: + 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 ddl_dialect.supports_refresh_materialized_view() is not True: + 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" + ) + + def test_generic_expression_renders_when_supported(self, ddl_dialect): + """The generic expressions must work on any backend that claims support. + + Catches formatters written against a private expression surface: those + raise ``AttributeError`` when handed the generic expression. + """ + if ddl_dialect.supports_materialized_view() is not True: + pytest.skip("backend does not advertise materialized view support") + + create = CreateMaterializedViewExpression( + dialect=ddl_dialect, view_name="mv_contract", query=_source_query(ddl_dialect) + ) + sql, _ = create.to_sql() + assert sql.upper().startswith("CREATE MATERIALIZED VIEW"), ( + f"CREATE MATERIALIZED VIEW rendered as {sql!r}" + ) + + drop = DropMaterializedViewExpression( + dialect=ddl_dialect, view_name="mv_contract", if_exists=True + ) + drop_sql, _ = drop.to_sql() + assert drop_sql.upper().startswith("DROP MATERIALIZED VIEW"), ( + f"DROP MATERIALIZED VIEW rendered as {drop_sql!r}" + ) + + +class TestMaterializedViewRendering: + """Statement shape for backends that advertise materialized views.""" + + @requires_protocol(ViewSupport, "supports_materialized_view") + def test_create_renders_query_and_data_clause(self, ddl_dialect): + expression = CreateMaterializedViewExpression( + dialect=ddl_dialect, + view_name="mv_contract", + query=_source_query(ddl_dialect), + ) + sql, params = expression.to_sql() + assert "mv_contract" in sql + assert params == (), "materialized view DDL must not bind parameters" + + @requires_protocol(ViewSupport, "supports_materialized_view") + def test_create_supports_column_aliases(self, ddl_dialect): + expression = CreateMaterializedViewExpression( + dialect=ddl_dialect, + view_name="mv_contract", + query=_source_query(ddl_dialect), + column_aliases=["alias_id"], + ) + sql, _ = expression.to_sql() + assert "alias_id" in sql + + @requires_protocol(ViewSupport, "supports_materialized_view") + def test_drop_supports_if_exists_and_cascade(self, ddl_dialect): + expression = DropMaterializedViewExpression( + dialect=ddl_dialect, + view_name="mv_contract", + if_exists=True, + cascade=True, + ) + sql, _ = expression.to_sql() + assert "IF EXISTS" in sql + assert "CASCADE" in sql + + @requires_protocol(ViewSupport, "supports_refresh_materialized_view") + def test_refresh_renders_statement(self, ddl_dialect): + expression = RefreshMaterializedViewExpression( + dialect=ddl_dialect, view_name="mv_contract" + ) + sql, params = expression.to_sql() + assert sql.upper().startswith("REFRESH MATERIALIZED VIEW") + assert params == () From 3db1c2c992a40a82350604b7944db70544156744 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 09:34:44 +0800 Subject: [PATCH 11/15] fix(ddl): grade MV contract skips by whether the dialect can resolve a version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Produced 12 failures on Oracle: OracleMaterializedViewMixin gates its formatter on self.version (MVs need 9i, IF NOT EXISTS needs 23ai), while the ddl_dialect fixture hands over an unadapted dialect, so every rendering assertion hit DialectNotAdaptedException. That is correct Oracle behaviour, not a defect — version-gated DDL genuinely needs a resolved server version. The contract exists to catch lying probes, not to require every backend to render SQL without a connection. Grading: - MRO ownership invariants (need no environment, always run): probe True implies owning the CREATE / refresh formatter. This is the valuable one; it is what caught ClickHouse. - Rendering assertions: skip when the probe or formatter raises DialectNotAdaptedException (handled uniformly by the _probe / _render helpers) Verified on nine backends: postgres 96 passed, oracle 6 passed / 18 skipped (ownership invariants still run), snowflake 6 passed / 2 skipped; sqlite/mysql/mariadb/sqlserver/clickhouse/bigquery/firebird all skip on capability. (cherry picked from commit 971225c0434b56bd06f6241211ae93c827d3c753) --- .../ddl/test_materialized_view_contract.py | 92 +++++++++++++++---- 1 file changed, 72 insertions(+), 20 deletions(-) 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 index c376579..1a29617 100644 --- 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 @@ -35,6 +35,7 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import DialectNotAdaptedException 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 @@ -43,7 +44,6 @@ DropMaterializedViewExpression, RefreshMaterializedViewExpression, ) -from rhosocial.activerecord.testsuite.utils import requires_protocol #: Core ``ViewMixin`` implementations a backend must not resolve to once it @@ -52,6 +52,51 @@ 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( @@ -72,12 +117,6 @@ def _resolved_formatter(dialect, name): class TestMaterializedViewCapabilityInvariants: """A declared capability must be backed by a real implementation.""" - def test_materialized_view_probe_is_callable(self, ddl_dialect): - """Every backend must answer the probe (used for gating).""" - probe = getattr(ddl_dialect, "supports_materialized_view", None) - assert callable(probe), "dialect does not expose supports_materialized_view()" - assert isinstance(probe(), bool) - def test_declared_support_requires_own_create_formatter(self, ddl_dialect): """``supports_materialized_view() is True`` implies a backend-owned CREATE. @@ -85,7 +124,7 @@ def test_declared_support_requires_own_create_formatter(self, ddl_dialect): leaves the generic ``ViewMixin`` implementation in place, so callers get SQL the target database cannot execute. """ - if ddl_dialect.supports_materialized_view() is not True: + if not _mv_supported(ddl_dialect): pytest.skip("backend does not advertise materialized view support") resolved = _resolved_formatter( @@ -106,7 +145,7 @@ def test_declared_support_requires_own_create_formatter(self, ddl_dialect): def test_declared_refresh_support_requires_own_formatter(self, ddl_dialect): """The same invariant for the REFRESH path.""" - if ddl_dialect.supports_refresh_materialized_view() is not True: + if not _mv_refresh_supported(ddl_dialect): pytest.skip("backend does not advertise materialized view refresh") resolved = _resolved_formatter( @@ -124,13 +163,13 @@ def test_generic_expression_renders_when_supported(self, ddl_dialect): Catches formatters written against a private expression surface: those raise ``AttributeError`` when handed the generic expression. """ - if ddl_dialect.supports_materialized_view() is not True: + if not _mv_supported(ddl_dialect): pytest.skip("backend does not advertise materialized view support") create = CreateMaterializedViewExpression( dialect=ddl_dialect, view_name="mv_contract", query=_source_query(ddl_dialect) ) - sql, _ = create.to_sql() + sql, _ = _render(create) assert sql.upper().startswith("CREATE MATERIALIZED VIEW"), ( f"CREATE MATERIALIZED VIEW rendered as {sql!r}" ) @@ -138,7 +177,7 @@ def test_generic_expression_renders_when_supported(self, ddl_dialect): drop = DropMaterializedViewExpression( dialect=ddl_dialect, view_name="mv_contract", if_exists=True ) - drop_sql, _ = drop.to_sql() + drop_sql, _ = _render(drop) assert drop_sql.upper().startswith("DROP MATERIALIZED VIEW"), ( f"DROP MATERIALIZED VIEW rendered as {drop_sql!r}" ) @@ -147,45 +186,58 @@ def test_generic_expression_renders_when_supported(self, ddl_dialect): class TestMaterializedViewRendering: """Statement shape for backends that advertise materialized views.""" - @requires_protocol(ViewSupport, "supports_materialized_view") def test_create_renders_query_and_data_clause(self, ddl_dialect): + if not _mv_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view support") expression = CreateMaterializedViewExpression( dialect=ddl_dialect, view_name="mv_contract", query=_source_query(ddl_dialect), ) - sql, params = expression.to_sql() + sql, params = _render(expression) assert "mv_contract" in sql assert params == (), "materialized view DDL must not bind parameters" - @requires_protocol(ViewSupport, "supports_materialized_view") def test_create_supports_column_aliases(self, ddl_dialect): + if not _mv_supported(ddl_dialect): + pytest.skip("backend does not advertise materialized view support") expression = CreateMaterializedViewExpression( dialect=ddl_dialect, view_name="mv_contract", query=_source_query(ddl_dialect), column_aliases=["alias_id"], ) - sql, _ = expression.to_sql() + sql, _ = _render(expression) assert "alias_id" in sql - @requires_protocol(ViewSupport, "supports_materialized_view") def test_drop_supports_if_exists_and_cascade(self, ddl_dialect): + 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, cascade=True, ) - sql, _ = expression.to_sql() + sql, _ = _render(expression) assert "IF EXISTS" in sql assert "CASCADE" in sql - @requires_protocol(ViewSupport, "supports_refresh_materialized_view") def test_refresh_renders_statement(self, ddl_dialect): + 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 = expression.to_sql() + sql, params = _render(expression) assert sql.upper().startswith("REFRESH MATERIALIZED VIEW") 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) From 5d1072f1e64a37add5952913e500769378d8599e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 10:07:32 +0800 Subject: [PATCH 12/15] fix(ddl): stop assuming MV refresh is a SQL-standard statement Landing Oracle's DBMS_MVIEW.REFRESH exposed the contract's second over-specification: it asserted the rendered REFRESH starts with "REFRESH MATERIALIZED VIEW", but Oracle renders BEGIN DBMS_MVIEW.REFRESH(...) END;. Same root cause as the earlier DROP case: asserting a concrete statement shape assumes every backend shares one syntax. What the contract must guarantee is that the request renders and refers to the target view; the statement shape is asserted per backend. Now: non-empty output plus a case-insensitive match on the view name (Oracle passes a string name to DBMS_MVIEW and it folds to upper case). Also fixes a self-contradictory assertion (upper-casing the SQL but comparing a lower-case needle), which the previous assertion had short-circuited before it could surface. Verified on ten backends: oracle 12 passed (the new refresh assertion actually executes), postgres 96 passed, the rest skip on capability. (cherry picked from commit 3e7d99756f6150eafa096546211b405390337dc4) --- .../basic/ddl/test_materialized_view_contract.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 index 1a29617..85597ce 100644 --- 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 @@ -224,13 +224,25 @@ def test_drop_supports_if_exists_and_cascade(self, ddl_dialect): assert "CASCADE" in sql 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. 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.upper().startswith("REFRESH MATERIALIZED VIEW") + 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 == () From 27acc46ac9565cebc53bfc9488b866c25b585208 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 10:20:09 +0800 Subject: [PATCH 13/15] fix(ddl): narrow the MV contract to semantics every MV backend shares Landing BigQuery exposed two more over-specifications: column aliases and DROP ... CASCADE. BigQuery's CREATE MATERIALIZED VIEW takes its column names from the query (no alias clause) and its DROP has no CASCADE. Asserting those while also asserting "it renders" again assumes one shared syntax. This is the third relaxation for the same reason (DROP ownership, REFRESH statement shape, aliases/CASCADE). The lesson is now written into the contract docstring as an explicit scope rule: the contract asserts only what every MV-capable database has; everything else (aliases, CASCADE, TABLESPACE, storage parameters, refresh scheduling) is a per-backend feature asserted in that backend's own suite. Verified on ten backends: bigquery 5 passed, postgres 84 passed, oracle 12 passed, snowflake 5 passed, the rest skip on capability. (cherry picked from commit 1de74c4336b31efd844f35424b368ebd718ba033) --- .../ddl/test_materialized_view_contract.py | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) 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 index 85597ce..3527fc5 100644 --- 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 @@ -30,7 +30,22 @@ **Capability-gated rendering.** Backends that do support materialized views must render the expected statement shape; backends that do not are skipped -declaratively via ``@requires_protocol``. +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``. + +Everything else (aliases, CASCADE, TABLESPACE, storage parameters, refresh +schedules) is a per-backend feature, asserted in that backend's own suite. """ import pytest @@ -198,30 +213,22 @@ def test_create_renders_query_and_data_clause(self, ddl_dialect): assert "mv_contract" in sql assert params == (), "materialized view DDL must not bind parameters" - def test_create_supports_column_aliases(self, ddl_dialect): - if not _mv_supported(ddl_dialect): - pytest.skip("backend does not advertise materialized view support") - expression = CreateMaterializedViewExpression( - dialect=ddl_dialect, - view_name="mv_contract", - query=_source_query(ddl_dialect), - column_aliases=["alias_id"], - ) - sql, _ = _render(expression) - assert "alias_id" in sql + def test_drop_supports_if_exists(self, ddl_dialect): + """``IF EXISTS`` is the one DROP modifier every MV database shares. - def test_drop_supports_if_exists_and_cascade(self, ddl_dialect): + ``CASCADE`` is deliberately not asserted: BigQuery's + ``DROP MATERIALIZED VIEW`` has no such clause. + """ 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, - cascade=True, ) sql, _ = _render(expression) assert "IF EXISTS" in sql - assert "CASCADE" in sql + def test_refresh_renders_statement(self, ddl_dialect): """A refreshing backend must render *something* that refreshes the view. From 6a7066999ab20dbc5e7314185dcc20877b3348b8 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 26 Sep 2026 11:22:17 +0800 Subject: [PATCH 14/15] fix(ddl): fourth narrowing - the generic expression and DROP keyword are not universal --- .../ddl/test_materialized_view_contract.py | 94 ++++++++++--------- 1 file changed, 49 insertions(+), 45 deletions(-) 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 index 3527fc5..e16570e 100644 --- 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 @@ -43,6 +43,12 @@ 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. @@ -50,7 +56,10 @@ import pytest -from rhosocial.activerecord.backend.dialect.exceptions import DialectNotAdaptedException +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 @@ -172,72 +181,66 @@ def test_declared_refresh_support_requires_own_formatter(self, ddl_dialect): f"resolves the formatter to the core ViewMixin implementation" ) - def test_generic_expression_renders_when_supported(self, ddl_dialect): - """The generic expressions must work on any backend that claims support. - Catches formatters written against a private expression surface: those - raise ``AttributeError`` when handed the generic expression. - """ - if not _mv_supported(ddl_dialect): - pytest.skip("backend does not advertise materialized view support") +class TestMaterializedViewRendering: + """Rendering checks for backends whose MV DDL the generic expression covers.""" - create = CreateMaterializedViewExpression( - dialect=ddl_dialect, view_name="mv_contract", query=_source_query(ddl_dialect) - ) - sql, _ = _render(create) - assert sql.upper().startswith("CREATE MATERIALIZED VIEW"), ( - f"CREATE MATERIALIZED VIEW rendered as {sql!r}" - ) + def _render_generic_create(self, dialect): + """Render the generic CREATE, skipping backends that need their own. - drop = DropMaterializedViewExpression( - dialect=ddl_dialect, view_name="mv_contract", if_exists=True - ) - drop_sql, _ = _render(drop) - assert drop_sql.upper().startswith("DROP MATERIALIZED VIEW"), ( - f"DROP MATERIALIZED VIEW rendered as {drop_sql!r}" + 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) ) - - -class TestMaterializedViewRendering: - """Statement shape for backends that advertise materialized views.""" - - def test_create_renders_query_and_data_clause(self, ddl_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") - expression = CreateMaterializedViewExpression( - dialect=ddl_dialect, - view_name="mv_contract", - query=_source_query(ddl_dialect), + sql, params = self._render_generic_create(ddl_dialect) + assert sql.upper().startswith("CREATE MATERIALIZED VIEW"), ( + f"CREATE MATERIALIZED VIEW rendered as {sql!r}" ) - sql, params = _render(expression) - assert "mv_contract" in sql + assert "MV_CONTRACT" in sql.upper() assert params == (), "materialized view DDL must not bind parameters" - def test_drop_supports_if_exists(self, ddl_dialect): - """``IF EXISTS`` is the one DROP modifier every MV database shares. + def test_drop_renders(self, ddl_dialect): + """``DROP`` is rendered either as ``DROP MATERIALIZED VIEW`` or ``DROP VIEW``. - ``CASCADE`` is deliberately not asserted: BigQuery's - ``DROP MATERIALIZED VIEW`` has no such clause. + 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, + dialect=ddl_dialect, view_name="mv_contract", if_exists=True ) - sql, _ = _render(expression) + 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. The contract is that the request - renders and refers to the view; the concrete statement shape is asserted - per backend. + ``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") @@ -253,6 +256,7 @@ def test_refresh_renders_statement(self, ddl_dialect): assert params == () + class TestMaterializedViewProtocolDeclaration: """The capability must be reachable through the declared protocol.""" From 5c082c954b4e7879d3f9bafa8b75601a13b7c423 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 27 Sep 2026 15:33:50 +0800 Subject: [PATCH 15/15] chore: add changelog fragment for PR #21 --- changelog.d/21.added.md | 2 +- changelog.d/21.changed.md | 1 + changelog.d/21.removed.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelog.d/21.changed.md create mode 100644 changelog.d/21.removed.md diff --git a/changelog.d/21.added.md b/changelog.d/21.added.md index 8546c2c..9474eef 100644 --- a/changelog.d/21.added.md +++ b/changelog.d/21.added.md @@ -1 +1 @@ -Add a cross-backend materialized view contract (`feature/basic/ddl/test_materialized_view_contract.py`): a dialect advertising `supports_materialized_view()` must own its CREATE formatter, a dialect advertising refresh must own its refresh formatter, and the generic MV expressions must render on any backend that claims support. Backends without materialized views skip the rendering tests declaratively. +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`.