diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 102ecdc0..ce44767e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,8 +68,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord - pip install rhosocial-activerecord-testsuite==1.0.0.dev15 + pip install rhosocial-activerecord==1.0.0.dev29 + pip install rhosocial-activerecord-testsuite==1.0.0.dev16 - name: Create postgres scenario config run: | @@ -123,13 +123,13 @@ jobs: --rootdir=. \ --confcutdir=. \ "$TESTSUITE_DIR/benchmark/crud/test_delete_async.py" \ - "$TESTSUITE_DIR/benchmark/crud/test_delete_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_delete.py" \ "$TESTSUITE_DIR/benchmark/crud/test_insert_async.py" \ - "$TESTSUITE_DIR/benchmark/crud/test_insert_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_insert.py" \ "$TESTSUITE_DIR/benchmark/crud/test_query_async.py" \ - "$TESTSUITE_DIR/benchmark/crud/test_query_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_query.py" \ "$TESTSUITE_DIR/benchmark/crud/test_update_async.py" \ - "$TESTSUITE_DIR/benchmark/crud/test_update_sync.py" \ + "$TESTSUITE_DIR/benchmark/crud/test_update.py" \ "$TESTSUITE_DIR/benchmark/query" \ "$TESTSUITE_DIR/benchmark/transaction" \ "$TESTSUITE_DIR/benchmark/mixin" \ @@ -220,7 +220,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" test-experimental-python-315: - # Temporarily disabled until Python 3.15 beta/toolchain compatibility stabilizes. + # Temporarily disabled until Python 3.15 toolchain compatibility stabilizes. if: ${{ false }} name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} (experimental) runs-on: ubuntu-latest @@ -231,8 +231,8 @@ jobs: fail-fast: false matrix: include: - - { python-version: '3.15.0-beta.2', postgres-version: '18', freethreaded: false } - - { python-version: '3.15.0-beta.2', postgres-version: '18', freethreaded: true } + - { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: false } + - { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: true } services: postgres: @@ -279,8 +279,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord - pip install rhosocial-activerecord-testsuite==1.0.0.dev15 + pip install rhosocial-activerecord==1.0.0.dev29 + pip install rhosocial-activerecord-testsuite==1.0.0.dev16 - name: Create postgres scenario config run: | @@ -391,8 +391,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord - pip install rhosocial-activerecord-testsuite==1.0.0.dev15 + pip install rhosocial-activerecord==1.0.0.dev29 + pip install rhosocial-activerecord-testsuite==1.0.0.dev16 - name: Create postgres scenario config run: | @@ -455,13 +455,13 @@ jobs: fail_ci_if_error: false test-postgres-19: - name: Python 3.14 / postgres 19beta1 (PGQ) + name: Python 3.14 / postgres 19beta3 (PGQ) runs-on: ubuntu-latest continue-on-error: true services: postgres: - image: postgres:19beta1 + image: postgres:19beta3 env: POSTGRES_DB: test_db POSTGRES_USER: root @@ -490,8 +490,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev28#egg=rhosocial-activerecord - pip install rhosocial-activerecord-testsuite==1.0.0.dev15 + pip install rhosocial-activerecord==1.0.0.dev29 + pip install rhosocial-activerecord-testsuite==1.0.0.dev16 - name: Create postgres scenario config run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 884197a0..a53e881d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [v1.0.0.dev16] - 2026-08-15 + +### Added + +- Added PostgreSQL-specific DataType subclasses and schema-diff support with `PostgresSchemaDiffer` for accurate schema introspection and column comparison. ([#46](https://github.com/rhosocial/python-activerecord-postgres/issues/46)) +- Added named-migration CLI subcommand with --async support and PostgreSQL-specific example migrations for the PostgreSQL backend. ([#47](https://github.com/rhosocial/python-activerecord-postgres/issues/47)) +- Add PGQ enhancements: fulltext search capability, ON CONFLICT switches, JSON arrow operators, cursor result-set pollution fix, and PostgreSQL 19 compatibility gate for quantified path and comma-separated patterns ([#49](https://github.com/rhosocial/python-activerecord-postgres/issues/49)) +- Added `PostgresAlterColumnModifierMixin` so PostgreSQL renders the vendor `IF [NOT] EXISTS` qualifiers on ALTER TABLE actions: `ADD COLUMN IF NOT EXISTS`, `DROP COLUMN IF EXISTS`, and `DROP CONSTRAINT IF EXISTS` (all supported since PostgreSQL 9.6). The qualifiers are opt-in via `if_not_exists` / `if_exists` on `AddColumn` / `DropColumn` / `DropTableConstraint`; `None` (default) renders the plain SQL-standard form. Also added the `supports_drop_constraint_if_exists` capability to `PostgresConstraintSupport` / `PostgresConstraintMixin`. ([#51](https://github.com/rhosocial/python-activerecord-postgres/issues/51)) +- Added PostgreSQL Row-Level Security POLICY DDL expressions and dialect support. New `PostgresCreatePolicyExpression`, `PostgresAlterPolicyExpression`, `PostgresDropPolicyExpression` (plus `PolicyType`, `PolicyCommand`, `AlterPolicyMode` enums) in `expression/ddl/policy.py` allow RLS policy administration via the Expression-Dialect pipeline; the new `PostgresPolicyMixin` provides version-gated `format_*` methods (PG 9.5+ for basic CREATE/ALTER/DROP, 10+ for `AS RESTRICTIVE` and explicit `AS PERMISSIVE`, 15+ for the `CURRENT_ROLE` keyword in `TO`) and capability switches; `PostgresPolicySupport` is the corresponding `@runtime_checkable` Protocol. Per the layering decision POLICY is a PostgreSQL extension (not SQL standard) and is therefore implemented in the postgres backend package only — the core library `backend/expression/statements/` is untouched. ([#52](https://github.com/rhosocial/python-activerecord-postgres/issues/52)) +- Added PostgreSQL coverage-completion DDL expressions and dialect support. New expression/mixin/protocol triads in the postgres backend package cover: `ALTER TABLE ... ENABLE/DISABLE [ALWAYS] ROW LEVEL SECURITY` and `FORCE/NO FORCE ROW LEVEL SECURITY` (`PostgresAlterTableRlsExpression`, `PostgresForceRlsExpression`; PG 9.5+); `ALTER TABLE ... SET LOGGED/UNLOGGED` (PG 9.6+) and `SET ACCESS METHOD` (PG 15+); `CLUSTER` (PG 9.6+, index re-cluster only); `CREATE/ALTER/DROP DOMAIN` (`PostgresAlterDomainExpression` supports SET DEFAULT / RENAME); `CREATE/DROP COLLATION` objects; `CREATE/DROP FOREIGN TABLE`; `CREATE/DROP FUNCTION` and `CREATE/DROP AGGREGATE` (PG 9.6+); and `CREATE/DROP PUBLICATION` / `CREATE/DROP SUBSCRIPTION` (PG 10+). Additionally `PostgresAlterColumnModifierMixin.format_alter_column_action` now renders PostgreSQL's `ALTER COLUMN ... SET DATA TYPE ... USING (expr)` conversion expression (opt-in via `dialect_options={"using": ...}`; rejected on non-type operations). Each feature has a version-gated capability switch and a matching `@runtime_checkable` Protocol (`PostgresRlsConfigSupport`, `PostgresAlterTableSettingsSupport`, `PostgresClusterSupport`, `PostgresDomainSupport`, `PostgresCollationDDLSupport`, `PostgresForeignTableDDLSupport`, `PostgresRoutineDDLSupport`, `PostgresPublicationSupport`). All are PostgreSQL extensions implemented only in the postgres backend package — the core library is untouched. ([#53](https://github.com/rhosocial/python-activerecord-postgres/issues/53)) + + ## [v1.0.0.dev15] - 2026-06-15 ### Added diff --git a/GEMINI.md b/GEMINI.md deleted file mode 120000 index 681311eb..00000000 --- a/GEMINI.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dee3111b..248ee365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rhosocial-activerecord-postgres" -version = "1.0.0.dev15" +version = "1.0.0.dev16" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } @@ -37,7 +37,7 @@ classifiers = [ ] dependencies = [ "psycopg[binary]", - "rhosocial-activerecord" + "rhosocial-activerecord>=1.0.0.dev0,<2.0.0" ] [project.optional-dependencies] @@ -90,6 +90,12 @@ markers = [ "requires_functions: marks tests requiring specific database functions", "requires_inner_join: marks tests requiring INNER JOIN support", "benchmark_fastapi: marks FastAPI benchmark tests", + "benchmark: marks benchmark tests", + "benchmark_backend: marks backend benchmark tests", + "benchmark_async: marks async benchmark tests", + "benchmark_sync: marks sync benchmark tests", + "benchmark_write: marks write benchmark tests", + "benchmark_read: marks read benchmark tests", ] [tool.coverage.run] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 83f988d5..9c6f720e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -23,6 +23,55 @@ from .dialect import PostgresDialect from .transaction import PostgresTransactionManager, AsyncPostgresTransactionManager from .types import PostgresEnumType +from .expression.types import ( + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCircleType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, + PostgresCidrType, +) from .explain import PostgresExplainResult, PostgresExplainPlanLine from .expression.ddl import ( PostgresCreateEnumTypeExpression, @@ -505,4 +554,52 @@ # hstore functions - Subscript "hstore_subscript_get", "hstore_subscript_set", + # PostgreSQL DataType subclasses for DDL + "PostgresBigSerialType", + "PostgresBitType", + "PostgresBoxType", + "PostgresByteaType", + "PostgresCIDType", + "PostgresCircleType", + "PostgresDateMultirangeType", + "PostgresDateRangeType", + "PostgresGeographyType", + "PostgresGeometryType", + "PostgresHstoreType", + "PostgresInetType", + "PostgresInt4MultirangeType", + "PostgresInt4RangeType", + "PostgresInt8MultirangeType", + "PostgresInt8RangeType", + "PostgresJsonPathType", + "PostgresLineSegmentType", + "PostgresLineType", + "PostgresMacAddr8Type", + "PostgresMacAddrType", + "PostgresMoneyType", + "PostgresNumMultirangeType", + "PostgresNumRangeType", + "PostgresOIDType", + "PostgresPathType", + "PostgresPgLSNType", + "PostgresPointType", + "PostgresPolygonType", + "PostgresRegClassType", + "PostgresRegTypeType", + "PostgresSerialType", + "PostgresSmallSerialType", + "PostgresTIDType", + "PostgresTSQueryType", + "PostgresTSVectorType", + "PostgresTsMultirangeType", + "PostgresTsRangeType", + "PostgresTsTzMultirangeType", + "PostgresTsTzRangeType", + "PostgresUUIDType", + "PostgresVarBitType", + "PostgresVectorType", + "PostgresXID8Type", + "PostgresXIDType", + "PostgresXMLType", + "PostgresCidrType", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py index 54045e8f..f1e4968a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/__init__.py @@ -5,7 +5,8 @@ COMMAND_NAMES = [ 'info', 'query', 'introspect', 'status', - 'named-expression', 'named-procedure', 'named-procedure-graph', 'named-connection', + 'named-expression', 'named-procedure', 'named-procedure-graph', + 'named-migration', 'named-connection', ] @@ -18,6 +19,7 @@ def register_commands(subparsers): from .named_expression import create_parser as nq_parser from .named_procedure import create_parser as np_parser from .named_procedure_graph import create_parser as npg_parser + from .named_migration import create_parser as nm_parser from .named_connection import create_parser as nc_parser info_parser(subparsers) query_parser(subparsers) @@ -26,6 +28,7 @@ def register_commands(subparsers): nq_parser(subparsers) np_parser(subparsers) npg_parser(subparsers) + nm_parser(subparsers) nc_parser(subparsers) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py new file mode 100644 index 00000000..3d89eb89 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py @@ -0,0 +1,115 @@ +# src/rhosocial/activerecord/backend/impl/postgres/cli/named_migration.py +"""named-migration subcommand — PostgreSQL adapter for the shared CLI helper.""" + +from __future__ import annotations + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, AsyncPostgresBackend + +from .connection import create_connection_parent_parser, resolve_connection_config_from_args +from .output import create_provider + + +def create_parser(subparsers): + """Create the named-migration subcommand parser. + + Provides connection and output arguments via a parent parser. + """ + from rhosocial.activerecord.backend.migration.cli import create_named_migration_parser + + local_parent = create_connection_parent_parser() + nm_epilog = """Examples: + # Apply a migration + %(prog)s myapp.migrations.v001.CreateUsersTable --host localhost --database mydb --direction up + + # Rollback + %(prog)s myapp.migrations.v001.CreateUsersTable --host localhost --database mydb --direction down + + # Apply with record store + %(prog)s myapp.migrations.v001.CreateUsersTable --database mydb --record-store ./migrations.json + + # Dry-run + %(prog)s myapp.migrations.v001.CreateUsersTable --database mydb --direction up --dry-run + + # Apply asynchronously + %(prog)s myapp.migrations.v001.CreateUsersTable --database mydb --direction up --async + + # Pass parameters to the migration + %(prog)s myapp.migrations.v001.CreateUsersTable --param table_name=custom_table + + # List available migrations + %(prog)s myapp.migrations --list + + # Describe a migration + %(prog)s myapp.migrations.v001.CreateUsersTable --describe + + # Batch: apply all pending migrations in a module + %(prog)s myapp.migrations --all --record-store ./migrations.json + + # Batch: dry-run all pending migrations + %(prog)s myapp.migrations --all --record-store ./migrations.json --dry-run + + # Batch: rollback all applied migrations + %(prog)s myapp.migrations --all --record-store ./migrations.json --direction down + + # Batch: wrap all migrations in a single transaction + %(prog)s myapp.migrations --all --record-store ./migrations.json --single-transaction + +End-to-end demo scripts with UP/DOWN round-trip verification: + https://github.com/rhosocial/python-activerecord-postgres/tree/main/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations + PYTHONPATH=src bash demo_basic.sh + PYTHONPATH=src bash demo_chain.sh + PYTHONPATH=src bash demo_all.sh +""" + return create_named_migration_parser(subparsers, local_parent, epilog=nm_epilog) + + +def handle(args): + """Handle the named-migration subcommand.""" + from rhosocial.activerecord.backend.migration.cli import handle_named_migration as handle_nm + + provider = create_provider(args.output, ascii_borders=args.rich_ascii) + + backend = None + + def backend_factory(): + nonlocal backend + config = resolve_connection_config_from_args(args) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + return backend + + def disconnect(): + if backend and getattr(backend, "_connection", None): + backend.disconnect() + + is_async = getattr(args, "is_async", False) + if is_async: + async_backend = None + + def backend_async_factory(): + nonlocal async_backend + config = resolve_connection_config_from_args(args) + async_backend = AsyncPostgresBackend(connection_config=config) + return async_backend + + async def disconnect_async(): + if async_backend and getattr(async_backend, "_connection", None): + await async_backend.disconnect() + + handle_nm( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + backend_async_factory=backend_async_factory, + disconnect_async=disconnect_async, + ) + return + + handle_nm( + args, + provider, + backend_factory=backend_factory, + disconnect=disconnect, + ) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index c59bb218..08cc6723 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -90,6 +90,7 @@ IntrospectionSupport, TransactionControlSupport, SQLFunctionSupport, + DDLTypeSupport, ) from .mixins import ( PostgresExtensionMixin, @@ -161,6 +162,15 @@ PostgresCommentMixin, PostgresTypeMixin, PostgresConstraintMixin, + PostgresPolicyMixin, + PostgresRlsConfigMixin, + PostgresAlterTableSettingsMixin, + PostgresClusterMixin, + PostgresDomainMixin, + PostgresCollationDDLMixin, + PostgresForeignTableMixin, + PostgresRoutineMixin, + PostgresPublicationMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -169,6 +179,7 @@ PostgresRangeTypeMixin, PostgresJSONBEnhancedMixin, PostgresArrayEnhancedMixin, + PostgresTypeFormatSupportMixin, # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, @@ -176,6 +187,7 @@ PostgresLockingMixin, # Introspection capability mixin PostgresIntrospectionCapabilityMixin, + PostgresAlterColumnModifierMixin, ) from .collation import validate_postgres_collation_name @@ -249,6 +261,15 @@ PostgresCommentSupport, PostgresTypeSupport, PostgresConstraintSupport, + PostgresPolicySupport, + PostgresRlsConfigSupport, + PostgresAlterTableSettingsSupport, + PostgresClusterSupport, + PostgresDomainSupport, + PostgresCollationDDLSupport, + PostgresForeignTableDDLSupport, + PostgresRoutineDDLSupport, + PostgresPublicationSupport, # Type feature protocols PostgresMultirangeSupport, PostgresEnumTypeSupport, @@ -346,6 +367,7 @@ class PostgresDialect( # PostgreSQL-specific mixins PostgresExtensionMixin, PostgresMaterializedViewMixin, + PostgresAlterColumnModifierMixin, # Before TableMixin/ConstraintMixin to override format_*_action PostgresTableMixin, # Before TableMixin to override supports_table_like_syntax TableMixin, ConstraintMixin, @@ -393,6 +415,15 @@ class PostgresDialect( PostgresCommentMixin, PostgresTypeMixin, PostgresConstraintMixin, + PostgresPolicyMixin, + PostgresRlsConfigMixin, + PostgresAlterTableSettingsMixin, + PostgresClusterMixin, + PostgresDomainMixin, + PostgresCollationDDLMixin, + PostgresForeignTableMixin, + PostgresRoutineMixin, + PostgresPublicationMixin, # Type mixins EnumTypeMixin, TypesDataTypeMixin, @@ -400,6 +431,7 @@ class PostgresDialect( PostgresFullTextSearchMixin, PostgresRangeTypeMixin, PostgresArrayEnhancedMixin, + PostgresTypeFormatSupportMixin, # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, PostgresStoredProcedureMixin, @@ -517,6 +549,15 @@ class PostgresDialect( PostgresCommentSupport, PostgresTypeSupport, PostgresConstraintSupport, + PostgresPolicySupport, + PostgresRlsConfigSupport, + PostgresAlterTableSettingsSupport, + PostgresClusterSupport, + PostgresDomainSupport, + PostgresCollationDDLSupport, + PostgresForeignTableDDLSupport, + PostgresRoutineDDLSupport, + PostgresPublicationSupport, # Type feature protocols PostgresMultirangeSupport, PostgresEnumTypeSupport, @@ -524,6 +565,8 @@ class PostgresDialect( PostgresRangeTypeSupport, PostgresJSONBEnhancedSupport, PostgresArrayEnhancedSupport, + # DataType Support Protocol + DDLTypeSupport, # New feature protocols PostgresParallelQuerySupport, PostgresStoredProcedureSupport, @@ -597,6 +640,14 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the PostgreSQL version this dialect is configured for.""" return self.version + def create_schema_differ(self): + """Return the PostgreSQL schema differ for this dialect.""" + from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( + PostgresSchemaDiffer, + ) + + return PostgresSchemaDiffer() + def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: start_sql, start_params = expr.start.to_sql() end_sql, end_params = expr.end.to_sql() @@ -689,6 +740,10 @@ def supports_for_update(self) -> bool: """ return True + def supports_json_arrow_operators(self) -> bool: + """PostgreSQL supports -> and ->> operators for JSON/JSONB access.""" + return True + def supports_lock_strength(self, strength) -> bool: """ Check if a specific lock strength is supported. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py index 44cc9fd2..2ec48e6a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/conftest.py @@ -262,6 +262,14 @@ 'min_version': '9', 'max_version': '18', }, + 'pgq/recursive_cte_graph_traversal.py': { + 'title': 'Recursive CTE Graph Traversal (PGQ Quantified Path Alternative)', + 'dialect_protocols': [], + 'priority': 5, + 'min_version': '9', + 'max_version': '18', + 'note': 'Uses recursive CTE + UNION ALL to simulate PGQ quantified path patterns; works on all PG versions', + }, 'transaction/basic.py': { 'title': 'Transaction Control', 'dialect_protocols': [], @@ -416,4 +424,46 @@ 'max_version': '18', 'note': 'Requires superuser or pg_read_server_files role; only available in PG15+ docker images', }, + 'schema_diff/table_changes.py': { + 'title': 'Schema Diff — Table Add/Remove', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/column_order_irrelevant.py': { + 'title': 'Schema Diff — Column Order Is Ignored', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/postgres10_identity.py': { + 'title': 'Schema Diff — PG9 SERIAL vs PG10+ IDENTITY', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/array_type_diff.py': { + 'title': 'Schema Diff — Array Type Dimension Changes', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/index_type_diff.py': { + 'title': 'Schema Diff — PostgreSQL Index Type Switches', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, + 'schema_diff/serialization_roundtrip.py': { + 'title': 'Schema Diff — Snapshot Serialization Roundtrip', + 'dialect_protocols': [], + 'priority': 10, + 'min_version': '9', + 'max_version': '18', + }, } diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py index 9005f9a7..117c989b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py @@ -38,7 +38,7 @@ drop_table = DropTableExpression( dialect=dialect, - table_name='users', + table='users', if_exists=True, cascade=True, ) @@ -47,7 +47,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='users', + table='users', columns=[ ColumnDefinition( 'id', @@ -148,13 +148,23 @@ print("Column renamed successfully") # Verify table structure -options = ExecutionOptions(stmt_type=StatementType.DQL) -result = backend.execute( - "SELECT column_name, data_type, is_nullable " - "FROM information_schema.columns " - "WHERE table_name = 'users' ORDER BY ordinal_position", - options=options +from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.query_parts import OrderByClause + +verify_expr = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'column_name'), + Column(dialect, 'data_type'), + Column(dialect, 'is_nullable'), + ], + from_=TableExpression(dialect, 'columns', schema_name='information_schema'), + where=Column(dialect, 'table_name') == Literal(dialect, 'users'), + order_by=OrderByClause(dialect, [Column(dialect, 'ordinal_position')]), ) +options = ExecutionOptions(stmt_type=StatementType.DQL) +result = backend.execute(*verify_expr.to_sql(), options=options) print("Table structure after alterations:") for row in result.data or []: print(f" {row}") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py index 9e097b89..5e0c1ea3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_index.py @@ -33,7 +33,7 @@ drop_table = DropTableExpression( dialect=dialect, - table_name='products', + table='products', if_exists=True, cascade=True, ) @@ -42,7 +42,7 @@ create_table = CreateTableExpression( dialect=dialect, - table_name='products', + table='products', columns=[ ColumnDefinition( 'id', @@ -83,11 +83,17 @@ print("Index created: idx_category_price") # Verify index creation -options = ExecutionOptions(stmt_type=StatementType.DQL) -verify_result = backend.execute( - "SELECT indexname FROM pg_indexes WHERE indexname = 'idx_category_price'", - options=options +from rhosocial.activerecord.backend.expression import QueryExpression, TableExpression +from rhosocial.activerecord.backend.expression.core import Column, Literal + +verify_expr = QueryExpression( + dialect=dialect, + select=[Column(dialect, 'indexname')], + from_=TableExpression(dialect, 'pg_indexes'), + where=Column(dialect, 'indexname') == Literal(dialect, 'idx_category_price'), ) +options = ExecutionOptions(stmt_type=StatementType.DQL) +verify_result = backend.execute(*verify_expr.to_sql(), options=options) print(f"Index info: {verify_result.data}") # ============================================================ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py index 557e2858..2aec6dd9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/materialized_view.py @@ -2,10 +2,16 @@ Materialized Views - PostgreSQL 9.3+. This example demonstrates: -1. Creating a materialized view -2. Refreshing materialized views -3. Using WITH NO DATA for fast definition -4. Materialized view with aggregations +1. Creating a materialized view with aggregations +2. Column aliases for the materialized view +3. Storage options (WITH clause) for physical storage tuning +4. TABLESPACE assignment +5. WITH DATA vs WITH NO DATA population modes +6. REFRESH MATERIALIZED VIEW (normal) +7. REFRESH MATERIALIZED VIEW CONCURRENTLY (PostgreSQL 9.4+) +8. REFRESH MATERIALIZED VIEW WITH DATA (after NO DATA) +9. Schema-qualified refresh (PostgresRefreshMaterializedViewExpression) +10. DROP MATERIALIZED VIEW with CASCADE and IF EXISTS """ # ============================================================ @@ -24,6 +30,7 @@ CreateMaterializedViewExpression, DropMaterializedViewExpression, RefreshMaterializedViewExpression, + CreateIndexExpression, GroupByHavingClause, FunctionCall, ) @@ -46,22 +53,35 @@ ) backend = PostgresBackend(connection_config=config) backend.connect() +backend.introspect_and_adapt() dialect = backend.dialect dql_options = ExecutionOptions(stmt_type=StatementType.DQL) +ddl_options = ExecutionOptions(stmt_type=StatementType.DDL) +_drop_mv = DropMaterializedViewExpression( + dialect=dialect, view_name='sales_summary', if_exists=True, cascade=True, +) +backend.execute(*_drop_mv.to_sql()) +_drop_mv = DropMaterializedViewExpression( + dialect=dialect, view_name='sales_daily', if_exists=True, cascade=True, +) +backend.execute(*_drop_mv.to_sql()) +_drop_mv = DropMaterializedViewExpression( + dialect=dialect, view_name='sales_product_summary', if_exists=True, cascade=True, +) +backend.execute(*_drop_mv.to_sql()) drop_table = DropTableExpression( dialect=dialect, - table_name='sales', + table='sales', if_exists=True, cascade=True, ) -sql, params = drop_table.to_sql() -backend.execute(sql, params) +backend.execute(*drop_table.to_sql()) create_table = CreateTableExpression( dialect=dialect, - table_name='sales', + table='sales', columns=[ ColumnDefinition( 'id', @@ -105,18 +125,18 @@ backend.execute(sql, params) # ============================================================ -# SECTION: Create Materialized View +# SECTION: 1. Create Materialized View with Column Aliases and Storage Options # ============================================================ -# Materialized views store the query result physically +# Column aliases rename the output columns of the materialized view. +# Storage options control physical storage behavior via WITH (param = value). -# Build the summary query with aggregations summary_query = QueryExpression( dialect=dialect, select=[ Column(dialect, 'product_id'), - FunctionCall(dialect, 'COUNT', WildcardExpression(dialect)).as_('total_sales'), - FunctionCall(dialect, 'SUM', Column(dialect, 'amount')).as_('total_amount'), - FunctionCall(dialect, 'AVG', Column(dialect, 'amount')).as_('avg_amount'), + FunctionCall(dialect, 'COUNT', WildcardExpression(dialect)), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount')), + FunctionCall(dialect, 'AVG', Column(dialect, 'amount')), ], from_=TableExpression(dialect, 'sales'), group_by_having=GroupByHavingClause( @@ -129,12 +149,18 @@ dialect=dialect, view_name='sales_summary', query=summary_query, + column_aliases=['product_id', 'total_sales', 'total_amount', 'avg_amount'], + storage_options={ + 'fillfactor': 70, + 'autovacuum_enabled': 'true', + }, ) sql, params = create_mv.to_sql() -print(f"Create MV SQL: {sql}") +print("Create MV with column aliases and storage options:") +print(f" SQL: {sql}") backend.execute(sql, params) -# Verify the materialized view content +# Verify verify_query = QueryExpression( dialect=dialect, select=[WildcardExpression(dialect)], @@ -143,53 +169,47 @@ ) sql, params = verify_query.to_sql() result = backend.execute(sql, params, options=dql_options) -print("Materialized view result:") +print("MV result:") for row in result.data or []: print(f" {row}") # ============================================================ -# SECTION: Refresh Materialized View +# SECTION: 2. TABLESPACE (SQL Generation Demo) # ============================================================ -# Refresh to update the data +# Tablespace requires an existing tablespace on the server. +# This section generates the SQL to show the syntax; execution is optional. -# Insert new data -insert_new = InsertExpression( +print() +sales_query = QueryExpression( dialect=dialect, - into='sales', - columns=['product_id', 'amount', 'sale_date'], - source=ValuesSource( - dialect, - [[Literal(dialect, '1'), Literal(dialect, '300.00'), Literal(dialect, "'2024-01-05'")]], - ), + select=[ + Column(dialect, 'product_id'), + Column(dialect, 'amount'), + Column(dialect, 'sale_date'), + ], + from_=TableExpression(dialect, 'sales'), ) -sql, params = insert_new.to_sql() -backend.execute(sql, params) -# Data in view is stale - refresh to see new data -refresh_mv = RefreshMaterializedViewExpression( +mv_tablespace = CreateMaterializedViewExpression( dialect=dialect, - view_name='sales_summary', + view_name='sales_on_fast_storage', + query=sales_query, + tablespace='fast_ssd', ) -sql, params = refresh_mv.to_sql() -backend.execute(sql, params) - -# Verify after refresh -sql, params = verify_query.to_sql() -result = backend.execute(sql, params, options=dql_options) -print("After refresh:") -for row in result.data or []: - print(f" {row}") +sql, params = mv_tablespace.to_sql() +print(f"TABLESPACE example SQL (not executed): {sql}") # ============================================================ -# SECTION: Create with NO DATA +# SECTION: 3. WITH NO DATA — Fast Definition Without Population # ============================================================ -# Use WITH NO DATA to create without populating +# Create a materialized view without initial data. The MV is registered +# but cannot be queried until a REFRESH populates it. daily_query = QueryExpression( dialect=dialect, select=[ Column(dialect, 'sale_date'), - FunctionCall(dialect, 'SUM', Column(dialect, 'amount')).as_('daily_total'), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount')), ], from_=TableExpression(dialect, 'sales'), group_by_having=GroupByHavingClause( @@ -202,12 +222,14 @@ dialect=dialect, view_name='sales_daily', query=daily_query, + column_aliases=['sale_date', 'daily_total'], with_data=False, ) sql, params = create_mv_nodata.to_sql() +print(f"\nWITH NO DATA SQL: {sql}") backend.execute(sql, params) -# Verify WITH NO DATA - query should fail or return empty +# Confirm it's unqueriable nodata_query = QueryExpression( dialect=dialect, select=[WildcardExpression(dialect)], @@ -218,17 +240,150 @@ result = backend.execute(sql, params, options=dql_options) print(f"WITH NO DATA result: {result.data}") except Exception as e: - print(f"WITH NO DATA query error (expected): {e}") + print(f"WITH NO DATA query error (expected): {type(e).__name__}: {e}") # ============================================================ -# SECTION: Drop Materialized View +# SECTION: 4. REFRESH MATERIALIZED VIEW (Normal) # ============================================================ +# Refresh recalculates the query and atomically replaces the MV content. + +# Insert new data so refresh has an effect +insert_new = InsertExpression( + dialect=dialect, + into='sales', + columns=['product_id', 'amount', 'sale_date'], + source=ValuesSource( + dialect, + [[Literal(dialect, '1'), Literal(dialect, '300.00'), Literal(dialect, "'2024-01-05'")]], + ), +) +sql, params = insert_new.to_sql() +backend.execute(sql, params) + +# Refresh sales_summary +refresh_mv = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', +) +sql, params = refresh_mv.to_sql() +print(f"\nREFRESH SQL: {sql}") +backend.execute(sql, params) + +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After normal refresh:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 5. REFRESH MATERIALIZED VIEW CONCURRENTLY (PG 9.4+) +# ============================================================ +# CONCURRENTLY allows the MV to be read during the refresh but +# requires a UNIQUE index on every row of the materialized view. + +# Create a unique index prerequisite +create_unique_idx = CreateIndexExpression( + dialect=dialect, + index_name='sales_summary_product_id_idx', + table_name='sales_summary', + columns=['product_id'], + unique=True, +) +sql, params = create_unique_idx.to_sql() +backend.execute(sql, params, options=ddl_options) + +# Insert more data +insert_more = InsertExpression( + dialect=dialect, + into='sales', + columns=['product_id', 'amount', 'sale_date'], + source=ValuesSource( + dialect, + [[Literal(dialect, '2'), Literal(dialect, '400.00'), Literal(dialect, "'2024-01-06'")]], + ), +) +sql, params = insert_more.to_sql() +backend.execute(sql, params) + +refresh_concurrent = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_summary', + concurrent=True, +) +sql, params = refresh_concurrent.to_sql() +print(f"\nREFRESH CONCURRENTLY SQL: {sql}") +backend.execute(sql, params) + +sql, params = verify_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After CONCURRENTLY refresh:") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 6. REFRESH WITH DATA — Populate a NO DATA View +# ============================================================ +# An MV created with WITH NO DATA can be populated later via +# REFRESH ... WITH DATA. + +refresh_with_data = RefreshMaterializedViewExpression( + dialect=dialect, + view_name='sales_daily', + with_data=True, +) +sql, params = refresh_with_data.to_sql() +print(f"\nREFRESH WITH DATA SQL: {sql}") +backend.execute(sql, params) + +# Now the NO-DATA view is queryable +sql, params = nodata_query.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("After REFRESH WITH DATA (sales_daily):") +for row in result.data or []: + print(f" {row}") + +# ============================================================ +# SECTION: 7. Schema-Qualified Refresh (PostgresRefreshMaterializedViewExpression) +# ============================================================ +# The PostgreSQL backend provides an extended refresh expression that +# supports specifying the schema for the materialized view. + +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.mv import ( + PostgresRefreshMaterializedViewExpression, +) + +# Refresh with schema (public is PG's default schema) +pg_refresh = PostgresRefreshMaterializedViewExpression( + dialect=dialect, + name='sales_summary', + schema='public', +) +sql, params = pg_refresh.to_sql() +print(f"\nSchema-qualified REFRESH SQL: {sql}") +backend.execute(sql, params) + +# Also supports backward-compatible aliases: name=, concurrently= +pg_refresh_c = PostgresRefreshMaterializedViewExpression( + dialect=dialect, + name='sales_summary', + concurrently=True, +) +sql, params = pg_refresh_c.to_sql() +print(f"Backward-compat CONCURRENTLY SQL: {sql}") + +# ============================================================ +# SECTION: 8. DROP MATERIALIZED VIEW with CASCADE and IF EXISTS +# ============================================================ +# CASCADE drops any objects that depend on the MV. + drop_mv1 = DropMaterializedViewExpression( dialect=dialect, view_name='sales_summary', if_exists=True, + cascade=True, ) sql, params = drop_mv1.to_sql() +print(f"\nDROP CASCADE SQL: {sql}") backend.execute(sql, params) drop_mv2 = DropMaterializedViewExpression( @@ -239,12 +394,26 @@ sql, params = drop_mv2.to_sql() backend.execute(sql, params) +# ============================================================ +# SECTION: 9. DROP MATERIALIZED VIEW Without IF EXISTS (SQL Generation Demo) +# ============================================================ +# Dropping a non-existent view without IF EXISTS will raise an error +# at runtime. This section shows the SQL generation to avoid that. + +drop_noexist = DropMaterializedViewExpression( + dialect=dialect, + view_name='nonexistent_mv', +) +sql, params = drop_noexist.to_sql() +print(f"DROP without IF EXISTS SQL (not executed): {sql}") +print(" Note: This would fail at runtime if the MV does not exist.") + # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ drop_table = DropTableExpression( dialect=dialect, - table_name='sales', + table='sales', if_exists=True, cascade=True, ) @@ -257,8 +426,15 @@ # ============================================================ # Key points: # 1. Use CreateMaterializedViewExpression to create MVs -# 2. Use RefreshMaterializedViewExpression to update MV data -# 3. Use with_data=False for WITH NO DATA (lazy population) -# 4. Use DropMaterializedViewExpression to drop MVs -# 5. Requires PostgreSQL 9.3+ -# 6. Use concurrent=True for CONCURRENTLY refresh (PG 9.4+) +# 2. Use column_aliases to name the MV output columns +# 3. Use storage_options for physical storage tuning (fillfactor, autovacuum, etc.) +# 4. Use tablespace to assign the MV to a specific storage location +# 5. Use with_data=False for WITH NO DATA (lazy population, MV unqueriable) +# 6. Use RefreshMaterializedViewExpression for standard refresh +# 7. Use RefreshMaterializedViewExpression with concurrent=True for CONCURRENTLY refresh (PG 9.4+, requires UNIQUE index) +# 8. Use RefreshMaterializedViewExpression with with_data=True to populate a NO-DATA MV +# 9. Use PostgresRefreshMaterializedViewExpression for schema-qualified refresh +# 10. Use DropMaterializedViewExpression with cascade=True to drop dependent objects +# 11. Use DropMaterializedViewExpression with if_exists=True for safe drop +# 12. Requires PostgreSQL 9.3+ for basic MV support +# 13. Requires PostgreSQL 9.4+ for CONCURRENTLY refresh \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py new file mode 100644 index 00000000..82a92881 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/__init__.py +"""Named migration examples for PostgreSQL backend.""" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh new file mode 100755 index 00000000..7748605c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_all.sh — batch migration (--all) for PostgreSQL +# +# Scenarios: +# - --all runs all pending migrations in dependency order +# - --all --dry-run preview without changes +# - --all --direction down rolls back everything +# +# Usage: +# cd python-activerecord-postgres +# DEMO_VENV_PYTHON=.venv3.14-ubuntu26.04/bin/python \ +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_all.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +STORE="./demo_pg_all_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Batch Migration (--all) for PostgreSQL ===" +echo + +echo "[1] --all without --record-store (should error):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test 2>&1 || true +echo + +echo "[2] --all --dry-run (preview SQL, no changes):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --dry-run --record-store "$STORE" +echo + +echo "[3] --all apply all pending migrations:" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --record-store "$STORE" +echo + +echo "[4] Re-run --all (all applied, should be no-op):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test --record-store "$STORE" 2>&1 || true +echo + +echo "[5] --all --single-transaction (rollback in one transaction):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test \ + --direction down --record-store "$STORE" --single-transaction +echo + +echo "[6] --all --single-transaction (re-apply UP):" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test \ + --direction up --record-store "$STORE" --single-transaction +echo + +echo "[7] --all --direction down rollback everything:" +$PYTHON named-migration "${MODULE}.migrations" --all --host localhost --database test \ + --direction down --record-store "$STORE" +echo + +echo "[8] Record store final state:" +cat "$STORE" +echo + +rm -f "$STORE" +echo "=== Batch Migration (--all) Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh new file mode 100755 index 00000000..3832ab4e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_async.sh — async migration execution (--async) for PostgreSQL +# +# Scenarios: +# - apply UP with --async +# - dry-run with --async +# - rollback DOWN with --async +# +# Requires: asyncpg +# +# Usage: +# cd python-activerecord-postgres +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_async.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V001CreateUsers" +STORE="./demo_pg_async_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Async Migration (--async) for PostgreSQL ===" +echo + +echo "[1] Async dry-run (preview SQL, no changes):" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --dry-run --async +echo + +echo "[2] Async apply UP:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --async --record-store "$STORE" +echo + +echo "[3] Async rollback DOWN:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction down --async --record-store "$STORE" +echo + +rm -f "$STORE" +echo "=== Async Migration Demo Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh new file mode 100755 index 00000000..c7dbe68d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_basic.sh — single migration basic operations (PostgreSQL) +# +# Scenarios: +# - apply / rollback a single migration +# - dry-run preview +# - duplicate execution protection +# +# Usage: +# cd python-activerecord-postgres +# DEMO_VENV_PYTHON=.venv3.14-ubuntu26.04/bin/python \ +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_basic.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V001CreateUsers" +STORE="./demo_pg_basic_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Single Migration Basic Operations (PostgreSQL) ===" +echo + +echo "[1] List all migrations in the module:" +$PYTHON named-migration "${MODULE}.migrations" --list -o table +echo + +echo "[2] Describe V001CreateUsers (--describe):" +$PYTHON named-migration "$FQN" --describe +echo + +echo "[3] Dry-run preview (no actual changes):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --dry-run +echo + +echo "[4] Apply UP (create users table):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --record-store "$STORE" +echo + +echo "[5] Record store contents:" +cat "$STORE" +echo + +echo +echo "[6] Duplicate UP (should be rejected):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --record-store "$STORE" 2>&1 || true +echo + +echo "[7] Apply DOWN (drop users table):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[8] Record store after rollback:" +cat "$STORE" +echo + +rm -f "$STORE" +echo "=== Single Migration Basic Operations Complete (PostgreSQL) ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh new file mode 100755 index 00000000..82c7f2f6 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_chain.sh — dependency chain migration (PostgreSQL) +# +# Scenarios: +# - multiple migrations with dependencies executed in order +# - dependency not satisfied — rejected +# - rollback in reverse order +# +# Usage: +# cd python-activerecord-postgres +# DEMO_VENV_PYTHON=.venv3.14-ubuntu26.04/bin/python \ +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_chain.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +V001="${MODULE}.migrations.V001CreateUsers" +V002="${MODULE}.migrations.V002CreatePosts" +STORE="./demo_pg_chain_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Dependency Chain Migration (PostgreSQL) ===" +echo + +echo "[1] List all migrations (dependencies column):" +$PYTHON named-migration "${MODULE}.migrations" --list -o table +echo + +echo "[2] V002CreatePosts dependencies (should declare v001_create_users):" +$PYTHON named-migration "$V002" --describe +echo + +echo "[3] Run V002 before V001 (should fail):" +$PYTHON named-migration "$V002" --host localhost --database test --direction up --record-store "$STORE" 2>&1 || true +echo + +echo "[4] Apply V001 (create users table):" +$PYTHON named-migration "$V001" --host localhost --database test --direction up --record-store "$STORE" +echo + +echo "[5] Apply V002 (create posts table):" +$PYTHON named-migration "$V002" --host localhost --database test --direction up --record-store "$STORE" +echo + +echo "[6] Rollback V002 (downstream first):" +$PYTHON named-migration "$V002" --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[7] Rollback V001:" +$PYTHON named-migration "$V001" --host localhost --database test --direction down --record-store "$STORE" +echo + +echo "[8] Record store final state (all rolled back):" +cat "$STORE" +echo + +rm -f "$STORE" +echo "=== Dependency Chain Migration Complete (PostgreSQL) ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh new file mode 100755 index 00000000..2945110c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_incompatible.sh — dialect incompatibility via dry-run for PostgreSQL +# +# Scenarios: +# - compatible migration dry-run succeeds +# - incompatible expression caught as MigrationDialectError +# +# Usage: +# cd python-activerecord-postgres +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_incompatible.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V001CreateUsers" +STORE="./demo_pg_ic_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Dialect Incompatibility Detection for PostgreSQL ===" +echo + +echo "[1] Compatible migration dry-run (should succeed):" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --dry-run +echo + +echo "[2] Demonstration:" +echo " The named migration system catches dialect incompatibilities at dry-run" +echo " time via to_sql() on each expression. If an expression uses a feature" +echo " not supported by the connected PostgreSQL version, UnsupportedFeatureError" +echo " is caught and wrapped as MigrationDialectError." +echo +echo " Incompatible expressions produce output like:" +echo " Dialect Error: 'postgresql' dialect does not support ..." +echo + +echo "[3] Compatible apply UP (succeeds):" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --record-store "$STORE" 2>&1 || true +echo + +echo "[4] Rollback DOWN:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction down --record-store "$STORE" +echo + +rm -f "$STORE" +echo "=== Dialect Incompatibility Demo Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh new file mode 100755 index 00000000..3f9a25e9 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# =========================================================================== +# demo_params.sh — parameterized migration (--param) for PostgreSQL +# +# Scenarios: +# - describe a parameterized migration +# - apply with custom --param table_name=my_config +# - dry-run with custom param +# - rollback with matching --param +# +# Usage: +# cd python-activerecord-postgres +# PYTHONPATH=src \ +# bash src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/demo_params.sh +# =========================================================================== +set -euo pipefail + +if [ -d "./src" ]; then + export PYTHONPATH="${PYTHONPATH:-}:$(pwd)/src" +fi + +MODULE="rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" +FQN="${MODULE}.migrations.V003CreateCustomTable" +STORE="./demo_pg_params_mig.json" +VENV_PYTHON="${DEMO_VENV_PYTHON:-python3}" +PYTHON="$VENV_PYTHON -m rhosocial.activerecord.backend.impl.postgres" + +rm -f "$STORE" +echo "=== Parameterized Migration (--param) for PostgreSQL ===" +echo + +echo "[1] Describe V003CreateCustomTable (shows table_name parameter):" +$PYTHON named-migration "$FQN" --describe +echo + +echo "[2] Dry-run with --param table_name=my_config:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --param table_name=my_config --dry-run +echo + +echo "[3] Apply UP with --param table_name=my_config:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction up --param table_name=my_config --record-store "$STORE" +echo + +echo "[4] Rollback with --param table_name=my_config:" +$PYTHON named-migration "$FQN" --host localhost --database test \ + --direction down --param table_name=my_config --record-store "$STORE" +echo + +echo "[5] Dry-run without --param (uses default 'custom_table'):" +$PYTHON named-migration "$FQN" --host localhost --database test --direction up --dry-run +echo + +rm -f "$STORE" +echo "=== Parameterized Migration Demo Complete for PostgreSQL ===" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py new file mode 100644 index 00000000..a37101fd --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -0,0 +1,88 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py +""" +DDL named expression functions for PostgreSQL migration examples. + +Each function receives a *dialect* and returns a DDL expression object. +These are the building blocks used by NamedMigration up()/down() methods. +""" + +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + DropTableExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresCharacterVaryingType, +) + + +def create_users_table(dialect): + """CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(255), email VARCHAR(255)).""" + return CreateTableExpression( + dialect, + table="users", + columns=[ + ColumnDefinition( + "id", + PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition("name", PostgresCharacterVaryingType(255)), + ColumnDefinition("email", PostgresCharacterVaryingType(255)), + ], + ) + + +def drop_users_table(dialect): + """DROP TABLE IF EXISTS users.""" + return DropTableExpression(dialect, table="users", if_exists=True) + + +def create_posts_table(dialect): + """CREATE TABLE posts (id SERIAL PRIMARY KEY, title VARCHAR(255), user_id INTEGER).""" + return CreateTableExpression( + dialect, + table="posts", + columns=[ + ColumnDefinition( + "id", + PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition("title", PostgresCharacterVaryingType(255)), + ColumnDefinition("user_id", PostgresCharacterVaryingType(255)), + ], + ) + + +def drop_posts_table(dialect): + """DROP TABLE IF EXISTS posts.""" + return DropTableExpression(dialect, table="posts", if_exists=True) + + +def create_custom_table(dialect, table_name: str = "custom_table"): + """CREATE TABLE (id SERIAL PRIMARY KEY, value VARCHAR(255)). + + This expression accepts an extra ``table_name`` parameter, allowing + the migration to control the target table name at runtime. + """ + return CreateTableExpression( + dialect, + table=table_name, + columns=[ + ColumnDefinition( + "id", + PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], + ), + ColumnDefinition("value", PostgresCharacterVaryingType(255)), + ], + ) + + +def drop_custom_table(dialect, table_name: str = "custom_table"): + """DROP TABLE IF EXISTS .""" + return DropTableExpression(dialect, table=table_name, if_exists=True) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py new file mode 100644 index 00000000..ced2fb4e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py @@ -0,0 +1,73 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/migrations.py +""" +NamedMigration subclasses for PostgreSQL migration examples. + +Each class represents a single schema migration with version, optional +dependencies, and up()/down() methods that call named expressions. +""" + +from rhosocial.activerecord.backend.migration import NamedMigration, MigrationContext + + +class V001CreateUsers(NamedMigration): + """Create the ``users`` table.""" + + version = "v001_create_users" + + def up(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.create_users_table" + ) + + def down(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.drop_users_table" + ) + + +class V002CreatePosts(NamedMigration): + """Create the ``posts`` table after ``users`` exists.""" + + version = "v002_create_posts" + dependencies = ["v001_create_users"] + + def up(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.create_posts_table" + ) + + def down(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.drop_posts_table" + ) + + +class V003CreateCustomTable(NamedMigration): + """Create a table with a user-specified name. + + Accepts a ``table_name`` parameter (default ``custom_table``). + Usage:: + + named-migration ... V003CreateCustomTable --param table_name=my_config + """ + + version = "v003_create_custom_table" + table_name: str = "custom_table" + + def up(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.create_custom_table", + {"table_name": self.table_name}, + ) + + def down(self, ctx: MigrationContext) -> None: + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations" + ".expressions.drop_custom_table", + {"table_name": self.table_name}, + ) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py new file mode 100644 index 00000000..8effaa83 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py @@ -0,0 +1,93 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_basic.py +""" +Basic migration example — single migration UP then DOWN (PostgreSQL). + +This script demonstrates: + 1. Creating a PostgreSQL backend + 2. Running a single ``NamedMigration`` UP (creates ``users`` table) + 3. Verifying the table was created + 4. Running the same migration DOWN (drops ``users`` table) + 5. Showing JSON record store persistence + 6. Dry-run mode (no actual changes) + 7. Duplicate execution protection + +Usage: + python -m rhosocial.activerecord.backend.impl.postgres.examples.named_migrations.run_basic +""" + +from pathlib import Path +import tempfile +import os + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.migration import ( + MigrationRunner, + MigrationDirection, + JSONFileMigrationRecordStore, + MigrationAlreadyAppliedError, +) + + +def main(): + print("=" * 60) + print("Named Migration Demo — Basic (PostgreSQL)") + print("=" * 60) + + config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + ) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + print("\n[1] PostgreSQL backend connected.") + + store_path = Path(tempfile.gettempdir()) / "mig_pg_basic.json" + if store_path.exists(): + store_path.unlink() + store = JSONFileMigrationRecordStore(store_path) + print(f"[2] Record store: {store_path}") + + fqn = ( + "rhosocial.activerecord.backend.impl.postgres.examples" + ".named_migrations.migrations.V001CreateUsers" + ) + runner = MigrationRunner(fqn) + + print("\n[3] Dry-run (UP) — no actual changes …") + result = runner.run(backend, MigrationDirection.UP, dry_run=True) + print(f" Result: version={result.version}, success={result.success}") + print(" ✓ Dry-run completed (table not created).") + + print("\n[4] Applying v001_create_users (UP) …") + result = runner.run(backend, MigrationDirection.UP, record_store=store) + print(f" Result: version={result.version}, success={result.success}") + print(" ✓ Table 'users' created.") + + print("\n[5] Duplicate UP (should be rejected) …") + try: + runner.run(backend, MigrationDirection.UP, record_store=store) + print(" ✗ ERROR: should have raised!") + except MigrationAlreadyAppliedError as e: + print(f" ✓ {e}") + + print("\n[6] Rolling back v001_create_users (DOWN) …") + result = runner.run(backend, MigrationDirection.DOWN, record_store=store) + print(f" Result: version={result.version}, success={result.success}") + print(" ✓ Table 'users' dropped.") + + applied = store.get_applied() + print(f"\n[7] Applied migrations: {len(applied)} (should be 0)") + + backend.disconnect() + if store_path.exists(): + store_path.unlink() + print("\n=== PostgreSQL basic migration demo completed ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py new file mode 100644 index 00000000..f2c4f07e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py @@ -0,0 +1,102 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/run_chain.py +""" +Multi-step migration chain example — two migrations with dependency (PostgreSQL). + +This script demonstrates: + 1. Running v001_create_users (creates ``users`` table) + 2. Running v002_create_posts (creates ``posts`` table, depends on v001) + 3. Dependency validation (v002 fails if v001 is not applied) + 4. Rolling back in reverse order + 5. Tracking applied migrations in JSON record store + +Usage: + python -m rhosocial.activerecord.backend.impl.postgres.examples.named_migrations.run_chain +""" + +from pathlib import Path +import tempfile +import os + +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.migration import ( + MigrationRunner, + MigrationDirection, + JSONFileMigrationRecordStore, + MigrationDependencyError, + MigrationNotAppliedError, +) + +BASE = "rhosocial.activerecord.backend.impl.postgres.examples.named_migrations.migrations" + + +def main(): + print("=" * 60) + print("Named Migration Demo — Dependency Chain (PostgreSQL)") + print("=" * 60) + + config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + ) + backend = PostgresBackend(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + + store_path = Path(tempfile.gettempdir()) / "mig_pg_chain.json" + if store_path.exists(): + store_path.unlink() + store = JSONFileMigrationRecordStore(store_path) + print(f"\n[1] Record store: {store_path}") + + r1 = MigrationRunner(f"{BASE}.V001CreateUsers") + r2 = MigrationRunner(f"{BASE}.V002CreatePosts") + + print("\n[2] Attempt v002 before v001 (should fail) …") + try: + r2.run(backend, MigrationDirection.UP, record_store=store) + print(" ✗ ERROR: should have raised!") + except MigrationDependencyError as e: + print(f" ✓ {e}") + + print("\n[3] Applying v001_create_users …") + r1.run(backend, MigrationDirection.UP, record_store=store) + print(" ✓ Table 'users' created.") + + print("\n[4] Applying v002_create_posts …") + r2.run(backend, MigrationDirection.UP, record_store=store) + print(" ✓ Table 'posts' created (dependency check passed).") + + print("\n[5] Applied migrations:") + for rec in store.get_applied(): + print(f" - {rec.version}") + + print("\n[6] Rolling back v002_create_posts …") + r2.run(backend, MigrationDirection.DOWN, record_store=store) + print(" ✓ Table 'posts' dropped; 'users' still exists.") + + print("\n[7] Rolling back v001_create_users …") + r1.run(backend, MigrationDirection.DOWN, record_store=store) + print(" ✓ Table 'users' dropped.") + + assert len(store.get_applied()) == 0 + print("\n[8] All migrations rolled back (0 applied).") + + print("\n[9] DOWN on unapplied migration (should fail) …") + try: + r1.run(backend, MigrationDirection.DOWN, record_store=store) + print(" ✗ ERROR: should have raised!") + except MigrationNotAppliedError as e: + print(f" ✓ {e}") + + backend.disconnect() + if store_path.exists(): + store_path.unlink() + print("\n=== PostgreSQL chain migration demo completed ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py new file mode 100644 index 00000000..2c7241df --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py @@ -0,0 +1,389 @@ +""" +Recursive CTE graph traversal — PGQ quantified path alternative +=============================================================== + +PostgreSQL 19 natively supports SQL/PGQ (MATCH / GRAPH_TABLE), with limitations: + - supports_quantified_path() → False (no variable-length paths +/*/{n,m}) + - supports_comma_separated_patterns() → False (no comma-separated multi-pattern) + +This example demonstrates recursive CTE + SetOperationExpression + UNION ALL +as an equivalent alternative for graph traversal patterns. + +Common use cases: + 1. Social network N-degree friend recommendations (replaces MATCH (a)-[e]->+(b)) + 2. AML fund source tracing (replaces MATCH (s)-[t*1..5]->(d)) + +Key components: + - CTEExpression: defines a CTE (WITH ... AS ...) + - SetOperationExpression: combines base + recursive queries (UNION ALL) + - WithQueryExpression(recursive=True): generates WITH RECURSIVE ... SELECT ... +""" + +# ============================================================ +# SECTION: Setup (reference only — creates tables and data) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig + +config = PostgresConnectionConfig( + host=os.getenv('POSTGRES_HOST', 'localhost'), + port=int(os.getenv('POSTGRES_PORT', 5432)), + database=os.getenv('POSTGRES_DATABASE', 'test'), + username=os.getenv('POSTGRES_USER', 'postgres'), + password=os.getenv('POSTGRES_PASSWORD', ''), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +dialect = backend.dialect + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + CTEExpression, + WithQueryExpression, + SetOperationExpression, + FunctionCall, + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import BetweenPredicate +from rhosocial.activerecord.backend.expression.query_parts import ( + WhereClause, + OrderByClause, + JoinExpression, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +dql_options = ExecutionOptions(stmt_type=StatementType.DQL) + +for t in ("transactions", "accounts", "follows", "users"): + sql, params = DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql() + backend.execute(sql, params) + +# ---- Social network tables ---- +users_cols = [ + ColumnDefinition('id', 'INTEGER', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition('name', 'VARCHAR(100)'), + ColumnDefinition('city', 'VARCHAR(50)'), +] +backend.execute(*CreateTableExpression(dialect, 'users', users_cols, if_not_exists=True).to_sql()) + +follows_cols = [ + ColumnDefinition('id', 'INTEGER', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition('follower_id', 'INTEGER'), + ColumnDefinition('followed_id', 'INTEGER'), +] +backend.execute(*CreateTableExpression(dialect, 'follows', follows_cols, if_not_exists=True).to_sql()) + +users_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 'Alice'), Literal(dialect, 'NYC')], + [Literal(dialect, 2), Literal(dialect, 'Bob'), Literal(dialect, 'NYC')], + [Literal(dialect, 3), Literal(dialect, 'Charlie'), Literal(dialect, 'LA')], + [Literal(dialect, 4), Literal(dialect, 'Diana'), Literal(dialect, 'NYC')], + [Literal(dialect, 5), Literal(dialect, 'Eve'), Literal(dialect, 'LA')], +]) +backend.execute(*InsertExpression(dialect, 'users', columns=['id', 'name', 'city'], source=users_data).to_sql()) + +follows_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2)], + [Literal(dialect, 2), Literal(dialect, 2), Literal(dialect, 3)], + [Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, 3)], + [Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, 1)], + [Literal(dialect, 5), Literal(dialect, 3), Literal(dialect, 5)], +]) +backend.execute(*InsertExpression(dialect, 'follows', columns=['id', 'follower_id', 'followed_id'], source=follows_data).to_sql()) + +# ---- AML tables ---- +accounts_cols = [ + ColumnDefinition('id', 'INTEGER', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition('account_holder', 'VARCHAR(100)'), + ColumnDefinition('account_type', 'VARCHAR(20)'), +] +backend.execute(*CreateTableExpression(dialect, 'accounts', accounts_cols, if_not_exists=True).to_sql()) + +txn_cols = [ + ColumnDefinition('id', 'INTEGER', constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition('source_account_id', 'INTEGER'), + ColumnDefinition('target_account_id', 'INTEGER'), + ColumnDefinition('amount', 'NUMERIC(12,2)'), + ColumnDefinition('ts', 'VARCHAR(20)'), +] +backend.execute(*CreateTableExpression(dialect, 'transactions', txn_cols, if_not_exists=True).to_sql()) + +accounts_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 'Alice Smith'), Literal(dialect, 'checking')], + [Literal(dialect, 2), Literal(dialect, 'Bob Corp'), Literal(dialect, 'business')], + [Literal(dialect, 3), Literal(dialect, 'Charlie Ltd'), Literal(dialect, 'business')], + [Literal(dialect, 4), Literal(dialect, 'Diana Offshore'), Literal(dialect, 'offshore')], + [Literal(dialect, 5), Literal(dialect, 'Eve Holding'), Literal(dialect, 'offshore')], + [Literal(dialect, 6), Literal(dialect, 'Foreign Entity'), Literal(dialect, 'offshore')], +]) +backend.execute(*InsertExpression(dialect, 'accounts', columns=['id', 'account_holder', 'account_type'], source=accounts_data).to_sql()) + +txn_data = ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 4), Literal(dialect, 3), Literal(dialect, 500000), Literal(dialect, '2024-01-15')], + [Literal(dialect, 2), Literal(dialect, 5), Literal(dialect, 3), Literal(dialect, 300000), Literal(dialect, '2024-02-01')], + [Literal(dialect, 3), Literal(dialect, 3), Literal(dialect, 2), Literal(dialect, 600000), Literal(dialect, '2024-03-01')], + [Literal(dialect, 4), Literal(dialect, 2), Literal(dialect, 1), Literal(dialect, 450000), Literal(dialect, '2024-04-01')], + [Literal(dialect, 5), Literal(dialect, 6), Literal(dialect, 5), Literal(dialect, 1000000), Literal(dialect, '2024-05-01')], +]) +backend.execute(*InsertExpression(dialect, 'transactions', columns=['id', 'source_account_id', 'target_account_id', 'amount', 'ts'], source=txn_data).to_sql()) + +# ============================================================ +# SECTION: Scenario 1 — Social Network N-degree Recommendation +# Equivalent to PGQ: MATCH (a:person {name: 'Alice'})-[e:follows]+{1,3}->(b:person) +# ============================================================ +print("=" * 72) +print("Scenario 1: Social Network N-degree Friend Recommendations") +print("Equivalent: MATCH (a)-[e:follows]+{1,3}->(b)") +print("Technique: Recursive CTE + UNION ALL + depth filter ({1,3})") +print("=" * 72) + +# --- Base query: start vertex Alice, depth=0 --- +base_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Literal(dialect, 0).as_('depth'), + ], + from_=TableExpression(dialect, 'users'), + where=WhereClause(dialect, condition=Column(dialect, 'name') == Literal(dialect, 'Alice')), +) + +# --- Recursive query: follow edges one hop, depth+1, bound depth<4 --- +recursive_join = JoinExpression( + dialect=dialect, + left_table=TableExpression(dialect, 'traversal', alias='t'), + right_table=TableExpression(dialect, 'follows', alias='f'), + join_type='INNER JOIN', + condition=Column(dialect, 'id', table='t') == Column(dialect, 'follower_id', table='f'), +).inner_join( + right_table=TableExpression(dialect, 'users', alias='u'), + condition=Column(dialect, 'followed_id', table='f') == Column(dialect, 'id', table='u'), +) + +recursive_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id', table='u'), + Column(dialect, 'name', table='u'), + FunctionCall(dialect, '+', Column(dialect, 'depth', table='t'), Literal(dialect, 1)).as_('depth'), + ], + from_=recursive_join, + where=WhereClause(dialect, condition=Column(dialect, 'depth', table='t') < Literal(dialect, 4)), +) + +# --- Combine with UNION ALL --- +union_expr = SetOperationExpression( + dialect=dialect, + left=base_query, + right=recursive_query, + operation='UNION ALL', +) + +# --- Define CTE --- +traversal_cte = CTEExpression( + dialect=dialect, + name='traversal', + query=union_expr, + columns=['id', 'name', 'depth'], +) + +# --- Main query: filter depth 1~3 (simulating quantified path {1,3}) --- +friend_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'name'), + Column(dialect, 'depth'), + ], + from_=TableExpression(dialect, 'traversal'), + where=WhereClause(dialect, condition=BetweenPredicate( + dialect, Column(dialect, 'depth'), Literal(dialect, 1), Literal(dialect, 3), + )), + order_by=OrderByClause(dialect, [Column(dialect, 'depth'), Column(dialect, 'name')]), +) + +with_query = WithQueryExpression( + dialect=dialect, + ctes=[traversal_cte], + main_query=friend_query, + recursive=True, +) + +sql, params = with_query.to_sql() +print(f"\nGenerated SQL:\n{sql}\n") +result = backend.execute(sql, params, options=dql_options) +print("Friend recommendations for Alice (depth 1~3):") +for row in result.data or []: + print(f" depth={row['depth']}: {row['name']}") +print() + +# ============================================================ +# SECTION: Scenario 2 — AML Fund Source Tracing +# Equivalent to PGQ: MATCH (s:account)-[t:transfer*1..5]->(d:account {id: 1}) +# ============================================================ +print("=" * 72) +print("Scenario 2: AML Fund Source Tracing") +print("Equivalent: MATCH (s)-[t:transfer*1..5]->(d {id: 1})") +print("Technique: Recursive CTE + UNION ALL + depth limit + amount aggregation") +print("=" * 72) + +# --- Base query: direct sources sending to target account ACC-001 --- +aml_base_join = JoinExpression( + dialect=dialect, + left_table=TableExpression(dialect, 'transactions', alias='tx'), + right_table=TableExpression(dialect, 'accounts', alias='a'), + join_type='INNER JOIN', + condition=Column(dialect, 'source_account_id', table='tx') == Column(dialect, 'id', table='a'), +) + +aml_base = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id', table='a'), + Column(dialect, 'account_holder', table='a'), + Column(dialect, 'account_type', table='a'), + Column(dialect, 'amount', table='tx'), + Literal(dialect, 1).as_('depth'), + ], + from_=aml_base_join, + where=WhereClause(dialect, condition=Column(dialect, 'target_account_id', table='tx') == Literal(dialect, 1)), +) + +# --- Recursive query: trace upstream along the transaction chain --- +aml_recursive_join = JoinExpression( + dialect=dialect, + left_table=TableExpression(dialect, 'fund_trace', alias='tr'), + right_table=TableExpression(dialect, 'transactions', alias='tx'), + join_type='INNER JOIN', + condition=Column(dialect, 'target_account_id', table='tx') == Column(dialect, 'id', table='tr'), +).inner_join( + right_table=TableExpression(dialect, 'accounts', alias='a'), + condition=Column(dialect, 'source_account_id', table='tx') == Column(dialect, 'id', table='a'), +) + +aml_recursive = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id', table='a'), + Column(dialect, 'account_holder', table='a'), + Column(dialect, 'account_type', table='a'), + Column(dialect, 'amount', table='tx'), + FunctionCall(dialect, '+', Column(dialect, 'depth', table='tr'), Literal(dialect, 1)).as_('depth'), + ], + from_=aml_recursive_join, + where=WhereClause(dialect, condition=Column(dialect, 'depth', table='tr') < Literal(dialect, 5)), +) + +# --- Combine with UNION ALL --- +aml_union = SetOperationExpression( + dialect=dialect, + left=aml_base, + right=aml_recursive, + operation='UNION ALL', +) + +# --- Define CTE --- +fund_trace_cte = CTEExpression( + dialect=dialect, + name='fund_trace', + query=aml_union, + columns=['id', 'account_holder', 'account_type', 'amount', 'depth'], +) + +# --- Main query: list the full fund chain --- +aml_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'id'), + Column(dialect, 'account_holder'), + Column(dialect, 'account_type'), + Column(dialect, 'amount'), + Column(dialect, 'depth'), + ], + from_=TableExpression(dialect, 'fund_trace'), + where=WhereClause(dialect, condition=Column(dialect, 'depth') >= Literal(dialect, 1)), + order_by=OrderByClause(dialect, [Column(dialect, 'depth'), Column(dialect, 'id')]), +) + +aml_with = WithQueryExpression( + dialect=dialect, + ctes=[fund_trace_cte], + main_query=aml_query, + recursive=True, +) + +sql, params = aml_with.to_sql() +print(f"\nGenerated SQL:\n{sql}\n") +result = backend.execute(sql, params, options=dql_options) +print("Fund source tracing (upstream chain for Alice Smith / ACC-001):") +print(f"{'depth':<6} {'Holder':<20} {'Type':<12} {'Amount':>12}") +print("-" * 52) +for row in result.data or []: + print(f" {row['depth']:<4} {row['account_holder']:<20} {row['account_type']:<12} {float(row['amount']):>10,.0f}") + +# --- Aggregation query: total suspicious amount per depth level --- +agg_query = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, 'depth'), + FunctionCall(dialect, 'COUNT', Column(dialect, 'id'), alias='account_count'), + FunctionCall(dialect, 'SUM', Column(dialect, 'amount'), alias='total_amount'), + ], + from_=TableExpression(dialect, 'fund_trace'), + order_by=OrderByClause(dialect, [Column(dialect, 'depth')]), +) + +agg_with = WithQueryExpression( + dialect=dialect, + ctes=[fund_trace_cte], + main_query=agg_query, + recursive=True, +) + +sql, params = agg_with.to_sql() +result = backend.execute(sql, params, options=dql_options) +print("\nAggregation: total suspicious amount per depth level") +print(f"{'depth':<8} {'Accounts':<10} {'Total Amount':>14}") +print("-" * 34) +for row in result.data or []: + print(f" {row['depth']:<6} {row['account_count']:<10} {float(row['total_amount']):>10,.0f}") + +# ============================================================ +# SECTION: Summary (key technical points) +# ============================================================ +print() +print("=" * 72) +print("Summary / Key Technical Points") +print("=" * 72) +print(""" +1. SetOperationExpression(operation='UNION ALL') combines base + recursive queries +2. CTEExpression(name=..., query=..., columns=...) defines the CTE column signature +3. WithQueryExpression(recursive=True) generates WITH RECURSIVE +4. Recursive query joins the CTE reference with physical tables via chained JoinExpression +5. depth field simulates the hop count of quantified path patterns +6. WHERE depth BETWEEN x AND y simulates {x,y} range constraint +7. Use cases: social recommendations, fund tracing, knowledge graph traversal +""") + +# ============================================================ +# SECTION: Teardown (reference only — cleanup resources) +# ============================================================ +for t in ("transactions", "accounts", "follows", "users"): + sql, params = DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql() + backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py new file mode 100644 index 00000000..5b0583c7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py @@ -0,0 +1,141 @@ +""" +Schema diff: PostgreSQL array type dimension changes. + +PostgresArrayType.is_equivalent() handles dimension comparison +internally. Column type changes like INTEGER[] → INTEGER[][] +are detected via parsed_data_type equivalence checks. + +Supported versions: PostgreSQL 8.4+ (arrays available since PG 8.4) +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, ArrayType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_v1 = DropTableExpression(dialect, "scores_v1", if_exists=True, cascade=True) +drop_v2 = DropTableExpression(dialect, "scores_v2", if_exists=True, cascade=True) +sql, params = drop_v1.to_sql() +backend.execute(sql, params) +sql, params = drop_v2.to_sql() +backend.execute(sql, params) + +# One-dimensional array column +scores_v1_table = CreateTableExpression( + dialect=dialect, table="scores_v1", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("values", ArrayType(IntegerType(), dimensions=1)), + ] +) +sql, params = scores_v1_table.to_sql() +backend.execute(sql, params) + +# Two-dimensional array column +scores_v2_table = CreateTableExpression( + dialect=dialect, table="scores_v2", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("values", ArrayType(IntegerType(), dimensions=2)), + ] +) +sql, params = scores_v2_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot = builder.build(schema="public") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +for tbl_name in ("scores_v1", "scores_v2"): + if tbl_name in snapshot.tables: + for col in snapshot.tables[tbl_name].columns: + if col.name == "values": + pt = col.parsed_data_type + dims = pt.dimensions if hasattr(pt, "dimensions") else "N/A" + print(f"{tbl_name}.values: data_type='{col.data_type}', dimensions={dims}") + +# Manually construct two snapshots that differ only in array dimensions +from rhosocial.activerecord.backend.introspection.types import ( # noqa: E402 + TableInfo, ColumnInfo, ColumnNullable, TableType, DatabaseInfo, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, ArrayType # noqa: E402 +from datetime import datetime, timezone # noqa: E402 + +db_info = DatabaseInfo(name="test", version="16", version_tuple=(16, 0, 0), vendor="postgresql") + +# V1: 1-d array +col1d = ColumnInfo( + name="values", table_name="t", ordinal_position=1, + data_type="INTEGER[]", parsed_data_type=ArrayType(IntegerType(), dimensions=1), + nullable=ColumnNullable.NULLABLE, +) +snap1d = snapshot.__class__( + dialect_class=snapshot.dialect_class, + captured_at=datetime.now(tz=timezone.utc), + database_info=db_info, + tables={"t": TableInfo(name="t", columns=[col1d], table_type=TableType.BASE_TABLE)}, +) + +# V2: 2-d array +col2d = ColumnInfo( + name="values", table_name="t", ordinal_position=1, + data_type="INTEGER[][]", parsed_data_type=ArrayType(IntegerType(), dimensions=2), + nullable=ColumnNullable.NULLABLE, +) +snap2d = snapshot.__class__( + dialect_class=snapshot.dialect_class, + captured_at=datetime.now(tz=timezone.utc), + database_info=db_info, + tables={"t": TableInfo(name="t", columns=[col2d], table_type=TableType.BASE_TABLE)}, +) + +differ = PostgresSchemaDiffer() +diff = differ.compare(snap1d, snap2d) +print("\nDiff 1-d vs 2-d array:") +print(f" Modified tables: {diff.modified_tables}") +print(f" Diff is empty: {diff.is_empty}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_v1.to_sql() +backend.execute(sql, params) +sql, params = drop_v2.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py new file mode 100644 index 00000000..1015e988 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py @@ -0,0 +1,103 @@ +""" +Schema diff: PostgreSQL column order has no semantic meaning. + +Unlike MySQL, PostgreSQL does not assign semantic significance to +column ordinal position. Reordering columns produces no diff because +PostgresSchemaDiffer only compares type, nullability, and default value. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterTableExpression, AddColumn, DropColumn, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_demo = DropTableExpression(dialect, "demo", if_exists=True, cascade=True) +sql, params = drop_demo.to_sql() +backend.execute(sql, params) + +demo_table = CreateTableExpression( + dialect=dialect, table="demo", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ColumnDefinition("email", VarCharType(200)), + ] +) +sql, params = demo_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot_before = builder.build(schema="public") + +# Add a column between name and email — shifts ordinal positions +# but PostgresSchemaDiffer ignores ordinal_position. +add_age = AlterTableExpression(dialect, "demo", [ + AddColumn(dialect, ColumnDefinition("age", IntegerType())) +]) +sql, params = add_age.to_sql() +backend.execute(sql, params) + +# Now drop it — column set is identical +drop_age = AlterTableExpression(dialect, "demo", [ + DropColumn(dialect, "age") +]) +sql, params = drop_age.to_sql() +backend.execute(sql, params) + +snapshot_after = builder.build(schema="public") + +differ = PostgresSchemaDiffer() +diff = differ.compare(snapshot_before, snapshot_after) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Modified tables: {diff.modified_tables}") +print(f"Diff is empty: {diff.is_empty}") +print("(No diff expected — column set unchanged regardless of position)") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_demo.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py new file mode 100644 index 00000000..eb2cc5d2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py @@ -0,0 +1,103 @@ +""" +Schema diff: PostgreSQL-specific index types (BTREE, GIN, GiST, HASH). + +PostgreSQL supports multiple index access methods. The differ checks +index_type for equivalence, so switching from BTREE to GIN or GiST +will be reported as a removed+added index pair. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + VarCharType, ArrayType, TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, DropIndexExpression, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_demo = DropTableExpression(dialect, "demo", if_exists=True, cascade=True) +sql, params = drop_demo.to_sql() +backend.execute(sql, params) + +demo_table = CreateTableExpression( + dialect=dialect, table="demo", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(200)), + ColumnDefinition("tags", ArrayType(TextType(), dimensions=1)), + ] +) +sql, params = demo_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) + +# Snapshot with BTREE index +idx_btree = CreateIndexExpression(dialect, "idx_name", "demo", ["name"], index_type="BTREE") +sql, params = idx_btree.to_sql() +backend.execute(sql, params) +snap_btree = builder.build(schema="public") + +# Drop BTREE, create GIN index on array column — different index type +drop_idx = DropIndexExpression(dialect, "idx_name", "demo", if_exists=True) +sql, params = drop_idx.to_sql() +backend.execute(sql, params) +idx_gin = CreateIndexExpression(dialect, "idx_tags", "demo", ["tags"], index_type="GIN") +sql, params = idx_gin.to_sql() +backend.execute(sql, params) +snap_gin = builder.build(schema="public") + +differ = PostgresSchemaDiffer() +diff = differ.compare(snap_btree, snap_gin) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Modified tables: {diff.modified_tables}") + +if "demo" in diff.table_diffs: + td = diff.table_diffs["demo"] + print(f" Removed indexes: {[(idx.name, idx.index_type.value) for idx in td.removed_indexes]}") + print(f" Added indexes: {[(idx.name, idx.index_type.value) for idx in td.added_indexes]}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_demo.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py new file mode 100644 index 00000000..7075b33e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py @@ -0,0 +1,116 @@ +""" +Schema diff: PostgreSQL 9 vs 10+ — SERIAL vs GENERATED AS IDENTITY. + +PostgreSQL 9: SERIAL creates a sequence + DEFAULT nextval(). +PostgreSQL 10+: GENERATED AS IDENTITY is the SQL-standard way. + +When comparing snapshots, a SERIAL column and an IDENTITY column +will appear differently in introspected metadata: +- SERIAL: default_value = 'nextval(''tab_col_seq''::regclass)' +- IDENTITY: is_auto_increment = True, special metadata + +Supported versions: PostgreSQL 9 — SERIAL only. + PostgreSQL 10+ — GENERATED AS IDENTITY available. +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_pg9 = DropTableExpression(dialect, "pg9_style", if_exists=True, cascade=True) +drop_pg10 = DropTableExpression(dialect, "pg10_style", if_exists=True, cascade=True) +sql, params = drop_pg9.to_sql() +backend.execute(sql, params) +sql, params = drop_pg10.to_sql() +backend.execute(sql, params) + +# PostgreSQL 9-compatible: SERIAL shorthand +pg9_table = CreateTableExpression( + dialect=dialect, table="pg9_style", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ] +) +sql, params = pg9_table.to_sql() +backend.execute(sql, params) + +# PostgreSQL 10+: GENERATED AS IDENTITY +# Note: dialect_options={'identity': 'ALWAYS'} triggers GENERATED ALWAYS AS IDENTITY +# in the PostgreSQL dialect's format_column_definition +try: + pg10_table = CreateTableExpression( + dialect=dialect, table="pg10_style", columns=[ + ColumnDefinition("id", IntegerType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)], + dialect_options={"identity": "ALWAYS"}), + ColumnDefinition("name", VarCharType(100)), + ] + ) + sql, params = pg10_table.to_sql() + backend.execute(sql, params) +except Exception as e: + print(f"(GENERATED AS IDENTITY not available on this server: {e})") + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot = builder.build(schema="public") + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +for tbl_name in ("pg9_style", "pg10_style"): + if tbl_name in snapshot.tables: + tbl = snapshot.tables[tbl_name] + for col in tbl.columns: + if col.is_auto_increment or col.is_primary_key: + print(f"{tbl_name}.{col.name}:") + print(f" data_type: {col.data_type}") + print(f" is_auto_increment:{col.is_auto_increment}") + print(f" is_generated: {col.is_generated}") + print(f" default_value: {col.default_value}") + print(f" generated_expr: {col.generated_expression}") + +print() +print("Note: SERIAL vs IDENTITY columns produce different metadata.") +print("When diff-ing PG9 vs PG10+ snapshots, these differences will appear.") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_pg9.to_sql() +backend.execute(sql, params) +sql, params = drop_pg10.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py new file mode 100644 index 00000000..fb41ddd0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py @@ -0,0 +1,127 @@ +""" +Schema diff: snapshot serialization roundtrip across sessions. + +Serialized snapshots can be stored as JSON files and compared +later — useful for CI/CD pipeline schema validation. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import json +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterTableExpression, AddColumn, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_users = DropTableExpression(dialect, "users", if_exists=True, cascade=True) +sql, params = drop_users.to_sql() +backend.execute(sql, params) + +users_table = CreateTableExpression( + dialect=dialect, table="users", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ColumnDefinition("email", VarCharType(200)), + ] +) +sql, params = users_table.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SchemaSnapshot, + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot_before = builder.build(schema="public") + +# Serialize to JSON and deserialize back +snapshot_json = json.dumps(snapshot_before.to_dict(), default=str) +snapshot_loaded = SchemaSnapshot.from_dict(json.loads(snapshot_json)) + +# Modify the database +add_phone = AlterTableExpression(dialect, "users", [ + AddColumn(dialect, ColumnDefinition("phone", VarCharType(20))) +]) +sql, params = add_phone.to_sql() +backend.execute(sql, params) + +snapshot_after = builder.build(schema="public") + +# Diff: loaded snapshot (before) vs current (after) +differ = PostgresSchemaDiffer() +diff = differ.compare(snapshot_loaded, snapshot_after) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Snapshot size (JSON): {len(snapshot_json)} chars") +print(f"Roundtrip preserves dialect: {snapshot_loaded.dialect_class == snapshot_before.dialect_class}") +# Verify table data survived the roundtrip +assert snapshot_loaded.dialect_class == snapshot_before.dialect_class +assert list(snapshot_loaded.tables.keys()) == list(snapshot_before.tables.keys()), ( + f"table names mismatch: {list(snapshot_loaded.tables.keys())} " + f"!= {list(snapshot_before.tables.keys())}" +) +for name in snapshot_before.tables: + before_t = snapshot_before.tables[name] + after_t = snapshot_loaded.tables[name] + assert [c.name for c in before_t.columns] == [c.name for c in after_t.columns], ( + f"columns mismatch in table '{name}'" + ) + for col_b, col_a in zip(before_t.columns, after_t.columns): + assert col_b.data_type.__class__.__name__ == col_a.data_type.__class__.__name__, ( + f"type mismatch in {name}.{col_b.name}" + ) +print("Table data roundtrip: all columns and types match") +print(f"Added tables: {diff.added_tables}") +print(f"Removed tables: {diff.removed_tables}") +print(f"Modified tables: {diff.modified_tables}") + +if "users" in diff.table_diffs: + td = diff.table_diffs["users"] + db_info = snapshot_after.database_info + print(f"Database version: {db_info.version} (vendor={db_info.vendor})") + for cd in td.column_diffs: + print(f" Column '{cd.column_name}': added") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_users.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py new file mode 100644 index 00000000..e9665e0b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py @@ -0,0 +1,109 @@ +""" +Schema diff: basic table add/remove detection in PostgreSQL. + +PostgreSQL uses multi-level namespaces (schema.table), and the +PostgresSchemaDiffer treats column order as semantically irrelevant. + +Supported versions: PostgreSQL 9+ +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import os +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, DropTableExpression, ColumnDefinition, + ColumnConstraint, ColumnConstraintType, TableConstraint, TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, DecimalType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + +config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "localhost"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + database=os.getenv("POSTGRES_DATABASE", "test"), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), +) +backend = PostgresBackend(connection_config=config) +backend.connect() +backend.introspect_and_adapt() +dialect = backend.dialect + +drop_users = DropTableExpression(dialect, "users", if_exists=True, cascade=True) +drop_orders = DropTableExpression(dialect, "orders", if_exists=True, cascade=True) +sql, params = drop_users.to_sql() +backend.execute(sql, params) +sql, params = drop_orders.to_sql() +backend.execute(sql, params) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.schema import ( # noqa: E402 + SyncSchemaSnapshotBuilder, +) +from rhosocial.activerecord.backend.impl.postgres.schema.differ import ( # noqa: E402 + PostgresSchemaDiffer, +) + +builder = SyncSchemaSnapshotBuilder(backend.introspector, dialect) +snapshot_before = builder.build(schema="public") + +users_table = CreateTableExpression( + dialect=dialect, table="users", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL)]), + ] +) +sql, params = users_table.to_sql() +backend.execute(sql, params) + +orders_table = CreateTableExpression( + dialect=dialect, table="orders", columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType()), + ColumnDefinition("amount", DecimalType(10, 2)), + ], + table_constraints=[ + TableConstraint( + constraint_type=TableConstraintType.FOREIGN_KEY, + columns=["user_id"], + foreign_key_table="users", + foreign_key_columns=["id"], + ) + ] +) +sql, params = orders_table.to_sql() +backend.execute(sql, params) + +snapshot_after = builder.build(schema="public") + +differ = PostgresSchemaDiffer() +diff = differ.compare(snapshot_before, snapshot_after) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +print(f"Added tables: {diff.added_tables}") +print(f"Removed tables: {diff.removed_tables}") +print(f"Modified tables:{diff.modified_tables}") +print(f"Diff is empty: {diff.is_empty}") + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +sql, params = drop_users.to_sql() +backend.execute(sql, params) +sql, params = drop_orders.to_sql() +backend.execute(sql, params) +backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 9de671dd..14200826 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -47,6 +47,58 @@ PostgresPgPartmanDeleteConfigExpression, ) +# DataType subclasses for DDL +from .types import ( + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCharacterVaryingType, + PostgresCIDType, + PostgresCircleType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, + PostgresCidrType, +) + __all__ = [ # advisory "PostgresAdvisoryLockExpression", @@ -82,4 +134,53 @@ "PostgresPgPartmanRunMaintenanceExpression", "PostgresPgPartmanUpdateConfigExpression", "PostgresPgPartmanDeleteConfigExpression", + # DataType subclasses for DDL + "PostgresBigSerialType", + "PostgresBitType", + "PostgresBoxType", + "PostgresByteaType", + "PostgresCharacterVaryingType", + "PostgresCIDType", + "PostgresCircleType", + "PostgresDateMultirangeType", + "PostgresDateRangeType", + "PostgresGeographyType", + "PostgresGeometryType", + "PostgresHstoreType", + "PostgresInetType", + "PostgresInt4MultirangeType", + "PostgresInt4RangeType", + "PostgresInt8MultirangeType", + "PostgresInt8RangeType", + "PostgresJsonPathType", + "PostgresLineSegmentType", + "PostgresLineType", + "PostgresMacAddr8Type", + "PostgresMacAddrType", + "PostgresMoneyType", + "PostgresNumMultirangeType", + "PostgresNumRangeType", + "PostgresOIDType", + "PostgresPathType", + "PostgresPgLSNType", + "PostgresPointType", + "PostgresPolygonType", + "PostgresRegClassType", + "PostgresRegTypeType", + "PostgresSerialType", + "PostgresSmallSerialType", + "PostgresTIDType", + "PostgresTSQueryType", + "PostgresTSVectorType", + "PostgresTsMultirangeType", + "PostgresTsRangeType", + "PostgresTsTzMultirangeType", + "PostgresTsTzRangeType", + "PostgresUUIDType", + "PostgresVarBitType", + "PostgresVectorType", + "PostgresXID8Type", + "PostgresXIDType", + "PostgresXMLType", + "PostgresCidrType", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 9fa46a23..1c4be6a0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -11,6 +11,15 @@ - mv.py - Materialized view expressions - type.py - Enum/Range type expressions - extension.py - Extension DDL expressions +- policy.py - Row-Level Security POLICY expressions (CREATE/ALTER/DROP) +- rls_config.py - Row-Level Security table configuration (ENABLE/DISABLE/FORCE) +- table_settings.py - ALTER TABLE SET LOGGED/UNLOGGED/ACCESS METHOD +- cluster.py - CLUSTER expressions +- domain.py - CREATE/ALTER/DROP DOMAIN expressions +- collation.py - CREATE/DROP COLLATION object expressions +- foreign_table.py - CREATE/DROP FOREIGN TABLE expressions +- routine.py - CREATE/DROP FUNCTION / AGGREGATE expressions +- publication.py - CREATE/DROP PUBLICATION / SUBSCRIPTION expressions Missing expressions — why? ============================ @@ -84,6 +93,50 @@ PostgresPgPartmanUpdateConfigExpression, PostgresPgPartmanDeleteConfigExpression, ) +from .policy import ( + AlterPolicyMode, + PolicyCommand, + PolicyType, + PostgresCreatePolicyExpression, + PostgresAlterPolicyExpression, + PostgresDropPolicyExpression, +) +from .rls_config import ( + RlsConfigurationMode, + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, +) +from .table_settings import ( + LoggingMode, + PostgresAlterTableSettingsExpression, +) +from .cluster import PostgresClusterExpression +from .domain import ( + AlterDomainActionType, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, +) +from .collation import ( + PostgresCreateCollationExpression, + PostgresDropCollationExpression, +) +from .foreign_table import ( + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, +) +from .routine import ( + PostgresCreateFunctionExpression, + PostgresDropFunctionExpression, + PostgresCreateAggregateExpression, + PostgresDropAggregateExpression, +) +from .publication import ( + PostgresCreatePublicationExpression, + PostgresDropPublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropSubscriptionExpression, +) __all__ = [ # vacuum @@ -120,4 +173,41 @@ "PostgresPgPartmanRunMaintenanceExpression", "PostgresPgPartmanUpdateConfigExpression", "PostgresPgPartmanDeleteConfigExpression", + # policy + "PolicyType", + "PolicyCommand", + "AlterPolicyMode", + "PostgresCreatePolicyExpression", + "PostgresAlterPolicyExpression", + "PostgresDropPolicyExpression", + # rls_config + "RlsConfigurationMode", + "PostgresAlterTableRlsExpression", + "PostgresForceRlsExpression", + # table_settings + "LoggingMode", + "PostgresAlterTableSettingsExpression", + # cluster + "PostgresClusterExpression", + # domain + "AlterDomainActionType", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", + # collation + "PostgresCreateCollationExpression", + "PostgresDropCollationExpression", + # foreign_table + "PostgresCreateForeignTableExpression", + "PostgresDropForeignTableExpression", + # routine + "PostgresCreateFunctionExpression", + "PostgresDropFunctionExpression", + "PostgresCreateAggregateExpression", + "PostgresDropAggregateExpression", + # publication + "PostgresCreatePublicationExpression", + "PostgresDropPublicationExpression", + "PostgresCreateSubscriptionExpression", + "PostgresDropSubscriptionExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py new file mode 100644 index 00000000..91b80782 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py @@ -0,0 +1,69 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py +""" +PostgreSQL DDL expressions: CLUSTER. + +PostgreSQL Documentation: +- CLUSTER: https://www.postgresql.org/docs/current/sql-cluster.html + +Version Requirements: +- CLUSTER table [ USING index ]: PostgreSQL 9.6+ (all supported versions) +- VERBOSE option: PostgreSQL 9.6+ (all supported versions) +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresClusterExpression"] + + +class PostgresClusterExpression(BaseExpression): + """PostgreSQL CLUSTER statement expression. + + Physically reorders a table (or all tables) to match the order of the + index used for clustering. Commonly run inside a transaction after a + bulk load. + + Attributes: + table_name: Name of the table to cluster. If None, clusters all + tables that have previously been clustered (``CLUSTER`` alone). + schema: Optional schema for the table. + using_index: Optional index name for ``CLUSTER table USING index``. + verbose: When True, report the table being clustered (``VERBOSE``). + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresClusterExpression( + ... dialect, table_name="orders", using_index="orders_pkey" + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: Optional[str] = None, + schema: Optional[str] = None, + using_index: Optional[str] = None, + verbose: bool = False, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.using_index = using_index + self.verbose = verbose + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CLUSTER statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_cluster_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py new file mode 100644 index 00000000..5427436c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py @@ -0,0 +1,113 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py +""" +PostgreSQL DDL expressions: COLLATION objects. + +PostgreSQL Documentation: +- CREATE COLLATION: https://www.postgresql.org/docs/current/sql-createcollation.html +- ALTER COLLATION: https://www.postgresql.org/docs/current/sql-altercollation.html +- DROP COLLATION: https://www.postgresql.org/docs/current/sql-dropcollation.html + +Version Requirements: +- CREATE/DROP COLLATION: PostgreSQL 9.6+ (all supported versions) +""" + +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresCreateCollationExpression", "PostgresDropCollationExpression"] + + +class PostgresCreateCollationExpression(BaseExpression): + """PostgreSQL CREATE COLLATION statement expression. + + Defines a new collation object, typically from an OS locale. + + Attributes: + name: Name of the collation. + schema: Optional schema for the collation. + locale: Optional ``LOCALE`` (provider-agnostic shorthand). + lc_collate: Optional ``LC_COLLATE``. + lc_ctype: Optional ``LC_CTYPE``. + provider: Optional ``PROVIDER`` (e.g. ``libc`` or ``icu``). + version: Optional ``VERSION``. + if_not_exists: When True, add ``IF NOT EXISTS``. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + locale: Optional[str] = None, + lc_collate: Optional[str] = None, + lc_ctype: Optional[str] = None, + provider: Optional[str] = None, + version: Optional[str] = None, + if_not_exists: bool = False, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.locale = locale + self.lc_collate = lc_collate + self.lc_ctype = lc_ctype + self.provider = provider + self.version = version + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE COLLATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_collation_ddl_statement(self) + + +class PostgresDropCollationExpression(BaseExpression): + """PostgreSQL DROP COLLATION statement expression. + + Attributes: + name: Name of the collation to drop. + schema: Optional schema for the collation. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP COLLATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_collation_ddl_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py new file mode 100644 index 00000000..5c155ced --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -0,0 +1,168 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +""" +PostgreSQL DDL expressions: DOMAIN operations. + +PostgreSQL Documentation: +- CREATE DOMAIN: https://www.postgresql.org/docs/current/sql-createdomain.html +- ALTER DOMAIN: https://www.postgresql.org/docs/current/sql-alterdomain.html +- DROP DOMAIN: https://www.postgresql.org/docs/current/sql-dropdomain.html + +Version Requirements: +- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ (all supported versions) +""" + +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "AlterDomainActionType", + "PostgresCreateDomainExpression", + "PostgresAlterDomainExpression", + "PostgresDropDomainExpression", +] + + +class AlterDomainActionType(Enum): + """Actions supported by ALTER DOMAIN (minimal subset).""" + + SET_DEFAULT = "SET DEFAULT" + DROP_DEFAULT = "DROP DEFAULT" + RENAME_TO = "RENAME TO" + + +class PostgresCreateDomainExpression(BaseExpression): + """PostgreSQL CREATE DOMAIN statement expression. + + Creates a new domain — a user-defined data type — over an underlying + PostgreSQL type, often reusing types and default/constraint logic. + + Attributes: + name: Name of the domain. + data_type: Underlying PostgreSQL type name (e.g. ``NUMERIC(10, 2)``). + schema: Optional schema for the domain. + collation: Optional collation name. + default: Optional literal default value expression. + constraints: Optional list of constraint clauses (e.g. ``CHECK (...)``). + dialect_options: Reserved for extensions. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type: str, + schema: Optional[str] = None, + collation: Optional[str] = None, + default: Optional[str] = None, + constraints: Optional[List[str]] = None, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.data_type = data_type + self.schema = schema + self.collation = collation + self.default = default + self.constraints = constraints or [] + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate CREATE DOMAIN SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_create_domain_statement(self) + + +class PostgresAlterDomainExpression(BaseExpression): + """PostgreSQL ALTER DOMAIN statement expression (minimal subset). + + Attributes: + name: Name of the domain to alter. + schema: Optional schema for the domain. + action: The action to perform (:class:`AlterDomainActionType`). + new_value: For ``SET DEFAULT``, the new literal default. Otherwise + unused. + new_name: For ``RENAME TO``, the new domain name. + dialect_options: Reserved. + + Returns: + Formatting is delegated to + ``dialect.format_alter_domain_statement``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + action: AlterDomainActionType, + schema: Optional[str] = None, + new_value: Any = None, + new_name: Optional[str] = None, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.action = action + self.new_value = new_value + self.new_name = new_name + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the ALTER DOMAIN SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_postgres_alter_domain_statement(self) + + +class PostgresDropDomainExpression(BaseExpression): + """PostgreSQL DROP DOMAIN statement expression. + + Attributes: + name: Name of the domain to drop. + schema: Optional schema for the domain. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the DROP DOMAIN SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_domain_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py new file mode 100644 index 00000000..66558f59 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py @@ -0,0 +1,107 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py +""" +PostgreSQL DDL expressions: FOREIGN TABLE. + +PostgreSQL Documentation: +- CREATE FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-createforeigntable.html +- DROP FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-dropforeigntable.html + +Version Requirements: +- CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = ["PostgresCreateForeignTableExpression", "PostgresDropForeignTableExpression"] + + +class PostgresCreateForeignTableExpression(BaseExpression): + """PostgreSQL CREATE FOREIGN TABLE statement expression. + + Defines a foreign table backed by a foreign server (FDW). + + Attributes: + name: Name of the foreign table. + schema: Optional schema for the table. + columns: Optional list of column definition strings. + server_name: Name of the foreign server to bind. + options: Optional ``OPTIONS ( option 'value', ... )`` list of + ``"option 'value'"`` strings. + if_not_exists: When True, add ``IF NOT EXISTS``. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + server_name: str, + schema: Optional[str] = None, + columns: Optional[List[str]] = None, + options: Optional[List[str]] = None, + if_not_exists: bool = False, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.server_name = server_name + self.columns = columns or [] + self.options = options or [] + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE FOREIGN TABLE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_foreign_table_statement(self) + + +class PostgresDropForeignTableExpression(BaseExpression): + """PostgreSQL DROP FOREIGN TABLE statement expression. + + Attributes: + name: Name of the foreign table to drop. + schema: Optional schema for the foreign table. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP FOREIGN TABLE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_foreign_table_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py new file mode 100644 index 00000000..42b907e2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py @@ -0,0 +1,249 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py +""" +PostgreSQL DDL expressions: Row-Level Security POLICY operations. + +PostgreSQL Documentation: +- CREATE POLICY: https://www.postgresql.org/docs/current/sql-createpolicy.html +- ALTER POLICY: https://www.postgresql.org/docs/current/sql-alterpolicy.html +- DROP POLICY: https://www.postgresql.org/docs/current/sql-droppolicy.html + +Version Requirements: +- CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+ +- AS RESTRICTIVE / explicit AS PERMISSIVE: PostgreSQL 10+ +- CURRENT_ROLE keyword in TO role list: PostgreSQL 15+ + +Note: These statements are PostgreSQL extensions (non-SQL-standard). They live in +the postgres backend package, not in the core backend/expression/statements/. +""" + +from enum import Enum +from typing import List, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import ( + BaseExpression, + SQLPredicate, + SQLQueryAndParams, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PolicyType", + "PolicyCommand", + "AlterPolicyMode", + "PostgresCreatePolicyExpression", + "PostgresAlterPolicyExpression", + "PostgresDropPolicyExpression", +] + + +class PolicyType(Enum): + """Row-Level Security policy type.""" + + PERMISSIVE = "PERMISSIVE" + RESTRICTIVE = "RESTRICTIVE" + + +class PolicyCommand(Enum): + """SQL command a policy applies to.""" + + ALL = "ALL" + SELECT = "SELECT" + INSERT = "INSERT" + UPDATE = "UPDATE" + DELETE = "DELETE" + + +class AlterPolicyMode(Enum): + """The two syntactic forms of ALTER POLICY. + + REPLACE: alter roles / USING / WITH CHECK (several may be omitted). + RENAME: ALTER POLICY name ON table RENAME TO new_name. + """ + + REPLACE = "REPLACE" + RENAME = "RENAME" + + +class PostgresCreatePolicyExpression(BaseExpression): + """PostgreSQL CREATE POLICY statement expression. + + Defines a new row-level security policy for a table. + + Attributes: + name: Name of the policy (distinct per table). + table_name: Name of the table the policy applies to. + schema: Optional schema for the table. + policy_type: Optional ``AS PERMISSIVE``/``AS RESTRICTIVE`` clause. + RESTRICTIVE requires PostgreSQL 10+; PERMISSIVE keyword is only + emitted on PostgreSQL 10+ (silently omitted on 9.5). + command: Optional ``FOR {ALL|SELECT|INSERT|UPDATE|DELETE}`` clause. + If None, the clause is omitted (PostgreSQL default = ALL). + roles: Optional ``TO role [, ...]`` clause list. Special literals + ``PUBLIC`` / ``CURRENT_ROLE`` / ``CURRENT_USER`` / ``SESSION_USER`` + are emitted verbatim (no quoting); any other value is treated as + an identifier. CURRENT_ROLE requires PostgreSQL 15+. + using: Optional ``USING (predicate)`` expression. Forbidden when + ``command == INSERT``. + with_check: Optional ``WITH CHECK (predicate)`` expression. Forbidden + when ``command`` is SELECT or DELETE. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresCreatePolicyExpression( + ... dialect=dialect, + ... name="user_select_own", + ... table_name="orders", + ... command=PolicyCommand.SELECT, + ... using=Column(dialect, "user_id") == 1, + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + table_name: str, + schema: Optional[str] = None, + policy_type: Optional[PolicyType] = None, + command: Optional[PolicyCommand] = None, + roles: Optional[List[str]] = None, + using: Optional[SQLPredicate] = None, + with_check: Optional[SQLPredicate] = None, + ): + super().__init__(dialect) + self.name = name + self.table_name = table_name + self.schema = schema + self.policy_type = policy_type + self.command = command + self.roles = roles + self.using = using + self.with_check = with_check + + def to_sql(self) -> "SQLQueryAndParams": + """Generate CREATE POLICY SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_create_policy_statement(self) + + +class PostgresAlterPolicyExpression(BaseExpression): + """PostgreSQL ALTER POLICY statement expression. + + PostgreSQL supports two syntactic forms: + + 1. ``ALTER POLICY name ON table RENAME TO new_name`` + 2. ``ALTER POLICY name ON table [TO roles] [USING (...)] [WITH CHECK (...)]`` + + Each clause of form 2 may be independently specified or omitted; clauses not + given are unchanged on the existing policy. + + Attributes: + name: Name of the existing policy to alter. + table_name: Name of the table the policy is on. + schema: Optional schema for the table. + new_name: If given, switches the expression to form 1 (RENAME TO). + Mutually exclusive with roles/using/with_check. + roles: Optional new ``TO role [, ...]`` clause (form 2 only). + using: Optional new ``USING (...)`` expression (form 2 only). + with_check: Optional new ``WITH CHECK (...)`` expression (form 2 only). + + Raises: + ValueError: if ``new_name`` is set together with any of roles/using/ + with_check (the two forms are mutually exclusive). + UnsupportedFeatureError: when the dialect version predates + PostgreSQL 9.5. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + table_name: str, + schema: Optional[str] = None, + new_name: Optional[str] = None, + roles: Optional[List[str]] = None, + using: Optional[SQLPredicate] = None, + with_check: Optional[SQLPredicate] = None, + ): + super().__init__(dialect) + self.name = name + self.table_name = table_name + self.schema = schema + self.new_name = new_name + self.roles = roles + self.using = using + self.with_check = with_check + + @property + def mode(self) -> AlterPolicyMode: + """Determine the ALTER POLICY form implied by the parameters.""" + if self.new_name is not None: + return AlterPolicyMode.RENAME + return AlterPolicyMode.REPLACE + + def to_sql(self) -> "SQLQueryAndParams": + """Generate ALTER POLICY SQL statement. + + Returns: + Tuple of (SQL string, params tuple). + + """ + return self.dialect.format_alter_policy_statement(self) + + +class PostgresDropPolicyExpression(BaseExpression): + """PostgreSQL DROP POLICY statement expression. + + Removes a row-level security policy from a table. + + Attributes: + name: Name of the policy to drop. + table_name: Name of the table the policy is on. + schema: Optional schema for the table. + if_exists: When True, add ``IF EXISTS`` (PostgreSQL 9.5+, always + supported). A notice — not an error — is issued if missing. + cascade: When True, append ``CASCADE``. Note: per PostgreSQL + documentation, CASCADE/RESTRICT have no effect for DROP POLICY + (no dependencies) but are accepted syntactically. + restrict: When True, append ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + table_name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.table_name = table_name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "SQLQueryAndParams": + """Generate DROP POLICY SQL statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_policy_statement(self) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py new file mode 100644 index 00000000..3ce661a1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py @@ -0,0 +1,174 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py +""" +PostgreSQL DDL expressions: PUBLICATION and SUBSCRIPTION (logical +replication). + +PostgreSQL Documentation: +- CREATE PUBLICATION: https://www.postgresql.org/docs/current/sql-createpublication.html +- DROP PUBLICATION: https://www.postgresql.org/docs/current/sql-droppublication.html +- CREATE SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-createsubscription.html +- DROP SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-dropsubscription.html + +Version Requirements: +- CREATE/DROP PUBLICATION: PostgreSQL 10+ +- CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCreatePublicationExpression", + "PostgresDropPublicationExpression", + "PostgresCreateSubscriptionExpression", + "PostgresDropSubscriptionExpression", +] + + +class PostgresCreatePublicationExpression(BaseExpression): + """PostgreSQL CREATE PUBLICATION statement expression. + + Attributes: + name: Name of the publication. + tables: Optional list of table identifiers to publish with + ``FOR TABLE``. Exactly one of ``tables`` or ``all_tables`` + should be given. + all_tables: When True, publish all tables (``FOR ALL TABLES``). + options: Optional list of ``WITH (...)`` options (e.g. ``publish` + strings). + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + tables: Optional[List[str]] = None, + all_tables: bool = False, + options: Optional[List[str]] = None, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.tables = tables + self.all_tables = all_tables + self.options = options or [] + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE PUBLICATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_publication_statement(self) + + +class PostgresDropPublicationExpression(BaseExpression): + """PostgreSQL DROP PUBLICATION statement expression. + + Attributes: + name: Name of the publication to drop. + if_exists: When True, add ``IF EXISTS``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + cascade: bool = False, + if_exists: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP PUBLICATION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_publication_statement(self) + + +class PostgresCreateSubscriptionExpression(BaseExpression): + """PostgreSQL CREATE SUBSCRIPTION statement expression. + + Attributes: + name: Name of the subscription. + connection: Connection string for ``CONNECTION '...'``. + publications: Publication names to subscribe to. + options: Optional ``WITH (``...)`` creation options. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + connection: str, + publications: List[str], + options: Optional[List[str]] = None, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.connection = connection + self.publications = publications + self.options = options or [] + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE SUBSCRIPTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_subscription_statement(self) + + +class PostgresDropSubscriptionExpression(BaseExpression): + """PostgreSQL DROP SUBSCRIPTION statement expression. + + Attributes: + name: Name of the subscription to drop. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + if_exists: bool = False, + cascade: bool = False, + ): + super().__init__(dialect) + self.name = name + self.if_exists = if_exists + self.cascade = cascade + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP SUBSCRIPTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_subscription_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py new file mode 100644 index 00000000..92b36c8c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py @@ -0,0 +1,130 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py +""" +PostgreSQL DDL expressions: Row-Level Security configuration. + +PostgreSQL Documentation: +- ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + +Version Requirements: +- ENABLE / DISABLE ROW LEVEL SECURITY: PostgreSQL 9.5+ +- ENABLE ALWAYS / FORCE / NO FORCE ROW LEVEL SECURITY: PostgreSQL 9.5+ + +Note: These statements are PostgreSQL extensions (non-SQL-standard). They +live in this backend package, together with the POLICY statements that build +the complete Row-Level Security ecosystem. +""" + +from enum import Enum +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "RlsConfigurationMode", + "PostgresAlterTableRlsExpression", + "PostgresForceRlsExpression", +] + + +class RlsConfigurationMode(Enum): + """The ``ENABLE``/``DISABLE`` forms of row-level security on a table. + + PostgreSQL maps these onto ``ALTER TABLE ... ENABLE ROW LEVEL SECURITY`` + / ``ALTER TABLE ... DISABLE ROW LEVEL SECURITY``. + """ + + ENABLE = "ENABLE" + DISABLE = "DISABLE" + + +class PostgresAlterTableRlsExpression(BaseExpression): + """PostgreSQL ``ALTER TABLE ... {ENABLE|DISABLE} ROW LEVEL SECURITY``. + + A single expression drives both states through the ``mode`` field. The + ``ENABLE ALWAYS`` variant is a separate keyword on the same statement and + is produced by :attr:`always` (see class :class:`PostgresForceRlsExpression` + for the related ``FORCE`` forms). + + Attributes: + table_name: Name of the target table. + schema: Optional schema for the table. + mode: ``ENABLE`` or ``DISABLE``. + always: If True (and ``mode`` is ENABLE), emit ``ENABLE ALWAYS``. + ``ALWAYS`` has no meaning with ``DISABLE`` and raises an error. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresAlterTableRlsExpression( + ... dialect, table_name="orders", mode=RlsConfigurationMode.ENABLE + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + Raises: + ValueError: if ``always`` is True with ``mode == DISABLE``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + mode: RlsConfigurationMode, + schema: Optional[str] = None, + always: bool = False, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.mode = mode + self.always = always + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the ALTER TABLE ... ROW LEVEL SECURITY statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_alter_table_rls_statement(self) + + +class PostgresForceRlsExpression(BaseExpression): + """PostgreSQL ``ALTER TABLE ... {FORCE|NO FORCE} ROW LEVEL SECURITY``. + + ``FORCE ROW LEVEL SECURITY`` causes the table owner to also be subject to + row-level security policies (the default exempts the table owner unless + ``ENABLE ALWAYS``). ``NO FORCE`` reverts to the default behaviour. Both are + PostgreSQL 9.5+. + + Attributes: + table_name: Name of the target table. + schema: Optional schema for the table. + force: When True emit ``FORCE``; when False emit ``NO FORCE``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + force: bool = True, + schema: Optional[str] = None, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.force = force + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the FORCE/NO FORCE ROW LEVEL SECURITY statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_force_rls_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py new file mode 100644 index 00000000..00d41ec1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py @@ -0,0 +1,222 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py +""" +PostgreSQL DDL expressions: FUNCTION and AGGREGATE routines. + +PostgreSQL Documentation: +- CREATE FUNCTION: https://www.postgresql.org/docs/current/sql-createfunction.html +- DROP FUNCTION: https://www.postgresql.org/docs/current/sql-dropfunction.html +- CREATE AGGREGATE: https://www.postgresql.org/docs/current/sql-createaggregate.html +- DROP AGGREGATE: https://www.postgresql.org/docs/current/sql-dropaggregate.html + +Version Requirements: +- CREATE/DROP FUNCTION: PostgreSQL 9.6+ +- CREATE/DROP AGGREGATE: PostgreSQL 9.6+ + +Note: CREATE/DROP PROCEDURE is covered by the existing +``PostgresStoredProcedureMixin``; this module covers FUNCTION and AGGREGATE. +""" + +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "PostgresCreateFunctionExpression", + "PostgresDropFunctionExpression", + "PostgresCreateAggregateExpression", + "PostgresDropAggregateExpression", +] + + +class PostgresCreateFunctionExpression(BaseExpression): + """PostgreSQL CREATE FUNCTION statement expression. + + Attributes: + name: Name of the function. + args: Optional list of argument declarations (e.g. ``["a integer"]`` + or ``[]`` for no arguments). + returns: Return type (required for functions that return a value). + language: Procedural language (default ``plpgsql``). + body: Function body (between the ``$$`` dollar-quoting delimiters). + schema: Optional schema for the function. + or_replace: Add ``OR REPLACE``. + security: ``DEFINER`` or ``INVOKER`` (optional). + cost: Estimated execution cost (optional). + rows: Estimated rows returned (optional). + is_strict: When True, add ``STRICT``. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + return_type: str, + body: str, + schema: Optional[str] = None, + args: Optional[List[str]] = None, + language: str = "plpgsql", + or_replace: bool = False, + security: Optional[str] = None, + cost: Optional[float] = None, + rows: Optional[int] = None, + strict: bool = False, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.return_type = return_type + self.args = args or [] + self.body = body + self.language = language + self.or_replace = or_replace + self.security = security + self.cost = cost + self.rows = rows + self.strict = strict + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the CREATE FUNCTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_function_ddl_statement(self) + + +class PostgresDropFunctionExpression(BaseExpression): + """PostgreSQL DROP FUNCTION statement expression. + + Attributes: + name: Name of the function to drop. + schema: Optional schema for the function. + args: Optional argument type list used for overload resolution + (e.g. ``["integer"]``). + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + args: Optional[List[str]] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.args = args or [] + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP FUNCTION statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_function_ddl_statement(self) + + +class PostgresCreateAggregateExpression(BaseExpression): + """PostgreSQL CREATE AGGREGATE statement expression (minimal). + + Attributes: + name: Name of the aggregate. + sfunc: State transition function name. + stype: State data type. + schema: Optional schema for the aggregate. + finalfunc: Optional final function name. + initcond: Optional initial condition value. + dialect_options: Reserved. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + sfunc: str, + stype: str, + schema: Optional[str] = None, + finalfunc: Optional[str] = None, + initcond: Optional[str] = None, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.sfunc = sfunc + self.stype = stype + self.finalfunc = finalfunc + self.initcond = initcond + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the CREATE AGGREGATE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_create_aggregate_ddl_statement(self) + + +class PostgresDropAggregateExpression(BaseExpression): + """PostgreSQL DROP AGGREGATE statement expression. + + Attributes: + name: Name of the aggregate to drop. + arg_type: Aggregated argument type (needed to identify the + aggregate, e.g. ``integer``). + schema: Optional schema for the aggregate. + if_exists: When True, add ``IF EXISTS``. + cascade: When True, add ``CASCADE``. + restrict: When True, add ``RESTRICT``. Mutually exclusive with + ``cascade``. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + arg_type: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + restrict: bool = False, + ): + super().__init__(dialect) + self.name = name + self.arg_type = arg_type + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.restrict = restrict + + def to_sql(self) -> "Tuple[str, tuple]": + """Return the DROP AGGREGATE statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_drop_aggregate_ddl_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py new file mode 100644 index 00000000..a763afdd --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py @@ -0,0 +1,90 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py +""" +PostgreSQL DDL expressions: ALTER TABLE table-level settings. + +PostgreSQL Documentation: +- ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + +Version Requirements: +- SET LOGGED / SET UNLOGGED: PostgreSQL 9.6+ (exists since 9.5) +- SET ACCESS METHOD: PostgreSQL 15+ +""" + +from enum import Enum +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "LoggingMode", + "PostgresAlterTableSettingsExpression", +] + + +class LoggingMode(Enum): + """Whether a table is WAL-logged or not. + + Switching between these with ``ALTER TABLE ... SET LOGGED`` / + ``SET UNLOGGED`` is commonly used to prototype with fast UNLOGGED tables + and migrate to LOGGED for production. + """ + + LOGGED = "LOGGED" + UNLOGGED = "UNLOGGED" + + +class PostgresAlterTableSettingsExpression(BaseExpression): + """PostgreSQL ``ALTER TABLE ... SET`` table-level settings. + + Attributes: + table_name: Name of the target table. + schema: Optional schema for the table. + logging_mode: Optional ``LOGGED`` / ``UNLOGGED`` switch + (PostgreSQL 9.6+, exists since 9.5). + access_method: Optional new access method name for ``SET ACCESS + METHOD`` (PostgreSQL 15+). Mutually exclusive with + ``logging_mode``. + + Example: + >>> from rhosocial.activerecord.backend.impl.postgres import PostgresDialect + >>> dialect = PostgresDialect((14, 0, 0)) + >>> expr = PostgresAlterTableSettingsExpression( + ... dialect, table_name="orders", mode=LoggingMode.UNLOGGED + ... ) + >>> sql, params = expr.to_sql() # doctest: +SKIP + + Raises: + ValueError: if neither ``mode`` nor ``access_method`` is given, or + if both are given. + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + table_name: str, + schema: Optional[str] = None, + mode: Optional[LoggingMode] = None, + access_method: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.table_name = table_name + self.schema = schema + self.mode = mode + self.access_method = access_method + self.dialect_options = dialect_options or {} + + def to_sql(self) -> "Tuple[str, tuple]": + """Generate the ALTER TABLE ... SET statement. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.dialect.format_alter_table_settings_statement(self) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py new file mode 100644 index 00000000..53d43944 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -0,0 +1,388 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +"""PostgreSQL-specific DataType subclasses. + +Naming convention +----------------- +PostgreSQL-specific types use the ``Postgres`` prefix to distinguish them +from the core types (which have no prefix). This avoids ambiguity when both +core and backend types are used together. + +Usage scope +----------- +These types are used **only** for PostgreSQL backend DDL column definitions, +introspection result parsing, and schema comparison. They should **not** +be used by application code directly — always use the core types for +DDL definition expressions (``ColumnDefinition.data_type``). +""" + +from __future__ import annotations + +from typing import Optional, Set + +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BlobType, + DataType, + VarCharType, +) + + +# --------------------------------------------------------------------------- +# Character varying alias +# --------------------------------------------------------------------------- + +class PostgresCharacterVaryingType(VarCharType, backend="postgres"): + """PostgreSQL ``CHARACTER VARYING(n)`` — alias for ``VARCHAR(n)``.""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'VarCharType'} + + +# --------------------------------------------------------------------------- +# Binary data: BYTEA +# --------------------------------------------------------------------------- + +class PostgresByteaType(BlobType, backend="postgres"): + """PostgreSQL ``BYTEA`` — variable-length binary string.""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'BlobType'} + + +# --------------------------------------------------------------------------- +# Serial (auto-increment) types +# --------------------------------------------------------------------------- + +class PostgresSmallSerialType(DataType, backend="postgres"): + """PostgreSQL ``SMALLSERIAL`` — auto-incrementing SMALLINT (2 bytes).""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'SmallSerialType', 'SmallIntType'} + + +class PostgresSerialType(DataType, backend="postgres"): + """PostgreSQL ``SERIAL`` — auto-incrementing INTEGER (4 bytes).""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'SerialType', 'IntegerType'} + + +class PostgresBigSerialType(DataType, backend="postgres"): + """PostgreSQL ``BIGSERIAL`` — auto-incrementing BIGINT (8 bytes).""" + + @classmethod + def synonyms(cls) -> Set[str]: + return {'BigSerialType', 'BigIntType'} + + +# --------------------------------------------------------------------------- +# UUID +# --------------------------------------------------------------------------- + +class PostgresUUIDType(DataType, backend="postgres"): + """PostgreSQL ``UUID`` — universally unique identifier.""" + + +# --------------------------------------------------------------------------- +# XML +# --------------------------------------------------------------------------- + +class PostgresXMLType(DataType, backend="postgres"): + """PostgreSQL ``XML`` — XML data type.""" + + +# --------------------------------------------------------------------------- +# Text search +# --------------------------------------------------------------------------- + +class PostgresTSVectorType(DataType, backend="postgres"): + """PostgreSQL ``TSVECTOR`` — text search document.""" + + +class PostgresTSQueryType(DataType, backend="postgres"): + """PostgreSQL ``TSQUERY`` — text search query.""" + + +# --------------------------------------------------------------------------- +# JSON path +# --------------------------------------------------------------------------- + +class PostgresJsonPathType(DataType, backend="postgres"): + """PostgreSQL ``JSONPATH`` — SQL/JSON path expression (PG 12+).""" + + +# --------------------------------------------------------------------------- +# Bit string types +# --------------------------------------------------------------------------- + +class PostgresBitType(DataType, backend="postgres"): + """PostgreSQL ``BIT(n)`` — fixed-length bit string.""" + + n: Optional[int] = None + + def __init__(self, n: Optional[int] = None): + super().__init__() + self.n = n + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.n == other.n + + def __hash__(self) -> int: + return hash((type(self), self.n)) + + +class PostgresVarBitType(DataType, backend="postgres"): + """PostgreSQL ``VARBIT(n)`` — variable-length bit string.""" + + n: Optional[int] = None + + def __init__(self, n: Optional[int] = None): + super().__init__() + self.n = n + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.n == other.n + + def __hash__(self) -> int: + return hash((type(self), self.n)) + + +# --------------------------------------------------------------------------- +# Network address types +# --------------------------------------------------------------------------- + +class PostgresInetType(DataType, backend="postgres"): + """PostgreSQL ``INET`` — IPv4 or IPv6 address.""" + + +class PostgresCidrType(DataType, backend="postgres"): + """PostgreSQL ``CIDR`` — IPv4 or IPv6 network.""" + + +class PostgresMacAddrType(DataType, backend="postgres"): + """PostgreSQL ``MACADDR`` — MAC address (EUI-48).""" + + +class PostgresMacAddr8Type(DataType, backend="postgres"): + """PostgreSQL ``MACADDR8`` — MAC address (EUI-64, PG 10+).""" + + +# --------------------------------------------------------------------------- +# Geometric types +# --------------------------------------------------------------------------- + +class PostgresPointType(DataType, backend="postgres"): + """PostgreSQL ``POINT`` — geometric point (x, y).""" + + +class PostgresLineType(DataType, backend="postgres"): + """PostgreSQL ``LINE`` — infinite line.""" + + +class PostgresLineSegmentType(DataType, backend="postgres"): + """PostgreSQL ``LSEG`` — line segment.""" + + +class PostgresBoxType(DataType, backend="postgres"): + """PostgreSQL ``BOX`` — rectangular box.""" + + +class PostgresPathType(DataType, backend="postgres"): + """PostgreSQL ``PATH`` — open or closed geometric path.""" + + +class PostgresPolygonType(DataType, backend="postgres"): + """PostgreSQL ``POLYGON`` — closed geometric polygon.""" + + +class PostgresCircleType(DataType, backend="postgres"): + """PostgreSQL ``CIRCLE`` — circle (center + radius).""" + + +# --------------------------------------------------------------------------- +# Monetary type +# --------------------------------------------------------------------------- + +class PostgresMoneyType(DataType, backend="postgres"): + """PostgreSQL ``MONEY`` — currency amount.""" + + +# --------------------------------------------------------------------------- +# Range types +# --------------------------------------------------------------------------- + +class PostgresInt4RangeType(DataType, backend="postgres"): + """PostgreSQL ``INT4RANGE`` — range of integer.""" + + +class PostgresInt8RangeType(DataType, backend="postgres"): + """PostgreSQL ``INT8RANGE`` — range of bigint.""" + + +class PostgresNumRangeType(DataType, backend="postgres"): + """PostgreSQL ``NUMRANGE`` — range of numeric.""" + + +class PostgresTsRangeType(DataType, backend="postgres"): + """PostgreSQL ``TSRANGE`` — range of timestamp without time zone.""" + + +class PostgresTsTzRangeType(DataType, backend="postgres"): + """PostgreSQL ``TSTZRANGE`` — range of timestamp with time zone.""" + + +class PostgresDateRangeType(DataType, backend="postgres"): + """PostgreSQL ``DATERANGE`` — range of date.""" + + +# --------------------------------------------------------------------------- +# Multirange types (PG 14+) +# --------------------------------------------------------------------------- + +class PostgresInt4MultirangeType(DataType, backend="postgres"): + """PostgreSQL ``INT4MULTIRANGE`` — multirange of integer (PG 14+).""" + + +class PostgresInt8MultirangeType(DataType, backend="postgres"): + """PostgreSQL ``INT8MULTIRANGE`` — multirange of bigint (PG 14+).""" + + +class PostgresNumMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``NUMMULTIRANGE`` — multirange of numeric (PG 14+).""" + + +class PostgresTsMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``TSMULTIRANGE`` — multirange of timestamp (PG 14+).""" + + +class PostgresTsTzMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``TSTZMULTIRANGE`` — multirange of timestamptz (PG 14+).""" + + +class PostgresDateMultirangeType(DataType, backend="postgres"): + """PostgreSQL ``DATEMULTIRANGE`` — multirange of date (PG 14+).""" + + +# --------------------------------------------------------------------------- +# Object identifier types +# --------------------------------------------------------------------------- + +class PostgresOIDType(DataType, backend="postgres"): + """PostgreSQL ``OID`` — object identifier.""" + + +class PostgresRegClassType(DataType, backend="postgres"): + """PostgreSQL ``REGCLASS`` — relation name (OID alias).""" + + +class PostgresRegTypeType(DataType, backend="postgres"): + """PostgreSQL ``REGTYPE`` — type name (OID alias).""" + + +class PostgresXIDType(DataType, backend="postgres"): + """PostgreSQL ``XID`` — transaction ID.""" + + +class PostgresXID8Type(DataType, backend="postgres"): + """PostgreSQL ``XID8`` — 64-bit transaction ID (PG 13+).""" + + +class PostgresCIDType(DataType, backend="postgres"): + """PostgreSQL ``CID`` — command ID.""" + + +class PostgresTIDType(DataType, backend="postgres"): + """PostgreSQL ``TID`` — tuple ID (page, tuple).""" + + +# --------------------------------------------------------------------------- +# pg_lsn type +# --------------------------------------------------------------------------- + +class PostgresPgLSNType(DataType, backend="postgres"): + """PostgreSQL ``PG_LSN`` — WAL log sequence number.""" + + +# --------------------------------------------------------------------------- +# Extension-provided types (minimal DataType wrappers) +# --------------------------------------------------------------------------- + +class PostgresHstoreType(DataType, backend="postgres"): + """PostgreSQL ``HSTORE`` — key-value store (hstore extension).""" + + +class PostgresGeometryType(DataType, backend="postgres"): + """PostGIS ``GEOMETRY`` — generic spatial geometry (PostGIS extension). + + For production use, subclass with SRID support as needed. + """ + + +class PostgresGeographyType(DataType, backend="postgres"): + """PostGIS ``GEOGRAPHY`` — geodetic spatial type (PostGIS extension).""" + + +class PostgresCitextType(DataType, backend="postgres"): + """PostgreSQL ``CITEXT`` — case-insensitive text (citext extension).""" + + +class PostgresCubeType(DataType, backend="postgres"): + """PostgreSQL ``CUBE`` — multi-dimensional cube (cube extension).""" + + +class PostgresLtreeType(DataType, backend="postgres"): + """PostgreSQL ``LTREE`` — label tree (ltree extension).""" + + +class PostgresRasterType(DataType, backend="postgres"): + """PostgreSQL ``RASTER`` — raster (PostGIS raster extension).""" + + +class PostgresVectorType(DataType, backend="postgres"): + """pgvector ``VECTOR(n)`` — vector embedding (pgvector extension). + + Args: + dim: Number of dimensions. + """ + + dim: int + + def __init__(self, dim: int): + super().__init__() + self.dim = dim + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return False + return self.dim == other.dim + + def __hash__(self) -> int: + return hash((type(self), self.dim)) + + +# --------------------------------------------------------------------------- +# Array container +# --------------------------------------------------------------------------- + +class PostgresArrayType(ArrayType): + """PostgreSQL array type. + + PostgreSQL normalises all multi-dimensional array declarations to a + single-dimensional internal representation at the storage level. + Therefore ``integer[][]`` and ``integer[]`` are considered equivalent + during schema comparison: ``is_equivalent`` intentionally ignores + ``dimensions``. + """ + + def is_equivalent(self, other: "DataType") -> bool: + if not isinstance(other, ArrayType): + return False + return self.element_type.is_equivalent(other.element_type) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index c4504f73..3ba187d1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -50,6 +50,7 @@ TriggerInfo, ViewInfo, ) +from rhosocial.activerecord.backend.expression.types import DataType from .status_introspector import ( SyncPostgreSQLStatusIntrospector, AsyncPostgreSQLStatusIntrospector, @@ -137,6 +138,7 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st data_type = row.get("data_type", "") # Extract base type (remove array brackets, size specifiers) base_type = data_type.split("[")[0].split("(")[0].strip() + parsed = DataType.parse_data_type_str(self._backend.dialect, data_type) columns.append( ColumnInfo( name=row["column_name"], @@ -145,6 +147,7 @@ def _parse_columns(self, rows: List[Dict[str, Any]], table_name: str, schema: st ordinal_position=row["ordinal_position"], data_type=base_type, data_type_full=data_type, + parsed_data_type=parsed, nullable=ColumnNullable.NULLABLE if row.get("is_nullable") else ColumnNullable.NOT_NULL, default_value=row.get("default_value"), is_primary_key=row.get("is_primary_key", False), diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index 232e1805..a43bfe77 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -22,6 +22,16 @@ from .ddl.comment import PostgresCommentMixin from .ddl.type import PostgresTypeMixin from .ddl.constraint import PostgresConstraintMixin +from .ddl.column import PostgresAlterColumnModifierMixin +from .ddl.policy import PostgresPolicyMixin +from .ddl.rls_config import PostgresRlsConfigMixin +from .ddl.table_settings import PostgresAlterTableSettingsMixin +from .ddl.cluster import PostgresClusterMixin +from .ddl.domain import PostgresDomainMixin +from .ddl.collation import PostgresCollationDDLMixin +from .ddl.foreign_table import PostgresForeignTableMixin +from .ddl.routine import PostgresRoutineMixin +from .ddl.publication import PostgresPublicationMixin # DML mixins from .dml.vacuum import PostgresVacuumMixin @@ -38,6 +48,7 @@ from .types.range_type import PostgresRangeTypeMixin from .types.jsonb_enhanced import PostgresJSONBEnhancedMixin from .types.array_enhanced import PostgresArrayEnhancedMixin +from .types.data_type_formatting import PostgresTypeFormatSupportMixin # Extension mixins from .extensions.pgvector import PostgresPgvectorMixin @@ -115,6 +126,16 @@ "PostgresCommentMixin", "PostgresTypeMixin", "PostgresConstraintMixin", + "PostgresAlterColumnModifierMixin", + "PostgresPolicyMixin", + "PostgresRlsConfigMixin", + "PostgresAlterTableSettingsMixin", + "PostgresClusterMixin", + "PostgresDomainMixin", + "PostgresCollationDDLMixin", + "PostgresForeignTableMixin", + "PostgresRoutineMixin", + "PostgresPublicationMixin", # DML mixins "PostgresVacuumMixin", "PostgresStoredProcedureMixin", @@ -129,6 +150,7 @@ "PostgresRangeTypeMixin", "PostgresJSONBEnhancedMixin", "PostgresArrayEnhancedMixin", + "PostgresTypeFormatSupportMixin", # Extension mixins "PostgresPgvectorMixin", "PostgresPostGISMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py index e4ed4e7a..27d7e25d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/__init__.py @@ -7,6 +7,16 @@ from .comment import PostgresCommentMixin from .type import PostgresTypeMixin from .constraint import PostgresConstraintMixin +from .column import PostgresAlterColumnModifierMixin +from .policy import PostgresPolicyMixin +from .rls_config import PostgresRlsConfigMixin +from .table_settings import PostgresAlterTableSettingsMixin +from .cluster import PostgresClusterMixin +from .domain import PostgresDomainMixin +from .collation import PostgresCollationDDLMixin +from .foreign_table import PostgresForeignTableMixin +from .routine import PostgresRoutineMixin +from .publication import PostgresPublicationMixin __all__ = [ "PostgresPartitionMixin", @@ -15,4 +25,14 @@ "PostgresCommentMixin", "PostgresTypeMixin", "PostgresConstraintMixin", + "PostgresAlterColumnModifierMixin", + "PostgresPolicyMixin", + "PostgresRlsConfigMixin", + "PostgresAlterTableSettingsMixin", + "PostgresClusterMixin", + "PostgresDomainMixin", + "PostgresCollationDDLMixin", + "PostgresForeignTableMixin", + "PostgresRoutineMixin", + "PostgresPublicationMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py new file mode 100644 index 00000000..a56e64a7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py @@ -0,0 +1,77 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/cluster.py +"""PostgreSQL CLUSTER DDL implementation. + +Implements the CLUSTER statement for the postgres dialect. + +Version Requirements: +- CLUSTER table [ USING index ]: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.cluster import PostgresClusterExpression + + +class PostgresClusterMixin: + """PostgreSQL CLUSTER implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_cluster(self) -> bool: + """CLUSTER is supported on all supported PostgreSQL versions.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_cluster_table_ref( + self, schema: Optional[str], table_name: str + ) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(table_name)}" + ) + return self.format_identifier(table_name) + + # ------------------------------------------------------------------ # + # Statement + # ------------------------------------------------------------------ # + def format_cluster_statement( + self, expr: "PostgresClusterExpression" + ) -> Tuple[str, tuple]: + """Format a CLUSTER statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresClusterExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_cluster(): + raise UnsupportedFeatureError( + self.name, + "CLUSTER", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = ["CLUSTER"] + if expr.verbose: + parts.append("VERBOSE") + if expr.table_name: + parts.append( + self._format_cluster_table_ref(expr.schema, expr.table_name) + ) + if expr.using_index: + parts.append("USING") + parts.append(self.format_identifier(expr.using_index)) + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py new file mode 100644 index 00000000..abee6a37 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py @@ -0,0 +1,127 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/collation.py +"""PostgreSQL COLLATION object DDL implementation. + +Implements CREATE/DROP COLLATION DDL formatting for the postgres dialect. +This is distinct from ``PostgresCollationMixin`` (mixins/collation.py), +which handles the ``COLLATE`` expression and nondeterministic-collation +capability detection. + +Version Requirements: +- CREATE/DROP COLLATION: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.collation import ( + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + ) + + +class PostgresCollationDDLMixin: + """PostgreSQL COLLATION object DDL implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_collation_ddl(self) -> bool: + """CREATE/DROP COLLATION require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_collation_ref(self, schema: Optional[str], name: str) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(name)}" + ) + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE COLLATION + # ------------------------------------------------------------------ # + def format_create_collation_ddl_statement( + self, expr: "PostgresCreateCollationExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE COLLATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateCollationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_collation_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE COLLATION", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = ["CREATE COLLATION"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_collation_ref(expr.schema, expr.name)) + params_list = [ + ("LOCALE", expr.locale), + ("LC_COLLATE", expr.lc_collate), + ("LC_CTYPE", expr.lc_ctype), + ("PROVIDER", expr.provider), + ("VERSION", expr.version), + ] + clauses = [] + for keyword, value in params_list: + if value is not None: + clauses.append(f"{keyword} = {value}") + if clauses: + parts.append("(" + ", ".join(clauses) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP COLLATION + # ------------------------------------------------------------------ # + def format_drop_collation_ddl_statement( + self, expr: "PostgresDropCollationExpression" + ) -> Tuple[str, tuple]: + """Format a DROP COLLATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropCollationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + ValueError: if both ``cascade`` and ``restrict`` are True. + + """ + if not self.supports_collation_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP COLLATION", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP COLLATION: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP COLLATION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_collation_ref(expr.schema, expr.name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py new file mode 100644 index 00000000..6c5923de --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py @@ -0,0 +1,99 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py +"""PostgreSQL ALTER TABLE / ALTER COLUMN formatting extensions. + +Covers PostgreSQL-specific qualifiers for ALTER TABLE actions as well as +the ``ALTER COLUMN ... SET DATA TYPE ... USING`` conversion expression, +which the core ``DDLColumnMixin`` does not emit. +""" + +from typing import Tuple + + +class PostgresAlterColumnModifierMixin: + """PostgreSQL IF [NOT] EXISTS qualifiers for ALTER TABLE actions. + + PostgreSQL has supported these qualifiers since 9.6: + - ADD [COLUMN] [IF NOT EXISTS] + - DROP [COLUMN] [IF EXISTS] + - DROP CONSTRAINT [IF EXISTS] + + The qualifiers are vendor extensions (not in ISO/IEC 9075-2 §11.10); + applications opt in by passing ``if_not_exists`` / ``if_exists`` on + the corresponding ``AddColumn`` / ``DropColumn`` / + ``DropTableConstraint`` actions. ``None`` (default) means "no + qualifier", matching the SQL standard form. + """ + + def supports_add_column_if_not_exists(self) -> bool: + """``ADD COLUMN IF NOT EXISTS`` is supported since PostgreSQL 9.6.""" + return True + + def supports_drop_column_if_exists(self) -> bool: + """``DROP COLUMN IF EXISTS`` is supported since PostgreSQL 9.6.""" + return True + + def supports_drop_constraint_if_exists(self) -> bool: + """``DROP CONSTRAINT IF EXISTS`` is supported since PostgreSQL 9.6.""" + return True + + def format_add_column_action(self, action) -> Tuple[str, tuple]: + column_sql, column_params = self.format_column_definition(action.column) + if getattr(action, "if_not_exists", None) is True: + return f"ADD COLUMN IF NOT EXISTS {column_sql}", column_params + return f"ADD COLUMN {column_sql}", column_params + + def format_drop_column_action(self, action) -> Tuple[str, tuple]: + if getattr(action, "if_exists", None) is True: + return f"DROP COLUMN IF EXISTS {self.format_identifier(action.column_name)}", () + return f"DROP COLUMN {self.format_identifier(action.column_name)}", () + + def format_drop_table_constraint_action(self, action) -> Tuple[str, tuple]: + result = "DROP CONSTRAINT" + if getattr(action, "if_exists", None) is True: + result = f"{result} IF EXISTS" + result = f"{result} {self.format_identifier(action.constraint_name)}" + if getattr(action, "cascade", None): + result = f"{result} CASCADE" + return result, () + + def format_alter_column_action(self, action) -> Tuple[str, tuple]: + """Format an ``ALTER COLUMN`` action, adding ``USING`` conversion. + + The core ``DDLColumnMixin.format_alter_column_action`` renders the + standard subclauses (SET/DROP DEFAULT, SET/DROP NOT NULL, and + ``SET DATA TYPE``). PostgreSQL additionally supports an optional + ``USING (conversion_expression)`` on ``SET DATA TYPE`` — an + extension the core dialect does not emit. This override injects it + from ``action.dialect_options["using"]``. + + Args: + action: An ``AlterColumn`` action (core ``ddl_alter`` module). + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + ValueError: when ``using`` is supplied for any operation other + than ``SET DATA TYPE`` (the only one PostgreSQL allows it on). + + """ + sql, params = super().format_alter_column_action(action) + dialect_options = getattr(action, "dialect_options", None) or {} + using = dialect_options.get("using") + if using is None: + return sql, params + + op = getattr(action, "operation", None) + op_str = op.value if hasattr(op, "value") else str(op) + if op_str != "SET DATA TYPE": + raise ValueError( + "USING conversion is only valid for ALTER COLUMN SET DATA TYPE" + ) + + using_sql, using_params = using.to_sql() + core, sep, cascade = sql.partition(" CASCADE") + if sep: + modified = f"{core} USING ({using_sql}){sep}{cascade}" + else: + modified = f"{sql} USING ({using_sql})" + return modified, tuple(params) + tuple(using_params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py index a936f54e..213e74f5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -41,3 +41,11 @@ def supports_exclude_constraint(self) -> bool: Supported by all PostgreSQL versions. """ return True + + def supports_drop_constraint_if_exists(self) -> bool: + """``DROP CONSTRAINT IF EXISTS`` (vendor extension). + + PostgreSQL has supported the ``IF EXISTS`` qualifier on + ``DROP CONSTRAINT`` since 9.6. + """ + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py new file mode 100644 index 00000000..aa5b32e1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py @@ -0,0 +1,156 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/domain.py +"""PostgreSQL DOMAIN DDL implementation. + +Implements CREATE/ALTER/DROP DOMAIN formatting for the postgres dialect. + +Version Requirements: +- CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from ...expression.ddl.domain import AlterDomainActionType + +if TYPE_CHECKING: + from ...expression.ddl.domain import ( + PostgresAlterDomainExpression, + PostgresCreateDomainExpression, + PostgresDropDomainExpression, + ) + + +class PostgresDomainMixin: + """PostgreSQL DOMAIN implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_create_domain(self) -> bool: + """CREATE/ALTER/DROP DOMAIN require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_domain_ref(self, schema: Optional[str], name: str) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return f"{self.format_identifier(schema)}.{self.format_identifier(name)}" + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE DOMAIN + # ------------------------------------------------------------------ # + def format_create_domain_statement( + self, expr: "PostgresCreateDomainExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE DOMAIN statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateDomainExpression`. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_create_domain(): + raise UnsupportedFeatureError( + self.name, + "CREATE DOMAIN", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = [ + "CREATE DOMAIN", + self._format_domain_ref(expr.schema, expr.name), + "AS", + expr.data_type, + ] + if expr.collation: + parts.append(f"COLLATE {expr.collation}") + if expr.default is not None: + parts.append(f"DEFAULT {expr.default}") + for constraint in expr.constraints: + parts.append(constraint) + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # ALTER DOMAIN + # ------------------------------------------------------------------ # + def format_postgres_alter_domain_statement( + self, expr: "PostgresAlterDomainExpression" + ) -> Tuple[str, tuple]: + """Format an ALTER DOMAIN statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresAlterDomainExpression`. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_create_domain(): + raise UnsupportedFeatureError( + self.name, + "ALTER DOMAIN", + suggestion="requires PostgreSQL 9.6+", + ) + + base = f"ALTER DOMAIN {self._format_domain_ref(expr.schema, expr.name)}" + action = expr.action + if action is AlterDomainActionType.SET_DEFAULT: + return f"{base} SET DEFAULT {expr.new_value}", () + if action is AlterDomainActionType.DROP_DEFAULT: + return f"{base} DROP DEFAULT", () + if action is AlterDomainActionType.RENAME_TO: + return ( + f"{base} RENAME TO {self.format_identifier(expr.new_name)}", + (), + ) + raise ValueError(f"Unsupported ALTER DOMAIN action: {action}") + + # ------------------------------------------------------------------ # + # DROP DOMAIN + # ------------------------------------------------------------------ # + def format_drop_domain_statement( + self, expr: "PostgresDropDomainExpression" + ) -> Tuple[str, tuple]: + """Format a DROP DOMAIN statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropDomainExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + ValueError: if both ``cascade`` and ``restrict`` are True. + + """ + if not self.supports_create_domain(): + raise UnsupportedFeatureError( + self.name, + "DROP DOMAIN", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP DOMAIN: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP DOMAIN"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_domain_ref(expr.schema, expr.name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py new file mode 100644 index 00000000..8cecf8ac --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py @@ -0,0 +1,120 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/foreign_table.py +"""PostgreSQL FOREIGN TABLE DDL implementation. + +Implements CREATE/DROP FOREIGN TABLE formatting for the postgres dialect. + +Version Requirements: +- CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.foreign_table import ( + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + ) + + +class PostgresForeignTableMixin: + """PostgreSQL FOREIGN TABLE implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_foreign_table_ddl(self) -> bool: + """CREATE/DROP FOREIGN TABLE require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_foreign_table_ref( + self, schema: Optional[str], name: str + ) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(name)}" + ) + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE FOREIGN TABLE + # ------------------------------------------------------------------ # + def format_create_foreign_table_statement( + self, expr: "PostgresCreateForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE FOREIGN TABLE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateForeignTableExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_foreign_table_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE FOREIGN TABLE", + suggestion="requires PostgreSQL 9.6+", + ) + + parts: List[str] = ["CREATE FOREIGN TABLE"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_foreign_table_ref(expr.schema, expr.name)) + if expr.columns: + parts.append("(" + ", ".join(expr.columns) + ")") + parts.append("SERVER") + parts.append(self.format_identifier(expr.server_name)) + if expr.options: + parts.append("OPTIONS (" + ", ".join(expr.options) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP FOREIGN TABLE + # ------------------------------------------------------------------ # + def format_drop_foreign_table_statement( + self, expr: "PostgresDropForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a DROP FOREIGN TABLE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropForeignTableExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + ValueError: if both ``cascade`` and ``restrict`` are True. + + """ + if not self.supports_foreign_table_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP FOREIGN TABLE", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP FOREIGN TABLE: CASCADE and RESTRICT are mutually " + "exclusive" + ) + parts: List[str] = ["DROP FOREIGN TABLE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_foreign_table_ref(expr.schema, expr.name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index 2e4d1282..3e576e0b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -194,51 +194,134 @@ def get_supported_index_types(self) -> List[str]: return ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"] # ========================================================================= - # Fulltext methods — PostgreSQL does NOT use MySQL-style FULLTEXT indexes + # Fulltext search — PostgreSQL uses tsvector/tsquery # ========================================================================= + _FT_DEFAULT_CONFIG = 'english' + def supports_fulltext_index(self) -> bool: - """PostgreSQL uses GIN index + tsvector/tsquery, not MySQL-style FULLTEXT.""" + """Whether MySQL-style FULLTEXT indexes are supported. + + PostgreSQL does not use CREATE FULLTEXT INDEX; instead it uses + CREATE INDEX ... USING GIN (to_tsvector(...)). + """ return False + def supports_fulltext_search(self) -> bool: + """Whether full-text search is supported. + + PostgreSQL supports full-text search via to_tsvector() @@ to_tsquery() + with GIN/GIST indexes, not via MySQL-style MATCH...AGAINST. + """ + return True + def supports_fulltext_parser(self) -> bool: """PostgreSQL does not have MySQL-style FULLTEXT parsers.""" return False def supports_fulltext_boolean_mode(self) -> bool: - """PostgreSQL does not support MySQL-style FULLTEXT boolean mode.""" - return False + """Whether tsquery-style boolean operators are supported. + + PostgreSQL's to_tsquery() supports & (AND), | (OR), ! (NOT) + operators, equivalent to MySQL's BOOLEAN MODE. + """ + return True def supports_fulltext_query_expansion(self) -> bool: - """PostgreSQL does not support MySQL-style FULLTEXT query expansion.""" + """Whether query expansion is supported. + + PostgreSQL supports this via the thesaurus dictionary or + phraseto_tsquery() for phrase matching. + """ return False - def format_fulltext_match(self, columns, search_term, mode=None) -> Tuple[str, Tuple]: - """Not supported — PostgreSQL uses tsvector @@ tsquery instead.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - "PostgreSQL", - "FULLTEXT MATCH", - suggestion="Use tsvector @@ tsquery for full-text search.", - ) + def format_fulltext_match( + self, columns: List[str], search_term: str, mode: Optional[str] = None + ) -> Tuple[str, Tuple]: + """Format tsvector @@ tsquery expression for PostgreSQL full-text search. + + Produces: to_tsvector('config', col1 || ' ' || col2) @@ tsquery_func(search_term) + + Args: + columns: Columns to include in the tsvector expression + search_term: Search term to convert to tsquery + mode: Search mode: + - None / 'NATURAL LANGUAGE': plainto_tsquery (plain text) + - 'BOOLEAN': to_tsquery (operators &, |, !) + - 'PHRASE': phraseto_tsquery (phrase matching) + + Returns: + Tuple of (SQL string, parameters tuple) + """ + config = self._FT_DEFAULT_CONFIG + ph = self.get_parameter_placeholder() + + if len(columns) == 1: + tsvector_expr = f"to_tsvector('{config}', {self.format_identifier(columns[0])})" + else: + parts = [self.format_identifier(c) for c in columns] + tsvector_expr = f"to_tsvector('{config}', {' || ' .join(parts)})" + + if mode: + mode_upper = mode.upper() + if mode_upper == "BOOLEAN": + tsquery_expr = f"to_tsquery('{config}', {ph})" + elif mode_upper == "PHRASE": + tsquery_expr = f"phraseto_tsquery('{config}', {ph})" + else: + tsquery_expr = f"plainto_tsquery('{config}', {ph})" + else: + tsquery_expr = f"plainto_tsquery('{config}', {ph})" + + return f"{tsvector_expr} @@ {tsquery_expr}", (search_term,) def format_create_fulltext_index_statement(self, expr) -> Tuple[str, tuple]: - """Not supported — use CREATE INDEX with GIN and tsvector expression.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - "PostgreSQL", - "CREATE FULLTEXT INDEX", - suggestion="Use CREATE INDEX ... ON ... USING GIN (to_tsvector(...))", - ) + """Format CREATE INDEX ... USING GIN for PostgreSQL full-text search. + + Converts a MySQL-style CREATE FULLTEXT INDEX expression into a + PostgreSQL GIN index on to_tsvector(). + + Args: + expr: CreateFulltextIndexExpression object + + Returns: + Tuple of (SQL string, parameters tuple) + """ + config = self._FT_DEFAULT_CONFIG + parts = ["CREATE INDEX"] + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self.format_identifier(expr.index_name)) + parts.append("ON") + parts.append(self.format_identifier(expr.table_name)) + parts.append("USING GIN") + + if len(expr.columns) == 1: + tsvector_expr = f"to_tsvector('{config}', {self.format_identifier(expr.columns[0])})" + else: + col_parts = [self.format_identifier(c) for c in expr.columns] + tsvector_expr = f"to_tsvector('{config}', {' || ' .join(col_parts)})" + parts.append(f"({tsvector_expr})") + + return " ".join(parts), () def format_drop_fulltext_index_statement(self, expr) -> Tuple[str, tuple]: - """Not supported — use DROP INDEX instead.""" - from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - raise UnsupportedFeatureError( - "PostgreSQL", - "DROP FULLTEXT INDEX", - suggestion="Use DROP INDEX to remove a GIN index.", - ) + """Format DROP INDEX statement for PostgreSQL. + + PostgreSQL uses DROP INDEX (without ON table) for all index types, + including GIN indexes used for full-text search. + + Args: + expr: DropFulltextIndexExpression object + + Returns: + Tuple of (SQL string, parameters tuple) + """ + parts = ["DROP INDEX"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.index_name)) + return " ".join(parts), () def format_create_index_statement(self, expr: "CreateIndexExpression") -> Tuple[str, tuple]: """Format CREATE INDEX statement with PostgreSQL-specific options. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py new file mode 100644 index 00000000..efedabf2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py @@ -0,0 +1,398 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/policy.py +"""PostgreSQL Row-Level Security POLICY DDL implementation. + +Implements CREATE/ALTER/DROP POLICY formatting for the postgres dialect. +All three statements are PostgreSQL extensions (non-SQL-standard). + +Version Requirements: +- CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+ +- AS RESTRICTIVE / explicit AS PERMISSIVE keyword: PostgreSQL 10+ +- DROP POLICY IF EXISTS: PostgreSQL 9.5+ (always supported here) +- CURRENT_ROLE keyword in TO role list: PostgreSQL 15+ +- CURRENT_USER / SESSION_USER / PUBLIC: all versions +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.bases import SQLPredicate + +from ...expression.ddl.policy import ( + AlterPolicyMode, + PolicyCommand, + PolicyType, +) + +if TYPE_CHECKING: + from ...expression.ddl.policy import ( + PostgresAlterPolicyExpression, + PostgresCreatePolicyExpression, + PostgresDropPolicyExpression, + ) + + +# Role tokens that MUST NOT be wrapped by format_identifier — they are +# PostgreSQL keywords / reserved role references, not identifiers. +_ROLE_KEYWORDS = frozenset( + {"PUBLIC", "CURRENT_ROLE", "CURRENT_USER", "SESSION_USER"} +) + +# CURRENT_ROLE was introduced in PostgreSQL 15; the other three role keywords +# exist in all supported 9.5+ versions. +_ROLE_KEYWORD_MIN_VERSION = { + "CURRENT_ROLE": (15, 0, 0), + "CURRENT_USER": (9, 5, 0), + "SESSION_USER": (9, 5, 0), + "PUBLIC": (9, 5, 0), +} + + +class PostgresPolicyMixin: + """PostgreSQL Row-Level Security POLICY implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_create_policy(self) -> bool: + """CREATE/ALTER/DROP POLICY basic syntax requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_policy_restrictive(self) -> bool: + """AS RESTRICTIVE clause requires PostgreSQL 10+.""" + return self.version >= (10, 0, 0) + + def supports_explicit_permissive_keyword(self) -> bool: + """The literal ``AS PERMISSIVE`` keyword requires PostgreSQL 10+. + + On PostgreSQL 9.5, permissive is the implicit default and the + keyword itself is not accepted by the parser; it must be omitted. + """ + return self.version >= (10, 0, 0) + + def supports_drop_policy_if_exists(self) -> bool: + """DROP POLICY IF EXISTS is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_alter_policy_rename(self) -> bool: + """ALTER POLICY ... RENAME TO ... is supported since PostgreSQL 9.5.""" + return self.version >= (9, 5, 0) + + def supports_alter_policy_replace(self) -> bool: + """ALTER POLICY (roles/USING/WITH CHECK) is supported since 9.5.""" + return self.version >= (9, 5, 0) + + def supports_current_role_keyword(self) -> bool: + """CURRENT_ROLE keyword in TO role list requires PostgreSQL 15+.""" + return self.version >= (15, 0, 0) + + # ------------------------------------------------------------------ # + # Shared helpers + # ------------------------------------------------------------------ # + def _format_table_ref(self, schema: Optional[str], table_name: str) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return f"{self.format_identifier(schema)}.{self.format_identifier(table_name)}" + return self.format_identifier(table_name) + + def _format_role_list(self, roles: List[str]) -> Tuple[str, tuple]: + """Format the ``TO role [, ...]`` clause. + + Returns ``(formatted_role_list, params)`` — params are always empty + for the role list itself (roles are identifiers or keywords, never + parameterised). + """ + parts: List[str] = [] + for r in roles: + upper = r.upper() + if upper in _ROLE_KEYWORDS: + min_ver = _ROLE_KEYWORD_MIN_VERSION[upper] + if self.version < min_ver: + raise UnsupportedFeatureError( + self.name, + f"role keyword {upper}", + suggestion=f"requires PostgreSQL " + f"{min_ver[0]}.{min_ver[1]}+", + ) + parts.append(upper) + else: + parts.append(self.format_identifier(r)) + return ", ".join(parts), () + + def _format_predicate( + self, expr: SQLPredicate, clause_keyword: str + ) -> Tuple[str, tuple]: + """Format ````USING``/``WITH CHECK`` ( predicate )`` clause.""" + sql, params = expr.to_sql() + return f"{clause_keyword} ({sql})", params + + # ------------------------------------------------------------------ # + # CREATE POLICY + # ------------------------------------------------------------------ # + def format_create_policy_statement( + self, expr: "PostgresCreatePolicyExpression" + ) -> Tuple[str, tuple]: + """Format CREATE POLICY statement (PostgreSQL-specific). + + Performs version gates and per-command validation in line with + PostgreSQL's policy semantics, then assembles the SQL. + + Args: + expr: :class:`PostgresCreatePolicyExpression` with the policy + definition. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: when the dialect predates PostgreSQL 9.5 + or when ``AS RESTRICTIVE`` is used on a version below 10. + ValueError: when a command/expression combination is invalid + (e.g. INSERT policy with a USING expression). + + """ + if not self.supports_create_policy(): + raise UnsupportedFeatureError( + self.name, + "CREATE POLICY", + suggestion="requires PostgreSQL 9.5+", + ) + + # AS clause handling + if expr.policy_type is PolicyType.RESTRICTIVE: + if not self.supports_policy_restrictive(): + raise UnsupportedFeatureError( + self.name, + "AS RESTRICTIVE policy", + suggestion="requires PostgreSQL 10+", + ) + as_clause = "AS RESTRICTIVE" + elif expr.policy_type is PolicyType.PERMISSIVE: + # On 9.5 the AS PERMISSIVE keyword is not accepted; silently omit. + if self.supports_explicit_permissive_keyword(): + as_clause = "AS PERMISSIVE" + else: + as_clause = "" + else: + as_clause = "" + + # FOR command clause + command_clause = "" + if expr.command is not None: + cmd = expr.command + cmd_combined_clause_errors(cmd, expr.using, expr.with_check) + command_clause = f"FOR {cmd.value}" + + # TO role list + to_clause = "" + if expr.roles is not None: + role_list, _ = self._format_role_list(list(expr.roles)) + to_clause = f"TO {role_list}" + + # USING / WITH CHECK + all_params: List[object] = [] + using_clause = "" + if expr.using is not None: + using_clause, params = self._format_predicate(expr.using, "USING") + all_params.extend(params) + with_check_clause = "" + if expr.with_check is not None: + with_check_clause, params = self._format_predicate( + expr.with_check, "WITH CHECK" + ) + all_params.extend(params) + + parts: List[str] = ["CREATE POLICY"] + parts.append(self.format_identifier(expr.name)) + parts.append("ON") + parts.append(self._format_table_ref(expr.schema, expr.table_name)) + if as_clause: + parts.append(as_clause) + if command_clause: + parts.append(command_clause) + if to_clause: + parts.append(to_clause) + if using_clause: + parts.append(using_clause) + if with_check_clause: + parts.append(with_check_clause) + + return " ".join(parts), tuple(all_params) + + # ------------------------------------------------------------------ # + # ALTER POLICY + # ------------------------------------------------------------------ # + def format_alter_policy_statement( + self, expr: "PostgresAlterPolicyExpression" + ) -> Tuple[str, tuple]: + """Format ALTER POLICY statement (PostgreSQL-specific). + + Handles two syntactic forms via :attr:`expr.mode`: + + - ``RENAME``: ``ALTER POLICY name ON table RENAME TO new_name`` + - ``REPLACE``: ``ALTER POLICY name ON table [TO ...] + [USING (...)] [WITH CHECK (...)]`` + + For REPLACE, each clause is emitted independently if specified; + omitted clauses leave the existing policy unchanged (no SQL token). + + Args: + expr: :class:`PostgresAlterPolicyExpression`. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5 or + when a role keyword needs a version not satisfied. + ValueError: if RENAME and REPLACE clauses are both specified, or + if no REPLACE clause is specified at all. + + """ + if not self.supports_create_policy(): + raise UnsupportedFeatureError( + self.name, + "ALTER POLICY", + suggestion="requires PostgreSQL 9.5+", + ) + + mode = expr.mode + if mode is AlterPolicyMode.RENAME: + if expr.roles is not None or expr.using is not None \ + or expr.with_check is not None: + raise ValueError( + "ALTER POLICY RENAME TO is mutually exclusive with " + "TO/USING/WITH CHECK clauses" + ) + if not self.supports_alter_policy_rename(): + raise UnsupportedFeatureError( + self.name, + "ALTER POLICY RENAME", + suggestion="requires PostgreSQL 9.5+", + ) + parts = [ + "ALTER POLICY", + self.format_identifier(expr.name), + "ON", + self._format_table_ref(expr.schema, expr.table_name), + "RENAME TO", + self.format_identifier(expr.new_name), + ] + return " ".join(parts), () + + # REPLACE form + if not self.supports_alter_policy_replace(): + raise UnsupportedFeatureError( + self.name, + "ALTER POLICY ... TO/USING/WITH CHECK", + suggestion="requires PostgreSQL 9.5+", + ) + if expr.roles is None and expr.using is None \ + and expr.with_check is None: + raise ValueError( + "ALTER POLICY (REPLACE form) requires at least one of " + "TO / USING / WITH CHECK" + ) + + all_params: List[object] = [] + parts: List[str] = [ + "ALTER POLICY", + self.format_identifier(expr.name), + "ON", + self._format_table_ref(expr.schema, expr.table_name), + ] + if expr.roles is not None: + role_list, _ = self._format_role_list(list(expr.roles)) + parts.append(f"TO {role_list}") + if expr.using is not None: + using_clause, params = self._format_predicate(expr.using, "USING") + parts.append(using_clause) + all_params.extend(params) + if expr.with_check is not None: + wc_clause, params = self._format_predicate( + expr.with_check, "WITH CHECK" + ) + parts.append(wc_clause) + all_params.extend(params) + + return " ".join(parts), tuple(all_params) + + # ------------------------------------------------------------------ # + # DROP POLICY + # ------------------------------------------------------------------ # + def format_drop_policy_statement( + self, expr: "PostgresDropPolicyExpression" + ) -> Tuple[str, tuple]: + """Format DROP POLICY statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropPolicyExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5. + ValueError: if both ``cascade`` and ``restrict`` are True. + + Note: + Per PostgreSQL documentation, ``CASCADE``/``RESTRICT`` have no + effect on DROP POLICY (the policy has no dependents), but are + accepted syntactically — we honour the user's choice and emit + the token, leaving semantics to the server. + + """ + if not self.supports_create_policy(): + raise UnsupportedFeatureError( + self.name, + "DROP POLICY", + suggestion="requires PostgreSQL 9.5+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP POLICY: CASCADE and RESTRICT are mutually exclusive" + ) + + parts: List[str] = ["DROP POLICY"] + if expr.if_exists and self.supports_drop_policy_if_exists(): + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name)) + parts.append("ON") + parts.append(self._format_table_ref(expr.schema, expr.table_name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () + + +# ------------------------------------------------------------------ # +# Module-level helper (no class binding so it can be reused and unit +# tested in isolation; intentionally avoids any dialect reference). +# ------------------------------------------------------------------ # +def cmd_combined_clause_errors( + cmd: PolicyCommand, + using: Optional[SQLPredicate], + with_check: Optional[SQLPredicate], +) -> None: + """Validate USING/WITH CHECK against the chosen FOR command type. + + PostgreSQL semantics: + - INSERT policy: must NOT have USING, may have WITH CHECK. + - SELECT / DELETE policy: must NOT have WITH CHECK; USING allowed. + - ALL / UPDATE: both allowed. + + Raises: + ValueError: describing the mismatch. + + """ + if cmd is PolicyCommand.INSERT and using is not None: + raise ValueError( + "INSERT policy cannot have a USING expression " + "(use WITH CHECK for new-row validation)" + ) + if cmd in (PolicyCommand.SELECT, PolicyCommand.DELETE) \ + and with_check is not None: + target = "SELECT" if cmd is PolicyCommand.SELECT else "DELETE" + raise ValueError( + f"{target} policy cannot have a WITH CHECK expression" + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py new file mode 100644 index 00000000..717f7628 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py @@ -0,0 +1,193 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/publication.py +"""PostgreSQL PUBLICATION / SUBSCRIPTION DDL implementation. + +Implements CREATE/DROP PUBLICATION and CREATE/DROP SUBSCRIPTION for the +postgres dialect (logical replication). + +Version Requirements: +- CREATE/DROP PUBLICATION: PostgreSQL 10+ +- CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ +""" + +from typing import List, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.publication import ( + PostgresCreatePublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropPublicationExpression, + PostgresDropSubscriptionExpression, + ) + + +class PostgresPublicationMixin: + """PostgreSQL PUBLICATION / SUBSCRIPTION implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_publication(self) -> bool: + """CREATE/DROP PUBLICATION require PostgreSQL 10+.""" + return self.version >= (10, 0, 0) + + def supports_subscription(self) -> bool: + """CREATE/DROP SUBSCRIPTION require PostgreSQL 10+.""" + return self.version >= (10, 0, 0) + + # ------------------------------------------------------------------ # + # CREATE PUBLICATION + # ------------------------------------------------------------------ # + def format_create_publication_statement( + self, expr: "PostgresCreatePublicationExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE PUBLICATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreatePublicationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + ValueError: if both ``tables`` and ``all_tables`` are given, or + neither is. + + """ + if not self.supports_publication(): + raise UnsupportedFeatureError( + self.name, + "CREATE PUBLICATION", + suggestion="requires PostgreSQL 10+", + ) + for_target = expr.tables is not None and len(expr.tables) > 0 + if expr.all_tables and expr.tables: + raise ValueError( + "CREATE PUBLICATION: tables and all_tables are mutually " + "exclusive" + ) + if not expr.all_tables and not for_target and expr.tables is None: + # No table list at all: PostgreSQL requires either FOR TABLE or + # FOR ALL TABLES in modern versions (defaults varied historically). + raise ValueError( + "CREATE PUBLICATION requires either tables or all_tables" + ) + + parts: List[str] = ["CREATE PUBLICATION", self.format_identifier(expr.name)] + if expr.all_tables: + parts.append("FOR ALL TABLES") + elif for_target: + quoted = ", ".join(self.format_identifier(t) for t in expr.tables) + parts.append(f"FOR TABLE {quoted}") + if expr.options: + parts.append("WITH (" + ", ".join(expr.options) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP PUBLICATION + # ------------------------------------------------------------------ # + def format_drop_publication_statement( + self, expr: "PostgresDropPublicationExpression" + ) -> Tuple[str, tuple]: + """Format a DROP PUBLICATION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropPublicationExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + + """ + if not self.supports_publication(): + raise UnsupportedFeatureError( + self.name, + "DROP PUBLICATION", + suggestion="requires PostgreSQL 10+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP PUBLICATION: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP PUBLICATION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name)) + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # CREATE SUBSCRIPTION + # ------------------------------------------------------------------ # + def format_create_subscription_statement( + self, expr: "PostgresCreateSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE SUBSCRIPTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateSubscriptionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + + """ + if not self.supports_subscription(): + raise UnsupportedFeatureError( + self.name, + "CREATE SUBSCRIPTION", + suggestion="requires PostgreSQL 10+", + ) + pub_list = ", ".join( + self.format_identifier(p) for p in expr.publications + ) + parts: List[str] = [ + "CREATE SUBSCRIPTION", + self.format_identifier(expr.name), + f"CONNECTION '{expr.connection}'", + f"PUBLICATION {pub_list}", + ] + if expr.options: + parts.append("WITH (" + ", ".join(expr.options) + ")") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP SUBSCRIPTION + # ------------------------------------------------------------------ # + def format_drop_subscription_statement( + self, expr: "PostgresDropSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a DROP SUBSCRIPTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropSubscriptionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 10. + + """ + if not self.supports_subscription(): + raise UnsupportedFeatureError( + self.name, + "DROP SUBSCRIPTION", + suggestion="requires PostgreSQL 10+", + ) + parts: List[str] = ["DROP SUBSCRIPTION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.name)) + if expr.cascade: + parts.append("CASCADE") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py new file mode 100644 index 00000000..d606a713 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py @@ -0,0 +1,125 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/rls_config.py +"""PostgreSQL Row-Level Security table configuration DDL implementation. + +Implements ``ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY`` and +``ALTER TABLE ... FORCE / NO FORCE ROW LEVEL SECURITY`` for the postgres +dialect. Together with the ``PostgresPolicyMixin`` (CREATE/ALTER/DROP +POLICY) this forms the complete Row-Level Security ecosystem. + +Version Requirements: +- All four forms: PostgreSQL 9.5+ +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.rls_config import ( + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + ) + + +class PostgresRlsConfigMixin: + """PostgreSQL Row-Level Security table configuration implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_rls_enable_disable(self) -> bool: + """``ENABLE/DISABLE ROW LEVEL SECURITY`` requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_rls_force(self) -> bool: + """``FORCE / NO FORCE ROW LEVEL SECURITY`` requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_rls_enable_always(self) -> bool: + """``ENABLE ALWAYS ROW LEVEL SECURITY`` requires PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_rls_table_ref( + self, schema: Optional[str], table_name: str + ) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(table_name)}" + ) + return self.format_identifier(table_name) + + # ------------------------------------------------------------------ # + # ENABLE / DISABLE + # ------------------------------------------------------------------ # + def format_alter_table_rls_statement( + self, expr: "PostgresAlterTableRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY``. + + Args: + expr: :class:`PostgresAlterTableRlsExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5. + ValueError: if ``always`` is combined with ``DISABLE``. + + """ + if not self.supports_rls_enable_disable(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY", + suggestion="requires PostgreSQL 9.5+", + ) + if expr.always and expr.mode.value == "DISABLE": + raise ValueError( + "ENABLE ALWAYS is only valid with ENABLE ROW LEVEL SECURITY" + ) + + mode = "ENABLE ALWAYS" if expr.always else expr.mode.value + parts = [ + "ALTER TABLE", + self._format_rls_table_ref(expr.schema, expr.table_name), + f"{mode} ROW LEVEL SECURITY", + ] + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # FORCE / NO FORCE + # ------------------------------------------------------------------ # + def format_force_rls_statement( + self, expr: "PostgresForceRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... FORCE/NO FORCE ROW LEVEL SECURITY``. + + Args: + expr: :class:`PostgresForceRlsExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.5. + + """ + if not self.supports_rls_force(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ... FORCE/NO FORCE ROW LEVEL SECURITY", + suggestion="requires PostgreSQL 9.5+", + ) + mode = "FORCE ROW LEVEL SECURITY" if expr.force else \ + "NO FORCE ROW LEVEL SECURITY" + parts = [ + "ALTER TABLE", + self._format_rls_table_ref(expr.schema, expr.table_name), + mode, + ] + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py new file mode 100644 index 00000000..a70b1df3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py @@ -0,0 +1,214 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/routine.py +"""PostgreSQL FUNCTION / AGGREGATE DDL implementation. + +Implements CREATE/DROP FUNCTION and CREATE/DROP AGGREGATE formatting for +the postgres dialect. (CREATE/DROP PROCEDURE lives in the existing +``PostgresStoredProcedureMixin``.) + +Version Requirements: +- CREATE/DROP FUNCTION: PostgreSQL 9.6+ +- CREATE/DROP AGGREGATE: PostgreSQL 9.6+ +""" + +from typing import List, Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.ddl.routine import ( + PostgresCreateAggregateExpression, + PostgresCreateFunctionExpression, + PostgresDropAggregateExpression, + PostgresDropFunctionExpression, + ) + + +class PostgresRoutineMixin: + """PostgreSQL FUNCTION / AGGREGATE DDL implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_function_ddl(self) -> bool: + """CREATE/DROP FUNCTION require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + def supports_aggregate_ddl(self) -> bool: + """CREATE/DROP AGGREGATE require PostgreSQL 9.6+.""" + return self.version >= (9, 6, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_routine_ref(self, schema: Optional[str], name: str) -> str: + """Format ``name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(name)}" + ) + return self.format_identifier(name) + + # ------------------------------------------------------------------ # + # CREATE FUNCTION + # ------------------------------------------------------------------ # + def format_create_function_ddl_statement( + self, expr: "PostgresCreateFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE FUNCTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateFunctionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_function_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE FUNCTION", + suggestion="requires PostgreSQL 9.6+", + ) + + replace = "OR REPLACE " if expr.or_replace else "" + base = ( + f"CREATE {replace}FUNCTION " + f"{self._format_routine_ref(expr.schema, expr.name)}" + ) + args = ", ".join(expr.args) if expr.args else "" + parts: List[str] = [f"{base}({args})"] + parts.append(f"RETURNS {expr.return_type}") + if expr.strict: + parts.append("STRICT") + if expr.security is not None: + security = expr.security.upper() + if security not in ("DEFINER", "INVOKER"): + raise ValueError("security must be 'DEFINER' or 'INVOKER'") + parts.append(f"SECURITY {security}") + if expr.cost is not None: + parts.append(f"COST {expr.cost}") + if expr.rows is not None: + parts.append(f"ROWS {expr.rows}") + parts.append(f"LANGUAGE {expr.language}") + parts.append(f"AS $$ {expr.body} $$") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # DROP FUNCTION + # ------------------------------------------------------------------ # + def format_drop_function_ddl_statement( + self, expr: "PostgresDropFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a DROP FUNCTION statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropFunctionExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + ValueError: if both ``cascade`` and ``restrict`` are True. + + """ + if not self.supports_function_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP FUNCTION", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP FUNCTION: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP FUNCTION"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_routine_ref(expr.schema, expr.name)) + if expr.args: + parts.append("(" + ", ".join(expr.args) + ")") + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () + + # ------------------------------------------------------------------ # + # CREATE AGGREGATE + # ------------------------------------------------------------------ # + def format_create_aggregate_ddl_statement( + self, expr: "PostgresCreateAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a CREATE AGGREGATE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresCreateAggregateExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + + """ + if not self.supports_aggregate_ddl(): + raise UnsupportedFeatureError( + self.name, + "CREATE AGGREGATE", + suggestion="requires PostgreSQL 9.6+", + ) + base = ( + f"CREATE AGGREGATE " + f"{self._format_routine_ref(expr.schema, expr.name)}" + ) + options = [f"SFUNC={expr.sfunc}", f"STYPE={expr.stype}"] + if expr.finalfunc: + options.append(f"FINALFUNC={expr.finalfunc}") + if expr.initcond is not None: + options.append(f"INITCOND={expr.initcond}") + return f"{base} ({', '.join(options)})", () + + # ------------------------------------------------------------------ # + # DROP AGGREGATE + # ------------------------------------------------------------------ # + def format_drop_aggregate_ddl_statement( + self, expr: "PostgresDropAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a DROP AGGREGATE statement (PostgreSQL-specific). + + Args: + expr: :class:`PostgresDropAggregateExpression`. + + Returns: + Tuple of (string, empty params tuple). + + Raises: + UnsupportedFeatureError: on a dialect predating PostgreSQL 9.6. + ValueError: if both ``cascade`` and ``restrict`` are True. + + """ + if not self.supports_aggregate_ddl(): + raise UnsupportedFeatureError( + self.name, + "DROP AGGREGATE", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.cascade and expr.restrict: + raise ValueError( + "DROP AGGREGATE: CASCADE and RESTRICT are mutually exclusive" + ) + parts: List[str] = ["DROP AGGREGATE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_routine_ref(expr.schema, expr.name)) + parts.append(f"({expr.arg_type})") + if expr.cascade: + parts.append("CASCADE") + elif expr.restrict: + parts.append("RESTRICT") + return " ".join(parts), () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py new file mode 100644 index 00000000..29c4277d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py @@ -0,0 +1,109 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/table_settings.py +"""PostgreSQL ALTER TABLE table-level settings DDL implementation. + +Implements ``ALTER TABLE ... SET LOGGED/UNLOGGED`` and +``ALTER TABLE ... SET ACCESS METHOD ...`` for the postgres dialect. + +Version Requirements: +- SET LOGGED / SET UNLOGGED: PostgreSQL 9.6+ (exists since 9.5) +- SET ACCESS METHOD: PostgreSQL 15+ +""" + +from typing import Optional, Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from ...expression.ddl.table_settings import LoggingMode + +if TYPE_CHECKING: + from ...expression.ddl.table_settings import ( + PostgresAlterTableSettingsExpression, + ) + + +class PostgresAlterTableSettingsMixin: + """PostgreSQL ALTER TABLE ... SET table-level settings implementation.""" + + # ------------------------------------------------------------------ # + # Capability switches + # ------------------------------------------------------------------ # + def supports_table_logging_switch(self) -> bool: + """``SET LOGGED/UNLOGGED`` requires PostgreSQL 9.6+ (since 9.5).""" + return self.version >= (9, 6, 0) + + def supports_table_set_access_method(self) -> bool: + """``SET ACCESS METHOD`` requires PostgreSQL 15+.""" + return self.version >= (15, 0, 0) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + def _format_settings_table_ref( + self, schema: Optional[str], table_name: str + ) -> str: + """Format ``table_name`` (optionally schema-qualified) as identifier(s).""" + if schema: + return ( + f"{self.format_identifier(schema)}." + f"{self.format_identifier(table_name)}" + ) + return self.format_identifier(table_name) + + # ------------------------------------------------------------------ # + # Statement + # ------------------------------------------------------------------ # + def format_alter_table_settings_statement( + self, expr: "PostgresAlterTableSettingsExpression" + ) -> Tuple[str, tuple]: + """Format an ALTER TABLE ... SET table-level settings statement. + + Args: + expr: :class:`PostgresAlterTableSettingsExpression`. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + ValueError: if neither ``mode`` nor ``access_method`` is given, + or if both are given. + UnsupportedFeatureError: when a version-gated setting is requested + on an insufficient PostgreSQL version. + + """ + has_logging = expr.mode is not None + has_access = expr.access_method is not None + if not has_logging and not has_access: + raise ValueError( + "ALTER TABLE settings requires exactly one of " + "mode (LOGGED/UNLOGGED) or access_method" + ) + if has_logging and has_access: + raise ValueError( + "ALTER TABLE settings: mode and access_method are mutually " + "exclusive" + ) + + table_ref = self._format_settings_table_ref(expr.schema, expr.table_name) + + if has_logging: + if not self.supports_table_logging_switch(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE SET LOGGED/UNLOGGED", + suggestion="requires PostgreSQL 9.6+", + ) + if expr.mode is LoggingMode.LOGGED: + setting = "SET LOGGED" + else: + setting = "SET UNLOGGED" + else: + if not self.supports_table_set_access_method(): + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE SET ACCESS METHOD", + suggestion="requires PostgreSQL 15+", + ) + setting = ( + f"SET ACCESS METHOD {self.format_identifier(expr.access_method)}" + ) + + return f"ALTER TABLE {table_ref} {setting}", () \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py index 8af4c289..806bd957 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/property_graph_query.py @@ -5,6 +5,11 @@ - GRAPH_TABLE (MATCH ... COLUMNS ...) - CREATE / DROP / ALTER PROPERTY GRAPH - Vertex/edge pattern matching with WHERE + +Known PG 19 PGQ limitations (not supported): +- Variable-length (quantified) path patterns (``+``, ``*``, ``{n,m}``) +- Comma-separated multiple patterns in a single MATCH clause +- Heterogeneous graphs require explicit edge label specification """ @@ -18,6 +23,16 @@ def supports_graph_match(self) -> bool: """MATCH clause is supported since PostgreSQL 19.""" return self.version >= (19, 0, 0) + def supports_quantified_path(self) -> bool: + """Quantified (variable-length) path patterns are **not** supported + in PostgreSQL 19.""" + return False + + def supports_comma_separated_patterns(self) -> bool: + """Comma-separated multi-pattern MATCH is **not** supported + in PostgreSQL 19.""" + return False + def supports_graph_table(self) -> bool: """GRAPH_TABLE expression is supported since PostgreSQL 19.""" return self.version >= (19, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py index 77e7c10f..83bebc5d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py @@ -17,6 +17,17 @@ def supports_temporary_table(self) -> bool: """TEMPORARY tables are supported in all versions.""" return True + def supports_unlogged_table(self) -> bool: + """CREATE UNLOGGED TABLE is supported since PostgreSQL 9.5. + + UNLOGGED tables write no WAL; they are faster for staging data but are + not crash-safe and are truncated on crash recovery. PostgreSQL 14 added + the ``UNLOGGED`` / ``LOGGED`` table as a distribution. The bare + ``UNLOGGED`` qualifier on CREATE TABLE exists since 9.5, matching the + §0 baseline. + """ + return self.version >= (9, 5, 0) + def supports_table_inheritance(self) -> bool: """PostgreSQL supports table inheritance.""" return True @@ -29,7 +40,39 @@ def supports_table_like_syntax(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True - def format_create_table_like(self, expr) -> tuple: + def format_create_table_statement(self, expr) -> Tuple[str, tuple]: + """Render CREATE TABLE, injecting the UNLOGGED qualifier. + + The core ``TableMixin.format_create_table_statement`` does not know + about PostgreSQL's ``UNLOGGED`` table classes (WAL-avoiding tables). + Callers opt in via ``dialect_options={"unlogged": True}`` on the + ``CreateTableExpression``; the qualifier is version-gated at 9.5+. + ``UNLOGGED`` and ``TEMPORARY`` are mutually exclusive qualifiers in + PostgreSQL's grammar; TEMPORARY wins when both are requested. + """ + sql, params = super().format_create_table_statement(expr) + if not (getattr(expr, "dialect_options", None) or {}).get("unlogged_table"): + return sql, params + if getattr(expr, "temporary", False): + return sql, params + if not self.supports_unlogged_table(): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + raise UnsupportedFeatureError( + self.name, + "CREATE UNLOGGED TABLE", + suggestion="requires PostgreSQL 9.5+", + ) + prefix = "CREATE TABLE " + if sql.startswith(prefix): + return sql.replace(prefix, "CREATE UNLOGGED TABLE ", 1), params + temp_prefix = "CREATE TEMPORARY TABLE " + if sql.startswith(temp_prefix): + return sql.replace(temp_prefix, "CREATE UNLOGGED TABLE ", 1), params + return sql, params + + def format_create_table_like(self, expr) -> Tuple[str, tuple]: """Format CREATE TABLE (LIKE ...) statement for PostgreSQL. Delegates to format_create_table_statement which already handles @@ -40,13 +83,20 @@ def format_create_table_like(self, expr) -> tuple: def format_column_definition(self, col_def) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.dialect.base import SQLDialectBase all_params: List[Any] = [] - data_type = col_def.data_type - if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", data_type): + type_sql, _ = col_def.data_type.to_sql(self) + if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): raise ValueError( - f"Invalid data type '{data_type}': " + f"Invalid data type '{type_sql}': " "must contain only alphanumeric characters, spaces, parentheses, commas, and brackets." ) - col_sql = f"{self.format_identifier(col_def.name)} {data_type}" + col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" + dialect_opts = col_def.dialect_options or {} + identity = dialect_opts.get("identity") + if identity: + if identity.upper() in ("ALWAYS", "BY DEFAULT"): + col_sql += f" GENERATED {identity.upper()} AS IDENTITY" + else: + raise ValueError(f"Invalid identity option '{identity}': must be 'ALWAYS' or 'BY DEFAULT'") for constraint in col_def.constraints: suffix, params = self.format_column_constraint(constraint) col_sql += suffix diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py index a223f6b2..0d877803 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/__init__.py @@ -8,6 +8,7 @@ from .range_type import PostgresRangeTypeMixin from .jsonb_enhanced import PostgresJSONBEnhancedMixin from .array_enhanced import PostgresArrayEnhancedMixin +from .data_type_formatting import PostgresTypeFormatSupportMixin __all__ = [ "EnumTypeMixin", @@ -17,4 +18,5 @@ "PostgresRangeTypeMixin", "PostgresJSONBEnhancedMixin", "PostgresArrayEnhancedMixin", + "PostgresTypeFormatSupportMixin", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py new file mode 100644 index 00000000..73c155f6 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py @@ -0,0 +1,821 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py +"""PostgreSQL DataType formatting and parsing mixin.""" + +from __future__ import annotations + +import re +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.mixins.ddl_type import DDLTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import DDLTypeSupport +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BigIntType, + BlobType, + BooleanType, + CharType, + DataType, + DateType, + DateTimeType, + DecimalType, + DoubleType, + FloatType, + IntegerType, + IntType, + IntervalType, + JsonBType, + JsonType, + RealType, + SmallIntType, + TextType, + TimeType, + TimeTzType, + TimestampType, + TimestampTzType, + TinyIntType, + VarCharType, +) +from ...expression.types import ( + PostgresArrayType, + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCircleType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, + PostgresCidrType, + PostgresCharacterVaryingType, + PostgresCitextType, + PostgresCubeType, + PostgresLtreeType, + PostgresRasterType, +) + +if TYPE_CHECKING: + pass + + +class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): + """PostgreSQL DataType formatting and parsing. + + Implements ``DDLTypeSupport`` so the dialect can render ``DataType`` + expressions to SQL strings and parse raw SQL type strings back into + ``DataType`` instances. + """ + + # ------------------------------------------------------------------ + # DDLTypeSupport — formatting + # ------------------------------------------------------------------ + + # --- PostgreSQL-specific formatters --- + + @DDLTypeMixin.handles(PostgresByteaType) + def format_data_type_bytea(self, data_type) -> Tuple[str, tuple]: + return "BYTEA", () + + @DDLTypeMixin.handles(PostgresSmallSerialType) + def format_data_type_small_serial(self, data_type) -> Tuple[str, tuple]: + return "SMALLSERIAL", () + + @DDLTypeMixin.handles(PostgresSerialType) + def format_data_type_serial(self, data_type) -> Tuple[str, tuple]: + return "SERIAL", () + + @DDLTypeMixin.handles(PostgresBigSerialType) + def format_data_type_big_serial(self, data_type) -> Tuple[str, tuple]: + return "BIGSERIAL", () + + @DDLTypeMixin.handles(PostgresUUIDType) + def format_data_type_uuid(self, data_type) -> Tuple[str, tuple]: + return "UUID", () + + @DDLTypeMixin.handles(PostgresXMLType) + def format_data_type_xml(self, data_type) -> Tuple[str, tuple]: + return "XML", () + + @DDLTypeMixin.handles(PostgresCharacterVaryingType) + def format_data_type_character_varying(self, data_type: PostgresCharacterVaryingType) -> Tuple[str, tuple]: + if data_type.length is not None: + return f"CHARACTER VARYING({data_type.length})", () + return "CHARACTER VARYING", () + + @DDLTypeMixin.handles(PostgresTSVectorType) + def format_data_type_ts_vector(self, data_type) -> Tuple[str, tuple]: + return "TSVECTOR", () + + @DDLTypeMixin.handles(PostgresTSQueryType) + def format_data_type_ts_query(self, data_type) -> Tuple[str, tuple]: + return "TSQUERY", () + + @DDLTypeMixin.handles(PostgresJsonPathType) + def format_data_type_json_path(self, data_type) -> Tuple[str, tuple]: + return "JSONPATH", () + + @DDLTypeMixin.handles(PostgresBitType) + def format_data_type_bit(self, data_type) -> Tuple[str, tuple]: + if data_type.n is not None: + return f"BIT({data_type.n})", () + return "BIT", () + + @DDLTypeMixin.handles(PostgresVarBitType) + def format_data_type_var_bit(self, data_type) -> Tuple[str, tuple]: + if data_type.n is not None: + return f"VARBIT({data_type.n})", () + return "VARBIT", () + + @DDLTypeMixin.handles(PostgresInetType) + def format_data_type_inet(self, data_type) -> Tuple[str, tuple]: + return "INET", () + + @DDLTypeMixin.handles(PostgresCidrType) + def format_data_type_cidr(self, data_type) -> Tuple[str, tuple]: + return "CIDR", () + + @DDLTypeMixin.handles(PostgresMacAddrType) + def format_data_type_mac_addr(self, data_type) -> Tuple[str, tuple]: + return "MACADDR", () + + @DDLTypeMixin.handles(PostgresMacAddr8Type) + def format_data_type_mac_addr8(self, data_type) -> Tuple[str, tuple]: + return "MACADDR8", () + + @DDLTypeMixin.handles(PostgresPointType) + def format_data_type_point(self, data_type) -> Tuple[str, tuple]: + return "POINT", () + + @DDLTypeMixin.handles(PostgresLineType) + def format_data_type_line(self, data_type) -> Tuple[str, tuple]: + return "LINE", () + + @DDLTypeMixin.handles(PostgresLineSegmentType) + def format_data_type_line_segment(self, data_type) -> Tuple[str, tuple]: + return "LSEG", () + + @DDLTypeMixin.handles(PostgresBoxType) + def format_data_type_box(self, data_type) -> Tuple[str, tuple]: + return "BOX", () + + @DDLTypeMixin.handles(PostgresPathType) + def format_data_type_path(self, data_type) -> Tuple[str, tuple]: + return "PATH", () + + @DDLTypeMixin.handles(PostgresPolygonType) + def format_data_type_polygon(self, data_type) -> Tuple[str, tuple]: + return "POLYGON", () + + @DDLTypeMixin.handles(PostgresCircleType) + def format_data_type_circle(self, data_type) -> Tuple[str, tuple]: + return "CIRCLE", () + + @DDLTypeMixin.handles(PostgresMoneyType) + def format_data_type_money(self, data_type) -> Tuple[str, tuple]: + return "MONEY", () + + @DDLTypeMixin.handles(PostgresInt4RangeType) + def format_data_type_int4_range(self, data_type) -> Tuple[str, tuple]: + return "INT4RANGE", () + + @DDLTypeMixin.handles(PostgresInt8RangeType) + def format_data_type_int8_range(self, data_type) -> Tuple[str, tuple]: + return "INT8RANGE", () + + @DDLTypeMixin.handles(PostgresNumRangeType) + def format_data_type_num_range(self, data_type) -> Tuple[str, tuple]: + return "NUMRANGE", () + + @DDLTypeMixin.handles(PostgresTsRangeType) + def format_data_type_ts_range(self, data_type) -> Tuple[str, tuple]: + return "TSRANGE", () + + @DDLTypeMixin.handles(PostgresTsTzRangeType) + def format_data_type_ts_tz_range(self, data_type) -> Tuple[str, tuple]: + return "TSTZRANGE", () + + @DDLTypeMixin.handles(PostgresDateRangeType) + def format_data_type_date_range(self, data_type) -> Tuple[str, tuple]: + return "DATERANGE", () + + @DDLTypeMixin.handles(PostgresInt4MultirangeType) + def format_data_type_int4_multirange(self, data_type) -> Tuple[str, tuple]: + return "INT4MULTIRANGE", () + + @DDLTypeMixin.handles(PostgresInt8MultirangeType) + def format_data_type_int8_multirange(self, data_type) -> Tuple[str, tuple]: + return "INT8MULTIRANGE", () + + @DDLTypeMixin.handles(PostgresNumMultirangeType) + def format_data_type_num_multirange(self, data_type) -> Tuple[str, tuple]: + return "NUMMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresTsMultirangeType) + def format_data_type_ts_multirange(self, data_type) -> Tuple[str, tuple]: + return "TSMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresTsTzMultirangeType) + def format_data_type_ts_tz_multirange(self, data_type) -> Tuple[str, tuple]: + return "TSTZMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresDateMultirangeType) + def format_data_type_date_multirange(self, data_type) -> Tuple[str, tuple]: + return "DATEMULTIRANGE", () + + @DDLTypeMixin.handles(PostgresOIDType) + def format_data_type_oid(self, data_type) -> Tuple[str, tuple]: + return "OID", () + + @DDLTypeMixin.handles(PostgresRegClassType) + def format_data_type_reg_class(self, data_type) -> Tuple[str, tuple]: + return "REGCLASS", () + + @DDLTypeMixin.handles(PostgresRegTypeType) + def format_data_type_reg_type(self, data_type) -> Tuple[str, tuple]: + return "REGTYPE", () + + @DDLTypeMixin.handles(PostgresXIDType) + def format_data_type_xid(self, data_type) -> Tuple[str, tuple]: + return "XID", () + + @DDLTypeMixin.handles(PostgresXID8Type) + def format_data_type_xid8(self, data_type) -> Tuple[str, tuple]: + return "XID8", () + + @DDLTypeMixin.handles(PostgresCIDType) + def format_data_type_cid(self, data_type) -> Tuple[str, tuple]: + return "CID", () + + @DDLTypeMixin.handles(PostgresTIDType) + def format_data_type_tid(self, data_type) -> Tuple[str, tuple]: + return "TID", () + + @DDLTypeMixin.handles(PostgresPgLSNType) + def format_data_type_pg_lsn(self, data_type) -> Tuple[str, tuple]: + return "PG_LSN", () + + @DDLTypeMixin.handles(PostgresHstoreType) + def format_data_type_hstore(self, data_type) -> Tuple[str, tuple]: + return "HSTORE", () + + @DDLTypeMixin.handles(PostgresGeometryType) + def format_data_type_geometry(self, data_type) -> Tuple[str, tuple]: + return "GEOMETRY", () + + @DDLTypeMixin.handles(PostgresGeographyType) + def format_data_type_geography(self, data_type) -> Tuple[str, tuple]: + return "GEOGRAPHY", () + + @DDLTypeMixin.handles(PostgresVectorType) + def format_data_type_vector(self, data_type) -> Tuple[str, tuple]: + return f"VECTOR({data_type.dim})", () + + @DDLTypeMixin.handles(PostgresCitextType) + def format_data_type_citext(self, data_type) -> Tuple[str, tuple]: + return "CITEXT", () + + @DDLTypeMixin.handles(PostgresCubeType) + def format_data_type_cube(self, data_type) -> Tuple[str, tuple]: + return "CUBE", () + + @DDLTypeMixin.handles(PostgresLtreeType) + def format_data_type_ltree(self, data_type) -> Tuple[str, tuple]: + return "LTREE", () + + @DDLTypeMixin.handles(PostgresRasterType) + def format_data_type_raster(self, data_type) -> Tuple[str, tuple]: + return "RASTER", () + + @DDLTypeMixin.handles(ArrayType) + def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: + elem = data_type.element_type + elem_cls = type(elem) + if elem_cls not in self._type_formatters: + raise TypeError( + f"ArrayType element type {elem_cls.__name__!r} is not supported " + f"by {type(self).__name__}. Use a PostgreSQL-specific DataType " + f"as the element type (e.g. PostgresIntegerType)." + ) + element_sql, _ = self.format_data_type(elem) + return element_sql + "[]" * data_type.dimensions, () + + # --- Core formatters (PostgreSQL specialized) --- + + @DDLTypeMixin.handles(TinyIntType) + def format_data_type_tiny_int(self, data_type: TinyIntType) -> Tuple[str, tuple]: + return "SMALLINT", () + + @DDLTypeMixin.handles(SmallIntType) + def format_data_type_small_int(self, data_type: SmallIntType) -> Tuple[str, tuple]: + return "SMALLINT", () + + @DDLTypeMixin.handles(IntType) + def format_data_type_int(self, data_type: IntType) -> Tuple[str, tuple]: + return "INTEGER", () + + @DDLTypeMixin.handles(IntegerType) + def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: + return "INTEGER", () + + @DDLTypeMixin.handles(BigIntType) + def format_data_type_big_int(self, data_type: BigIntType) -> Tuple[str, tuple]: + return "BIGINT", () + + @DDLTypeMixin.handles(FloatType) + def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"FLOAT({data_type.precision})", () + return "REAL", () + + @DDLTypeMixin.handles(RealType) + def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: + return "REAL", () + + @DDLTypeMixin.handles(DoubleType) + def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: + return "DOUBLE PRECISION", () + + @DDLTypeMixin.handles(DecimalType) + def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: + if data_type.precision is not None and data_type.scale is not None: + return f"DECIMAL({data_type.precision},{data_type.scale})", () + if data_type.precision is not None: + return f"DECIMAL({data_type.precision})", () + return "DECIMAL", () + + @DDLTypeMixin.handles(CharType) + def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: + if data_type.length is not None: + return f"CHAR({data_type.length})", () + return "CHAR", () + + @DDLTypeMixin.handles(VarCharType) + def format_data_type_var_char(self, data_type: VarCharType) -> Tuple[str, tuple]: + if data_type.length is not None: + return f"VARCHAR({data_type.length})", () + return "VARCHAR", () + + @DDLTypeMixin.handles(TextType) + def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: + return "TEXT", () + + @DDLTypeMixin.handles(BooleanType) + def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: + return "BOOLEAN", () + + @DDLTypeMixin.handles(BlobType) + def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: + return "BYTEA", () + + @DDLTypeMixin.handles(DateType) + def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: + return "DATE", () + + @DDLTypeMixin.handles(TimeType) + def format_data_type_time(self, data_type: TimeType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIME({data_type.precision})", () + return "TIME", () + + @DDLTypeMixin.handles(TimeTzType) + def format_data_type_time_tz(self, data_type: TimeTzType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIME({data_type.precision}) WITH TIME ZONE", () + return "TIME WITH TIME ZONE", () + + @DDLTypeMixin.handles(DateTimeType) + def format_data_type_date_time(self, data_type: DateTimeType) -> Tuple[str, tuple]: + return "TIMESTAMP", () + + @DDLTypeMixin.handles(TimestampType) + def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIMESTAMP({data_type.precision})", () + return "TIMESTAMP", () + + @DDLTypeMixin.handles(TimestampTzType) + def format_data_type_timestamp_tz(self, data_type: TimestampTzType) -> Tuple[str, tuple]: + if data_type.precision is not None: + return f"TIMESTAMP({data_type.precision}) WITH TIME ZONE", () + return "TIMESTAMP WITH TIME ZONE", () + + @DDLTypeMixin.handles(IntervalType) + def format_data_type_interval(self, data_type: IntervalType) -> Tuple[str, tuple]: + if data_type.fields is not None: + return f"INTERVAL {data_type.fields}", () + return "INTERVAL", () + + @DDLTypeMixin.handles(JsonType) + def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: + return "JSON", () + + @DDLTypeMixin.handles(JsonBType) + def format_data_type_json_b(self, data_type: JsonBType) -> Tuple[str, tuple]: + return "JSONB", () + + # ------------------------------------------------------------------ + # DDLTypeSupport — parsing + # ------------------------------------------------------------------ + + _PG_INTEGER_TYPES = re.compile( + r"^(?:SMALLINT|INT2|INT|INTEGER|INT4|BIGINT|INT8)\b", + re.IGNORECASE, + ) + _PG_SERIAL_TYPES = re.compile( + r"^(?:SMALLSERIAL|SERIAL2|SERIAL|SERIAL4|BIGSERIAL|SERIAL8)\b", + re.IGNORECASE, + ) + _PG_FLOAT_TYPES = re.compile( + r"^(?:REAL|FLOAT4|FLOAT|DOUBLE)\b", + re.IGNORECASE, + ) + _PG_DECIMAL_TYPES = re.compile( + r"^(?:DECIMAL|NUMERIC)\b", + re.IGNORECASE, + ) + _PG_STRING_TYPES = re.compile( + r"^(?:CHARACTER|CHAR|VARCHAR|TEXT)\b", + re.IGNORECASE, + ) + _PG_BINARY_TYPES = re.compile( + r"^(?:BYTEA|BLOB)\b", + re.IGNORECASE, + ) + _PG_DATE_TYPES = re.compile( + r"^(?:DATETIME|DATE|TIMESTAMPTZ|TIMESTAMP|TIMETZ|TIME|INTERVAL)\b", + re.IGNORECASE, + ) + _PG_JSON_TYPES = re.compile( + r"^(?:JSON|JSONB|JSONPATH)\b", + re.IGNORECASE, + ) + _PG_UUID_TYPES = re.compile( + r"^(?:UUID)\b", + re.IGNORECASE, + ) + _PG_NET_TYPES = re.compile( + r"^(?:INET|CIDR|MACADDR|MACADDR8)\b", + re.IGNORECASE, + ) + _PG_GEOM_TYPES = re.compile( + r"^(?:POINT|LINE|LSEG|BOX|PATH|POLYGON|CIRCLE)\b", + re.IGNORECASE, + ) + _PG_BIT_TYPES = re.compile( + r"^(?:BIT|VARBIT)\b", + re.IGNORECASE, + ) + _PG_RANGE_TYPES = re.compile( + r"^(?:INT4RANGE|INT8RANGE|NUMRANGE|TSRANGE|TSTZRANGE|DATERANGE)\b", + re.IGNORECASE, + ) + _PG_MULTIRANGE_TYPES = re.compile( + r"^(?:INT4MULTIRANGE|INT8MULTIRANGE|NUMMULTIRANGE|" + r"TSMULTIRANGE|TSTZMULTIRANGE|DATEMULTIRANGE)\b", + re.IGNORECASE, + ) + _PG_OID_TYPES = re.compile( + r"^(?:OID|REGCLASS|REGTYPE|XID|XID8|CID|TID)\b", + re.IGNORECASE, + ) + _PG_TS_TYPES = re.compile( + r"^(?:TSVECTOR|TSQUERY)\b", + re.IGNORECASE, + ) + _PG_MISC_TYPES = re.compile( + r"^(?:MONEY|XML|PG_LSN|HSTORE|GEOMETRY|GEOGRAPHY)\b", + re.IGNORECASE, + ) + _PG_ARRAY_SUFFIX = re.compile(r"(\[(\d*)\])+\s*$") + _PG_ARRAY_KEYWORD = re.compile(r"\s+ARRAY(?:\s*\[\s*(\d+)\s*\])?\s*$", re.IGNORECASE) + + def parse_type(self, raw: str) -> DataType: + stripped = raw.strip() + upper = stripped.upper() + + # ---- Array detection (must run before any type-specific match) ---- + + # Check for ARRAY keyword: e.g. "INTEGER ARRAY" or "INTEGER ARRAY[3]" + kw_match = self._PG_ARRAY_KEYWORD.search(upper) + if kw_match: + base_raw = stripped[:kw_match.start()] + element_type = self.parse_type(base_raw) + # PG normalises all array declarations to 1-D internally + return PostgresArrayType(element_type, dimensions=1) + + # Check for array bracket suffix: e.g. "INTEGER[]", "INTEGER[][]" + remaining = stripped + while remaining.endswith("[]"): + remaining = remaining[:-2] + while remaining.endswith("]"): + m = self._PG_ARRAY_SUFFIX.search(remaining) + if m: + remaining = remaining[:m.start()] + else: + break + + if remaining != stripped: + element_type = self.parse_type(remaining) + # PG normalises all array declarations to 1-D internally + return PostgresArrayType(element_type, dimensions=1) + + # ---- Standard type dispatch ---- + + # Serial family + if self._PG_SERIAL_TYPES.match(upper): + from ...expression.types import ( + PostgresBigSerialType, + PostgresSerialType, + PostgresSmallSerialType, + ) + if upper.startswith("BIGSERIAL") or upper.startswith("SERIAL8"): + return PostgresBigSerialType() + if upper.startswith("SMALLSERIAL") or upper.startswith("SERIAL2"): + return PostgresSmallSerialType() + return PostgresSerialType() + + # Integer family + if self._PG_INTEGER_TYPES.match(upper): + if upper.startswith("SMALLINT") or upper.startswith("INT2"): + return SmallIntType() + if upper.startswith("BIGINT") or upper.startswith("INT8"): + return BigIntType() + return IntegerType() + + # Float family + if self._PG_FLOAT_TYPES.match(upper): + if upper.startswith("DOUBLE"): + return DoubleType() + if upper.startswith("REAL") or upper.startswith("FLOAT4"): + return RealType() + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + return FloatType(precision) + + # Decimal family + if self._PG_DECIMAL_TYPES.match(upper): + nums = re.findall(r"\d+", stripped) + if len(nums) >= 2: + return DecimalType(int(nums[0]), int(nums[1])) + if len(nums) == 1: + return DecimalType(int(nums[0])) + return DecimalType() + + # String family + if self._PG_STRING_TYPES.match(upper): + length_match = re.search(r"\((\d+)\)", stripped) + length = int(length_match.group(1)) if length_match else None + if upper.startswith("VARCHAR") or upper.startswith("CHARACTER VARYING"): + return VarCharType(length) + if upper.startswith("CHAR") or upper.startswith("CHARACTER"): + return CharType(length) + return TextType() + + # Binary + if self._PG_BINARY_TYPES.match(upper): + from ...expression.types import PostgresByteaType + return PostgresByteaType() + + # Date/time + if self._PG_DATE_TYPES.match(upper): + if upper.startswith("DATETIME"): + return DateTimeType() + if upper.startswith("DATE"): + return DateType() + if upper.startswith("TIMESTAMP"): + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + if "WITH TIME ZONE" in upper or upper.startswith("TIMESTAMPTZ"): + return TimestampTzType(precision) + return TimestampType(precision) + if upper.startswith("TIME"): + nums = re.findall(r"\d+", stripped) + precision = int(nums[0]) if nums else None + if "WITH TIME ZONE" in upper or upper.startswith("TIMETZ"): + return TimeTzType(precision) + return TimeType(precision) + if upper.startswith("INTERVAL"): + fields_match = re.search(r"INTERVAL\s+(.*)", upper) + fields = fields_match.group(1).strip() if fields_match else None + return IntervalType(fields) + + # JSON + if self._PG_JSON_TYPES.match(upper): + if upper.startswith("JSONB"): + return JsonBType() + if upper.startswith("JSONPATH"): + from ...expression.types import PostgresJsonPathType + return PostgresJsonPathType() + return JsonType() + + # UUID + if self._PG_UUID_TYPES.match(upper): + from ...expression.types import PostgresUUIDType + return PostgresUUIDType() + + # Boolean + if upper.startswith("BOOLEAN") or upper.startswith("BOOL"): + return BooleanType() + + # Bit string + if self._PG_BIT_TYPES.match(upper): + nums = re.findall(r"\d+", stripped) + n = int(nums[0]) if nums else None + if upper.startswith("BIT"): + from ...expression.types import PostgresBitType + return PostgresBitType(n) + from ...expression.types import PostgresVarBitType + return PostgresVarBitType(n) + + # Network address + if self._PG_NET_TYPES.match(upper): + from ...expression.types import ( + PostgresCidrType, + PostgresInetType, + PostgresMacAddr8Type, + PostgresMacAddrType, + ) + if upper.startswith("CIDR"): + return PostgresCidrType() + if upper.startswith("MACADDR8"): + return PostgresMacAddr8Type() + if upper.startswith("MACADDR"): + return PostgresMacAddrType() + return PostgresInetType() + + # Geometric + if self._PG_GEOM_TYPES.match(upper): + from ...expression.types import ( + PostgresBoxType, + PostgresCircleType, + PostgresLineSegmentType, + PostgresLineType, + PostgresPathType, + PostgresPointType, + PostgresPolygonType, + ) + geom_map = { + "POINT": PostgresPointType, + "LINE": PostgresLineType, + "LSEG": PostgresLineSegmentType, + "BOX": PostgresBoxType, + "PATH": PostgresPathType, + "POLYGON": PostgresPolygonType, + "CIRCLE": PostgresCircleType, + } + for name, cls in geom_map.items(): + if upper.startswith(name): + return cls() + return PostgresPointType() + + # Range types + if self._PG_RANGE_TYPES.match(upper): + from ...expression.types import ( + PostgresDateRangeType, + PostgresInt4RangeType, + PostgresInt8RangeType, + PostgresNumRangeType, + PostgresTsRangeType, + PostgresTsTzRangeType, + ) + range_map = { + "INT4RANGE": PostgresInt4RangeType, + "INT8RANGE": PostgresInt8RangeType, + "NUMRANGE": PostgresNumRangeType, + "TSRANGE": PostgresTsRangeType, + "TSTZRANGE": PostgresTsTzRangeType, + "DATERANGE": PostgresDateRangeType, + } + for name, cls in range_map.items(): + if upper.startswith(name): + return cls() + return PostgresInt4RangeType() + + # Multirange types + if self._PG_MULTIRANGE_TYPES.match(upper): + from ...expression.types import ( + PostgresDateMultirangeType, + PostgresInt4MultirangeType, + PostgresInt8MultirangeType, + PostgresNumMultirangeType, + PostgresTsMultirangeType, + PostgresTsTzMultirangeType, + ) + mr_map = { + "INT4MULTIRANGE": PostgresInt4MultirangeType, + "INT8MULTIRANGE": PostgresInt8MultirangeType, + "NUMMULTIRANGE": PostgresNumMultirangeType, + "TSMULTIRANGE": PostgresTsMultirangeType, + "TSTZMULTIRANGE": PostgresTsTzMultirangeType, + "DATEMULTIRANGE": PostgresDateMultirangeType, + } + for name, cls in mr_map.items(): + if upper.startswith(name): + return cls() + return PostgresInt4MultirangeType() + + # OID types + if self._PG_OID_TYPES.match(upper): + from ...expression.types import ( + PostgresCIDType, + PostgresOIDType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresTIDType, + PostgresXID8Type, + PostgresXIDType, + ) + oid_map = { + "OID": PostgresOIDType, + "REGCLASS": PostgresRegClassType, + "REGTYPE": PostgresRegTypeType, + "XID8": PostgresXID8Type, + "XID": PostgresXIDType, + "CID": PostgresCIDType, + "TID": PostgresTIDType, + } + for name, cls in oid_map.items(): + if upper.startswith(name): + return cls() + return PostgresOIDType() + + # Text search + if self._PG_TS_TYPES.match(upper): + from ...expression.types import ( + PostgresTSQueryType, + PostgresTSVectorType, + ) + if upper.startswith("TSVECTOR"): + return PostgresTSVectorType() + return PostgresTSQueryType() + + # Miscellaneous + if self._PG_MISC_TYPES.match(upper): + from ...expression.types import ( + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresMoneyType, + PostgresPgLSNType, + PostgresXMLType, + ) + if upper.startswith("MONEY"): + return PostgresMoneyType() + if upper.startswith("XML"): + return PostgresXMLType() + if upper.startswith("PG_LSN"): + return PostgresPgLSNType() + if upper.startswith("HSTORE"): + return PostgresHstoreType() + if upper.startswith("GEOGRAPHY"): + return PostgresGeographyType() + if upper.startswith("GEOMETRY"): + return PostgresGeometryType() + + # Vector type (pgvector) + if upper.startswith("VECTOR"): + nums = re.findall(r"\d+", stripped) + dim = int(nums[0]) if nums else 0 + from ...expression.types import PostgresVectorType + return PostgresVectorType(dim) + + # Fallback + from rhosocial.activerecord.backend.expression.types import CustomType + return CustomType(stripped) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py index cf9706f9..08b35287 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py @@ -5,6 +5,11 @@ Implements the PostgresJSONBEnhancedSupport protocol. """ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression import JSONExpression + class PostgresJSONBEnhancedMixin: """Mixin for PostgreSQL enhanced JSONB support. @@ -13,7 +18,7 @@ class PostgresJSONBEnhancedMixin: JSON and JSONB features beyond the standard JSONSupport protocol. """ - def format_json_expression(self, expr): + def format_json_expression(self, expr: "JSONExpression"): from rhosocial.activerecord.backend.expression import bases if isinstance(expr.column, bases.BaseExpression): diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py index b5c4140b..31612d0e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py @@ -10,3 +10,10 @@ class PostgresUpsertMixin: def supports_upsert(self) -> bool: return self.version >= (9, 5, 0) + + def supports_on_conflict_clause(self) -> bool: + return True + + def supports_multiple_on_conflict_clauses(self) -> bool: + """PostgreSQL grammar allows only a single ON CONFLICT clause per INSERT.""" + return False diff --git a/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py index d8a2f623..2da88248 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/named_expressions/partition.py @@ -15,13 +15,12 @@ - PostgreSQL Partitioning: https://www.postgresql.org/docs/current/ddl-partitioning.html """ -from datetime import date, datetime, timedelta -from typing import Dict, Optional, Tuple +from datetime import date +from typing import Optional, Tuple from rhosocial.activerecord.backend.expression.bases import BaseExpression from rhosocial.activerecord.backend.impl.postgres.expression.ddl.partition import ( PostgresCreatePartitionExpression, - PostgresPartitionMetadataExpression, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py index 90c64355..d844bcdf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/__init__.py @@ -21,6 +21,15 @@ from .ddl.comment import PostgresCommentSupport from .ddl.type import PostgresTypeSupport from .ddl.constraint import PostgresConstraintSupport +from .ddl.policy import PostgresPolicySupport +from .ddl.rls_config import PostgresRlsConfigSupport +from .ddl.table_settings import PostgresAlterTableSettingsSupport +from .ddl.cluster import PostgresClusterSupport +from .ddl.domain import PostgresDomainSupport +from .ddl.collation import PostgresCollationDDLSupport +from .ddl.foreign_table import PostgresForeignTableDDLSupport +from .ddl.routine import PostgresRoutineDDLSupport +from .ddl.publication import PostgresPublicationSupport # DML protocols from .dml.vacuum import PostgresVacuumSupport @@ -111,6 +120,15 @@ "PostgresCommentSupport", "PostgresTypeSupport", "PostgresConstraintSupport", + "PostgresPolicySupport", + "PostgresRlsConfigSupport", + "PostgresAlterTableSettingsSupport", + "PostgresClusterSupport", + "PostgresDomainSupport", + "PostgresCollationDDLSupport", + "PostgresForeignTableDDLSupport", + "PostgresRoutineDDLSupport", + "PostgresPublicationSupport", # DML protocols "PostgresVacuumSupport", "PostgresStoredProcedureSupport", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py index b35ad690..26f778a5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/__init__.py @@ -7,6 +7,15 @@ from .comment import PostgresCommentSupport from .type import PostgresTypeSupport from .constraint import PostgresConstraintSupport +from .policy import PostgresPolicySupport +from .rls_config import PostgresRlsConfigSupport +from .table_settings import PostgresAlterTableSettingsSupport +from .cluster import PostgresClusterSupport +from .domain import PostgresDomainSupport +from .collation import PostgresCollationDDLSupport +from .foreign_table import PostgresForeignTableDDLSupport +from .routine import PostgresRoutineDDLSupport +from .publication import PostgresPublicationSupport __all__ = [ "PostgresPartitionSupport", @@ -15,4 +24,13 @@ "PostgresCommentSupport", "PostgresTypeSupport", "PostgresConstraintSupport", + "PostgresPolicySupport", + "PostgresRlsConfigSupport", + "PostgresAlterTableSettingsSupport", + "PostgresClusterSupport", + "PostgresDomainSupport", + "PostgresCollationDDLSupport", + "PostgresForeignTableDDLSupport", + "PostgresRoutineDDLSupport", + "PostgresPublicationSupport", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py new file mode 100644 index 00000000..17e7e22c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py @@ -0,0 +1,45 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/cluster.py +"""PostgreSQL CLUSTER protocol definition. + +This module contains the :class:`PostgresClusterSupport` protocol which +defines the interface for PostgreSQL's native CLUSTER command. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.cluster import PostgresClusterExpression + + +@runtime_checkable +class PostgresClusterSupport(Protocol): + """PostgreSQL CLUSTER protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CLUSTER: https://www.postgresql.org/docs/current/sql-cluster.html + + Version Requirements: + - CLUSTER table [ USING index ]: PostgreSQL 9.6+ + """ + + def supports_cluster(self) -> bool: + """Whether CLUSTER is supported (9.6+).""" + ... + + def format_cluster_statement( + self, expr: "PostgresClusterExpression" + ) -> Tuple[str, tuple]: + """Format a ``CLUSTER`` statement. + + Args: + expr: ``PostgresClusterExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py new file mode 100644 index 00000000..29c5efa5 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py @@ -0,0 +1,67 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/collation.py +"""PostgreSQL COLLATION object DDL protocol definition. + +This module contains the :class:`PostgresCollationDDLSupport` protocol. +It is distinct from the ``COLLATE``-expression support provided by the +existing ``PostgresCollationSupport``. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.collation import ( + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + ) + + +@runtime_checkable +class PostgresCollationDDLSupport(Protocol): + """PostgreSQL COLLATION object DDL protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE COLLATION: https://www.postgresql.org/docs/current/sql-createcollation.html + - DROP COLLATION: https://www.postgresql.org/docs/current/sql-dropcollation.html + + Version Requirements: + - CREATE/DROP COLLATION: PostgreSQL 9.6+ + """ + + def supports_collation_ddl(self) -> bool: + """Whether CREATE/DROP COLLATION is supported (9.6+).""" + ... + + def format_create_collation_ddl_statement( + self, expr: "PostgresCreateCollationExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE COLLATION`` statement. + + Args: + expr: ``PostgresCreateCollationExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... + + def format_drop_collation_ddl_statement( + self, expr: "PostgresDropCollationExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP COLLATION`` statement. + + Args: + expr: ``PostgresDropCollationExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + ValueError: both ``cascade`` and ``restrict``. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py index 9e2d2109..7f324ac0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py @@ -55,3 +55,11 @@ def supports_exclude_constraint(self) -> bool: Requires appropriate operator class (e.g., btree_gist for gist). """ ... + + def supports_drop_constraint_if_exists(self) -> bool: + """Whether ``DROP CONSTRAINT IF EXISTS`` is supported. + + Vendor extension (not in ISO/IEC 9075-2 §11.10). PostgreSQL has + supported it since 9.6. + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py new file mode 100644 index 00000000..7a0a64f0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py @@ -0,0 +1,84 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/domain.py +"""PostgreSQL DOMAIN protocol definition. + +This module contains the :class:`PostgresDomainSupport` protocol which +defines the interface for PostgreSQL's native CREATE/ALTER/DROP DOMAIN DDL. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.domain import ( + PostgresAlterDomainExpression, + PostgresCreateDomainExpression, + PostgresDropDomainExpression, + ) + + +@runtime_checkable +class PostgresDomainSupport(Protocol): + """PostgreSQL DOMAIN protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE DOMAIN: https://www.postgresql.org/docs/current/sql-createdomain.html + - ALTER DOMAIN: https://www.postgresql.org/docs/current/sql-alterdomain.html + - DROP DOMAIN: https://www.postgresql.org/docs/current/sql-dropdomain.html + + Version Requirements: + - CREATE/ALTER/DROP DOMAIN: PostgreSQL 9.6+ + """ + + def supports_create_domain(self) -> bool: + """Whether CREATE/ALTER/DROP DOMAIN is supported (9.6+).""" + ... + + def format_create_domain_statement( + self, expr: "PostgresCreateDomainExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE DOMAIN`` statement. + + Args: + expr: ``PostgresCreateDomainExpression``. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... + + def format_postgres_alter_domain_statement( + self, expr: "PostgresAlterDomainExpression" + ) -> Tuple[str, tuple]: + """Format an ``ALTER DOMAIN`` statement. + + Args: + expr: ``PostgresAlterDomainExpression``. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... + + def format_drop_domain_statement( + self, expr: "PostgresDropDomainExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP DOMAIN`` statement. + + Args: + expr: ``PostgresDropDomainExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + ValueError: both ``cascade`` and ``restrict``. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py new file mode 100644 index 00000000..d0795947 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py @@ -0,0 +1,66 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/foreign_table.py +"""PostgreSQL FOREIGN TABLE DDL protocol definition. + +This module contains the :class:`PostgresForeignTableDDLSupport` protocol +which defines the interface for PostgreSQL's native CREATE/DROP FOREIGN TABLE. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.foreign_table import ( + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + ) + + +@runtime_checkable +class PostgresForeignTableDDLSupport(Protocol): + """PostgreSQL FOREIGN TABLE DDL protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-createforeigntable.html + - DROP FOREIGN TABLE: https://www.postgresql.org/docs/current/sql-dropforeigntable.html + + Version Requirements: + - CREATE/DROP FOREIGN TABLE: PostgreSQL 9.6+ + """ + + def supports_foreign_table_ddl(self) -> bool: + """Whether CREATE/DROP FOREIGN TABLE is supported (9.6+).""" + ... + + def format_create_foreign_table_statement( + self, expr: "PostgresCreateForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE FOREIGN TABLE`` statement. + + Args: + expr: ``PostgresCreateForeignTableExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + """ + ... + + def format_drop_foreign_table_statement( + self, expr: "PostgresDropForeignTableExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP FOREIGN TABLE`` statement. + + Args: + expr: ``PostgresDropForeignTableExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.6. + ValueError: both ``cascade`` and ``restrict``. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py new file mode 100644 index 00000000..f7f85e0d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py @@ -0,0 +1,137 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/policy.py +"""PostgreSQL Row-Level Security POLICY protocol definition. + +This module contains the :class:`PostgresPolicySupport` protocol which +defines the interface for PostgreSQL's native POLICY DDL features. + +POLICY (CREATE/ALTER/DROP) is a PostgreSQL extension — not SQL standard. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.policy import ( + PostgresAlterPolicyExpression, + PostgresCreatePolicyExpression, + PostgresDropPolicyExpression, + ) + + +@runtime_checkable +class PostgresPolicySupport(Protocol): + """PostgreSQL Row-Level Security POLICY protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE POLICY: https://www.postgresql.org/docs/current/sql-createpolicy.html + - ALTER POLICY: https://www.postgresql.org/docs/current/sql-alterpolicy.html + - DROP POLICY: https://www.postgresql.org/docs/current/sql-droppolicy.html + + Version Requirements: + - CREATE/ALTER/DROP POLICY (basic): PostgreSQL 9.5+ + - AS RESTRICTIVE / explicit AS PERMISSIVE keyword: PostgreSQL 10+ + - DROP POLICY IF EXISTS: PostgreSQL 9.5+ (always supported here) + - ALTER POLICY ... RENAME TO: PostgreSQL 9.5+ + - CURRENT_ROLE keyword in TO role list: PostgreSQL 15+ + - CURRENT_USER / SESSION_USER / PUBLIC: all versions + """ + + def supports_create_policy(self) -> bool: + """Whether CREATE/ALTER/DROP POLICY basic syntax is supported (9.5+).""" + ... + + def supports_policy_restrictive(self) -> bool: + """Whether ``AS RESTRICTIVE`` clause is supported (10+).""" + ... + + def supports_explicit_permissive_keyword(self) -> bool: + """Whether the literal ``AS PERMISSIVE`` keyword is accepted (10+). + + On 9.5 the permissive mode is implicit (the parser rejects the + keyword itself); it must be omitted silently. + """ + ... + + def supports_drop_policy_if_exists(self) -> bool: + """Whether ``DROP POLICY IF EXISTS`` is supported (9.5+).""" + ... + + def supports_alter_policy_rename(self) -> bool: + """Whether ``ALTER POLICY ... RENAME TO`` is supported (9.5+).""" + ... + + def supports_alter_policy_replace(self) -> bool: + """Whether ``ALTER POLICY ... TO/USING/WITH CHECK`` is supported (9.5+).""" + ... + + def supports_current_role_keyword(self) -> bool: + """Whether ``CURRENT_ROLE`` may appear in the ``TO`` role list (15+).""" + ... + + def format_create_policy_statement( + self, expr: "PostgresCreatePolicyExpression" + ) -> Tuple[str, tuple]: + """Format ``CREATE POLICY`` statement (PostgreSQL-specific syntax). + + Args: + expr: ``PostgresCreatePolicyExpression`` defining the policy. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5, or + ``AS RESTRICTIVE`` on a version below 10, or an unsupported + role keyword for the active version. + ValueError: an incompatible ``command`` / USING / WITH CHECK + combination (e.g. INSERT+USING, SELECT+WITH CHECK). + """ + ... + + def format_alter_policy_statement( + self, expr: "PostgresAlterPolicyExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER POLICY`` statement (PostgreSQL-specific syntax). + + Handles two forms via :attr:`expr.mode`: + + - ``RENAME``: ``ALTER POLICY name ON table RENAME TO new_name`` + - ``REPLACE``: ``ALTER POLICY name ON table [TO ...] + [USING (...)] [WITH CHECK (...)]`` + + Args: + expr: ``PostgresAlterPolicyExpression``. + + Returns: + Tuple of (SQL string, params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5 or + role keyword needs a newer version. + ValueError: both RENAME and REPLACE clauses present, or no + REPLACE clause present at all. + """ + ... + + def format_drop_policy_statement( + self, expr: "PostgresDropPolicyExpression" + ) -> Tuple[str, tuple]: + """Format ``DROP POLICY`` statement (PostgreSQL-specific syntax). + + Args: + expr: ``PostgresDropPolicyExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5. + ValueError: both ``cascade`` and ``restrict`` are True. + + Note: + Per PostgreSQL documentation, ``CASCADE``/``RESTRICT`` have no + effect on DROP POLICY (no dependencies), but are accepted + syntactically. + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py new file mode 100644 index 00000000..e8925160 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py @@ -0,0 +1,67 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/publication.py +"""PostgreSQL PUBLICATION / SUBSCRIPTION protocol definition. + +This module contains the :class:`PostgresPublicationSupport` protocol which +defines the interface for PostgreSQL's logical replication PUBLICATION and +SUBSCRIPTION DDL commands. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.publication import ( + PostgresCreatePublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropPublicationExpression, + PostgresDropSubscriptionExpression, + ) + + +@runtime_checkable +class PostgresPublicationSupport(Protocol): + """PostgreSQL PUBLICATION / SUBSCRIPTION protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE PUBLICATION: https://www.postgresql.org/docs/current/sql-createpublication.html + - DROP PUBLICATION: https://www.postgresql.org/docs/current/sql-droppublication.html + - CREATE SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-createsubscription.html + - DROP SUBSCRIPTION: https://www.postgresql.org/docs/current/sql-dropsubscription.html + + Version Requirements: + - CREATE/DROP PUBLICATION: PostgreSQL 10+ + - CREATE/DROP SUBSCRIPTION: PostgreSQL 10+ + """ + + def supports_publication(self) -> bool: + """Whether CREATE/DROP PUBLICATION is supported (10+).""" + ... + + def supports_subscription(self) -> bool: + """Whether CREATE/DROP SUBSCRIPTION is supported (10+).""" + ... + + def format_create_publication_statement( + self, expr: "PostgresCreatePublicationExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE PUBLICATION`` statement.""" + ... + + def format_drop_publication_statement( + self, expr: "PostgresDropPublicationExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP PUBLICATION`` statement.""" + ... + + def format_create_subscription_statement( + self, expr: "PostgresCreateSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE SUBSCRIPTION`` statement.""" + ... + + def format_drop_subscription_statement( + self, expr: "PostgresDropSubscriptionExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP SUBSCRIPTION`` statement.""" + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py new file mode 100644 index 00000000..037ed901 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py @@ -0,0 +1,79 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/rls_config.py +"""PostgreSQL Row-Level Security table configuration protocol definition. + +This module contains the :class:`PostgresRlsConfigSupport` protocol which +defines the interface for PostgreSQL's native ALTER TABLE ... ROW LEVEL +SECURITY configuration commands. + +ENABLE/DISABLE/FORCE/NO FORCE ROW LEVEL SECURITY is a PostgreSQL extension — +not SQL standard. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.rls_config import ( + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + ) + + +@runtime_checkable +class PostgresRlsConfigSupport(Protocol): + """PostgreSQL Row-Level Security table configuration protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + + Version Requirements: + - ENABLE / DISABLE ROW LEVEL SECURITY: PostgreSQL 9.5+ + - ENABLE ALWAYS ROW LEVEL SECURITY: PostgreSQL 9.5+ + - FORCE / NO FORCE ROW LEVEL SECURITY: PostgreSQL 9.5+ + """ + + def supports_rls_enable_disable(self) -> bool: + """Whether ``ENABLE/DISABLE ROW LEVEL SECURITY`` is supported (9.5+).""" + ... + + def supports_rls_force(self) -> bool: + """Whether ``FORCE/NO FORCE ROW LEVEL SECURITY`` is supported (9.5+).""" + ... + + def supports_rls_enable_always(self) -> bool: + """Whether ``ENABLE ALWAYS ROW LEVEL SECURITY`` is supported (9.5+).""" + ... + + def format_alter_table_rls_statement( + self, expr: "PostgresAlterTableRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... ENABLE/DISABLE ROW LEVEL SECURITY``. + + Args: + expr: ``PostgresAlterTableRlsExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5. + ValueError: ``always`` combined with ``DISABLE``. + """ + ... + + def format_force_rls_statement( + self, expr: "PostgresForceRlsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... FORCE/NO FORCE ROW LEVEL SECURITY``. + + Args: + expr: ``PostgresForceRlsExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: dialect predates PostgreSQL 9.5. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py new file mode 100644 index 00000000..b36b395b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py @@ -0,0 +1,74 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/routine.py +"""PostgreSQL FUNCTION / AGGREGATE routine DDL protocol definition. + +This module contains the :class:`PostgresRoutineDDLSupport` protocol. +CREATE/DROP PROCEDURE is covered by the existing +``PostgresStoredProcedureSupport``. +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.routine import ( + PostgresCreateAggregateExpression, + PostgresCreateFunctionExpression, + PostgresDropAggregateExpression, + PostgresDropFunctionExpression, + ) + + +@runtime_checkable +class PostgresRoutineDDLSupport(Protocol): + """PostgreSQL FUNCTION / AGGREGATE routine DDL protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - CREATE FUNCTION: https://www.postgresql.org/docs/current/sql-createfunction.html + - DROP FUNCTION: https://www.postgresql.org/docs/current/sql-dropfunction.html + - CREATE AGGREGATE: https://www.postgresql.org/docs/current/sql-createaggregate.html + - DROP AGGREGATE: https://www.postgresql.org/docs/current/sql-dropaggregate.html + + Version Requirements: + - CREATE/DROP FUNCTION: PostgreSQL 9.6+ + - CREATE/DROP AGGREGATE: PostgreSQL 9.6+ + """ + + def supports_function_ddl(self) -> bool: + """Whether CREATE/DROP FUNCTION is supported (9.6+).""" + ... + + def supports_aggregate_ddl(self) -> bool: + """Whether CREATE/DROP AGGREGATE is supported (9.6+).""" + ... + + def format_create_function_ddl_statement( + self, expr: "PostgresCreateFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE FUNCTION`` statement. + + Args: + expr: ``PostgresCreateFunctionExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + """ + ... + + def format_drop_function_ddl_statement( + self, expr: "PostgresDropFunctionExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP FUNCTION`` statement.""" + ... + + def format_create_aggregate_ddl_statement( + self, expr: "PostgresCreateAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a ``CREATE AGGREGATE`` statement.""" + ... + + def format_drop_aggregate_ddl_statement( + self, expr: "PostgresDropAggregateExpression" + ) -> Tuple[str, tuple]: + """Format a ``DROP AGGREGATE`` statement.""" + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py new file mode 100644 index 00000000..6114b452 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py @@ -0,0 +1,55 @@ +# src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/table_settings.py +"""PostgreSQL ALTER TABLE table-level settings protocol definition. + +This module contains the :class:`PostgresAlterTableSettingsSupport` protocol +which defines the interface for PostgreSQL's native ALTER TABLE ... SET +table-level settings (LOGGED/UNLOGGED, ACCESS METHOD). +""" + +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.ddl.table_settings import ( + PostgresAlterTableSettingsExpression, + ) + + +@runtime_checkable +class PostgresAlterTableSettingsSupport(Protocol): + """PostgreSQL ALTER TABLE ... SET table-level settings protocol. + + Feature Source: Native support (no extension required) + + Official Documentation: + - ALTER TABLE: https://www.postgresql.org/docs/current/sql-altertable.html + + Version Requirements: + - SET LOGGED / SET UNLOGGED: PostgreSQL 9.6+ (exists since 9.5) + - SET ACCESS METHOD: PostgreSQL 15+ + """ + + def supports_table_logging_switch(self) -> bool: + """Whether ``SET LOGGED/UNLOGGED`` is supported (9.6+).""" + ... + + def supports_table_set_access_method(self) -> bool: + """Whether ``SET ACCESS METHOD`` is supported (15+).""" + ... + + def format_alter_table_settings_statement( + self, expr: "PostgresAlterTableSettingsExpression" + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE ... SET`` table-level settings. + + Args: + expr: ``PostgresAlterTableSettingsExpression``. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + ValueError: no setting or conflicting settings supplied. + UnsupportedFeatureError: version-gated setting on an + insufficient PostgreSQL version. + """ + ... \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py index a7842ac2..9ff7b6a0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -37,6 +37,15 @@ def supports_table_inheritance(self) -> bool: """ ... + def supports_unlogged_table(self) -> bool: + """Whether CREATE UNLOGGED TABLE is supported. + + UNLOGGED tables skip WAL writes (9.6+), trading crash safety for + speed; callers opt in via ``dialect_options={"unlogged_table": + True}`` on ``CreateTableExpression``. + """ + ... + def supports_table_like_syntax(self) -> bool: """Whether CREATE TABLE (LIKE ...) syntax is supported. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py index 1e47db69..fbd53533 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/jsonb_enhanced.py @@ -5,7 +5,10 @@ enhancements beyond the core JSON support. """ -from typing import Protocol, runtime_checkable +from typing import Protocol, runtime_checkable, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression import JSONExpression @runtime_checkable @@ -38,7 +41,7 @@ class PostgresJSONBEnhancedSupport(Protocol): - Numeric infinity in JSONB: PostgreSQL 17+ """ - def format_json_expression(self, expr): + def format_json_expression(self, expr: "JSONExpression"): """Format a PostgreSQL JSON expression.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py index a6e144e4..a2ca5175 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py @@ -8,4 +8,8 @@ class PostgresUpsertSupport(Protocol): """PostgreSQL upsert feature support protocol.""" - def supports_upsert(self)-> bool: ... + def supports_upsert(self) -> bool: ... + + def supports_on_conflict_clause(self) -> bool: ... + + def supports_multiple_on_conflict_clauses(self) -> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py new file mode 100644 index 00000000..5da28ebe --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py @@ -0,0 +1,6 @@ +# src/rhosocial/activerecord/backend/impl/postgres/schema/__init__.py +"""PostgreSQL schema differ.""" + +from .differ import PostgresSchemaDiffer + +__all__ = ["PostgresSchemaDiffer"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py b/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py new file mode 100644 index 00000000..cc5f46c7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py @@ -0,0 +1,15 @@ +# src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py +"""PostgreSQL schema differ — column order has no semantic meaning.""" + +from rhosocial.activerecord.backend.schema.differ import SchemaDiffer + + +class PostgresSchemaDiffer(SchemaDiffer): + """PostgreSQL schema differ. + + Column order has no semantic meaning in PostgreSQL, and array + dimension equivalence is already handled by + ``PostgresArrayType.is_equivalent()``, so the default + ``_columns_equivalent`` is sufficient. + """ + pass diff --git a/tests/benchmark/activerecord_bulk/test_bulk_create.py b/tests/benchmark/activerecord_bulk/test_bulk_create.py index 6713f01e..8abfa754 100644 --- a/tests/benchmark/activerecord_bulk/test_bulk_create.py +++ b/tests/benchmark/activerecord_bulk/test_bulk_create.py @@ -1,3 +1,5 @@ # Bridge file for bulk_create benchmark tests from the testsuite. -from rhosocial.activerecord.testsuite.benchmark.crud.test_bulk_create_sync import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.benchmark.crud.test_bulk_create import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.benchmark.crud.test_bulk_create_async import * # noqa: F403 from rhosocial.activerecord.testsuite.benchmark.crud.test_bulk_create_async import * # noqa: F401, F403 + diff --git a/tests/benchmark/relation/__init__.py b/tests/benchmark/relation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark/relation/conftest.py b/tests/benchmark/relation/conftest.py new file mode 100644 index 00000000..cce2ecbd --- /dev/null +++ b/tests/benchmark/relation/conftest.py @@ -0,0 +1,158 @@ +"""Benchmark fixtures for relation cache comparison. + +Bridges ``user_post_comment_classes`` from the testsuite's relation conftest +so that benchmarks can reuse the same model definitions and backend setup. + +Redis benchmark support is not introduced in this release. The related +source snippets are kept as comments for follow-up external cache design. +""" + +from rhosocial.activerecord.testsuite.feature.relation.conftest import ( # noqa: F401 + user_post_comment_classes, +) + +from dataclasses import dataclass +from typing import Any + +import pytest + +from rhosocial.activerecord.relation.cache import CacheConfig, InstanceCache +from rhosocial.activerecord.relation.cache_backends import InMemoryCache + +# Not introduced in this release; keep snippets for follow-up external cache design. +# from rhosocial.activerecord.relation.cache_backends import CacheSerializer, RedisCache + +pytestmark = [ + pytest.mark.benchmark, + pytest.mark.benchmark_relation, +] + + +def pytest_configure(config): + config.addinivalue_line("markers", "benchmark_relation: relation cache benchmark tests") + + +@dataclass +class RelationBenchmarkContext: + """Context for a single benchmark run.""" + + user: Any + post_class: Any + scenario: str + + +# --------------------------------------------------------------------------- +# Redis benchmark is not introduced in this release. Keep the fixture shape as +# comments for follow-up external cache design instead of deleting the source. +# --------------------------------------------------------------------------- +# @pytest.fixture(scope="session") +# def _redis_client(): +# redis = pytest.importorskip("redis") +# try: +# from providers.redis_scenarios import get_redis_scenario +# +# config = get_redis_scenario("benchmark") +# except (FileNotFoundError, KeyError): +# from rhosocial.activerecord.relation.cache_backends import RedisConfig +# +# config = RedisConfig.from_env() +# try: +# client = redis.Redis( +# host=config.host, +# port=config.port, +# password=config.password or None, +# db=config.db, +# socket_connect_timeout=config.socket_connect_timeout, +# ) +# client.ping() +# yield client +# client.flushdb() +# client.close() +# except Exception: +# pytest.skip("Redis not available for benchmark") +# yield None + + +# --------------------------------------------------------------------------- +# Shared model + data setup (function-scoped so each test gets fresh data) +# --------------------------------------------------------------------------- +@pytest.fixture(scope="function") +def _raw_models(user_post_comment_classes): # noqa: F811 + """Create a user + 100 posts + 200 comments under the same user.""" + user_cls, post_cls, comment_cls = user_post_comment_classes + user = user_cls(name="Bench User", email="bench@example.com") + user.save() + + for i in range(100): + p = post_cls(title=f"Post {i}", body=f"Body {i}", user_id=user.id) + p.save() + + yield user, post_cls, comment_cls + + for p in reversed(post_cls.find_all()): + p.delete() + user.delete() + + +# --------------------------------------------------------------------------- +# Cache scenario parametrization +# --------------------------------------------------------------------------- +CACHE_SCENARIOS = [ + pytest.param("no_cache", id="no_cache"), + pytest.param("in_memory", id="in_memory"), + # Not introduced in this release; keep for follow-up external cache design. + # pytest.param("redis", id="redis"), +] + + +@pytest.fixture(params=CACHE_SCENARIOS) +def cache_scenario(request): + """Parametrized cache scenario name.""" + return request.param + + +@pytest.fixture(scope="function") +def relation_benchmark_env(cache_scenario, _raw_models): + """Configure the InstanceCache backend and return a benchmark context. + + Each test function receives a ``RelationBenchmarkContext`` with: + - user — the seeded user instance + - post_class — for queries + - scenario — current scenario name + """ + user, post_cls, comment_cls = _raw_models + original_backend = InstanceCache._backend + + try: + if cache_scenario == "no_cache": + InstanceCache.set_backend(InMemoryCache()) + config = CacheConfig(enabled=False) + elif cache_scenario == "in_memory": + InstanceCache.set_backend(InMemoryCache()) + config = CacheConfig(enabled=True, ttl=None) + # Not introduced in this release; keep for follow-up external cache design. + # elif cache_scenario == "redis": + # InstanceCache.set_backend( + # RedisCache( + # client=_redis_client, + # prefix="bench:cache:", + # serializer=CacheSerializer(format="pickle"), + # ) + # ) + # config = CacheConfig(enabled=True, ttl=None) + else: + raise ValueError(f"Unknown scenario: {cache_scenario}") + + # Apply the cache config to all relation descriptors + for rel_name in ["posts", "comments"]: + rel = user.get_relation(rel_name) + if rel is not None: + rel._cache_config = config + + yield RelationBenchmarkContext( + user=user, + post_class=post_cls, + scenario=cache_scenario, + ) + finally: + InstanceCache.set_backend(original_backend) diff --git a/tests/benchmark/relation/test_cache_comparison.py b/tests/benchmark/relation/test_cache_comparison.py new file mode 100644 index 00000000..364b48af --- /dev/null +++ b/tests/benchmark/relation/test_cache_comparison.py @@ -0,0 +1,75 @@ +"""Benchmark comparing relation cache strategies. + +Measures HasMany relation access time under current-release scenarios: +- ``no_cache`` — CacheConfig(enabled=False), always queries DB +- ``in_memory`` — InMemoryCache, caches after first access + +Redis benchmark support is not introduced in this release. +""" + +import pytest + +pytestmark = [ + pytest.mark.benchmark, + pytest.mark.benchmark_relation, +] + + +class TestRelationFirstAccess: + """First access — always queries DB regardless of cache strategy.""" + + def test_posts_first_access(self, benchmark, relation_benchmark_env): + ctx = relation_benchmark_env + result = benchmark(ctx.user.posts) + assert len(result) == 100 + + +class TestRelationCachedAccess: + """Second+ access — cache hit (except no_cache which hits DB each time).""" + + def test_posts_second_access(self, benchmark, relation_benchmark_env): + ctx = relation_benchmark_env + ctx.user.posts() + result = benchmark(ctx.user.posts) + assert len(result) == 100 + + +class TestRelationRepeatedAccess: + """100 repeated accesses — amplifies cache vs no-cache gap.""" + + def test_posts_repeated_access(self, benchmark, relation_benchmark_env): + ctx = relation_benchmark_env + + def repeated(): + for _ in range(100): + ctx.user.posts() + + benchmark(repeated) + + +class TestRelationWithNewData: + """Access with new data — validates behaviour after mutations.""" + + def test_posts_after_new_record(self, benchmark, relation_benchmark_env): + """After a new record is added, cached backends return stale data. + + This is expected — cache invalidation is handled by model save/delete + events, not by the descriptor layer itself. + """ + ctx = relation_benchmark_env + ctx.user.posts() + ctx.post_class(title="New", body="New", user_id=ctx.user.id).save() + result = benchmark(ctx.user.posts) + if ctx.scenario == "no_cache": + assert len(result) == 101 + else: + assert len(result) == 100 # stale cache + + def test_posts_after_delete(self, benchmark, relation_benchmark_env): + ctx = relation_benchmark_env + ctx.user.posts() + p = ctx.post_class(title="Temp", body="Temp", user_id=ctx.user.id) + p.save() + p.delete() + result = benchmark(ctx.user.posts) + assert len(result) == 100 diff --git a/tests/conftest.py b/tests/conftest.py index 6abfccdc..34dcd084 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,19 @@ def ensure_compatible_event_loop(): 'providers.registry:provider_registry' ) +# Early-parse --scenarios from sys.argv and set POSTGRES_ACTIVE_SCENARIOS env var. +# This must happen before providers.scenarios is imported (it filters its +# SCENARIO_MAP at import time). +_argv_scenarios = None +for _i, _arg in enumerate(sys.argv): + if _arg.startswith('--scenarios='): + _argv_scenarios = _arg.split('=', 1)[1] + elif _arg == '--scenarios' and _i + 1 < len(sys.argv): + _argv_scenarios = sys.argv[_i + 1] + +if _argv_scenarios: + os.environ['POSTGRES_ACTIVE_SCENARIOS'] = _argv_scenarios + # ============================================================================= # Scenario Parallel Scheduling Plugin # @@ -92,6 +105,12 @@ def pytest_addoption(parser): default=False, help='Scenario parallel mode: distribute scenarios across workers, keep each scenario on one worker.', ) + parser.addoption( + '--scenarios', + default=None, + help='Comma-separated list of scenario names to run (e.g., --scenarios=postgres_16,postgres_17). ' + 'Compatible with pytest -k "" as a native alternative.', + ) def pytest_configure(config): @@ -100,6 +119,12 @@ def pytest_configure(config): "xdist_group(name): specify the xdist group for a test (provided by pytest-xdist).", ) + # Propagate --scenarios to environment variable so that all conftest.py + # instances (which load SCENARIO_MAP independently) can filter consistently. + scenarios_opt = config.getoption('--scenarios', default=None) + if scenarios_opt: + os.environ['POSTGRES_ACTIVE_SCENARIOS'] = scenarios_opt + def pytest_collection_modifyitems(config, items): if not config.getoption('--scenario-parallel', default=False): diff --git a/tests/providers/basic.py b/tests/providers/basic.py index ab385dbb..964849be 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -13,18 +13,23 @@ import os import sys import logging -from typing import Type, List, Tuple, Optional # noqa: F401 +from typing import Type, List, Tuple, Optional, Set # noqa: F401 logger = logging.getLogger(__name__) from rhosocial.activerecord.model import ActiveRecord # noqa: E402 from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter # noqa: E402 -from rhosocial.activerecord.testsuite.feature.basic.interfaces import IBasicProvider # noqa: E402 +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType # noqa: E402 +from rhosocial.activerecord.testsuite.feature.basic.interfaces import IBasicSyncProvider, IBasicAsyncProvider # noqa: E402 from rhosocial.activerecord.testsuite.core.protocols import WorkerTestProtocol # noqa: E402 # Import the fixture selector utility from rhosocial.activerecord.testsuite.utils import select_fixture # noqa: E402 +# Expression-based DDL fixtures +from providers.fixtures._common import drop_table # noqa: E402 +from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_TABLE_EXPRESSIONS # noqa: E402 + # Import base version models (Python 3.8+) from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( # noqa: E402 User as UserBase, TypeCase as TypeCaseBase, ValidatedFieldUser as ValidatedFieldUserBase, @@ -179,144 +184,107 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 -class BasicProvider(IBasicProvider, WorkerTestProtocol): - """ - This is the postgres backend's implementation for the basic features test group. - It connects the generic tests in the testsuite with the actual postgres database. - """ - +class BasicProviderBase: def __init__(self): - self._active_backends = [] - self._active_async_backends = [] + self._scenario_db_files = {} + self._created_tables: Set[str] = set() def get_test_scenarios(self) -> List[str]: - """Returns a list of names for all enabled scenarios for this backend.""" return list(get_enabled_scenarios().keys()) - def _track_backend(self, backend_instance, collection: List) -> None: + def get_yes_no_adapter(self) -> 'BaseSQLTypeAdapter': + return YesOrNoBooleanAdapter() + + def get_dialect(self, scenario_name: str = "default"): + """Return a bare, fully-constructed PostgreSQL dialect instance. + + Used by the ``feature/basic/ddl`` subtopic (expression/dialect + contract). ``(16, 0, 0)`` advertises support for all three + ``IF [NOT] EXISTS`` modifiers (available since PG 9.6). + """ + from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + return PostgresDialect(version=(16, 0, 0)) + + def _track_backend(self, backend_instance, collection) -> None: if backend_instance not in collection: collection.append(backend_instance) + def _load_postgres_schema(self, filename: str) -> str: + schema_dir = os.path.join( + os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "basic", "schema" + ) + schema_path = os.path.join(schema_dir, filename) + with open(schema_path, 'r', encoding='utf-8') as f: + return f.read() + + +class BasicSyncProvider(BasicProviderBase, IBasicSyncProvider, WorkerTestProtocol): + + def __init__(self): + super().__init__() + self._active_backends = [] + def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: - """A generic helper method to handle the setup for any given model.""" backend_class, config = get_scenario(scenario_name) model_class.configure(config, backend_class) - backend_instance = model_class.__backend__ self._track_backend(backend_instance, self._active_backends) - self._reset_table_sync(model_class, table_name) - return model_class - - async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: # noqa: E501 - """A generic helper method to handle the setup for any given async model.""" - from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend - - _, config = get_scenario(scenario_name) - await model_class.configure(config, AsyncPostgresBackend) - - backend_instance = model_class.__backend__ - self._track_backend(backend_instance, self._active_async_backends) - - await self._reset_table_async(model_class, table_name) + self._created_tables.add(table_name) return model_class def _reset_table_sync(self, model_class: Type[ActiveRecord], table_name: str) -> None: + opts = ExecutionOptions(stmt_type=StatementType.DDL) try: - model_class.__backend__.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + sql, params = drop_table(model_class.__backend__.dialect, table_name).to_sql() + model_class.__backend__.execute(sql, params, options=opts) except Exception as e: print(f"Could not drop table {table_name}: {e}") - - schema_sql = self._load_postgres_schema(f"{table_name}.sql") - model_class.__backend__.execute(schema_sql) - - async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: - try: - await model_class.__backend__.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') - except Exception as e: - print(f"Could not drop table {table_name}: {e}") - - schema_sql = self._load_postgres_schema(f"{table_name}.sql") - await model_class.__backend__.execute(schema_sql) + if fn := BASIC_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(model_class.__backend__.dialect, table_name) + sql, params = create_expr.to_sql() + model_class.__backend__.execute(sql, params, options=opts) def _initialize_model_schema(self, model_class: Type[ActiveRecord], table_name: str) -> None: - """Initialize schema for a model that shares backend with another model.""" self._reset_table_sync(model_class, table_name) - async def _initialize_async_model_schema(self, model_class: Type[ActiveRecord], table_name: str) -> None: - """Initialize schema for an async model that shares backend with another model.""" - await self._reset_table_async(model_class, table_name) - - def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 - """Helper to set up multiple related models for a test, sharing a single backend.""" + def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: if not model_classes: return tuple() - first_model_class, first_table_name = model_classes[0] first_model = self._setup_model(first_model_class, scenario_name, first_table_name) shared_backend = first_model.__backend__ - result = [first_model] - for model_class, table_name in model_classes[1:]: model_class.__connection_config__ = first_model.__connection_config__ model_class.__backend_class__ = first_model.__backend_class__ model_class.__backend__ = shared_backend self._track_backend(shared_backend, self._active_backends) self._initialize_model_schema(model_class, table_name) + self._created_tables.add(table_name) result.append(model_class) - - return tuple(result) - - async def _setup_multiple_models_async(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 - """Helper to set up multiple related async models for a test, sharing a single backend.""" - if not model_classes: - return tuple() - - first_model_class, first_table_name = model_classes[0] - first_model = await self._setup_async_model(first_model_class, scenario_name, first_table_name) - shared_backend = first_model.__backend__ - - result = [first_model] - - for model_class, table_name in model_classes[1:]: - model_class.__connection_config__ = first_model.__connection_config__ - model_class.__backend_class__ = first_model.__backend_class__ - model_class.__backend__ = shared_backend - self._track_backend(shared_backend, self._active_async_backends) - await self._initialize_async_model_schema(model_class, table_name) - result.append(model_class) - return tuple(result) - # --- Implementation of the IBasicProvider interface --- - def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for user model tests.""" return self._setup_model(User, scenario_name, "users") def setup_type_case_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for type case model tests.""" return self._setup_model(TypeCase, scenario_name, "type_cases") def setup_type_test_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for type test model tests.""" return self._setup_model(TypeTestModel, scenario_name, "type_tests") def setup_validated_field_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for validated field user model tests.""" return self._setup_model(ValidatedFieldUser, scenario_name, "validated_field_users") def setup_validated_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for validated user model tests.""" return self._setup_model(ValidatedUser, scenario_name, "validated_users") def setup_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for Pydantic native validation tests.""" return self._setup_model(PydanticValidatedModel, scenario_name, "pydantic_validated_models") - def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for MappedUser, MappedPost, and MappedComment models.""" + def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: return self._setup_multiple_models([ (MappedUser, "users"), (MappedPost, "posts"), @@ -324,7 +292,6 @@ def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], T ], scenario_name) def setup_mixed_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for ColumnMappingModel and MixedAnnotationModel.""" from rhosocial.activerecord_postgres_test.feature.basic.fixtures.models import PostgresMixedAnnotationModel return self._setup_multiple_models([ (ColumnMappingModel, "column_mapping_items"), @@ -332,161 +299,40 @@ def setup_mixed_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], .. ], scenario_name) def setup_type_adapter_model_and_schema(self, scenario_name: str = None) -> Type[ActiveRecord]: - """Sets up the database for the `TypeAdapterTest` model tests.""" if scenario_name is None: scenario_name = self.get_test_scenarios()[0] if self.get_test_scenarios() else "default" return self._setup_model(TypeAdapterTest, scenario_name, "type_adapter_tests") - async def setup_async_type_adapter_model_and_schema(self, scenario_name: str = None) -> Type[ActiveRecord]: - """Sets up the database for the `AsyncTypeAdapterTest` model tests.""" - if scenario_name is None: - scenario_name = self.get_test_scenarios()[0] if self.get_test_scenarios() else "default" - return await self._setup_async_model(AsyncTypeAdapterTest, scenario_name, "type_adapter_tests") - def setup_bulk_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for the `BulkUser` model tests.""" return self._setup_model(BulkUser, scenario_name, "bulk_users") - async def setup_async_bulk_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for the `AsyncBulkUser` model tests.""" - return await self._setup_async_model(AsyncBulkUser, scenario_name, "bulk_users") + def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError - def get_yes_no_adapter(self) -> 'BaseSQLTypeAdapter': - """Returns an instance of the YesOrNoBooleanAdapter.""" - return YesOrNoBooleanAdapter() + def setup_order_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError - # --- Async implementations --- + def setup_mapped_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError - async def setup_async_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for async user model tests.""" - return await self._setup_async_model(AsyncUser, scenario_name, "users") + def setup_product_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError - async def setup_async_type_case_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for async type case model tests.""" - return await self._setup_async_model(AsyncTypeCase, scenario_name, "type_cases") - - async def setup_async_type_test_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for async type test model tests.""" - return await self._setup_async_model(AsyncTypeTestModel, scenario_name, "type_tests") - - async def setup_async_validated_field_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for async validated field user model tests.""" - return await self._setup_async_model(AsyncValidatedFieldUser, scenario_name, "validated_field_users") + def setup_product_form_a_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError - async def setup_async_validated_user_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for async validated user model tests.""" - return await self._setup_async_model(AsyncValidatedUser, scenario_name, "validated_users") + def setup_product_with_proxy_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError - async def setup_async_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for async Pydantic native validation tests.""" - return await self._setup_async_model(AsyncPydanticValidatedModel, scenario_name, "pydantic_validated_models") - - async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for AsyncMappedUser, AsyncMappedPost, and AsyncMappedComment models.""" - return await self._setup_multiple_models_async([ - (AsyncMappedUser, "users"), - (AsyncMappedPost, "posts"), - (AsyncMappedComment, "comments") - ], scenario_name) - - async def setup_async_mixed_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for AsyncColumnMappingModel and AsyncMixedAnnotationModel.""" - from rhosocial.activerecord_postgres_test.feature.basic.fixtures.models import AsyncPostgresMixedAnnotationModel - return await self._setup_multiple_models_async([ - (AsyncColumnMappingModel, "column_mapping_items"), - (AsyncPostgresMixedAnnotationModel, "mixed_annotation_items") - ], scenario_name) - - async def cleanup_after_test_async(self, scenario_name: str): - """ - Performs async cleanup after a test, dropping all tables and disconnecting async backends. - """ - tables_to_drop = [ - 'users', 'type_cases', 'type_tests', 'validated_field_users', - 'validated_users', 'pydantic_validated_models', 'type_adapter_tests', - 'posts', 'comments', 'column_mapping_items', 'mixed_annotation_items' - ] - for backend_instance in self._active_async_backends: - try: - for table_name in tables_to_drop: - try: - await backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') - except Exception: - pass - finally: - try: - await backend_instance.disconnect() - except: # noqa: E722 - pass - - self._active_async_backends.clear() - - def _load_postgres_schema(self, filename: str) -> str: - """Helper to load a SQL schema file from this project's fixtures.""" - # Schemas are stored in the centralized location for basic feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "basic", "schema") # noqa: E501 - schema_path = os.path.join(schema_dir, filename) - - with open(schema_path, 'r', encoding='utf-8') as f: - return f.read() - - def cleanup_after_test(self, scenario_name: str): - """ - Performs cleanup after a test. This now iterates through the backends - that were created during setup, drops tables, and explicitly disconnects them. - """ - tables_to_drop = [ - 'users', 'type_cases', 'type_tests', 'validated_field_users', - 'validated_users', 'pydantic_validated_models', 'type_adapter_tests', - 'posts', 'comments', 'column_mapping_items', 'mixed_annotation_items' - ] - for backend_instance in self._active_backends: - try: - # Drop all tables that might have been created for basic tests - for table_name in tables_to_drop: - try: - backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') - except Exception: - # Continue with other tables if one fails - pass - finally: - # Always disconnect the backend instance that was used in the test - try: - backend_instance.disconnect() - except: # noqa: E722 - # Ignore errors during disconnect - pass - - # Clear the list of active backends for the next test - self._active_backends.clear() - - # --- Implementation of WorkerTestProtocol --- + def setup_product_with_column_and_adapter_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError def get_worker_connection_params(self, scenario_name: str, fixture_type: str = None) -> dict: - """ - Return serializable connection parameters for Worker processes. - - This method provides all information needed to recreate the database - connection in a Worker process, including the schema SQL for table creation. - - Args: - scenario_name: The test scenario name - fixture_type: Optional fixture type hint (e.g., 'user', 'async_user') - Used to determine if async backend is needed - - Returns: - Dictionary with connection parameters and schema SQL - """ from .scenarios import SCENARIO_MAP - - # Determine if async backend is needed based on fixture_type is_async = fixture_type and fixture_type.startswith('async_') backend_class_name = 'AsyncPostgresBackend' if is_async else 'PostgresBackend' - - # Determine schema file based on fixture_type - table_name = 'users' # default + table_name = 'users' if fixture_type: - # Remove 'async_' prefix if present to get the base type base_type = fixture_type.replace('async_', '') table_map = { 'user': 'users', @@ -498,16 +344,12 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N 'type_adapter_test': 'type_adapter_tests', } table_name = table_map.get(base_type, 'users') - - # Get connection config from scenario if scenario_name not in SCENARIO_MAP: if SCENARIO_MAP: scenario_name = next(iter(SCENARIO_MAP)) else: raise ValueError("No scenarios registered") - config_dict = SCENARIO_MAP[scenario_name] - return { 'backend_module': 'rhosocial.activerecord.backend.impl.postgres', 'backend_class_name': backend_class_name, @@ -518,14 +360,148 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N } def get_worker_schema_sql(self, scenario_name: str, table_name: str) -> str: - """ - Return the SQL statement to create a specific table. + return self._load_postgres_schema(f'{table_name}.sql') - Args: - scenario_name: The test scenario name (unused for Postgres as schema is fixed) - table_name: Name of the table to create + def cleanup_after_test(self, scenario_name: str): + for backend_instance in self._active_backends: + try: + for table_name in list(self._created_tables): + try: + backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + except Exception: + pass + finally: + try: + backend_instance.disconnect() + except Exception: + pass + self._active_backends.clear() + self._created_tables.clear() - Returns: - CREATE TABLE SQL statement - """ - return self._load_postgres_schema(f'{table_name}.sql') + +class BasicAsyncProvider(BasicProviderBase, IBasicAsyncProvider): + + def __init__(self): + super().__init__() + self._active_async_backends = [] + + async def get_dialect(self, scenario_name: str = "default"): + """Async mirror of ``BasicProviderBase.get_dialect``.""" + return super().get_dialect(scenario_name) + + async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + _, config = get_scenario(scenario_name) + await model_class.configure(config, AsyncPostgresBackend) + backend_instance = model_class.__backend__ + self._track_backend(backend_instance, self._active_async_backends) + await self._reset_table_async(model_class, table_name) + self._created_tables.add(table_name) + return model_class + + async def _reset_table_async(self, model_class: Type[ActiveRecord], table_name: str) -> None: + opts = ExecutionOptions(stmt_type=StatementType.DDL) + try: + sql, params = drop_table(model_class.__backend__.dialect, table_name).to_sql() + await model_class.__backend__.execute(sql, params, options=opts) + except Exception as e: + print(f"Could not drop table {table_name}: {e}") + if fn := BASIC_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(model_class.__backend__.dialect, table_name) + sql, params = create_expr.to_sql() + await model_class.__backend__.execute(sql, params, options=opts) + + async def _initialize_async_model_schema(self, model_class: Type[ActiveRecord], table_name: str) -> None: + await self._reset_table_async(model_class, table_name) + + async def _setup_multiple_models_async(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + if not model_classes: + return tuple() + first_model_class, first_table_name = model_classes[0] + first_model = await self._setup_async_model(first_model_class, scenario_name, first_table_name) + shared_backend = first_model.__backend__ + result = [first_model] + for model_class, table_name in model_classes[1:]: + model_class.__connection_config__ = first_model.__connection_config__ + model_class.__backend_class__ = first_model.__backend_class__ + model_class.__backend__ = shared_backend + self._track_backend(shared_backend, self._active_async_backends) + await self._initialize_async_model_schema(model_class, table_name) + self._created_tables.add(table_name) + result.append(model_class) + return tuple(result) + + async def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncUser, scenario_name, "users") + + async def setup_type_case_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncTypeCase, scenario_name, "type_cases") + + async def setup_type_test_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncTypeTestModel, scenario_name, "type_tests") + + async def setup_validated_field_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncValidatedFieldUser, scenario_name, "validated_field_users") + + async def setup_validated_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncValidatedUser, scenario_name, "validated_users") + + async def setup_pydantic_validated_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncPydanticValidatedModel, scenario_name, "pydantic_validated_models") + + async def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + return await self._setup_multiple_models_async([ + (AsyncMappedUser, "users"), + (AsyncMappedPost, "posts"), + (AsyncMappedComment, "comments") + ], scenario_name) + + async def setup_mixed_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + from rhosocial.activerecord_postgres_test.feature.basic.fixtures.models import AsyncPostgresMixedAnnotationModel + return await self._setup_multiple_models_async([ + (AsyncColumnMappingModel, "column_mapping_items"), + (AsyncPostgresMixedAnnotationModel, "mixed_annotation_items") + ], scenario_name) + + async def setup_type_adapter_model_and_schema(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncTypeAdapterTest, scenario_name, "type_adapter_tests") + + async def setup_bulk_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncBulkUser, scenario_name, "bulk_users") + + async def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def setup_order_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def setup_mapped_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def setup_product_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def setup_product_form_a_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def setup_product_with_proxy_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def setup_product_with_column_and_adapter_model(self, scenario_name: str) -> Type[ActiveRecord]: + raise NotImplementedError + + async def cleanup_after_test(self, scenario_name: str): + for backend_instance in self._active_async_backends: + try: + for table_name in list(self._created_tables): + try: + await backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + except Exception: + pass + finally: + try: + await backend_instance.disconnect() + except Exception: + pass + self._active_async_backends.clear() + self._created_tables.clear() diff --git a/tests/providers/basic_connection.py b/tests/providers/basic_connection.py index ebcfa369..36aefaf8 100644 --- a/tests/providers/basic_connection.py +++ b/tests/providers/basic_connection.py @@ -130,10 +130,17 @@ async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBac def cleanup_sync(self, scenario_name: str, pool: BackendPool): """Cleanup after sync tests.""" - pool.close(timeout=1.0) + try: + pool.close(timeout=1.0, force=True) + except Exception: + pass # Disconnect backends for backend in self._active_backends: + try: + backend.execute("DROP TABLE IF EXISTS test_users CASCADE") + except Exception: + pass try: backend.disconnect() except Exception: @@ -142,10 +149,17 @@ def cleanup_sync(self, scenario_name: str, pool: BackendPool): async def cleanup_async(self, scenario_name: str, pool: AsyncBackendPool): """Cleanup after async tests.""" - await pool.close(timeout=1.0) + try: + await pool.close(timeout=1.0, force=True) + except Exception: + pass # Disconnect backends for backend in self._active_async_backends: + try: + await backend.execute("DROP TABLE IF EXISTS test_users CASCADE") + except Exception: + pass try: await backend.disconnect() except Exception: diff --git a/tests/providers/crud_benchmark.py b/tests/providers/crud_benchmark.py index 1c35af46..675a8d14 100644 --- a/tests/providers/crud_benchmark.py +++ b/tests/providers/crud_benchmark.py @@ -164,6 +164,10 @@ def _schema_sql(self): def _cleanup_sync(self): for backend in self._active_backends: + try: + backend.execute("DROP TABLE IF EXISTS benchmark_users CASCADE") + except Exception: + pass try: backend.disconnect() except Exception: @@ -172,6 +176,10 @@ def _cleanup_sync(self): async def _cleanup_async(self): for backend in self._active_async_backends: + try: + await backend.execute("DROP TABLE IF EXISTS benchmark_users CASCADE") + except Exception: + pass try: await backend.disconnect() except Exception: diff --git a/tests/providers/events.py b/tests/providers/events.py index b27cecc5..b53f7499 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -26,7 +26,8 @@ # Import base version models (Python 3.8+) from rhosocial.activerecord.testsuite.feature.events.fixtures.models import ( # noqa: E402 EventTestModel as EventTestModelBase, - EventTrackingModel as EventTrackingModelBase + EventTrackingModel as EventTrackingModelBase, + AsyncEventTestModel as AsyncEventTestModelBase, ) # Conditionally import Python 3.10+ models @@ -78,95 +79,121 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s # Select models EventTestModel = _select_model_class(EventTestModelBase, EventTestModel312, EventTestModel311, EventTestModel310, "EventTestModel") # noqa: E501 EventTrackingModel = _select_model_class(EventTrackingModelBase, EventTrackingModel312, EventTrackingModel311, EventTrackingModel310, "EventTrackingModel") # noqa: E501 +AsyncEventTestModel = AsyncEventTestModelBase -from rhosocial.activerecord.testsuite.feature.events.interfaces import IEventsProvider # noqa: E402 +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType # noqa: E402 +from rhosocial.activerecord.testsuite.feature.events.interfaces import IEventsSyncProvider, IEventsAsyncProvider # noqa: E402 # ...and the scenarios are defined specifically for this backend. from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 +# Expression-based DDL fixtures +from .fixtures._common import drop_table # noqa: E402 +from .fixtures.events import TABLE_EXPRESSIONS as EVENTS_TABLE_EXPRESSIONS # noqa: E402 -class EventsProvider(IEventsProvider): - """ - This is the postgres backend's implementation for the events test group. - It connects the generic tests in the testsuite with the actual postgres database. - """ +class EventsProviderBase: def __init__(self): - # This list will track the backend instances created during the setup phase. self._active_backends = [] def get_test_scenarios(self) -> List[str]: - """Returns a list of names for all enabled scenarios for this backend.""" return list(get_enabled_scenarios().keys()) + def _load_postgres_schema(self, filename: str) -> str: + schema_dir = os.path.join( + os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "events", "schema" + ) + schema_path = os.path.join(schema_dir, filename) + with open(schema_path, 'r', encoding='utf-8') as f: + return f.read() + + +class EventsSyncProvider(EventsProviderBase, IEventsSyncProvider): + + def __init__(self): + super().__init__() + def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: - """A generic helper method to handle the setup for any given model.""" - # 1. Get the backend class (PostgresBackend) and connection config for the requested scenario. backend_class, config = get_scenario(scenario_name) - - # 2. Configure the generic model class with our specific backend and config. model_class.configure(config, backend_class) - backend_instance = model_class.__backend__ if backend_instance not in self._active_backends: self._active_backends.append(backend_instance) - - # 3. Prepare the database schema. + opts = ExecutionOptions(stmt_type=StatementType.DDL) try: - # Drop the table if it exists, with cascade to handle dependencies. - model_class.__backend__.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + sql, params = drop_table(backend_instance.dialect, table_name).to_sql() + backend_instance.execute(sql, params, options=opts) except Exception as e: - # Ignore errors if the table didn't exist, but print for debugging. print(f"Could not drop table {table_name}: {e}") - pass - - # Execute the schema SQL to create the table. - schema_sql = self._load_postgres_schema(f"{table_name}.sql") - model_class.__backend__.execute(schema_sql) - + if fn := EVENTS_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(backend_instance.dialect, table_name) + sql, params = create_expr.to_sql() + backend_instance.execute(sql, params, options=opts) return model_class - # --- Implementation of the IEventsProvider interface --- - def setup_event_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for event model tests.""" - return self._setup_model(EventTestModel, scenario_name, "event_tests") def setup_event_tracking_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for event tracking model tests.""" - return self._setup_model(EventTrackingModel, scenario_name, "event_tracking_models") - def _load_postgres_schema(self, filename: str) -> str: - """Helper to load a SQL schema file from this project's fixtures.""" - # Schemas are stored in the centralized location for events feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "events", "schema") # noqa: E501 - schema_path = os.path.join(schema_dir, filename) - - with open(schema_path, 'r', encoding='utf-8') as f: - return f.read() - def cleanup_after_test(self, scenario_name: str): - """ - Performs cleanup after a test. This now iterates through the backends - that were created during setup, drops tables, and explicitly disconnects them. - """ for backend_instance in self._active_backends: try: - # Drop all tables that might have been created for events tests for table_name in ['event_tests', 'event_tracking_models']: try: backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') except Exception: - # Continue with other tables if one fails pass finally: - # Always disconnect the backend instance that was used in the test try: backend_instance.disconnect() - except: # noqa: E722 - # Ignore errors during disconnect + except Exception: pass + self._active_backends.clear() + + +class EventsAsyncProvider(EventsProviderBase, IEventsAsyncProvider): + + def __init__(self): + super().__init__() + self._active_async_backends = [] + + async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + _, config = get_scenario(scenario_name) + await model_class.configure(config, AsyncPostgresBackend) + backend_instance = model_class.__backend__ + if backend_instance not in self._active_async_backends: + self._active_async_backends.append(backend_instance) + opts = ExecutionOptions(stmt_type=StatementType.DDL) + try: + sql, params = drop_table(backend_instance.dialect, table_name).to_sql() + await backend_instance.execute(sql, params, options=opts) + except Exception as e: + print(f"Could not drop table {table_name}: {e}") + if fn := EVENTS_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(backend_instance.dialect, table_name) + sql, params = create_expr.to_sql() + await backend_instance.execute(sql, params, options=opts) + return model_class + + async def setup_event_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncEventTestModel, scenario_name, "event_tests") + + async def setup_event_tracking_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(EventTrackingModel, scenario_name, "event_tracking_models") - # Clear the list of active backends for the next test - self._active_backends.clear() \ No newline at end of file + async def cleanup_after_test(self, scenario_name: str): + for backend_instance in self._active_async_backends: + try: + for table_name in ['event_tests', 'event_tracking_models']: + try: + await backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + except Exception: + pass + finally: + try: + await backend_instance.disconnect() + except Exception: + pass + self._active_async_backends.clear() diff --git a/tests/providers/fixtures/__init__.py b/tests/providers/fixtures/__init__.py new file mode 100644 index 00000000..70857923 --- /dev/null +++ b/tests/providers/fixtures/__init__.py @@ -0,0 +1,14 @@ +# tests/providers/fixtures/__init__.py +"""PostgreSQL-specific DDL expression fixtures for the testsuite providers. + +Each module in this package exposes a ``TABLE_EXPRESSIONS`` mapping of +table name -> factory callable ``Callable[[DialectLike, str], CreateTableExpression]``. + +The factory functions build :class:`CreateTableExpression` instances that emit +PostgreSQL-compatible DDL (SERIAL / UUID / JSONB / BYTEA / TIMESTAMPTZ / BOOLEAN / +DECIMAL / VARCHAR / TEXT / FK constraints / CHECK / UNIQUE / NOT NULL / DEFAULT). +The pre-existing ``.sql`` schema files under +``tests/rhosocial/activerecord_postgres_test/feature//schema/`` +remain as the authoritative reference for what the expressions here must +produce; they are simply no longer read at runtime by the providers. +""" diff --git a/tests/providers/fixtures/_common.py b/tests/providers/fixtures/_common.py new file mode 100644 index 00000000..d07e02b9 --- /dev/null +++ b/tests/providers/fixtures/_common.py @@ -0,0 +1,34 @@ +# tests/providers/fixtures/_common.py +"""Shared helpers for the PostgreSQL DDL expression fixtures. + +Provides the ``drop_table`` helper used by all feature providers. +""" + +from __future__ import annotations + +from typing import Tuple + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + DropTableExpression, + TableExpression, +) + + +def drop_table(dialect, table_name: str) -> DropTableExpression: + """Build a ``DROP TABLE IF EXISTS table_name CASCADE`` expression.""" + return DropTableExpression( + dialect=dialect, + table=TableExpression(dialect, table_name), + if_exists=True, + ) + + +def create_table_sql(expr: CreateTableExpression) -> Tuple[str, tuple]: + """Generate canonical PostgreSQL DDL for a :class:`CreateTableExpression`. + + For PostgreSQL no post-processing is required (unlike MySQL where storage + option quotes must be stripped). This pass-through wrapper exists so the + call site is the same as the MySQL counterpart. + """ + return expr.to_sql() diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py new file mode 100644 index 00000000..aaea9129 --- /dev/null +++ b/tests/providers/fixtures/basic.py @@ -0,0 +1,499 @@ +# tests/providers/fixtures/basic.py +"""DDL expressions for the ``feature/basic`` table group (PostgreSQL). + +Each factory builds a :class:`CreateTableExpression` whose generated DDL is +semantically equivalent to the reference ``.sql`` schema files under +``tests/rhosocial/activerecord_postgres_test/feature/basic/schema/``. Those +``.sql`` files are kept as the authoritative reference and are no longer +loaded at runtime. +""" + +from typing import Callable, Dict + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + ForeignKeyConstraint, + TableConstraint, + TableConstraintType, + ReferentialAction, +) +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BigIntType, + BooleanType, + DateType, + DecimalType, + DoubleType, + FloatType, + IntegerType, + JsonBType, + SmallIntType, + TextType, + TimeTzType, + TimestampTzType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresSerialType, + PostgresUUIDType, + PostgresByteaType, +) + +_CASCADE = ReferentialAction.CASCADE + + +# --------------------------------------------------------------------------- +# basic/users.sql +# --------------------------------------------------------------------------- + +def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("username", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("age", IntegerType()), + ColumnDefinition("balance", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("is_active", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/type_cases.sql +# --------------------------------------------------------------------------- + +def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresUUIDType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("username", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tiny_int", SmallIntType()), + ColumnDefinition("small_int", SmallIntType()), + ColumnDefinition("big_int", BigIntType()), + ColumnDefinition("float_val", FloatType()), + ColumnDefinition("double_val", DoubleType()), + ColumnDefinition("decimal_val", DecimalType(precision=10, scale=4)), + ColumnDefinition("char_val", VarCharType(255)), + ColumnDefinition("varchar_val", VarCharType(255)), + ColumnDefinition("text_val", TextType()), + ColumnDefinition("date_val", DateType()), + ColumnDefinition("time_val", TimeTzType()), + ColumnDefinition("timestamp_val", TimestampTzType()), + ColumnDefinition("blob_val", PostgresByteaType()), + ColumnDefinition("json_val", JsonBType()), + ColumnDefinition("array_val", ArrayType(IntegerType())), + ColumnDefinition("is_active", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ], + table_constraints=[ + TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + columns=["id"]), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/type_tests.sql +# --------------------------------------------------------------------------- + +def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresUUIDType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("string_field", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("int_field", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("float_field", FloatType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("decimal_field", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("bool_field", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("datetime_field", TimestampTzType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("json_field", JsonBType()), + ColumnDefinition("nullable_field", VarCharType(255)), + ], + table_constraints=[ + TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + columns=["id"]), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/validated_field_users.sql +# --------------------------------------------------------------------------- + +def create_validated_field_users_table(dialect, table_name: str = "validated_field_users") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("username", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("age", IntegerType()), + ColumnDefinition("balance", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("credit_score", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=300)]), + ColumnDefinition("status", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="active")]), + ColumnDefinition("is_active", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/validated_users.sql +# --------------------------------------------------------------------------- + +def create_validated_users_table(dialect, table_name: str = "validated_users") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("username", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("age", IntegerType()), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/pydantic_validated_models.sql +# --------------------------------------------------------------------------- + +def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_validated_models") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("code", VarCharType(32)), + ColumnDefinition("quantity", IntegerType()), + ColumnDefinition("step_count", IntegerType()), + ColumnDefinition("price", DecimalType(precision=10, scale=2)), + ColumnDefinition("start_at", TimestampTzType()), + ColumnDefinition("end_at", TimestampTzType()), + ColumnDefinition("status", VarCharType(32)), + ColumnDefinition("normalized_name", VarCharType(50)), + ColumnDefinition("created_token", VarCharType(255)), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/bulk_users.sql +# --------------------------------------------------------------------------- + +def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("age", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="")]), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/posts.sql +# --------------------------------------------------------------------------- + +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("author", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("title", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("content", TextType()), + ColumnDefinition("published_at", TimestampTzType()), + ColumnDefinition("published", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/comments.sql +# --------------------------------------------------------------------------- + +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("post_ref", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("author", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("text", TextType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampTzType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition("approved", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["post_ref"], foreign_key_table="posts", foreign_key_columns=["id"], + on_delete=_CASCADE), + ForeignKeyConstraint(columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/column_mapping_items.sql +# --------------------------------------------------------------------------- + +def create_column_mapping_items_table(dialect, table_name: str = "column_mapping_items") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("item_total", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("remarks", IntegerType()), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/mixed_annotation_items.sql +# --------------------------------------------------------------------------- + +def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annotation_items") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tags", ArrayType(TextType())), + ColumnDefinition("meta", TextType()), + ColumnDefinition("description", TextType()), + ColumnDefinition("status", TextType()), + ], + ) + + +# --------------------------------------------------------------------------- +# basic/type_adapter_tests.sql +# --------------------------------------------------------------------------- + +def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tests") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=True, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("optional_name", VarCharType(255)), + ColumnDefinition("optional_age", IntegerType()), + ColumnDefinition("last_login", TimestampTzType()), + ColumnDefinition("is_premium", BooleanType()), + ColumnDefinition("unsupported_union", VarCharType(255)), + ColumnDefinition("custom_bool", VarCharType(10)), + ColumnDefinition("optional_custom_bool", VarCharType(10)), + ], + ) + + +# --------------------------------------------------------------------------- +# composite_pk/order_items.sql (composite PK) +# --------------------------------------------------------------------------- + +def create_composite_pk_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("order_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("product_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("quantity", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition("unit_price", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ], + table_constraints=[ + TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + columns=["order_id", "product_id"]), + ], + ) + + +# --------------------------------------------------------------------------- +# composite_pk/store_inventory.sql (composite PK, no FK) +# --------------------------------------------------------------------------- + +def create_store_inventory_table(dialect, table_name: str = "store_inventory") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("store_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("product_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("batch_id", VarCharType(64), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("stock", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + ], + table_constraints=[ + TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + columns=["store_id", "product_id", "batch_id"]), + ], + ) + + +# --------------------------------------------------------------------------- +# derived_field/orders.sql (single PK) +# --------------------------------------------------------------------------- + +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("total", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TextType()), + ColumnDefinition("updated_at", TextType()), + ], + ) + + +# --------------------------------------------------------------------------- +# derived_field/product.sql (single PK) +# --------------------------------------------------------------------------- + +def create_product_table(dialect, table_name: str = "product") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=True, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", TextType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("price", FloatType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("quantity", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ], + ) + + +TABLE_EXPRESSIONS: Dict[str, Callable] = { + "users": create_users_table, + "type_cases": create_type_cases_table, + "type_tests": create_type_tests_table, + "validated_field_users": create_validated_field_users_table, + "validated_users": create_validated_users_table, + "pydantic_validated_models": create_pydantic_validated_models_table, + "bulk_users": create_bulk_users_table, + "posts": create_posts_table, + "comments": create_comments_table, + "column_mapping_items": create_column_mapping_items_table, + "mixed_annotation_items": create_mixed_annotation_items_table, + "type_adapter_tests": create_type_adapter_tests_table, + "order_items": create_composite_pk_order_items_table, + "store_inventory": create_store_inventory_table, + "orders": create_orders_table, + "product": create_product_table, +} diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py new file mode 100644 index 00000000..39960fb5 --- /dev/null +++ b/tests/providers/fixtures/events.py @@ -0,0 +1,82 @@ +# tests/providers/fixtures/events.py +"""DDL expressions for the ``feature/events`` table group (PostgreSQL). + +Reference: ``tests/rhosocial/activerecord_postgres_test/feature/events/schema/``. +""" + +from typing import Callable, Dict + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + TextType, + TimestampTzType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresSerialType, +) + + +# --------------------------------------------------------------------------- +# events/event_tests.sql +# --------------------------------------------------------------------------- + +def create_event_tests_table(dialect, table_name: str = "event_tests") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("status", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), + ColumnDefinition("revision", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition("content", TextType()), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + ) + + +# --------------------------------------------------------------------------- +# events/event_tracking_models.sql +# --------------------------------------------------------------------------- + +def create_event_tracking_models_table(dialect, table_name: str = "event_tracking_models") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("title", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("content", TextType()), + ColumnDefinition("view_count", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + ColumnDefinition("last_viewed_at", TimestampTzType(), + constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ], + ) + + +TABLE_EXPRESSIONS: Dict[str, Callable] = { + "event_tests": create_event_tests_table, + "event_tracking_models": create_event_tracking_models_table, +} diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py new file mode 100644 index 00000000..b7c8f832 --- /dev/null +++ b/tests/providers/fixtures/mixins.py @@ -0,0 +1,132 @@ +# tests/providers/fixtures/mixins.py +"""DDL expressions for the ``feature/mixins`` table group (PostgreSQL). + +Reference: ``tests/rhosocial/activerecord_postgres_test/feature/mixins/schema/``. +""" + +from typing import Callable, Dict + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + BooleanType, + DecimalType, + IntegerType, + TextType, + TimestampTzType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresSerialType, +) + + +# --------------------------------------------------------------------------- +# mixins/combined_articles.sql +# --------------------------------------------------------------------------- + +def create_combined_articles_table(dialect, table_name: str = "combined_articles") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("title", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("content", TextType()), + ColumnDefinition("status", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition("version", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition("deleted_at", TimestampTzType(), + constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ], + ) + + +# --------------------------------------------------------------------------- +# mixins/tasks.sql +# --------------------------------------------------------------------------- + +def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("title", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("is_completed", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition("deleted_at", TimestampTzType(), + constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ], + ) + + +# --------------------------------------------------------------------------- +# mixins/timestamped_posts.sql +# --------------------------------------------------------------------------- + +def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("title", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("content", TextType()), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + ) + + +# --------------------------------------------------------------------------- +# mixins/versioned_products.sql +# --------------------------------------------------------------------------- + +def create_versioned_products_table(dialect, table_name: str = "versioned_products") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("price", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("version", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ], + ) + + +TABLE_EXPRESSIONS: Dict[str, Callable] = { + "combined_articles": create_combined_articles_table, + "tasks": create_tasks_table, + "timestamped_posts": create_timestamped_posts_table, + "versioned_products": create_versioned_products_table, +} diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py new file mode 100644 index 00000000..86e279fc --- /dev/null +++ b/tests/providers/fixtures/query.py @@ -0,0 +1,383 @@ +# tests/providers/fixtures/query.py +"""DDL expressions for the ``feature/query`` table group (PostgreSQL). + +Reference: ``tests/rhosocial/activerecord_postgres_test/feature/query/schema/``. +""" + +from typing import Callable, Dict + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + ForeignKeyConstraint, + ReferentialAction, +) +from rhosocial.activerecord.backend.expression.types import ( + BooleanType, + DecimalType, + IntegerType, + JsonBType, + TextType, + TimestampTzType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresSerialType, +) + +_CASCADE = ReferentialAction.CASCADE + + +# --------------------------------------------------------------------------- +# query/users.sql +# --------------------------------------------------------------------------- + +def create_users_table(dialect, table_name: str = "users") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("username", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("age", IntegerType()), + ColumnDefinition("balance", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("is_active", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + ) + + +# --------------------------------------------------------------------------- +# query/posts.sql +# --------------------------------------------------------------------------- + +def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("title", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("content", TextType()), + ColumnDefinition("status", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="published")]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# query/comments.sql +# --------------------------------------------------------------------------- + +def create_comments_table(dialect, table_name: str = "comments") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("post_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("content", TextType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("is_hidden", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + on_delete=_CASCADE), + ForeignKeyConstraint(columns=["post_id"], foreign_key_table="posts", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# query/orders.sql +# --------------------------------------------------------------------------- + +def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("order_number", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("total_amount", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("status", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ], + ) + + +# --------------------------------------------------------------------------- +# query/order_items.sql +# --------------------------------------------------------------------------- + +def create_order_items_table(dialect, table_name: str = "order_items") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("order_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("product_name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("quantity", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition("unit_price", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("subtotal", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["order_id"], foreign_key_table="orders", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# query/profiles.sql +# --------------------------------------------------------------------------- + +def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("bio", TextType()), + ColumnDefinition("avatar_url", VarCharType(512)), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ], + ) + + +# --------------------------------------------------------------------------- +# query/json_users.sql +# --------------------------------------------------------------------------- + +def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("username", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("email", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("age", IntegerType()), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition("settings", JsonBType()), + ColumnDefinition("tags", JsonBType()), + ColumnDefinition("profile", JsonBType()), + ColumnDefinition("roles", JsonBType()), + ColumnDefinition("scores", JsonBType()), + ColumnDefinition("subscription", JsonBType()), + ColumnDefinition("preferences", JsonBType()), + ], + ) + + +# --------------------------------------------------------------------------- +# query/nodes.sql (self-referential FK) +# --------------------------------------------------------------------------- + +def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("parent_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ColumnDefinition("value", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["parent_id"], foreign_key_table="nodes", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# query/searchable_items.sql +# --------------------------------------------------------------------------- + +def create_searchable_items_table(dialect, table_name: str = "searchable_items") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(255)), + ColumnDefinition("tags", TextType()), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + ) + + +# --------------------------------------------------------------------------- +# query/extended_orders.sql +# --------------------------------------------------------------------------- + +def create_extended_orders_table(dialect, table_name: str = "extended_orders") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("user_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("order_number", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("total_amount", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition("status", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), + ColumnDefinition("priority", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="medium")]), + ColumnDefinition("region", VarCharType(50), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="default")]), + ColumnDefinition("category", VarCharType(255)), + ColumnDefinition("product", VarCharType(255)), + ColumnDefinition("department", VarCharType(255)), + ColumnDefinition("year", VarCharType(10)), + ColumnDefinition("quarter", VarCharType(10)), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +# --------------------------------------------------------------------------- +# query/extended_order_items.sql +# --------------------------------------------------------------------------- + +def create_extended_order_items_table(dialect, table_name: str = "extended_order_items") -> CreateTableExpression: + return CreateTableExpression( + dialect=dialect, + table=table_name, + if_not_exists=False, + columns=[ + ColumnDefinition("id", PostgresSerialType(), + constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("order_id", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("product_name", VarCharType(255), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("quantity", IntegerType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition("price", DecimalType(precision=10, scale=2), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("category", VarCharType(255)), + ColumnDefinition("region", VarCharType(50)), + ColumnDefinition("created_at", TimestampTzType()), + ColumnDefinition("updated_at", TimestampTzType()), + ], + table_constraints=[ + ForeignKeyConstraint(columns=["order_id"], foreign_key_table="extended_orders", foreign_key_columns=["id"], + on_delete=_CASCADE), + ], + ) + + +TABLE_EXPRESSIONS: Dict[str, Callable] = { + "users": create_users_table, + "posts": create_posts_table, + "comments": create_comments_table, + "orders": create_orders_table, + "order_items": create_order_items_table, + "profiles": create_profiles_table, + "json_users": create_json_users_table, + "nodes": create_nodes_table, + "searchable_items": create_searchable_items_table, + "extended_orders": create_extended_orders_table, + "extended_order_items": create_extended_order_items_table, +} diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index d803b6de..72afb884 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -28,7 +28,11 @@ TimestampedPost as TimestampedPostBase, VersionedProduct as VersionedProductBase, Task as TaskBase, - CombinedArticle as CombinedArticleBase + CombinedArticle as CombinedArticleBase, + AsyncTimestampedPost as AsyncTimestampedPostBase, + AsyncVersionedProduct as AsyncVersionedProductBase, + AsyncTask as AsyncTaskBase, + AsyncCombinedArticle as AsyncCombinedArticleBase, ) # Conditionally import Python 3.10+ models @@ -89,113 +93,138 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s Task = _select_model_class(TaskBase, Task312, Task311, Task310, "Task") CombinedArticle = _select_model_class(CombinedArticleBase, CombinedArticle312, CombinedArticle311, CombinedArticle310, "CombinedArticle") # noqa: E501 -from rhosocial.activerecord.testsuite.feature.mixins.interfaces import IMixinsProvider # noqa: E402 +AsyncTimestampedPost = AsyncTimestampedPostBase +AsyncVersionedProduct = AsyncVersionedProductBase +AsyncTask = AsyncTaskBase +AsyncCombinedArticle = AsyncCombinedArticleBase + +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType # noqa: E402 +from rhosocial.activerecord.testsuite.feature.mixins.interfaces import IMixinsSyncProvider, IMixinsAsyncProvider # noqa: E402 # ...and the scenarios are defined specifically for this backend. from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 +# Expression-based DDL fixtures +from .fixtures._common import drop_table # noqa: E402 +from .fixtures.mixins import TABLE_EXPRESSIONS as MIXINS_TABLE_EXPRESSIONS # noqa: E402 -class MixinsProvider(IMixinsProvider): - """ - This is the postgres backend's implementation for the mixins test group. - It connects the generic tests in the testsuite with the actual postgres database. - """ +class MixinsProviderBase: def __init__(self): - # This list will track the backend instances created during the setup phase. self._active_backends = [] def get_test_scenarios(self) -> List[str]: - """Returns a list of names for all enabled scenarios for this backend.""" return list(get_enabled_scenarios().keys()) + def _load_postgres_schema(self, filename: str) -> str: + schema_dir = os.path.join( + os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "mixins", "schema" + ) + schema_path = os.path.join(schema_dir, filename) + with open(schema_path, 'r', encoding='utf-8') as f: + return f.read() + + +class MixinsSyncProvider(MixinsProviderBase, IMixinsSyncProvider): + + def __init__(self): + super().__init__() + def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: - """A generic helper method to handle the setup for any given model.""" - # 1. Get the backend class (PostgresBackend) and connection config for the requested scenario. backend_class, config = get_scenario(scenario_name) - - # 2. Configure the generic model class with our specific backend and config. model_class.configure(config, backend_class) - backend_instance = model_class.__backend__ if backend_instance not in self._active_backends: self._active_backends.append(backend_instance) - - # 3. Prepare the database schema. + opts = ExecutionOptions(stmt_type=StatementType.DDL) try: - # Drop the table if it exists, with cascade to handle dependencies. - model_class.__backend__.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + sql, params = drop_table(backend_instance.dialect, table_name).to_sql() + backend_instance.execute(sql, params, options=opts) except Exception as e: - # Ignore errors if the table didn't exist, but print for debugging. print(f"Could not drop table {table_name}: {e}") - pass - - # Execute the schema SQL to create the table. - schema_sql = self._load_postgres_schema(f"{table_name}.sql") - model_class.__backend__.execute(schema_sql) - + if fn := MIXINS_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(backend_instance.dialect, table_name) + sql, params = create_expr.to_sql() + backend_instance.execute(sql, params, options=opts) return model_class - # --- Implementation of the IMixinsProvider interface --- - - - - - - - def setup_timestamped_post_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for timestamps model tests.""" - return self._setup_model(TimestampedPost, scenario_name, "timestamped_posts") def setup_versioned_product_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for optimistic lock model tests.""" - return self._setup_model(VersionedProduct, scenario_name, "versioned_products") def setup_task_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for soft delete model tests.""" - return self._setup_model(Task, scenario_name, "tasks") def setup_combined_article_model(self, scenario_name: str) -> Type[ActiveRecord]: - """Sets up the database for combined article model tests.""" - return self._setup_model(CombinedArticle, scenario_name, "combined_articles") - - - def _load_postgres_schema(self, filename: str) -> str: - """Helper to load a SQL schema file from this project's fixtures.""" - # Schemas are stored in the centralized location for mixins feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "mixins", "schema") # noqa: E501 - schema_path = os.path.join(schema_dir, filename) - - with open(schema_path, 'r', encoding='utf-8') as f: - return f.read() - def cleanup_after_test(self, scenario_name: str): - """ - Performs cleanup after a test. This now iterates through the backends - that were created during setup, drops tables, and explicitly disconnects them. - """ for backend_instance in self._active_backends: try: - # Drop all tables that might have been created for mixins tests for table_name in ['timestamped_posts', 'versioned_products', 'tasks', 'combined_articles']: try: backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') except Exception: - # Continue with other tables if one fails pass finally: - # Always disconnect the backend instance that was used in the test try: backend_instance.disconnect() - except: # noqa: E722 - # Ignore errors during disconnect + except Exception: pass + self._active_backends.clear() + + +class MixinsAsyncProvider(MixinsProviderBase, IMixinsAsyncProvider): - # Clear the list of active backends for the next test - self._active_backends.clear() \ No newline at end of file + def __init__(self): + super().__init__() + self._active_async_backends = [] + + async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + _, config = get_scenario(scenario_name) + await model_class.configure(config, AsyncPostgresBackend) + backend_instance = model_class.__backend__ + if backend_instance not in self._active_async_backends: + self._active_async_backends.append(backend_instance) + opts = ExecutionOptions(stmt_type=StatementType.DDL) + try: + sql, params = drop_table(backend_instance.dialect, table_name).to_sql() + await backend_instance.execute(sql, params, options=opts) + except Exception as e: + print(f"Could not drop table {table_name}: {e}") + if fn := MIXINS_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(backend_instance.dialect, table_name) + sql, params = create_expr.to_sql() + await backend_instance.execute(sql, params, options=opts) + return model_class + + async def setup_timestamped_post_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncTimestampedPost, scenario_name, "timestamped_posts") + + async def setup_versioned_product_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncVersionedProduct, scenario_name, "versioned_products") + + async def setup_task_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncTask, scenario_name, "tasks") + + async def setup_combined_article_model(self, scenario_name: str) -> Type[ActiveRecord]: + return await self._setup_async_model(AsyncCombinedArticle, scenario_name, "combined_articles") + + async def cleanup_after_test(self, scenario_name: str): + for backend_instance in self._active_async_backends: + try: + for table_name in ['timestamped_posts', 'versioned_products', 'tasks', + 'combined_articles']: + try: + await backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + except Exception: + pass + finally: + try: + await backend_instance.disconnect() + except Exception: + pass + self._active_async_backends.clear() diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 2f99935c..64a73d68 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -25,6 +25,13 @@ from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord from rhosocial.activerecord.testsuite.feature.partition.interfaces import IPartitionProvider +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, IntegerType, TextType, TimestampType, DecimalType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) + from .scenarios import get_enabled_scenarios, get_scenario @@ -245,11 +252,11 @@ def _create_parent_sql(self, dialect): dialect=dialect, table=self.TABLE_NAME, columns=[ - ColumnDefinition("id", "BIGINT GENERATED BY DEFAULT AS IDENTITY"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("tenant_id", "INTEGER NOT NULL"), - ColumnDefinition("payload", "TEXT"), - ColumnDefinition("amount", "NUMERIC(12, 2)"), + ColumnDefinition("id", BigIntType(), dialect_options={"identity": "BY DEFAULT"}), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tenant_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType()), + ColumnDefinition("amount", DecimalType(12, 2)), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/providers/query.py b/tests/providers/query.py index 6fac6c84..209121c0 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -13,9 +13,9 @@ import os import sys import logging -from typing import Type, List, Tuple +from typing import Set, Type, List, Tuple -from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord # Setup logging for fixture selection debugging logger = logging.getLogger(__name__) @@ -104,82 +104,75 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s MappedComment = _select_model_class(MappedCommentBase, MappedComment312, MappedComment311, MappedComment310, "MappedComment") # noqa: E501 Profile = _select_model_class(ProfileBase, Profile312, Profile311, Profile310, "Profile") -from rhosocial.activerecord.testsuite.feature.query.interfaces import IQueryProvider # noqa: E402 +from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType # noqa: E402 +from rhosocial.activerecord.testsuite.feature.query.interfaces import IQuerySyncProvider, IQueryAsyncProvider # noqa: E402 from rhosocial.activerecord.testsuite.core.protocols import WorkerTestProtocol # noqa: E402 + +# Composite-PK model classes used by setup_order_item_model. +from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( # noqa: E402 + OrderItem as CompositeOrderItemBase, + AsyncOrderItem as AsyncCompositeOrderItemBase, +) + +# Expression-based DDL fixtures +from providers.fixtures._common import drop_table # noqa: E402 +from providers.fixtures.query import TABLE_EXPRESSIONS as QUERY_TABLE_EXPRESSIONS # noqa: E402 + # The scenarios are defined specifically for this backend. from .scenarios import get_enabled_scenarios, get_scenario # noqa: E402 -class QueryProvider(IQueryProvider, WorkerTestProtocol): - """ - This is the postgres backend's implementation for the query features test group. - It connects the generic tests in the testsuite with the actual postgres database. - """ - +class QueryProviderBase: def __init__(self): - self._active_backends = [] - self._active_async_backends = [] + self._scenario_db_files = {} + self._created_tables: Set[str] = set() def get_test_scenarios(self) -> List[str]: - """Returns a list of names for all enabled scenarios for this backend.""" return list(get_enabled_scenarios().keys()) def _track_backend(self, backend_instance, collection): if backend_instance not in collection: collection.append(backend_instance) - def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: # noqa: E501 - """A generic helper method to handle the setup for any given model.""" - backend_class, config = get_scenario(scenario_name) - - if shared_backend is None: - model_class.configure(config, backend_class) - else: - model_class.__connection_config__ = config - model_class.__backend_class__ = backend_class - model_class.__backend__ = shared_backend - - backend_instance = model_class.__backend__ - self._track_backend(backend_instance, self._active_backends) - - try: - backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') - except Exception: - pass - - schema_sql = self._load_postgres_schema(f"{table_name}.sql") - backend_instance.execute(schema_sql) + def _load_postgres_schema(self, filename: str) -> str: + schema_dir = os.path.join( + os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "query", "schema" + ) + schema_path = os.path.join(schema_dir, filename) + with open(schema_path, 'r', encoding='utf-8') as f: + return f.read() - return model_class - async def _setup_model_async(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: # noqa: E501 - """A generic helper method to handle the setup for any given async model.""" - from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend +class QuerySyncProvider(QueryProviderBase, IQuerySyncProvider, WorkerTestProtocol): - _, config = get_scenario(scenario_name) + def __init__(self): + super().__init__() + self._active_backends = [] + def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: + backend_class, config = get_scenario(scenario_name) if shared_backend is None: - await model_class.configure(config, AsyncPostgresBackend) + model_class.configure(config, backend_class) else: model_class.__connection_config__ = config - model_class.__backend_class__ = AsyncPostgresBackend + model_class.__backend_class__ = backend_class model_class.__backend__ = shared_backend - backend_instance = model_class.__backend__ - self._track_backend(backend_instance, self._active_async_backends) - + self._track_backend(backend_instance, self._active_backends) + opts = ExecutionOptions(stmt_type=StatementType.DDL) try: - await backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + sql, params = drop_table(backend_instance.dialect, table_name).to_sql() + backend_instance.execute(sql, params, options=opts) except Exception: pass - - schema_sql = self._load_postgres_schema(f"{table_name}.sql") - await backend_instance.execute(schema_sql) - + if fn := QUERY_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(backend_instance.dialect, table_name) + sql, params = create_expr.to_sql() + backend_instance.execute(sql, params, options=opts) + self._created_tables.add(table_name) return model_class - def _setup_multiple_models(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 - """A helper to set up multiple models for fixture groups, sharing the same backend.""" + def _setup_multiple_models(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: result = [] shared_backend = None for i, (model_class, table_name) in enumerate(models_and_tables): @@ -187,27 +180,11 @@ def _setup_multiple_models(self, models_and_tables: List[Tuple[Type[ActiveRecord configured_model = self._setup_model(model_class, scenario_name, table_name) shared_backend = configured_model.__backend__ else: - configured_model = self._setup_model(model_class, scenario_name, table_name, shared_backend=shared_backend) # noqa: E501 + configured_model = self._setup_model(model_class, scenario_name, table_name, shared_backend=shared_backend) result.append(configured_model) return tuple(result) - async def _setup_multiple_models_async(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: # noqa: E501 - """A helper to set up multiple async models for fixture groups, sharing the same backend.""" - result = [] - shared_backend = None - for i, (model_class, table_name) in enumerate(models_and_tables): - if i == 0: - configured_model = await self._setup_model_async(model_class, scenario_name, table_name) - shared_backend = configured_model.__backend__ - else: - configured_model = await self._setup_model_async(model_class, scenario_name, table_name, shared_backend=shared_backend) # noqa: E501 - result.append(configured_model) - return tuple(result) - - # --- Implementation of the IQueryProvider interface --- - - def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for order-related models (User, Order, OrderItem) tests.""" + def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: models_and_tables = [ (User, "users"), (Order, "orders"), @@ -215,8 +192,7 @@ def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ] return self._setup_multiple_models(models_and_tables, scenario_name) - def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for blog-related models (User, Post, Comment) tests.""" + def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: models_and_tables = [ (User, "users"), (Post, "posts"), @@ -225,34 +201,24 @@ def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], T return self._setup_multiple_models(models_and_tables, scenario_name) def setup_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the JSON user model. - - Registers PostgresJSONBAdapter for JSON fields so that psycopg3's - automatic JSONB deserialization (dict/list) is converted back to str - before Pydantic validation, matching the model's Optional[str] type. - """ from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter - jsonb_adapter = PostgresJSONBAdapter() json_fields = ["settings", "tags", "profile", "roles", "scores", "subscription", "preferences"] for field_name in json_fields: if field_name not in JsonUser.__field_adapters__: JsonUser.__field_adapters__[field_name] = (jsonb_adapter, str) - models_and_tables = [ (JsonUser, "json_users"), ] return self._setup_multiple_models(models_and_tables, scenario_name) def setup_tree_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the tree structure (Node) model.""" models_and_tables = [ (Node, "nodes"), ] return self._setup_multiple_models(models_and_tables, scenario_name) - def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for extended order-related models (User, ExtendedOrder, ExtendedOrderItem) tests.""" + def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: models_and_tables = [ (User, "users"), (ExtendedOrder, "extended_orders"), @@ -260,8 +226,7 @@ def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[Active ] return self._setup_multiple_models(models_and_tables, scenario_name) - def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for combined models (User, Order, OrderItem, Post, Comment) tests.""" + def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: models_and_tables = [ (User, "users"), (Order, "orders"), @@ -272,40 +237,155 @@ def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord return self._setup_multiple_models(models_and_tables, scenario_name) def setup_annotated_query_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the SearchableItem model tests.""" from rhosocial.activerecord.testsuite.feature.query.fixtures.annotated_adapter_models import SearchableItem return self._setup_multiple_models([ (SearchableItem, "searchable_items"), ], scenario_name) - def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for MappedUser, MappedPost, and MappedComment models.""" + def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: return self._setup_multiple_models([ (MappedUser, "users"), (MappedPost, "posts"), (MappedComment, "comments") ], scenario_name) - def setup_profile_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for User and Profile models.""" + def setup_profile_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: return self._setup_multiple_models([ (User, "users"), (Profile, "profiles"), ], scenario_name) - # --- Async implementations --- + def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: + """Set up the composite-PK OrderItem model for the query feature tests.""" + from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType + from providers.fixtures._common import drop_table + from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS + + backend_class, config = get_scenario(scenario_name) + CompositeOrderItemBase.configure(config, backend_class) + backend_instance = CompositeOrderItemBase.__backend__ + self._track_backend(backend_instance, self._active_backends) + opts = ExecutionOptions(stmt_type=StatementType.DDL) + try: + sql, params = drop_table(backend_instance.dialect, "order_items").to_sql() + backend_instance.execute(sql, params, options=opts) + except Exception: + pass + if fn := BASIC_EXPRS.get("order_items"): + create_expr = fn(backend_instance.dialect, "order_items") + sql, params = create_expr.to_sql() + backend_instance.execute(sql, params, options=opts) + self._created_tables.add("order_items") + return CompositeOrderItemBase + + def _get_schema_sql_for_fixture_type(self, fixture_type: str) -> dict: + schemas = {} + if fixture_type == 'order': + tables = ['users', 'orders', 'order_items'] + elif fixture_type == 'blog': + tables = ['users', 'posts', 'comments'] + elif fixture_type == 'user': + tables = ['users'] + elif fixture_type == 'combined': + tables = ['users', 'orders', 'order_items', 'posts', 'comments'] + else: + tables = ['users'] + for table in tables: + schemas[table] = self._load_postgres_schema(f'{table}.sql') + return schemas + + def get_worker_connection_params(self, scenario_name: str, fixture_type: str = 'order') -> dict: + from .scenarios import SCENARIO_MAP + is_async = fixture_type and fixture_type.startswith('async_') + backend_class_name = 'AsyncPostgresBackend' if is_async else 'PostgresBackend' + base_fixture_type = fixture_type.replace('async_', '') if fixture_type else 'order' + schema_sql = self._get_schema_sql_for_fixture_type(base_fixture_type) + if scenario_name not in SCENARIO_MAP: + if SCENARIO_MAP: + scenario_name = next(iter(SCENARIO_MAP)) + else: + raise ValueError("No scenarios registered") + config_dict = SCENARIO_MAP[scenario_name] + return { + 'backend_module': 'rhosocial.activerecord.backend.impl.postgres', + 'backend_class_name': backend_class_name, + 'config_class_module': 'rhosocial.activerecord.backend.impl.postgres.config', + 'config_class_name': 'PostgresConnectionConfig', + 'config_kwargs': config_dict, + 'schema_sql': schema_sql, + } + + def get_worker_schema_sql(self, scenario_name: str, table_name: str) -> str: + return self._load_postgres_schema(f'{table_name}.sql') + + def cleanup_after_test(self, scenario_name: str): + for backend_instance in self._active_backends: + try: + for table_name in list(self._created_tables): + try: + backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') + except Exception: + pass + finally: + try: + backend_instance.disconnect() + except Exception: + pass + self._active_backends.clear() + self._created_tables.clear() + - async def setup_async_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for async order-related models (AsyncUser, AsyncOrder, AsyncOrderItem) tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem # noqa: E501 +class QueryAsyncProvider(QueryProviderBase, IQueryAsyncProvider): + + def __init__(self): + super().__init__() + self._active_async_backends = [] + + async def _setup_async_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None) -> Type[ActiveRecord]: + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + _, config = get_scenario(scenario_name) + if shared_backend is None: + await model_class.configure(config, AsyncPostgresBackend) + else: + model_class.__connection_config__ = config + model_class.__backend_class__ = AsyncPostgresBackend + model_class.__backend__ = shared_backend + backend_instance = model_class.__backend__ + self._track_backend(backend_instance, self._active_async_backends) + opts = ExecutionOptions(stmt_type=StatementType.DDL) + try: + sql, params = drop_table(backend_instance.dialect, table_name).to_sql() + await backend_instance.execute(sql, params, options=opts) + except Exception: + pass + if fn := QUERY_TABLE_EXPRESSIONS.get(table_name): + create_expr = fn(backend_instance.dialect, table_name) + sql, params = create_expr.to_sql() + await backend_instance.execute(sql, params, options=opts) + self._created_tables.add(table_name) + return model_class + + async def _setup_multiple_models_async(self, models_and_tables: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + result = [] + shared_backend = None + for i, (model_class, table_name) in enumerate(models_and_tables): + if i == 0: + configured_model = await self._setup_async_model(model_class, scenario_name, table_name) + shared_backend = configured_model.__backend__ + else: + configured_model = await self._setup_async_model(model_class, scenario_name, table_name, shared_backend=shared_backend) + result.append(configured_model) + return tuple(result) + + async def setup_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem return await self._setup_multiple_models_async([ (AsyncUser, "users"), (AsyncOrder, "orders"), (AsyncOrderItem, "order_items") ], scenario_name) - async def setup_async_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for async blog-related models (AsyncUser, AsyncPost, AsyncComment) tests.""" + async def setup_blog_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser from rhosocial.activerecord.testsuite.feature.query.fixtures.async_blog_models import AsyncPost, AsyncComment return await self._setup_multiple_models_async([ @@ -314,45 +394,34 @@ async def setup_async_blog_fixtures(self, scenario_name: str) -> Tuple[Type[Acti (AsyncComment, "comments") ], scenario_name) - async def setup_async_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the async JSON user model. - - Registers PostgresJSONBAdapter for JSON fields so that psycopg3's - automatic JSONB deserialization (dict/list) is converted back to str - before Pydantic validation, matching the model's Optional[str] type. - """ + async def setup_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: from rhosocial.activerecord.testsuite.feature.query.fixtures.async_json_models import AsyncJsonUser from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter - jsonb_adapter = PostgresJSONBAdapter() json_fields = ["settings", "tags", "profile", "roles", "scores", "subscription", "preferences"] for field_name in json_fields: if field_name not in AsyncJsonUser.__field_adapters__: AsyncJsonUser.__field_adapters__[field_name] = (jsonb_adapter, str) - return await self._setup_multiple_models_async([ (AsyncJsonUser, "json_users"), ], scenario_name) - async def setup_async_tree_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the async tree structure (AsyncNode) model.""" + async def setup_tree_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: from rhosocial.activerecord.testsuite.feature.query.fixtures.async_cte_models import AsyncNode return await self._setup_multiple_models_async([ (AsyncNode, "nodes"), ], scenario_name) - async def setup_async_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for async extended order-related models (AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem) tests.""" # noqa: E501 - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_extended_models import AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem # noqa: E501 + async def setup_extended_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_extended_models import AsyncUser, AsyncExtendedOrder, AsyncExtendedOrderItem return await self._setup_multiple_models_async([ (AsyncUser, "users"), (AsyncExtendedOrder, "extended_orders"), (AsyncExtendedOrderItem, "extended_order_items") ], scenario_name) - async def setup_async_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for async combined models (AsyncUser, AsyncOrder, AsyncOrderItem, AsyncPost, AsyncComment) tests.""" # noqa: E501 - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem # noqa: E501 + async def setup_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem from rhosocial.activerecord.testsuite.feature.query.fixtures.async_blog_models import AsyncPost, AsyncComment return await self._setup_multiple_models_async([ (AsyncUser, "users"), @@ -362,39 +431,55 @@ async def setup_async_combined_fixtures(self, scenario_name: str) -> Tuple[Type[ (AsyncComment, "comments") ], scenario_name) - async def setup_async_annotated_query_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: - """Sets up the database for the async SearchableItem model tests.""" - from rhosocial.activerecord.testsuite.feature.query.fixtures.async_annotated_adapter_models import AsyncSearchableItem # noqa: E501 + async def setup_annotated_query_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_annotated_adapter_models import AsyncSearchableItem return await self._setup_multiple_models_async([ (AsyncSearchableItem, "searchable_items"), ], scenario_name) - async def setup_async_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for AsyncMappedUser, AsyncMappedPost, and AsyncMappedComment models.""" - from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import AsyncMappedUser, AsyncMappedPost, AsyncMappedComment # noqa: E501 + async def setup_mapped_models(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import AsyncMappedUser, AsyncMappedPost, AsyncMappedComment return await self._setup_multiple_models_async([ (AsyncMappedUser, "users"), (AsyncMappedPost, "posts"), (AsyncMappedComment, "comments") ], scenario_name) - async def setup_async_profile_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: # noqa: E501 - """Sets up the database for AsyncUser and AsyncProfile models.""" + async def setup_profile_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncProfile return await self._setup_multiple_models_async([ (AsyncUser, "users"), (AsyncProfile, "profiles"), ], scenario_name) - async def cleanup_after_test_async(self, scenario_name: str): - """Performs async cleanup after a test.""" - tables_to_drop = [ - 'users', 'orders', 'order_items', 'posts', 'comments', 'json_users', 'nodes', - 'extended_orders', 'extended_order_items', 'searchable_items' - ] + async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + """Set up the composite-PK AsyncOrderItem model for the query feature tests.""" + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType + from providers.fixtures._common import drop_table + from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS + + _, config = get_scenario(scenario_name) + await AsyncCompositeOrderItemBase.configure(config, AsyncPostgresBackend) + backend_instance = AsyncCompositeOrderItemBase.__backend__ + self._track_backend(backend_instance, self._active_async_backends) + opts = ExecutionOptions(stmt_type=StatementType.DDL) + try: + sql, params = drop_table(backend_instance.dialect, "order_items").to_sql() + await backend_instance.execute(sql, params, options=opts) + except Exception: + pass + if fn := BASIC_EXPRS.get("order_items"): + create_expr = fn(backend_instance.dialect, "order_items") + sql, params = create_expr.to_sql() + await backend_instance.execute(sql, params, options=opts) + self._created_tables.add("order_items") + return AsyncCompositeOrderItemBase + + async def cleanup_after_test(self, scenario_name: str): for backend_instance in self._active_async_backends: try: - for table_name in tables_to_drop: + for table_name in list(self._created_tables): try: await backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') except Exception: @@ -402,133 +487,7 @@ async def cleanup_after_test_async(self, scenario_name: str): finally: try: await backend_instance.disconnect() - except: # noqa: E722 + except Exception: pass self._active_async_backends.clear() - - def _load_postgres_schema(self, filename: str) -> str: - """Helper to load a SQL schema file from this project's fixtures.""" - # Schemas are stored in the centralized location for query feature. - schema_dir = os.path.join(os.path.dirname(__file__), "..", "rhosocial", "activerecord_postgres_test", "feature", "query", "schema") # noqa: E501 - schema_path = os.path.join(schema_dir, filename) - - with open(schema_path, 'r', encoding='utf-8') as f: - return f.read() - - def cleanup_after_test(self, scenario_name: str): - """ - Performs cleanup after a test. This uses the same backend instance - that was used for setup to ensure proper cleanup with foreign key constraints. - """ - tables_to_drop = [ - 'users', 'orders', 'order_items', 'posts', 'comments', 'json_users', 'nodes', - 'extended_orders', 'extended_order_items', 'searchable_items' - ] - for backend_instance in self._active_backends: - try: - # Drop all tables that might have been created for query tests - # Use CASCADE to handle foreign key dependencies - for table_name in tables_to_drop: - try: - backend_instance.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') - except Exception: - # Continue with other tables if one fails - pass - finally: - # Always disconnect the backend instance that was used in the test - try: - backend_instance.disconnect() - except: # noqa: E722 - # Ignore errors during disconnect - pass - - # Clear the list of active backends for the next test - self._active_backends.clear() - - # --- Implementation of WorkerTestProtocol --- - - def get_worker_connection_params(self, scenario_name: str, fixture_type: str = 'order') -> dict: - """ - Return serializable connection parameters for Worker processes. - - This method provides all information needed to recreate the database - connection in a Worker process, including the schema SQL for table creation. - - Args: - scenario_name: The test scenario name - fixture_type: Type of fixture ('order', 'blog', 'user', 'combined', - or with 'async_' prefix for async backends) - - Returns: - Dictionary with connection parameters and schema SQL - """ - from .scenarios import SCENARIO_MAP - - # Determine if async backend is needed based on fixture_type - is_async = fixture_type and fixture_type.startswith('async_') - backend_class_name = 'AsyncPostgresBackend' if is_async else 'PostgresBackend' - - # Get base fixture type (remove 'async_' prefix if present) - base_fixture_type = fixture_type.replace('async_', '') if fixture_type else 'order' - - # Build schema SQL based on fixture type - schema_sql = self._get_schema_sql_for_fixture_type(base_fixture_type) - - # Get connection config from scenario - if scenario_name not in SCENARIO_MAP: - if SCENARIO_MAP: - scenario_name = next(iter(SCENARIO_MAP)) - else: - raise ValueError("No scenarios registered") - - config_dict = SCENARIO_MAP[scenario_name] - - return { - 'backend_module': 'rhosocial.activerecord.backend.impl.postgres', - 'backend_class_name': backend_class_name, - 'config_class_module': 'rhosocial.activerecord.backend.impl.postgres.config', - 'config_class_name': 'PostgresConnectionConfig', - 'config_kwargs': config_dict, - 'schema_sql': schema_sql, - } - - def get_worker_schema_sql(self, scenario_name: str, table_name: str) -> str: - """ - Return the SQL statement to create a specific table. - - Args: - scenario_name: The test scenario name (unused for Postgres as schema is fixed) - table_name: Name of the table to create - - Returns: - CREATE TABLE SQL statement - """ - return self._load_postgres_schema(f'{table_name}.sql') - - def _get_schema_sql_for_fixture_type(self, fixture_type: str) -> dict: - """ - Get schema SQL for a specific fixture type. - - Args: - fixture_type: Type of fixture ('order', 'blog', 'user', 'combined') - - Returns: - Dictionary mapping table names to CREATE TABLE statements - """ - schemas = {} - - if fixture_type == 'order': - tables = ['users', 'orders', 'order_items'] - elif fixture_type == 'blog': - tables = ['users', 'posts', 'comments'] - elif fixture_type == 'user': - tables = ['users'] - elif fixture_type == 'combined': - tables = ['users', 'orders', 'order_items', 'posts', 'comments'] - else: - tables = ['users'] - - for table in tables: - schemas[table] = self._load_postgres_schema(f'{table}.sql') - - return schemas + self._created_tables.clear() diff --git a/tests/providers/query_connection.py b/tests/providers/query_connection.py index ab962f1e..7fab8e23 100644 --- a/tests/providers/query_connection.py +++ b/tests/providers/query_connection.py @@ -134,6 +134,10 @@ def cleanup_sync(self, scenario_name: str, pool: BackendPool): # Disconnect backends for backend in self._active_backends: + try: + backend.execute("DROP TABLE IF EXISTS test_users CASCADE") + except Exception: + pass try: backend.disconnect() except Exception: @@ -146,6 +150,10 @@ async def cleanup_async(self, scenario_name: str, pool: AsyncBackendPool): # Disconnect backends for backend in self._active_async_backends: + try: + await backend.execute("DROP TABLE IF EXISTS test_users CASCADE") + except Exception: + pass try: await backend.disconnect() except Exception: diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 152d0088..32738870 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -6,11 +6,11 @@ interfaces for the postgres backend. """ from rhosocial.activerecord.testsuite.core.registry import ProviderRegistry -from .basic import BasicProvider -from .events import EventsProvider -from .mixins import MixinsProvider -from .query import QueryProvider -from .relation import RelationProvider +from .basic import BasicSyncProvider, BasicAsyncProvider +from .events import EventsSyncProvider, EventsAsyncProvider +from .mixins import MixinsSyncProvider, MixinsAsyncProvider +from .query import QuerySyncProvider, QueryAsyncProvider +from .relation import RelationSyncProvider, RelationAsyncProvider from .basic_connection import BasicConnectionProvider from .query_connection import QueryConnectionProvider from .crud_benchmark import CrudBenchmarkProvider @@ -24,11 +24,25 @@ # Register the concrete implementations as the providers for the # interfaces defined in the testsuite. -provider_registry.register("feature.basic.IBasicProvider", BasicProvider) -provider_registry.register("feature.events.IEventsProvider", EventsProvider) -provider_registry.register("feature.mixins.IMixinsProvider", MixinsProvider) -provider_registry.register("feature.query.IQueryProvider", QueryProvider) -provider_registry.register("feature.relation.IRelationProvider", RelationProvider) +provider_registry.register("feature.basic.IBasicProvider", BasicSyncProvider) +provider_registry.register("feature.basic.IBasicSyncProvider", BasicSyncProvider) +provider_registry.register("feature.basic.IBasicAsyncProvider", BasicAsyncProvider) + +provider_registry.register("feature.events.IEventsProvider", EventsSyncProvider) +provider_registry.register("feature.events.IEventsSyncProvider", EventsSyncProvider) +provider_registry.register("feature.events.IEventsAsyncProvider", EventsAsyncProvider) + +provider_registry.register("feature.mixins.IMixinsProvider", MixinsSyncProvider) +provider_registry.register("feature.mixins.IMixinsSyncProvider", MixinsSyncProvider) +provider_registry.register("feature.mixins.IMixinsAsyncProvider", MixinsAsyncProvider) + +provider_registry.register("feature.query.IQueryProvider", QuerySyncProvider) +provider_registry.register("feature.query.IQuerySyncProvider", QuerySyncProvider) +provider_registry.register("feature.query.IQueryAsyncProvider", QueryAsyncProvider) + +provider_registry.register("feature.relation.IRelationProvider", RelationSyncProvider) +provider_registry.register("feature.relation.IRelationSyncProvider", RelationSyncProvider) +provider_registry.register("feature.relation.IRelationAsyncProvider", RelationAsyncProvider) # Register connection pool context awareness providers provider_registry.register( diff --git a/tests/providers/relation.py b/tests/providers/relation.py index c852ad55..f58fb93d 100644 --- a/tests/providers/relation.py +++ b/tests/providers/relation.py @@ -1,11 +1,10 @@ # tests/providers/relation.py -import asyncio -from typing import Dict, List, Tuple, Type +from typing import Dict, List, Tuple, Type, Set -from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend from rhosocial.activerecord.backend.impl.postgres.adapters.json import PostgresJSONBAdapter -from rhosocial.activerecord.testsuite.feature.relation.interfaces import IRelationProvider +from rhosocial.activerecord.testsuite.feature.relation.interfaces import IRelationSyncProvider, IRelationAsyncProvider from rhosocial.activerecord.testsuite.feature.relation.fixtures.models import ( Employee, Department, Author, Book, Chapter, Profile, User, Post, Comment, @@ -104,44 +103,22 @@ """ -class RelationProvider(IRelationProvider): - +class RelationProviderBase: def __init__(self): - self._active_backends = [] - self._active_async_backends = [] self._sync_user_post_comment_setup = False self._async_user_post_comment_setup = False self._sync_relation_boundary_setup = False self._async_relation_boundary_setup = False + self._created_tables: Set[str] = set() def get_test_scenarios(self) -> List[str]: return list(get_enabled_scenarios().keys()) - def _execute_script(self, backend, sql: str): - for statement in sql.split(";"): - statement = statement.strip() - if statement: - backend.execute(statement) - - async def _execute_script_async(self, backend, sql: str): - for statement in sql.split(";"): - statement = statement.strip() - if statement: - await backend.execute(statement) - def _configure_with_shared_backend(self, model_class, config, backend_class, backend): model_class.__connection_config__ = config model_class.__backend_class__ = backend_class model_class.__backend__ = backend - def _configure_async_model_without_connection(self, model_class, config, backend=None): - if backend is None: - backend = AsyncPostgresBackend(connection_config=config) - model_class.__connection_config__ = config - model_class.__backend_class__ = AsyncPostgresBackend - model_class.__backend__ = backend - return backend - def _configure_json_field_adapters(self): adapter = PostgresJSONBAdapter() User.__field_adapters__["settings"] = (adapter, str) @@ -151,6 +128,25 @@ def _configure_json_field_adapters(self): AsyncPost.__field_adapters__["metadata"] = (adapter, str) AsyncComment.__field_adapters__["meta"] = (adapter, str) + def _reset_setup_state(self): + self._sync_user_post_comment_setup = False + self._async_user_post_comment_setup = False + self._sync_relation_boundary_setup = False + self._async_relation_boundary_setup = False + + +class RelationSyncProvider(RelationProviderBase, IRelationSyncProvider): + + def __init__(self): + super().__init__() + self._active_backends = [] + + def _execute_script(self, backend, sql: str): + for statement in sql.split(";"): + statement = statement.strip() + if statement: + backend.execute(statement) + def _setup_employee_department(self, scenario_name): backend_class, config = get_scenario(scenario_name) Employee.configure(config, backend_class) @@ -189,23 +185,6 @@ def _setup_user_post_comment_sync(self, scenario_name): self._configure_with_shared_backend(Comment, config, backend_class, backend) self._sync_user_post_comment_setup = True - def _setup_user_post_comment_async(self, scenario_name): - if not self._async_user_post_comment_setup: - self._configure_json_field_adapters() - _, config = get_scenario(scenario_name) - backend = self._configure_async_model_without_connection(AsyncUser, config) - self._configure_async_model_without_connection(AsyncPost, config, backend) - self._configure_async_model_without_connection(AsyncComment, config, backend) - self._active_async_backends.append(backend) - self._async_user_post_comment_setup = True - - async def _ensure_user_post_comment_async_schema(self): - backend = AsyncUser.backend() - if backend not in self._active_async_backends: - self._active_async_backends.append(backend) - await backend.introspect_and_adapt() - await self._execute_script_async(backend, USER_POST_COMMENT_SCHEMA) - def _setup_relation_boundary_sync(self, scenario_name): if not self._sync_relation_boundary_setup: backend_class, config = get_scenario(scenario_name) @@ -216,41 +195,13 @@ def _setup_relation_boundary_sync(self, scenario_name): self._active_backends.append(backend) self._execute_script(backend, RELATION_BOUNDARY_SCHEMA) self._configure_with_shared_backend( - BoundaryProfile, - config, - backend_class, - backend, + BoundaryProfile, config, backend_class, backend, ) self._configure_with_shared_backend( - BoundaryPost, - config, - backend_class, - backend, + BoundaryPost, config, backend_class, backend, ) self._sync_relation_boundary_setup = True - async def _setup_relation_boundary_async(self, scenario_name): - if not self._async_relation_boundary_setup: - _, config = get_scenario(scenario_name) - backend = self._configure_async_model_without_connection( - AsyncBoundaryOwner, - config, - ) - self._configure_async_model_without_connection( - AsyncBoundaryProfile, - config, - backend, - ) - self._configure_async_model_without_connection( - AsyncBoundaryPost, - config, - backend, - ) - self._active_async_backends.append(backend) - await backend.introspect_and_adapt() - await self._execute_script_async(backend, RELATION_BOUNDARY_SCHEMA) - self._async_relation_boundary_setup = True - def setup_employee_department_fixtures( self, scenario_name: str, @@ -260,12 +211,7 @@ def setup_employee_department_fixtures( def setup_author_book_fixtures( self, scenario_name: str, - ) -> Tuple[ - Type[ActiveRecord], - Type[ActiveRecord], - Type[ActiveRecord], - Type[ActiveRecord], - ]: + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: return self._setup_author_book(scenario_name) def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: @@ -280,18 +226,6 @@ def setup_comment_model(self, scenario_name: str) -> Type[ActiveRecord]: self._setup_user_post_comment_sync(scenario_name) return Comment - def setup_async_user_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - self._setup_user_post_comment_async(scenario_name) - return AsyncUser - - def setup_async_post_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - self._setup_user_post_comment_async(scenario_name) - return AsyncPost - - def setup_async_comment_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: - self._setup_user_post_comment_async(scenario_name) - return AsyncComment - def setup_relation_boundary_fixtures( self, scenario_name: str, @@ -299,40 +233,10 @@ def setup_relation_boundary_fixtures( self._setup_relation_boundary_sync(scenario_name) return BoundaryOwner, BoundaryProfile, BoundaryPost - def setup_async_relation_boundary_fixtures( - self, - scenario_name: str, - ) -> Tuple[ - Type[AsyncActiveRecord], - Type[AsyncActiveRecord], - Type[AsyncActiveRecord], - ]: - _, config = get_scenario(scenario_name) - self._configure_async_model_without_connection(AsyncBoundaryOwner, config) - self._configure_async_model_without_connection( - AsyncBoundaryProfile, - config, - AsyncBoundaryOwner.backend(), - ) - self._configure_async_model_without_connection( - AsyncBoundaryPost, - config, - AsyncBoundaryOwner.backend(), - ) - return AsyncBoundaryOwner, AsyncBoundaryProfile, AsyncBoundaryPost - def load_relation_boundary_dataset(self, scenario_name: str, dataset_name: str) -> Dict[str, int]: self._setup_relation_boundary_sync(scenario_name) return self._load_relation_boundary_dataset(dataset_name) - async def load_async_relation_boundary_dataset( - self, - scenario_name: str, - dataset_name: str, - ) -> Dict[str, int]: - await self._setup_relation_boundary_async(scenario_name) - return await self._load_async_relation_boundary_dataset(dataset_name) - def _load_relation_boundary_dataset(self, dataset_name): if dataset_name == "null_foreign_key": profile = BoundaryProfile(bio="No owner", owner_id=None) @@ -365,6 +269,141 @@ def _load_relation_boundary_dataset(self, dataset_name): raise ValueError(f"Unknown relation boundary dataset: {dataset_name}") + def cleanup_after_test(self, scenario_name: str) -> None: + tables_to_drop = [ + 'employees', 'departments', + 'authors', 'books', 'chapters', 'profiles', + 'users', 'posts', 'comments', + 'relation_boundary_owners', 'relation_boundary_profiles', 'relation_boundary_posts', + ] + for backend in self._active_backends: + try: + for table in tables_to_drop: + try: + backend.execute(f'DROP TABLE IF EXISTS "{table}" CASCADE') + except Exception: + pass + finally: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + self._reset_setup_state() + + +class RelationAsyncProvider(RelationProviderBase, IRelationAsyncProvider): + + def __init__(self): + super().__init__() + self._active_async_backends = [] + + def _configure_async_model_without_connection(self, model_class, config, backend=None): + if backend is None: + backend = AsyncPostgresBackend(connection_config=config) + model_class.__connection_config__ = config + model_class.__backend_class__ = AsyncPostgresBackend + model_class.__backend__ = backend + return backend + + async def _execute_script_async(self, backend, sql: str): + for statement in sql.split(";"): + statement = statement.strip() + if statement: + await backend.execute(statement) + + async def _setup_employee_department_fixtures_async(self, scenario_name): + _, config = get_scenario(scenario_name) + await Employee.configure(config, AsyncPostgresBackend) + backend = Employee.backend() + await backend.connect() + await backend.introspect_and_adapt() + self._active_async_backends.append(backend) + await self._execute_script_async(backend, EMPLOYEE_DEPARTMENT_SCHEMA) + self._configure_with_shared_backend(Department, config, AsyncPostgresBackend, backend) + return Employee, Department + + async def _setup_author_book_fixtures_async(self, scenario_name): + _, config = get_scenario(scenario_name) + await Author.configure(config, AsyncPostgresBackend) + backend = Author.backend() + await backend.connect() + await backend.introspect_and_adapt() + self._active_async_backends.append(backend) + await self._execute_script_async(backend, AUTHOR_BOOK_SCHEMA) + self._configure_with_shared_backend(Book, config, AsyncPostgresBackend, backend) + self._configure_with_shared_backend(Chapter, config, AsyncPostgresBackend, backend) + self._configure_with_shared_backend(Profile, config, AsyncPostgresBackend, backend) + return Author, Book, Chapter, Profile + + async def _setup_user_post_comment_async(self, scenario_name): + if not self._async_user_post_comment_setup: + self._configure_json_field_adapters() + _, config = get_scenario(scenario_name) + backend = self._configure_async_model_without_connection(AsyncUser, config) + self._configure_async_model_without_connection(AsyncPost, config, backend) + self._configure_async_model_without_connection(AsyncComment, config, backend) + self._active_async_backends.append(backend) + await backend.introspect_and_adapt() + await self._execute_script_async(backend, USER_POST_COMMENT_SCHEMA) + self._async_user_post_comment_setup = True + + async def _setup_relation_boundary_async(self, scenario_name): + if not self._async_relation_boundary_setup: + _, config = get_scenario(scenario_name) + backend = self._configure_async_model_without_connection( + AsyncBoundaryOwner, config, + ) + self._configure_async_model_without_connection( + AsyncBoundaryProfile, config, backend, + ) + self._configure_async_model_without_connection( + AsyncBoundaryPost, config, backend, + ) + self._active_async_backends.append(backend) + await backend.introspect_and_adapt() + await self._execute_script_async(backend, RELATION_BOUNDARY_SCHEMA) + self._async_relation_boundary_setup = True + + async def setup_employee_department_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: + return await self._setup_employee_department_fixtures_async(scenario_name) + + async def setup_author_book_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + return await self._setup_author_book_fixtures_async(scenario_name) + + async def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + await self._setup_user_post_comment_async(scenario_name) + return AsyncUser + + async def setup_post_model(self, scenario_name: str) -> Type[ActiveRecord]: + await self._setup_user_post_comment_async(scenario_name) + return AsyncPost + + async def setup_comment_model(self, scenario_name: str) -> Type[ActiveRecord]: + await self._setup_user_post_comment_async(scenario_name) + return AsyncComment + + async def setup_relation_boundary_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + await self._setup_relation_boundary_async(scenario_name) + return AsyncBoundaryOwner, AsyncBoundaryProfile, AsyncBoundaryPost + + async def load_relation_boundary_dataset( + self, + scenario_name: str, + dataset_name: str, + ) -> Dict[str, int]: + await self._setup_relation_boundary_async(scenario_name) + return await self._load_async_relation_boundary_dataset(dataset_name) + async def _load_async_relation_boundary_dataset(self, dataset_name): if dataset_name == "null_foreign_key": profile = AsyncBoundaryProfile(bio="No owner", owner_id=None) @@ -397,38 +436,24 @@ async def _load_async_relation_boundary_dataset(self, dataset_name): raise ValueError(f"Unknown relation boundary dataset: {dataset_name}") - def _reset_setup_state(self): - self._sync_user_post_comment_setup = False - self._async_user_post_comment_setup = False - self._sync_relation_boundary_setup = False - self._async_relation_boundary_setup = False - - def cleanup_after_test(self, scenario_name: str) -> None: - for backend in self._active_backends: - try: - backend.disconnect() - except Exception: - pass - self._active_backends.clear() - for backend in self._active_async_backends: - try: - asyncio.run(backend.disconnect()) - except Exception: - pass - self._active_async_backends.clear() - self._reset_setup_state() - - async def cleanup_after_test_async(self, scenario_name: str): - for backend in self._active_backends: - try: - backend.disconnect() - except Exception: - pass - self._active_backends.clear() + async def cleanup_after_test(self, scenario_name: str): + tables_to_drop = [ + 'employees', 'departments', + 'authors', 'books', 'chapters', 'profiles', + 'users', 'posts', 'comments', + 'relation_boundary_owners', 'relation_boundary_profiles', 'relation_boundary_posts', + ] for backend in self._active_async_backends: try: - await backend.disconnect() - except Exception: - pass + for table in tables_to_drop: + try: + await backend.execute(f'DROP TABLE IF EXISTS "{table}" CASCADE') + except Exception: + pass + finally: + try: + await backend.disconnect() + except Exception: + pass self._active_async_backends.clear() self._reset_setup_state() diff --git a/tests/providers/scenarios.py b/tests/providers/scenarios.py index db10b9e0..bdedbd04 100644 --- a/tests/providers/scenarios.py +++ b/tests/providers/scenarios.py @@ -79,10 +79,37 @@ def _load_scenarios_from_config(): for scenario_name, config in config_data['scenarios'].items(): register_scenario(scenario_name, config) + _apply_scenario_filter() + except Exception as e: raise RuntimeError(f"Failed to load or parse scenario configuration file {config_path}: {e}") # noqa: B904 +def _apply_scenario_filter(): + """Filter SCENARIO_MAP based on the active scenarios env var. + + The env var is set either by the --scenarios pytest option in the + testsuite's root conftest (TESTSUITE_ACTIVE_SCENARIOS) or by a backend + local override (POSTGRES_ACTIVE_SCENARIOS). It contains comma-separated + full scenario names (e.g., "postgres_16,postgres_17"). + """ + filter_str = os.getenv("POSTGRES_ACTIVE_SCENARIOS") or os.getenv("TESTSUITE_ACTIVE_SCENARIOS") + if not filter_str: + return + + allowed = set(s.strip() for s in filter_str.split(',') if s.strip()) + if not allowed: + return + + to_remove = [name for name in SCENARIO_MAP if name not in allowed] + for name in to_remove: + del SCENARIO_MAP[name] + + if to_remove: + print(f"Filtered scenarios: kept {list(SCENARIO_MAP.keys())}, " + f"removed {to_remove} (--scenarios={filter_str})") + + def _register_default_scenarios(): """ Registers the scenarios loaded from an external configuration file. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py new file mode 100644 index 00000000..5fee2aa0 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py @@ -0,0 +1,280 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py +"""Tests for ALTER TABLE IF [NOT] EXISTS qualifier rendering on PostgreSQL. + +PostgreSQL supports the vendor extensions since 9.6: + - ADD COLUMN IF NOT EXISTS + - DROP COLUMN IF EXISTS + - DROP CONSTRAINT IF EXISTS + +The qualifiers are opt-in via ``if_not_exists`` / ``if_exists`` on the +``AddColumn`` / ``DropColumn`` / ``DropTableConstraint`` actions; ``None`` +(the default) renders the plain SQL-standard form. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AddColumn, + AlterTableExpression, + DropColumn, + DropTableConstraint, +) +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestPostgresAlterTableModifierCapabilities: + def test_supports_switches(self, dialect): + assert dialect.supports_add_column_if_not_exists() is True + assert dialect.supports_drop_column_if_exists() is True + assert dialect.supports_drop_constraint_if_exists() is True + + +class TestPostgresAddColumnIfNotExists: + def test_if_not_exists_renders_qualifier(self, dialect): + action = AddColumn( + dialect, + ColumnDefinition("content", TextType()), + if_not_exists=True, + ) + sql, params = action.to_sql() + assert 'ADD COLUMN IF NOT EXISTS "content" TEXT' == sql + assert params == () + + def test_none_renders_plain_form(self, dialect): + action = AddColumn(dialect, ColumnDefinition("content", TextType())) + sql, params = action.to_sql() + assert 'ADD COLUMN "content" TEXT' == sql + assert "IF NOT EXISTS" not in sql + assert params == () + + def test_inside_alter_table(self, dialect): + action = AddColumn( + dialect, + ColumnDefinition("content", TextType()), + if_not_exists=True, + ) + expr = AlterTableExpression( + dialect, table_name="users", actions=[action] + ) + sql, params = expr.to_sql() + assert 'ALTER TABLE "users"' in sql + assert 'ADD COLUMN IF NOT EXISTS "content" TEXT' in sql + assert params == () + + +class TestPostgresDropColumnIfExists: + def test_if_exists_renders_qualifier(self, dialect): + action = DropColumn(dialect, column_name="x", if_exists=True) + sql, params = action.to_sql() + assert 'DROP COLUMN IF EXISTS "x"' == sql + assert params == () + + def test_none_renders_plain_form(self, dialect): + action = DropColumn(dialect, column_name="x") + sql, params = action.to_sql() + assert 'DROP COLUMN "x"' == sql + assert "IF EXISTS" not in sql + assert params == () + + +class TestPostgresDropConstraintIfExists: + def test_if_exists_renders_qualifier(self, dialect): + action = DropTableConstraint( + dialect, constraint_name="fkey", if_exists=True + ) + sql, params = action.to_sql() + assert 'DROP CONSTRAINT IF EXISTS "fkey"' == sql + assert params == () + + def test_if_exists_with_cascade(self, dialect): + action = DropTableConstraint( + dialect, constraint_name="fkey", if_exists=True, cascade=True + ) + sql, params = action.to_sql() + assert 'DROP CONSTRAINT IF EXISTS "fkey" CASCADE' == sql + assert params == () + + def test_none_renders_plain_form(self, dialect): + action = DropTableConstraint(dialect, constraint_name="fkey") + sql, params = action.to_sql() + assert 'DROP CONSTRAINT "fkey"' == sql + assert "IF EXISTS" not in sql + assert params == () + + +class TestPostgresAlterColumnTypeAndUsing: + """SET DATA TYPE with the PostgreSQL ``USING`` conversion expression. + + Covered here per plan §6.3: the ALTER COLUMN subclauses added by the + PostgreSQL DDL coverage-completion work must render through the same + ALTER TABLE action pipeline as the IF [NOT] EXISTS qualifiers. + """ + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(15, 0, 0)) + + def test_set_data_type_plain(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterColumn, + ) + + action = AlterColumn( + dialect, "price", "SET DATA TYPE", new_value="NUMERIC(10,2)" + ) + sql, params = action.to_sql() + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' == sql + assert params == () + + def test_set_data_type_with_using(self, dialect): + from rhosocial.activerecord.backend.expression import Column, Literal + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterColumn, + ) + + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + ) + sql, serialized = action.to_sql() + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price" + %s)' in sql + assert serialized == (1,) + + def test_set_data_type_using_rejected_for_other_ops(self, dialect): + from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + AlterColumn, + ) + + action = AlterColumn( + dialect, + "price", + "SET DEFAULT", + new_value="0", + dialect_options={"using": Column(dialect, "price")}, + ) + with pytest.raises(ValueError, match="USING"): + action.to_sql() + + +class TestPostgresRenameColumnAndTable: + """RENAME COLUMN / RENAME TABLE render through the core actions.""" + + def test_rename_column(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + RenameColumn, + ) + + sql, params = RenameColumn( + dialect, old_name="id", new_name="order_id" + ).to_sql() + assert 'RENAME COLUMN "id" TO "order_id"' == sql + assert params == () + + def test_rename_column_inside_alter_table(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + RenameColumn, + ) + + expr = AlterTableExpression( + dialect, + table_name="orders", + actions=[RenameColumn(dialect, old_name="id", new_name="order_id")], + ) + sql, params = expr.to_sql() + assert 'ALTER TABLE "orders"' in sql + assert 'RENAME COLUMN "id" TO "order_id"' in sql + assert params == () + + def test_rename_table(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( + RenameTable, + ) + + sql, params = RenameTable( + dialect, old_name="orders", new_name="orders2" + ).to_sql() + assert 'RENAME TO "orders2"' == sql + assert params == () + + def test_capability_switches(self, dialect): + assert dialect.supports_rename_column() is True + assert dialect.supports_rename_table() is True + + +class TestPostgresCreateUnloggedTable: + """CREATE UNLOGGED TABLE via dialect_options on CreateTableExpression.""" + + def test_unlogged_renders_qualifier(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + expr = CreateTableExpression( + dialect, + table="audit", + columns=[ColumnDefinition("id", TextType())], + dialect_options={"unlogged_table": True}, + ) + sql, params = expr.to_sql() + assert sql.startswith('CREATE UNLOGGED TABLE "audit"') + assert params == () + + def test_plain_omits_qualifier(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + expr = CreateTableExpression( + dialect, + table="audit", + columns=[ColumnDefinition("id", TextType())], + ) + sql, params = expr.to_sql() + assert not sql.startswith("CREATE UNLOGGED") + assert params == () + + def test_temporary_wins_over_unlogged(self, dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + expr = CreateTableExpression( + dialect, + table="audit", + columns=[ColumnDefinition("id", TextType())], + temporary=True, + dialect_options={"unlogged_table": True}, + ) + sql, params = expr.to_sql() + assert sql.startswith("CREATE TEMPORARY TABLE") + assert params == () + + def test_version_gate_94(self): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableExpression, + ) + + low = PostgresDialect(version=(9, 4, 0)) + expr = CreateTableExpression( + low, + table="audit", + columns=[ColumnDefinition("id", TextType())], + dialect_options={"unlogged_table": True}, + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_drop_table_cascade.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_drop_table_cascade.py new file mode 100644 index 00000000..564abdfd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_drop_table_cascade.py @@ -0,0 +1,53 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_drop_table_cascade.py +"""Tests for DROP TABLE ... CASCADE/RESTRICT rendering on PostgreSQL. + +PostgreSQL declares full SQL-standard CASCADE/RESTRICT support; the generic +``TableMixin.format_drop_table_statement`` helper (optimistic True/True) +emits the correct token without backend override. These tests pin that +contract so a future override that drops support is caught early. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import DropTableExpression +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestPostgresDropTableCascade: + def test_capability_switches(self, dialect): + assert dialect.supports_drop_table_cascade() is True + assert dialect.supports_drop_table_restrict() is True + + def test_cascade_renders_standard_token(self, dialect): + expr = DropTableExpression(dialect, table="users", cascade=True) + sql, params = expr.to_sql() + assert sql.endswith(" CASCADE") + assert "CASCADE CONSTRAINTS" not in sql + assert params == () + + def test_restrict_renders_standard_token(self, dialect): + expr = DropTableExpression(dialect, table="users", cascade=False) + sql, params = expr.to_sql() + assert sql.endswith(" RESTRICT") + assert params == () + + def test_cascade_none_omits_token(self, dialect): + expr = DropTableExpression(dialect, table="users", cascade=None) + sql, params = expr.to_sql() + assert "CASCADE" not in sql + assert "RESTRICT" not in sql + assert params == () + + def test_if_exists_combined_with_cascade(self, dialect): + expr = DropTableExpression( + dialect, table="users", if_exists=True, cascade=True + ) + sql, params = expr.to_sql() + assert sql.startswith("DROP TABLE IF EXISTS") + assert sql.endswith(" CASCADE") + assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_insert_on_conflict_clauses.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_insert_on_conflict_clauses.py new file mode 100644 index 00000000..fb83bdf1 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_insert_on_conflict_clauses.py @@ -0,0 +1,110 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_insert_on_conflict_clauses.py +"""Tests for PostgreSQL ON CONFLICT clause capability and rendering. + +Covers: +- Capability switches: single clause supported, multiple clauses rejected. +- Single ON CONFLICT DO NOTHING / DO UPDATE rendering (incl. EXCLUDED). +- Multiple ON CONFLICT clauses rejected by the generic gate. +""" + +import pytest + +from rhosocial.activerecord.backend.dialect import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + Column, + InsertExpression, + Literal, + OnConflictClause, + ValuesSource, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +class TestPostgresOnConflictCapabilities: + """Capability switch tests.""" + + def test_supports_upsert_pg94(self): + assert PostgresDialect((9, 4, 0)).supports_upsert() is False + + def test_supports_upsert_pg95(self): + assert PostgresDialect((9, 5, 0)).supports_upsert() is True + + def test_supports_upsert_modern(self): + assert PostgresDialect((16, 0, 0)).supports_upsert() is True + + def test_supports_on_conflict_clause(self, dialect): + assert dialect.supports_on_conflict_clause() is True + + def test_supports_on_conflict_clause_old_version(self): + assert PostgresDialect((9, 4, 0)).supports_on_conflict_clause() is True + + def test_does_not_support_multiple_on_conflict_clauses(self, dialect): + assert dialect.supports_multiple_on_conflict_clauses() is False + + def test_multiple_on_conflict_clauses_rejected(self, dialect): + """PostgreSQL grammar allows only one ON CONFLICT clause per INSERT.""" + source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) + clause1 = OnConflictClause(dialect, conflict_target=["col_a"], do_nothing=True) + clause2 = OnConflictClause(dialect, conflict_target=["col_b"], do_nothing=True) + expr = InsertExpression(dialect, into="t", source=source, on_conflict=[clause1, clause2]) + + with pytest.raises(UnsupportedFeatureError, match="multiple ON CONFLICT clauses"): + expr.to_sql() + + +class TestPostgresOnConflictRendering: + """SQL rendering tests for a single ON CONFLICT clause.""" + + def test_do_nothing(self, dialect): + source = ValuesSource(dialect, values_list=[[Literal(dialect, 1)]]) + clause = OnConflictClause(dialect, conflict_target=["id"], do_nothing=True) + expr = InsertExpression(dialect, into="users", columns=["id"], source=source, on_conflict=clause) + sql, params = expr.to_sql() + assert sql == 'INSERT INTO "users" ("id") VALUES (%s) ON CONFLICT ("id") DO NOTHING' + assert params == (1,) + + def test_do_update_with_excluded(self, dialect): + """EXCLUDED pseudo-table must not be double-quoted in PostgreSQL.""" + source = ValuesSource( + dialect, values_list=[[Literal(dialect, 1), Literal(dialect, "new_name")]] + ) + clause = OnConflictClause( + dialect, + conflict_target=["id"], + update_assignments={"name": Column(dialect, "name", "EXCLUDED")}, + ) + expr = InsertExpression( + dialect, into="users", columns=["id", "name"], source=source, on_conflict=clause + ) + sql, params = expr.to_sql() + assert sql == ( + 'INSERT INTO "users" ("id", "name") VALUES (%s, %s) ' + 'ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name"' + ) + assert params == (1, "new_name") + + def test_do_update_with_where(self, dialect): + source = ValuesSource( + dialect, values_list=[[Literal(dialect, 1), Literal(dialect, 10)]] + ) + clause = OnConflictClause( + dialect, + conflict_target=["id"], + update_assignments={"qty": Column(dialect, "qty", "EXCLUDED")}, + update_where=Column(dialect, "qty", "users") > Column(dialect, "qty", "EXCLUDED"), + ) + expr = InsertExpression( + dialect, into="users", columns=["id", "qty"], source=source, on_conflict=clause + ) + sql, params = expr.to_sql() + assert sql == ( + 'INSERT INTO "users" ("id", "qty") VALUES (%s, %s) ' + 'ON CONFLICT ("id") DO UPDATE SET "qty" = EXCLUDED."qty" ' + 'WHERE "users"."qty" > "EXCLUDED"."qty"' + ) + assert params == (1, 10) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py index 723d351c..2318f5cf 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_property_graph_query_format.py @@ -52,6 +52,34 @@ def test_graph_support_protocol_implemented_pg15(self): assert isinstance(d, GraphTableSupport) +class TestPGQLimitationGating: + """Tests that PG 19 correctly reports unsupported features.""" + + def test_quantified_path_unsupported_pg19(self): + d = PostgresDialect((19, 0, 0)) + assert d.supports_quantified_path() is False + + def test_quantified_path_unsupported_pg15(self): + d = PostgresDialect((15, 0, 0)) + assert d.supports_quantified_path() is False + + def test_comma_separated_patterns_unsupported_pg19(self): + d = PostgresDialect((19, 0, 0)) + assert d.supports_comma_separated_patterns() is False + + def test_comma_separated_patterns_unsupported_pg15(self): + d = PostgresDialect((15, 0, 0)) + assert d.supports_comma_separated_patterns() is False + + def test_quantified_path_raises_on_format(self): + d = PostgresDialect((19, 0, 0)) + from rhosocial.activerecord.backend.expression import QuantifiedPath + edge = GraphEdge(d, "e", "knows", GraphEdgeDirection.RIGHT) + qp = QuantifiedPath(d, edge) + with pytest.raises(UnsupportedFeatureError): + qp.to_sql() + + @pytest.fixture def pg19_dialect(): return PostgresDialect((19, 0, 0)) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py index 814d970b..b0509aff 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py @@ -33,6 +33,13 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + TABLE_NAME = "test_bloom" ASYNC_TABLE_NAME = "test_bloom_async" @@ -55,13 +62,13 @@ def bloom_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -159,12 +166,12 @@ def test_bloom_index_with_options(self, bloom_env): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="code", data_type="TEXT"), + ColumnDefinition(name="code", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -232,13 +239,13 @@ async def async_bloom_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -336,12 +343,12 @@ async def test_async_bloom_index_with_options(self, async_bloom_env): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="code", data_type="TEXT"), + ColumnDefinition(name="code", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py index a94ded0a..a6fd3461 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py @@ -39,6 +39,15 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresArrayType, +) + @pytest.fixture def btree_gin_env(postgres_backend_single): @@ -51,14 +60,14 @@ def btree_gin_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="value", data_type="INTEGER"), - ColumnDefinition(name="tags", data_type="TEXT[]"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="value", data_type=IntegerType()), + ColumnDefinition(name="tags", data_type=PostgresArrayType(TextType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -206,14 +215,14 @@ async def async_btree_gin_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="value", data_type="INTEGER"), - ColumnDefinition(name="tags", data_type="TEXT[]"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="value", data_type=IntegerType()), + ColumnDefinition(name="tags", data_type=PostgresArrayType(TextType())), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py index 39636320..8e6fc040 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py @@ -41,6 +41,15 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + TextType, + TimestampType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) + @pytest.fixture def btree_gist_env(postgres_backend_single): @@ -53,14 +62,14 @@ def btree_gist_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="created_at", data_type="TIMESTAMP"), - ColumnDefinition(name="value", data_type="INTEGER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="created_at", data_type=TimestampType()), + ColumnDefinition(name="value", data_type=IntegerType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -217,14 +226,14 @@ async def async_btree_gist_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="created_at", data_type="TIMESTAMP"), - ColumnDefinition(name="value", data_type="INTEGER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="created_at", data_type=TimestampType()), + ColumnDefinition(name="value", data_type=IntegerType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py index c87b27d2..19277faf 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py @@ -38,6 +38,14 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresCitextType, +) + # --- Helper functions --- @@ -47,12 +55,12 @@ def _setup_citext_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -81,14 +89,14 @@ def _setup_citext_unique_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( name="email", - data_type="CITEXT", + data_type=PostgresCitextType(), constraints=[ ColumnConstraint(ColumnConstraintType.UNIQUE), ], @@ -121,12 +129,12 @@ def _setup_citext_like_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -158,12 +166,12 @@ def _setup_citext_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -192,13 +200,13 @@ def _setup_citext_roles_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), - ColumnDefinition(name="role", data_type="TEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), + ColumnDefinition(name="role", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -238,12 +246,12 @@ async def _async_setup_citext_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -272,14 +280,14 @@ async def _async_setup_citext_unique_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( name="email", - data_type="CITEXT", + data_type=PostgresCitextType(), constraints=[ ColumnConstraint(ColumnConstraintType.UNIQUE), ], @@ -312,12 +320,12 @@ async def _async_setup_citext_like_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="CITEXT"), + ColumnDefinition(name="name", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -349,12 +357,12 @@ async def _async_setup_citext_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -383,13 +391,13 @@ async def _async_setup_citext_roles_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="CITEXT"), - ColumnDefinition(name="role", data_type="TEXT"), + ColumnDefinition(name="username", data_type=PostgresCitextType()), + ColumnDefinition(name="role", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py index a7be5d5f..d2966b0d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py @@ -38,6 +38,11 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresCubeType, + PostgresSerialType, +) + # --- Sync fixture and tests --- @@ -62,12 +67,12 @@ def cube_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -97,12 +102,12 @@ def cube_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -132,12 +137,12 @@ def cube_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -348,12 +353,12 @@ async def async_cube_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -383,12 +388,12 @@ async def async_cube_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) @@ -418,12 +423,12 @@ async def async_cube_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type="CUBE"), + ColumnDefinition(name="c", data_type=PostgresCubeType()), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py index 3af2872a..90298a2c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py @@ -29,6 +29,11 @@ from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresPointType, + PostgresSerialType, +) + TABLE_NAME = "test_earth" ASYNC_TABLE_NAME = "test_earth_async" @@ -51,12 +56,12 @@ def earthdistance_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="loc", data_type="POINT"), + ColumnDefinition(name="loc", data_type=PostgresPointType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -163,12 +168,12 @@ async def async_earthdistance_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="loc", data_type="POINT"), + ColumnDefinition(name="loc", data_type=PostgresPointType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py index 311d54ee..64be7549 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py @@ -36,6 +36,12 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) TABLE_NAME = "test_fuzzy_lev" @@ -49,12 +55,12 @@ def _setup_lev_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="word", data_type="TEXT"), + ColumnDefinition(name="word", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -86,12 +92,12 @@ def _setup_diff_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -133,12 +139,12 @@ async def _async_setup_lev_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="word", data_type="TEXT"), + ColumnDefinition(name="word", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -170,12 +176,12 @@ async def _async_setup_diff_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py index f8b99261..1530b978 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py @@ -38,6 +38,10 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresHstoreType, +) # --- Helper functions --- @@ -48,12 +52,12 @@ def _make_hstore_columns(): return [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="data", data_type="HSTORE"), + ColumnDefinition(name="data", data_type=PostgresHstoreType()), ] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py index d477159b..7cf7ab52 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py @@ -33,6 +33,12 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) @pytest.fixture @@ -46,13 +52,13 @@ def hypopg_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -262,13 +268,13 @@ async def async_hypopg_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="email", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="email", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py index 8a70b4ff..ccd5a613 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py @@ -39,6 +39,13 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresArrayType, +) # --- Helper functions --- @@ -49,12 +56,12 @@ def _setup_intarray_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -87,12 +94,12 @@ def _setup_intarray_overlap_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -125,12 +132,12 @@ def _setup_intarray_contained_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -163,12 +170,12 @@ def _setup_intarray_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -201,12 +208,12 @@ def _setup_intarray_gin_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -253,12 +260,12 @@ async def _async_setup_intarray_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -291,12 +298,12 @@ async def _async_setup_intarray_overlap_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -329,12 +336,12 @@ async def _async_setup_intarray_contained_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -367,12 +374,12 @@ async def _async_setup_intarray_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, @@ -405,12 +412,12 @@ async def _async_setup_intarray_gin_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type="INTEGER[]"), + ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py index d0c56ecc..6a968c93 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py @@ -37,6 +37,13 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresLtreeType, +) # --- Sync fixture and tests --- @@ -63,12 +70,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -101,13 +108,13 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) @@ -140,12 +147,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -172,12 +179,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -220,12 +227,12 @@ def ltree_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -569,12 +576,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -607,13 +614,13 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) @@ -646,12 +653,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -678,12 +685,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) @@ -726,12 +733,12 @@ async def async_ltree_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type="LTREE"), + ColumnDefinition(name="path", data_type=PostgresLtreeType()), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py index 59463877..eb0243b8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py @@ -38,6 +38,14 @@ ensure_extension_installed, async_ensure_extension_installed, ) +from rhosocial.activerecord.backend.expression.types import ( + TextType, + DateType, + DecimalType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) TABLE_NAME = "test_orafce_dates" @@ -55,16 +63,16 @@ def orafce_backend(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="dt", data_type="DATE"), - ColumnDefinition(name="ref_dt", data_type="DATE"), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="discount", data_type="NUMERIC"), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="dt", data_type=DateType()), + ColumnDefinition(name="ref_dt", data_type=DateType()), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="discount", data_type=DecimalType()), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -316,16 +324,16 @@ async def async_orafce_backend(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="dt", data_type="DATE"), - ColumnDefinition(name="ref_dt", data_type="DATE"), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="discount", data_type="NUMERIC"), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="dt", data_type=DateType()), + ColumnDefinition(name="ref_dt", data_type=DateType()), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="discount", data_type=DecimalType()), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py index 29c0be98..b022fb98 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py @@ -36,6 +36,12 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) # Table names for sync tests @@ -56,12 +62,12 @@ def _setup_trgm_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -93,12 +99,12 @@ def _setup_trgm_ops_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -134,12 +140,12 @@ def _setup_trgm_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -174,12 +180,12 @@ def _setup_trgm_word_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -219,12 +225,12 @@ async def _async_setup_trgm_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -256,12 +262,12 @@ async def _async_setup_trgm_ops_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -297,12 +303,12 @@ async def _async_setup_trgm_idx_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -337,12 +343,12 @@ async def _async_setup_trgm_word_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="description", data_type="TEXT"), + ColumnDefinition(name="description", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py index 5a6e331f..7005a7dd 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py @@ -36,6 +36,13 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresByteaType, +) # Table names for sync tests @@ -52,13 +59,13 @@ def _setup_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="TEXT"), - ColumnDefinition(name="password_hash", data_type="TEXT"), + ColumnDefinition(name="username", data_type=TextType()), + ColumnDefinition(name="password_hash", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -93,12 +100,12 @@ def _setup_encrypt_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="secret_data", data_type="BYTEA"), + ColumnDefinition(name="secret_data", data_type=PostgresByteaType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -144,13 +151,13 @@ async def _async_setup_users_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type="TEXT"), - ColumnDefinition(name="password_hash", data_type="TEXT"), + ColumnDefinition(name="username", data_type=TextType()), + ColumnDefinition(name="password_hash", data_type=TextType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -185,12 +192,12 @@ async def _async_setup_encrypt_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="secret_data", data_type="BYTEA"), + ColumnDefinition(name="secret_data", data_type=PostgresByteaType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py index 4452dc02..622b86d7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py @@ -30,6 +30,13 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + FloatType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) @pytest.fixture @@ -43,19 +50,19 @@ def pgrouting_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="source", data_type="INT"), - ColumnDefinition(name="target", data_type="INT"), - ColumnDefinition(name="cost", data_type="FLOAT"), - ColumnDefinition(name="reverse_cost", data_type="FLOAT"), - ColumnDefinition(name="x1", data_type="FLOAT"), - ColumnDefinition(name="y1", data_type="FLOAT"), - ColumnDefinition(name="x2", data_type="FLOAT"), - ColumnDefinition(name="y2", data_type="FLOAT"), + ColumnDefinition(name="source", data_type=IntegerType()), + ColumnDefinition(name="target", data_type=IntegerType()), + ColumnDefinition(name="cost", data_type=FloatType()), + ColumnDefinition(name="reverse_cost", data_type=FloatType()), + ColumnDefinition(name="x1", data_type=FloatType()), + ColumnDefinition(name="y1", data_type=FloatType()), + ColumnDefinition(name="x2", data_type=FloatType()), + ColumnDefinition(name="y2", data_type=FloatType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -172,19 +179,19 @@ async def async_pgrouting_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="source", data_type="INT"), - ColumnDefinition(name="target", data_type="INT"), - ColumnDefinition(name="cost", data_type="FLOAT"), - ColumnDefinition(name="reverse_cost", data_type="FLOAT"), - ColumnDefinition(name="x1", data_type="FLOAT"), - ColumnDefinition(name="y1", data_type="FLOAT"), - ColumnDefinition(name="x2", data_type="FLOAT"), - ColumnDefinition(name="y2", data_type="FLOAT"), + ColumnDefinition(name="source", data_type=IntegerType()), + ColumnDefinition(name="target", data_type=IntegerType()), + ColumnDefinition(name="cost", data_type=FloatType()), + ColumnDefinition(name="reverse_cost", data_type=FloatType()), + ColumnDefinition(name="x1", data_type=FloatType()), + ColumnDefinition(name="y1", data_type=FloatType()), + ColumnDefinition(name="x2", data_type=FloatType()), + ColumnDefinition(name="y2", data_type=FloatType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py index 8a775349..1541c41b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py @@ -37,6 +37,10 @@ from rhosocial.activerecord.backend.expression.query_parts import OrderByClause from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresVectorType, +) # --- Helper: common VECTOR(3) column definitions --- @@ -45,7 +49,7 @@ def _id_column(): return ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], @@ -53,7 +57,7 @@ def _id_column(): def _embedding_column(dim=3): - return ColumnDefinition(name="embedding", data_type=f"VECTOR({dim})") + return ColumnDefinition(name="embedding", data_type=PostgresVectorType(dim)) def _vector_literal(value, vector_type="vector"): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py index aefaf055..a0a89473 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py @@ -33,6 +33,11 @@ from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresGeometryType, + PostgresGeographyType, +) # --- Helper functions --- @@ -43,12 +48,12 @@ def _setup_geo_points_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -80,12 +85,12 @@ def _setup_geo_distance_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -117,12 +122,12 @@ def _setup_geo_contains_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POLYGON, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -160,12 +165,12 @@ def _setup_geo_index_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -207,12 +212,12 @@ def _setup_geog_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geog", data_type="GEOGRAPHY(POINT, 4326)"), + ColumnDefinition(name="geog", data_type=PostgresGeographyType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -254,12 +259,12 @@ async def _async_setup_geo_points_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -291,12 +296,12 @@ async def _async_setup_geo_distance_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -328,12 +333,12 @@ async def _async_setup_geo_contains_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POLYGON, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -371,12 +376,12 @@ async def _async_setup_geo_index_table(backend, dialect, table, index_name): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type="GEOMETRY(POINT, 4326)"), + ColumnDefinition(name="geom", data_type=PostgresGeometryType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -418,12 +423,12 @@ async def _async_setup_geog_table(backend, dialect, table): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geog", data_type="GEOGRAPHY(POINT, 4326)"), + ColumnDefinition(name="geog", data_type=PostgresGeographyType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py index 0f1fef5d..e5b462e2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py @@ -36,6 +36,13 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, + PostgresRasterType, +) TABLE_NAME = "test_raster_data" @@ -83,13 +90,13 @@ def raster_env(postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="rast", data_type="RASTER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="rast", data_type=PostgresRasterType()), ] create_expr = CreateTableExpression( dialect=dialect, @@ -202,13 +209,13 @@ async def async_raster_env(async_postgres_backend_single): columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type="TEXT"), - ColumnDefinition(name="rast", data_type="RASTER"), + ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(name="rast", data_type=PostgresRasterType()), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py index b5e58b99..7b1fa9df 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py @@ -36,6 +36,12 @@ ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.types import ( + TextType, IntegerType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresSerialType, +) from rhosocial.activerecord.backend.impl.postgres.functions.tablefunc import ( normal_rand, crosstab, @@ -56,14 +62,14 @@ def tablefunc_env(postgres_backend_single): sales_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="month", data_type="TEXT"), - ColumnDefinition(name="category", data_type="TEXT"), - ColumnDefinition(name="amount", data_type="INT"), + ColumnDefinition(name="month", data_type=TextType()), + ColumnDefinition(name="category", data_type=TextType()), + ColumnDefinition(name="amount", data_type=IntegerType()), ] create_sales = CreateTableExpression( dialect=dialect, @@ -96,13 +102,13 @@ def tablefunc_env(postgres_backend_single): tree_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="node_name", data_type="TEXT"), - ColumnDefinition(name="parent_id", data_type="INT"), + ColumnDefinition(name="node_name", data_type=TextType()), + ColumnDefinition(name="parent_id", data_type=IntegerType()), ] create_tree = CreateTableExpression( dialect=dialect, @@ -334,14 +340,14 @@ async def async_tablefunc_env(async_postgres_backend_single): sales_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="month", data_type="TEXT"), - ColumnDefinition(name="category", data_type="TEXT"), - ColumnDefinition(name="amount", data_type="INT"), + ColumnDefinition(name="month", data_type=TextType()), + ColumnDefinition(name="category", data_type=TextType()), + ColumnDefinition(name="amount", data_type=IntegerType()), ] create_sales = CreateTableExpression( dialect=dialect, @@ -374,13 +380,13 @@ async def async_tablefunc_env(async_postgres_backend_single): tree_columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=PostgresSerialType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="node_name", data_type="TEXT"), - ColumnDefinition(name="parent_id", data_type="INT"), + ColumnDefinition(name="node_name", data_type=TextType()), + ColumnDefinition(name="parent_id", data_type=IntegerType()), ] create_tree = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py index 9f409c00..388bee98 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py @@ -33,6 +33,12 @@ from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal, FunctionCall from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresUUIDType, +) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -71,7 +77,7 @@ def uuid_ossp_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ColumnConstraint( @@ -80,7 +86,7 @@ def uuid_ossp_env(postgres_backend_single): ), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ], if_not_exists=True, ) @@ -107,12 +113,12 @@ def uuid_ossp_env(postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) @@ -276,7 +282,7 @@ async def async_uuid_ossp_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ColumnConstraint( @@ -285,7 +291,7 @@ async def async_uuid_ossp_env(async_postgres_backend_single): ), ], ), - ColumnDefinition(name="name", data_type="TEXT"), + ColumnDefinition(name="name", data_type=TextType()), ], if_not_exists=True, ) @@ -312,12 +318,12 @@ async def async_uuid_ossp_env(async_postgres_backend_single): columns=[ ColumnDefinition( name="id", - data_type="UUID", + data_type=PostgresUUIDType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="label", data_type="TEXT"), + ColumnDefinition(name="label", data_type=TextType()), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py new file mode 100644 index 00000000..7dd8ea9e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py @@ -0,0 +1,211 @@ +"""Tests for PostgreSQL core type → SQL string mappings.""" + +import pytest + + + +@pytest.fixture +def dialect(): + # Import directly to avoid triggering backend init which requires psycopg + from rhosocial.activerecord.backend.impl.postgres.mixins.types import ( + PostgresTypeFormatSupportMixin, + ) + from rhosocial.activerecord.backend.dialect.base import SQLDialectBase + + # Build a minimal dialect class that only has the type formatting + class TestPGDialect(PostgresTypeFormatSupportMixin, SQLDialectBase): + pass + + d = TestPGDialect() + d.version = (16, 0, 0) + return d + + +class TestCoreTypeMappings: + """Verify core DataType → SQL rendering via PostgreSQL dialect.""" + + # The five critical mappings + def test_boolean_to_boolean(self, dialect): + from rhosocial.activerecord.backend.expression.types import BooleanType + assert BooleanType().to_sql(dialect) == ("BOOLEAN", ()) + + def test_tinyint_to_smallint(self, dialect): + from rhosocial.activerecord.backend.expression.types import TinyIntType + assert TinyIntType().to_sql(dialect) == ("SMALLINT", ()) + + def test_blob_to_bytea(self, dialect): + from rhosocial.activerecord.backend.expression.types import BlobType + assert BlobType().to_sql(dialect) == ("BYTEA", ()) + + def test_datetime_to_timestamp(self, dialect): + from rhosocial.activerecord.backend.expression.types import DateTimeType + assert DateTimeType().to_sql(dialect) == ("TIMESTAMP", ()) + + def test_int_to_integer(self, dialect): + from rhosocial.activerecord.backend.expression.types import IntType + assert IntType().to_sql(dialect) == ("INTEGER", ()) + + # Standard integer family + def test_smallint(self, dialect): + from rhosocial.activerecord.backend.expression.types import SmallIntType + assert SmallIntType().to_sql(dialect) == ("SMALLINT", ()) + + def test_integer(self, dialect): + from rhosocial.activerecord.backend.expression.types import IntegerType + assert IntegerType().to_sql(dialect) == ("INTEGER", ()) + + def test_bigint(self, dialect): + from rhosocial.activerecord.backend.expression.types import BigIntType + assert BigIntType().to_sql(dialect) == ("BIGINT", ()) + + # Numeric family + def test_real(self, dialect): + from rhosocial.activerecord.backend.expression.types import RealType + assert RealType().to_sql(dialect) == ("REAL", ()) + + def test_double(self, dialect): + from rhosocial.activerecord.backend.expression.types import DoubleType + assert DoubleType().to_sql(dialect) == ("DOUBLE PRECISION", ()) + + def test_numeric(self, dialect): + from rhosocial.activerecord.backend.expression.types import DecimalType + assert DecimalType(10, 2).to_sql(dialect) == ("DECIMAL(10,2)", ()) + assert DecimalType().to_sql(dialect) == ("DECIMAL", ()) + + # String family + def test_varchar(self, dialect): + from rhosocial.activerecord.backend.expression.types import VarCharType + assert VarCharType(255).to_sql(dialect) == ("VARCHAR(255)", ()) + assert VarCharType().to_sql(dialect) == ("VARCHAR", ()) + + def test_text(self, dialect): + from rhosocial.activerecord.backend.expression.types import TextType + assert TextType().to_sql(dialect) == ("TEXT", ()) + + # JSON family + def test_json(self, dialect): + from rhosocial.activerecord.backend.expression.types import JsonType + assert JsonType().to_sql(dialect) == ("JSON", ()) + + def test_jsonb(self, dialect): + from rhosocial.activerecord.backend.expression.types import JsonBType + assert JsonBType().to_sql(dialect) == ("JSONB", ()) + + +class TestArrayType: + """PostgreSQL array type (T[]) rendering and parsing.""" + + def test_array_1d_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + arr = PostgresArrayType(IntegerType()) + assert arr.to_sql(dialect) == ("INTEGER[]", ()) + + def test_array_2d_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + arr = PostgresArrayType(IntegerType(), dimensions=2) + assert arr.to_sql(dialect) == ("INTEGER[][]", ()) + + def test_array_varchar_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import VarCharType + arr = PostgresArrayType(VarCharType(255)) + assert arr.to_sql(dialect) == ("VARCHAR(255)[]", ()) + + def test_array_boolean_rendering(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import BooleanType + arr = PostgresArrayType(BooleanType()) + assert arr.to_sql(dialect) == ("BOOLEAN[]", ()) + + def test_array_equality(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, + ) + a1 = PostgresArrayType(IntegerType(), 2) + a2 = PostgresArrayType(IntegerType(), 2) + a3 = PostgresArrayType(VarCharType(255), 2) + assert a1 == a2 + assert a1 != a3 + assert hash(a1) == hash(a2) + # PostgresArrayType.is_equivalent ignores dimensions + a4 = PostgresArrayType(IntegerType(), 1) + assert a1.is_equivalent(a4) + + def test_parse_array_bracket_suffix(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER[]") + assert isinstance(result, PostgresArrayType) + assert result.dimensions == 1 + assert isinstance(result.element_type, IntegerType) + + def test_parse_array_2d_bracket_suffix(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER[][]") + assert isinstance(result, PostgresArrayType) + # PG normalises all array declarations to 1-D internally + assert result.dimensions == 1 + assert isinstance(result.element_type, IntegerType) + + def test_parse_array_keyword(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER ARRAY") + assert isinstance(result, PostgresArrayType) + assert result.dimensions == 1 + assert isinstance(result.element_type, IntegerType) + + def test_parse_array_varchar(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import VarCharType + result = dialect.parse_type("VARCHAR(255)[]") + assert isinstance(result, PostgresArrayType) + assert result.dimensions == 1 + assert isinstance(result.element_type, VarCharType) + assert result.element_type.length == 255 + + def test_parse_non_array_passthrough(self, dialect): + from rhosocial.activerecord.backend.expression.types import IntegerType + result = dialect.parse_type("INTEGER") + assert isinstance(result, IntegerType) + + def test_is_element_type_equivalent(self, dialect): + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + ) + from rhosocial.activerecord.backend.expression.types import ( + IntegerType, SmallIntType, + ) + a1 = PostgresArrayType(IntegerType(), 2) + a2 = PostgresArrayType(IntegerType(), 1) # different dimension + a3 = PostgresArrayType(SmallIntType()) # different element type + # Same element, different dimension → equivalent + assert a1.is_element_type_equivalent(a2) + # Different element → not equivalent + assert not a1.is_element_type_equivalent(a3) + # Plain IntegerType (not array) as other → matches element + assert a1.is_element_type_equivalent(IntegerType()) + # Plain SmallIntType as other → doesn't match Integer element + assert not a1.is_element_type_equivalent(SmallIntType()) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py new file mode 100644 index 00000000..c44a8926 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -0,0 +1,368 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +"""Tests for PostgreSQL DDL expression classes. + +This module tests the expression-based format methods for DDL operations, +including materialized view refresh, comment, and partition expressions. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + AddColumn, + AlterColumn, + ColumnDefinition, + DropColumn, + DropTableConstraint, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresVacuumExpression, # noqa: F401 + PostgresAnalyzeExpression, # noqa: F401 + LoggingMode, + RlsConfigurationMode, + PostgresAlterTableRlsExpression, + PostgresForceRlsExpression, + PostgresAlterTableSettingsExpression, + PostgresClusterExpression, +) +from rhosocial.activerecord.backend.expression.types import ( + TextType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( + PostgresExtendedStatisticsMixin, # noqa: F401 +) + +class TestPostgresRlsConfigExpression: + """Test RLS enable/disable/always/force DDL expressions.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_enable(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE + ).to_sql() + assert sql == 'ALTER TABLE "orders" ENABLE ROW LEVEL SECURITY' + + def test_disable(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.DISABLE + ).to_sql() + assert sql == 'ALTER TABLE "orders" DISABLE ROW LEVEL SECURITY' + + def test_enable_always(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE, always=True + ).to_sql() + assert sql == 'ALTER TABLE "orders" ENABLE ALWAYS ROW LEVEL SECURITY' + + def test_schema_qualify(self, dialect): + sql, _ = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.ENABLE, schema="app" + ).to_sql() + assert sql == 'ALTER TABLE "app"."orders" ENABLE ROW LEVEL SECURITY' + + def test_force(self, dialect): + sql, _ = PostgresForceRlsExpression(dialect, "orders").to_sql() + assert sql == 'ALTER TABLE "orders" FORCE ROW LEVEL SECURITY' + + def test_no_force(self, dialect): + sql, _ = PostgresForceRlsExpression(dialect, "orders", force=False).to_sql() + assert sql == 'ALTER TABLE "orders" NO FORCE ROW LEVEL SECURITY' + + def test_version_gate_94(self): + d = PostgresDialect(version=(9, 4, 0)) + expr = PostgresAlterTableRlsExpression(d, "orders", RlsConfigurationMode.ENABLE) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_disable_with_always_raises(self, dialect): + expr = PostgresAlterTableRlsExpression( + dialect, "orders", RlsConfigurationMode.DISABLE, always=True + ) + with pytest.raises(ValueError, match="ALWAYS"): + expr.to_sql() + + def test_force_version_gate_94(self): + d = PostgresDialect(version=(9, 4, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresForceRlsExpression(d, "orders").to_sql() + + +class TestPostgresAlterTableSettingsExpression: + """SET LOGGED / UNLOGGED / ACCESS METHOD DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(15, 0, 0)) + + def test_unlogged(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", mode=LoggingMode.UNLOGGED + ).to_sql() + assert sql == 'ALTER TABLE "t" SET UNLOGGED' + + def test_logged_schema(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", schema="s", mode=LoggingMode.LOGGED + ).to_sql() + assert sql == 'ALTER TABLE "s"."t" SET LOGGED' + + def test_access_method(self, dialect): + sql, _ = PostgresAlterTableSettingsExpression( + dialect, "t", access_method="heap" + ).to_sql() + assert sql == 'ALTER TABLE "t" SET ACCESS METHOD "heap"' + + def test_access_method_requires_15(self): + d = PostgresDialect(version=(14, 0, 0)) + expr = PostgresAlterTableSettingsExpression(d, "t", access_method="heap") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_no_clause_raises(self, dialect): + expr = PostgresAlterTableSettingsExpression(dialect, "t") + with pytest.raises(ValueError): + expr.to_sql() + + def test_both_clauses_raises(self, dialect): + expr = PostgresAlterTableSettingsExpression( + dialect, "t", mode=LoggingMode.UNLOGGED, access_method="heap" + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_logging_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + expr = PostgresAlterTableSettingsExpression( + d, "t", mode=LoggingMode.LOGGED + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +class TestPostgresClusterExpression: + """CLUSTER DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_full(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", schema="public", + using_index="orders_pkey", verbose=True, + ).to_sql() + assert sql == 'CLUSTER VERBOSE "public"."orders" USING "orders_pkey"' + + def test_bare(self, dialect): + sql, _ = PostgresClusterExpression(dialect, verbose=True).to_sql() + assert sql == "CLUSTER VERBOSE" + + def test_version_gate_95(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresClusterExpression(d, "t").to_sql() + + def test_without_verbose(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", using_index="orders_pkey" + ).to_sql() + assert sql == 'CLUSTER "orders" USING "orders_pkey"' + + def test_without_index(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", verbose=True + ).to_sql() + assert sql == 'CLUSTER VERBOSE "orders"' + + def test_verbose_false_schema(self, dialect): + sql, _ = PostgresClusterExpression( + dialect, "orders", schema="app", verbose=False + ).to_sql() + assert sql == 'CLUSTER "app"."orders"' + + +class TestPostgresAlterColumnUsingExpression: + """ALTER COLUMN ... SET DATA TYPE ... USING.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_using_clause(self, dialect): + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, + ) + sql, serialized = dialect.format_alter_column_action(action) + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price" + %s)' in sql + assert serialized == (1,) + + def test_using_clause_with_cascade(self, dialect): + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + cascade=True, + dialect_options={"using": Column(dialect, "price")}, + ) + sql, serialized = dialect.format_alter_column_action(action) + assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql + assert 'USING ("price")' in sql + assert sql.endswith(" CASCADE") + + def test_using_rejected_for_non_set_data_type(self, dialect): + """USING is only valid on SET DATA TYPE.""" + action = AlterColumn( + dialect, + "price", + "SET DEFAULT", + new_value="0", + dialect_options={"using": Column(dialect, "price")}, + ) + with pytest.raises(ValueError, match="USING"): + dialect.format_alter_column_action(action) + + def test_without_using_unaffected(self, dialect): + """No USING -> output matches the standard form.""" + action = AlterColumn( + dialect, + "price", + "SET DATA TYPE", + new_value="NUMERIC(10,2)", + ) + sql, _ = dialect.format_alter_column_action(action) + assert sql == 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' + + +class TestPostgresAlterColumnModifierMixin: + """ADD/DROP COLUMN and DROP CONSTRAINT IF [NOT] EXISTS qualifiers.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_capabilities(self, dialect): + assert dialect.supports_add_column_if_not_exists() is True + assert dialect.supports_drop_column_if_exists() is True + assert dialect.supports_drop_constraint_if_exists() is True + + def test_add_column(self, dialect): + column = ColumnDefinition( + "email", TextType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + sql, params = dialect.format_add_column_action( + AddColumn(dialect, column) + ) + assert sql == 'ADD COLUMN "email" TEXT NOT NULL' + assert params == () + + def test_add_column_if_not_exists(self, dialect): + column = ColumnDefinition("email", TextType()) + sql, _ = dialect.format_add_column_action( + AddColumn(dialect, column, if_not_exists=True) + ) + assert sql == 'ADD COLUMN IF NOT EXISTS "email" TEXT' + + def test_drop_column(self, dialect): + sql, _ = dialect.format_drop_column_action( + DropColumn(dialect, "email") + ) + assert sql == 'DROP COLUMN "email"' + + def test_drop_column_if_exists(self, dialect): + sql, _ = dialect.format_drop_column_action( + DropColumn(dialect, "email", if_exists=True) + ) + assert sql == 'DROP COLUMN IF EXISTS "email"' + + def test_drop_constraint(self, dialect): + sql, _ = dialect.format_drop_table_constraint_action( + DropTableConstraint(dialect, "user_email_key") + ) + assert sql == 'DROP CONSTRAINT "user_email_key"' + + def test_drop_constraint_if_exists(self, dialect): + sql, _ = dialect.format_drop_table_constraint_action( + DropTableConstraint(dialect, "user_email_key", if_exists=True) + ) + assert sql == 'DROP CONSTRAINT IF EXISTS "user_email_key"' + + def test_drop_constraint_if_exists_cascade(self, dialect): + sql, _ = dialect.format_drop_table_constraint_action( + DropTableConstraint( + dialect, "user_email_key", if_exists=True, cascade=True + ) + ) + assert sql == 'DROP CONSTRAINT IF EXISTS "user_email_key" CASCADE' + + +class TestPostgresConstraintCapabilities: + """PostgreSQL-only constraint capability switches.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_capabilities(self, dialect): + assert dialect.supports_constraint_novalidate() is True + assert dialect.supports_exclude_constraint() is True + assert dialect.supports_drop_constraint_if_exists() is True + + +class TestPostgresTypeDDL: + """CREATE/DROP TYPE capability switches and formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_capabilities(self, dialect): + assert dialect.supports_create_type() is True + assert dialect.supports_drop_type() is True + assert dialect.supports_type_if_not_exists() is False + assert dialect.supports_type_if_exists() is True + assert dialect.supports_type_cascade() is True + + def test_create_enum(self, dialect): + sql, params = dialect.format_create_type_enum_statement( + "color", ["red", "blue"] + ) + assert sql == 'CREATE TYPE "color" AS ENUM (\'red\', \'blue\')' + assert params == () + + def test_create_enum_with_schema(self, dialect): + sql, _ = dialect.format_create_type_enum_statement( + "color", ["red"], schema="app" + ) + assert sql == 'CREATE TYPE "app"."color" AS ENUM (\'red\')' + + def test_create_enum_empty_values_raises(self, dialect): + with pytest.raises(ValueError, match="at least one value"): + dialect.format_create_type_enum_statement("color", []) + + def test_drop_type(self, dialect): + sql, _ = dialect.format_drop_type_statement("color") + assert sql == 'DROP TYPE "color"' + + def test_drop_type_full(self, dialect): + sql, _ = dialect.format_drop_type_statement( + "color", schema="app", if_exists=True, cascade=True + ) + assert sql == 'DROP TYPE IF EXISTS "app"."color" CASCADE' + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index 28c02950..22231b29 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -31,12 +31,17 @@ PostgresPgPartmanUpdateConfigExpression, PostgresVacuumExpression, # noqa: F401 PostgresAnalyzeExpression, # noqa: F401 + ) +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, TextType, TimestampType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, ) from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( PostgresExtendedStatisticsMixin, # noqa: F401 ) - class TestPostgresRefreshMaterializedViewExpression: """Test PostgresRefreshMaterializedViewExpression.""" @@ -242,8 +247,11 @@ def test_create_range_partitioned_parent_table(self, dialect): dialect=dialect, table="events", columns=[ - ColumnDefinition("id", "BIGINT"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("id", BigIntType()), + ColumnDefinition( + "created_at", TimestampType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), ], partition=PartitionClause( dialect=dialect, @@ -263,8 +271,8 @@ def test_create_list_partitioned_parent_table(self, dialect): dialect=dialect, table="events", columns=[ - ColumnDefinition("id", "BIGINT"), - ColumnDefinition("status", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType()), + ColumnDefinition("status", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -283,7 +291,12 @@ def test_create_hash_partitioned_parent_table_pg10(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("tenant_id", "BIGINT NOT NULL")], + columns=[ + ColumnDefinition( + "tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + ], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, @@ -300,7 +313,12 @@ def test_create_hash_partitioned_parent_table_pg11(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("tenant_id", "BIGINT NOT NULL")], + columns=[ + ColumnDefinition( + "tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + ], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.HASH, @@ -318,7 +336,12 @@ def test_create_partitioned_parent_table_pg9(self): expr = CreateTableExpression( dialect=dialect, table="events", - columns=[ColumnDefinition("created_at", "TIMESTAMP NOT NULL")], + columns=[ + ColumnDefinition( + "created_at", TimestampType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ) + ], partition=PartitionClause( dialect=dialect, method=PartitionStrategy.RANGE, @@ -344,9 +367,15 @@ def test_multi_column_range_partitioned_parent_table(self, dialect): dialect=dialect, table="tenanted_events", columns=[ - ColumnDefinition("tenant_id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT"), + ColumnDefinition( + "tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition( + "created_at", TimestampType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + ), + ColumnDefinition("payload", TextType()), ], partition=PartitionClause( dialect=dialect, @@ -945,3 +974,4 @@ def test_run_maintenance_global_expression(self, dialect): sql, params = expr.to_sql() assert sql == 'SELECT "partman"."run_maintenance"()' assert params == () + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py new file mode 100644 index 00000000..b8032b8e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_objects.py @@ -0,0 +1,495 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +"""Tests for PostgreSQL DDL expression classes. + +This module tests the expression-based format methods for DDL operations, +including materialized view refresh, comment, and partition expressions. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( + PostgresVacuumExpression, # noqa: F401 + PostgresAnalyzeExpression, # noqa: F401 + AlterDomainActionType, + PostgresCreateDomainExpression, + PostgresAlterDomainExpression, + PostgresDropDomainExpression, + PostgresCreateCollationExpression, + PostgresDropCollationExpression, + PostgresCreateForeignTableExpression, + PostgresDropForeignTableExpression, + PostgresCreateFunctionExpression, + PostgresDropFunctionExpression, + PostgresCreateAggregateExpression, + PostgresDropAggregateExpression, + PostgresCreatePublicationExpression, + PostgresDropPublicationExpression, + PostgresCreateSubscriptionExpression, + PostgresDropSubscriptionExpression, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.dml.extended_statistics import ( + PostgresExtendedStatisticsMixin, # noqa: F401 +) + +class TestPostgresDomainExpression: + """CREATE / ALTER / DROP DOMAIN.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateDomainExpression( + dialect, "posint", "NUMERIC", + default="0", constraints=["CHECK (VALUE > 0)"], + ).to_sql() + assert sql == "CREATE DOMAIN \"posint\" AS NUMERIC DEFAULT 0 CHECK (VALUE > 0)" + + def test_alter_set_default(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.SET_DEFAULT, new_value="0" + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" SET DEFAULT 0' + + def test_alter_rename(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.RENAME_TO, new_name="posint2" + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" RENAME TO "posint2"' + + def test_drop(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, "posint", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP DOMAIN IF EXISTS "posint" CASCADE' + + def test_create_with_schema_and_collation(self, dialect): + sql, _ = PostgresCreateDomainExpression( + dialect, "posint", "NUMERIC", + schema="app", collation="C", + ).to_sql() + assert sql == 'CREATE DOMAIN "app"."posint" AS NUMERIC COLLATE C' + + def test_create_without_default(self, dialect): + sql, _ = PostgresCreateDomainExpression( + dialect, "posint", "NUMERIC", constraints=["CHECK (VALUE > 0)"] + ).to_sql() + assert sql == 'CREATE DOMAIN "posint" AS NUMERIC CHECK (VALUE > 0)' + + def test_alter_drop_default(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.DROP_DEFAULT + ).to_sql() + assert sql == 'ALTER DOMAIN "posint" DROP DEFAULT' + + def test_alter_set_default_with_schema(self, dialect): + sql, _ = PostgresAlterDomainExpression( + dialect, "posint", AlterDomainActionType.SET_DEFAULT, + schema="app", new_value="1", + ).to_sql() + assert sql == 'ALTER DOMAIN "app"."posint" SET DEFAULT 1' + + def test_alter_unsupported_action_raises(self, dialect): + expr = PostgresAlterDomainExpression(dialect, "posint", "RANDOM") + with pytest.raises(ValueError, match="Unsupported ALTER DOMAIN"): + expr.to_sql() + + def test_drop_restrict(self, dialect): + sql, _ = PostgresDropDomainExpression( + dialect, "posint", schema="app", restrict=True + ).to_sql() + assert sql == 'DROP DOMAIN "app"."posint" RESTRICT' + + def test_drop_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropDomainExpression( + dialect, "posint", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateDomainExpression(d, "posint", "INTEGER").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresAlterDomainExpression( + d, "posint", AlterDomainActionType.SET_DEFAULT, new_value="0" + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropDomainExpression(d, "posint").to_sql() + + +class TestPostgresCollationDDLExpression: + """CREATE / DROP COLLATION DDL.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "enloc", locale="en_US.UTF-8", provider="libc" + ).to_sql() + assert sql == 'CREATE COLLATION "enloc" (LOCALE = en_US.UTF-8, PROVIDER = libc)' + + def test_create_if_not_exists(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "myloc", if_not_exists=True, lc_collate="en_US", + ).to_sql() + assert sql == 'CREATE COLLATION IF NOT EXISTS "myloc" (LC_COLLATE = en_US)' + + def test_drop(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", if_exists=True + ).to_sql() + assert sql == 'DROP COLLATION IF EXISTS "enloc"' + + def test_create_with_schema_and_all_params(self, dialect): + sql, _ = PostgresCreateCollationExpression( + dialect, "custom", schema="app", if_not_exists=True, + locale="en_US.UTF-8", lc_collate="en_US", lc_ctype="en_US", + provider="icu", version="153.14", + ).to_sql() + assert sql == ( + 'CREATE COLLATION IF NOT EXISTS "app"."custom" ' + '(LOCALE = en_US.UTF-8, LC_COLLATE = en_US, LC_CTYPE = en_US, ' + 'PROVIDER = icu, VERSION = 153.14)' + ) + + def test_create_without_params(self, dialect): + sql, _ = PostgresCreateCollationExpression(dialect, "bare").to_sql() + assert sql == 'CREATE COLLATION "bare"' + + def test_drop_restrict_with_schema(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", schema="app", restrict=True + ).to_sql() + assert sql == 'DROP COLLATION "app"."enloc" RESTRICT' + + def test_drop_cascade(self, dialect): + sql, _ = PostgresDropCollationExpression( + dialect, "enloc", cascade=True + ).to_sql() + assert sql == 'DROP COLLATION "enloc" CASCADE' + + def test_drop_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropCollationExpression( + dialect, "enloc", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateCollationExpression(d, "c").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropCollationExpression(d, "c").to_sql() + + +class TestPostgresForeignTableDDLExpression: + """CREATE / DROP FOREIGN TABLE.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "ft", "srv", columns=["a integer", "b text"], + options=["host 'h'"], + ).to_sql() + assert sql == ( + 'CREATE FOREIGN TABLE "ft" (a integer, b text) ' + 'SERVER "srv" OPTIONS (host \'h\')' + ) + + def test_drop(self, dialect): + sql, _ = PostgresDropForeignTableExpression( + dialect, "ft", if_exists=True, cascade=True + ).to_sql() + assert sql == 'DROP FOREIGN TABLE IF EXISTS "ft" CASCADE' + + def test_create_if_not_exists_schema(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "ft", "srv", schema="app", if_not_exists=True, + columns=["a integer"], + ).to_sql() + assert sql == ( + 'CREATE FOREIGN TABLE IF NOT EXISTS "app"."ft" (a integer) ' + 'SERVER "srv"' + ) + + def test_create_without_columns_or_options(self, dialect): + sql, _ = PostgresCreateForeignTableExpression( + dialect, "bar_ft", "fdw_bar" + ).to_sql() + assert sql == 'CREATE FOREIGN TABLE "bar_ft" SERVER "fdw_bar"' + + def test_drop_restrict_with_schema(self, dialect): + sql, _ = PostgresDropForeignTableExpression( + dialect, "ft", schema="app", restrict=True + ).to_sql() + assert sql == 'DROP FOREIGN TABLE "app"."ft" RESTRICT' + + def test_drop_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropForeignTableExpression( + dialect, "ft", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateForeignTableExpression(d, "ft", "srv").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropForeignTableExpression(d, "ft").to_sql() + + +class TestPostgresRoutineDDLExpression: + """CREATE / DROP FUNCTION and AGGREGATE.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_function(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "add", "integer", "int 1;", + args=["a integer"], strict=True, security="DEFINER", + ).to_sql() + assert sql == ( + 'CREATE FUNCTION "add"(a integer) RETURNS integer ' + 'STRICT SECURITY DEFINER LANGUAGE plpgsql AS $$ int 1; $$' + ) + + def test_drop_function(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", args=["integer"], cascade=True + ).to_sql() + assert sql == 'DROP FUNCTION "add" (integer) CASCADE' + + def test_create_aggregate(self, dialect): + sql, _ = PostgresCreateAggregateExpression( + dialect, "mysum", "sum", "integer", initcond="0" + ).to_sql() + assert sql == 'CREATE AGGREGATE "mysum" (SFUNC=sum, STYPE=integer, INITCOND=0)' + + def test_drop_aggregate(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", if_exists=True + ).to_sql() + assert sql == 'DROP AGGREGATE IF EXISTS "mysum" (integer)' + + def test_create_function_full_options(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "add", "integer", "int 1;", schema="app", + args=["a integer", "b integer"], or_replace=True, + security="INVOKER", cost=10.0, rows=50, + ).to_sql() + assert 'CREATE OR REPLACE FUNCTION "app"."add"' in sql + assert "(a integer, b integer)" in sql + assert "SECURITY INVOKER" in sql + assert "COST 10.0" in sql + assert "ROWS 50" in sql + + def test_create_function_no_args_no_strict(self, dialect): + sql, _ = PostgresCreateFunctionExpression( + dialect, "now", "timestamptz", "SELECT now();", language="sql" + ).to_sql() + assert sql == ( + 'CREATE FUNCTION "now"() RETURNS timestamptz LANGUAGE sql ' + 'AS $$ SELECT now(); $$' + ) + + def test_create_function_invalid_security(self, dialect): + expr = PostgresCreateFunctionExpression( + dialect, "f", "integer", "int 1;", security="OWNER" + ) + with pytest.raises(ValueError, match="DEFINER"): + expr.to_sql() + + def test_drop_function_restrict(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", schema="app", if_exists=True, restrict=True + ).to_sql() + assert sql == 'DROP FUNCTION IF EXISTS "app"."add" RESTRICT' + + def test_drop_function_without_args(self, dialect): + sql, _ = PostgresDropFunctionExpression( + dialect, "add", schema="app" + ).to_sql() + assert sql == 'DROP FUNCTION "app"."add"' + + def test_drop_function_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropFunctionExpression( + dialect, "add", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_create_aggregate_full(self, dialect): + sql, _ = PostgresCreateAggregateExpression( + dialect, "mysum", "sum", "integer", schema="app", + finalfunc="mysum_final", + ).to_sql() + assert sql == ( + 'CREATE AGGREGATE "app"."mysum" ' + '(SFUNC=sum, STYPE=integer, FINALFUNC=mysum_final)' + ) + + def test_drop_aggregate_cascade(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", schema="app", cascade=True + ).to_sql() + assert sql == 'DROP AGGREGATE "app"."mysum" (integer) CASCADE' + + def test_drop_aggregate_restrict(self, dialect): + sql, _ = PostgresDropAggregateExpression( + dialect, "mysum", "integer", if_exists=True, restrict=True + ).to_sql() + assert sql == 'DROP AGGREGATE IF EXISTS "mysum" (integer) RESTRICT' + + def test_drop_aggregate_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropAggregateExpression( + dialect, "mysum", "integer", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_routine_version_gate_96(self): + d = PostgresDialect(version=(9, 5, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreateFunctionExpression( + d, "f", "integer", "int 1;" + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropFunctionExpression(d, "f").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresCreateAggregateExpression(d, "s", "sum", "integer").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropAggregateExpression(d, "s", "integer").to_sql() + + +class TestPostgresPublicationExpression: + """CREATE / DROP PUBLICATION and SUBSCRIPTION.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect(version=(14, 0, 0)) + + def test_create_publication_tables(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders", "users"] + ).to_sql() + assert sql == 'CREATE PUBLICATION "pub1" FOR TABLE "orders", "users"' + + def test_create_publication_all_tables(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub_all", all_tables=True, + options=["publish='insert'"], + ).to_sql() + assert sql == ( + 'CREATE PUBLICATION "pub_all" FOR ALL TABLES WITH (publish=\'insert\')' + ) + + def test_drop_publication(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", if_exists=True + ).to_sql() + assert sql == 'DROP PUBLICATION IF EXISTS "pub1"' + + def test_create_subscription(self, dialect): + sql, _ = PostgresCreateSubscriptionExpression( + dialect, "sub1", "host=db port=5432", ["pub1"] + ).to_sql() + assert sql == ( + 'CREATE SUBSCRIPTION "sub1" CONNECTION \'host=db port=5432\' ' + 'PUBLICATION "pub1"' + ) + + def test_drop_subscription(self, dialect): + sql, _ = PostgresDropSubscriptionExpression(dialect, "sub1", cascade=True).to_sql() + assert sql == 'DROP SUBSCRIPTION "sub1" CASCADE' + + def test_create_publication_tables_options(self, dialect): + sql, _ = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders"], + options=["publish='insert'"], + ).to_sql() + assert sql == ( + 'CREATE PUBLICATION "pub1" FOR TABLE "orders" ' + "WITH (publish='insert')" + ) + + def test_create_publication_tables_and_all_raises(self, dialect): + expr = PostgresCreatePublicationExpression( + dialect, "pub1", tables=["orders"], all_tables=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_create_publication_neither_raises(self, dialect): + expr = PostgresCreatePublicationExpression(dialect, "pub1") + with pytest.raises(ValueError, match="requires either"): + expr.to_sql() + + def test_drop_publication_cascade(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", cascade=True + ).to_sql() + assert sql == 'DROP PUBLICATION "pub1" CASCADE' + + def test_drop_publication_restrict(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", restrict=True + ).to_sql() + assert sql == 'DROP PUBLICATION "pub1" RESTRICT' + + def test_drop_publication_cascade_and_restrict_raises(self, dialect): + expr = PostgresDropPublicationExpression( + dialect, "pub1", cascade=True, restrict=True + ) + with pytest.raises(ValueError, match="mutually exclusive"): + expr.to_sql() + + def test_drop_publication_if_exists(self, dialect): + sql, _ = PostgresDropPublicationExpression( + dialect, "pub1", if_exists=True + ).to_sql() + assert sql == 'DROP PUBLICATION IF EXISTS "pub1"' + + def test_create_subscription_options(self, dialect): + sql, _ = PostgresCreateSubscriptionExpression( + dialect, "sub2", "host=db port=5432", ["pub1", "pub2"], + options=["copy_data = false"], + ).to_sql() + assert sql == ( + 'CREATE SUBSCRIPTION "sub2" CONNECTION \'host=db port=5432\' ' + 'PUBLICATION "pub1", "pub2" WITH (copy_data = false)' + ) + + def test_drop_subscription_if_exists(self, dialect): + sql, _ = PostgresDropSubscriptionExpression( + dialect, "sub1", if_exists=True + ).to_sql() + assert sql == 'DROP SUBSCRIPTION IF EXISTS "sub1"' + + def test_pubsub_version_gate_10(self): + d = PostgresDialect(version=(9, 6, 0)) + with pytest.raises(UnsupportedFeatureError): + PostgresCreatePublicationExpression( + d, "pub1", tables=["orders"] + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropPublicationExpression(d, "pub1").to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresCreateSubscriptionExpression(d, "s", "conn", ["p"]).to_sql() + with pytest.raises(UnsupportedFeatureError): + PostgresDropSubscriptionExpression(d, "s").to_sql() + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py index 6be8da1d..d0e75ccc 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_protocols.py @@ -6,6 +6,7 @@ - PostgresCommentSupport - PostgresIndexSupport - PostgresMaterializedViewSupport +- PostgresPolicySupport """ import pytest # noqa: F401 @@ -392,4 +393,245 @@ def test_postgres_materialized_view_support_is_runtime_checkable(self): PostgresMaterializedViewSupport, ) dialect = PostgresDialect(version=(14, 0, 0)) - assert isinstance(dialect, PostgresMaterializedViewSupport) \ No newline at end of file + assert isinstance(dialect, PostgresMaterializedViewSupport) + + def test_postgres_policy_support_is_runtime_checkable(self): + """PostgresPolicySupport should be runtime checkable.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPolicySupport, + ) + # Versions >= 9.5 should satisfy the Protocol + dialect = PostgresDialect(version=(14, 0, 0)) + assert isinstance(dialect, PostgresPolicySupport) + + def test_postgres_policy_support_rejected_on_unsupported_version(self): + """Pre-9.5 dialects still satisfy Protocol methods (no attributes + differ structurally), but capability switches report False.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPolicySupport, + ) + dialect = PostgresDialect(version=(9, 4, 0)) + # Structural isinstance still passes (Protocol only checks method + # presence, not version gates), but capability report is False: + assert dialect.supports_create_policy() is False + # The Protocol *presence* is still satisfied (the methods exist): + assert isinstance(dialect, PostgresPolicySupport) + + +class TestPostgresRlsConfigFeatureDetection: + """Test PostgresRlsConfigSupport feature detection methods.""" + + def test_rls_enable_disable_pg94(self): + dialect = PostgresDialect(version=(9, 4, 0)) + assert dialect.supports_rls_enable_disable() is False + + def test_rls_enable_disable_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_rls_enable_disable() is True + assert dialect.supports_rls_force() is True + assert dialect.supports_rls_enable_always() is True + + def test_rls_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresRlsConfigSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresRlsConfigSupport) + + +class TestPostgresAlterTableSettingsFeatureDetection: + """Test PostgresAlterTableSettingsSupport feature detection methods.""" + + def test_table_logging_switch_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_table_logging_switch() is False + + def test_table_logging_switch_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_table_logging_switch() is True + assert dialect.supports_table_set_access_method() is False + + def test_table_set_access_method_pg15(self): + dialect = PostgresDialect(version=(15, 0, 0)) + assert dialect.supports_table_set_access_method() is True + + def test_alter_table_settings_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresAlterTableSettingsSupport, + ) + assert isinstance( + PostgresDialect(version=(14, 0, 0)), PostgresAlterTableSettingsSupport + ) + + +class TestPostgresClusterFeatureDetection: + """Test PostgresClusterSupport feature detection methods.""" + + def test_cluster_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_cluster() is False + + def test_cluster_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_cluster() is True + + def test_cluster_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresClusterSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresClusterSupport) + + +class TestPostgresDomainFeatureDetection: + """Test PostgresDomainSupport feature detection methods.""" + + def test_domain_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_create_domain() is False + + def test_domain_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_create_domain() is True + + def test_domain_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresDomainSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresDomainSupport) + + +class TestPostgresCollationDDLFeatureDetection: + """Test PostgresCollationDDLSupport feature detection methods.""" + + def test_collation_ddl_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_collation_ddl() is False + + def test_collation_ddl_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_collation_ddl() is True + + def test_collation_ddl_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresCollationDDLSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresCollationDDLSupport) + + +class TestPostgresForeignTableDDLFeatureDetection: + """Test PostgresForeignTableDDLSupport feature detection methods.""" + + def test_foreign_table_ddl_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_foreign_table_ddl() is False + + def test_foreign_table_ddl_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_foreign_table_ddl() is True + + def test_foreign_table_ddl_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresForeignTableDDLSupport, + ) + assert isinstance( + PostgresDialect(version=(14, 0, 0)), PostgresForeignTableDDLSupport + ) + + +class TestPostgresRoutineDDLFeatureDetection: + """Test PostgresRoutineDDLSupport feature detection methods.""" + + def test_routine_ddl_pg95(self): + dialect = PostgresDialect(version=(9, 5, 0)) + assert dialect.supports_function_ddl() is False + assert dialect.supports_aggregate_ddl() is False + + def test_routine_ddl_pg96(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_function_ddl() is True + assert dialect.supports_aggregate_ddl() is True + + def test_routine_ddl_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresRoutineDDLSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresRoutineDDLSupport) + + +class TestPostgresPublicationFeatureDetection: + """Test PostgresPublicationSupport feature detection methods.""" + + def test_publication_pg99(self): + dialect = PostgresDialect(version=(9, 6, 0)) + assert dialect.supports_publication() is False + assert dialect.supports_subscription() is False + + def test_publication_pg100(self): + dialect = PostgresDialect(version=(10, 0, 0)) + assert dialect.supports_publication() is True + assert dialect.supports_subscription() is True + + def test_publication_support_is_runtime_checkable(self): + from rhosocial.activerecord.backend.impl.postgres.protocols.ddl import ( + PostgresPublicationSupport, + ) + assert isinstance(PostgresDialect(version=(14, 0, 0)), PostgresPublicationSupport) + + +class TestDdlVersionBaselineCoverage: + """Version-boundary sweep across the plan's supported baseline 9.6–19. + + Plan §6.3 requires capability switches be exercised at + 9.6 / 10 / 11 / 14 / 15 / 17 / 19 — not just the historical 9.4/9.5 set. + """ + + def test_baseline_96(self): + d = PostgresDialect(version=(9, 6, 0)) + assert d.supports_rls_enable_disable() is True + assert d.supports_table_logging_switch() is True + assert d.supports_cluster() is True + assert d.supports_create_domain() is True + assert d.supports_collation_ddl() is True + assert d.supports_foreign_table_ddl() is True + assert d.supports_function_ddl() is True + assert d.supports_aggregate_ddl() is True + assert d.supports_unlogged_table() is True + assert d.supports_publication() is False + assert d.supports_subscription() is False + + def test_baseline_10(self): + d = PostgresDialect(version=(10, 0, 0)) + assert d.supports_publication() is True + assert d.supports_subscription() is True + + def test_baseline_11(self): + d = PostgresDialect(version=(11, 0, 0)) + assert d.supports_publication() is True + assert d.supports_subscription() is True + assert d.supports_create_domain() is True + + def test_baseline_14(self): + d = PostgresDialect(version=(14, 0, 0)) + assert d.supports_cluster() is True + assert d.supports_function_ddl() is True + + def test_baseline_15(self): + d = PostgresDialect(version=(15, 0, 0)) + assert d.supports_table_set_access_method() is True + + def test_baseline_17(self): + d = PostgresDialect(version=(17, 0, 0)) + assert d.supports_unlogged_table() is True + assert d.supports_rls_enable_always() is True + assert d.supports_table_set_access_method() is True + + def test_baseline_19(self): + d = PostgresDialect(version=(19, 0, 0)) + assert d.supports_table_set_access_method() is True + assert d.supports_subscription() is True + assert d.supports_create_domain() is True + assert d.supports_collation_ddl() is True + assert d.supports_publication() is True + + def test_unsupported_94_rejects_unlogged(self): + d = PostgresDialect(version=(9, 4, 0)) + assert d.supports_unlogged_table() is False \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py index 6f03f418..f8fa1597 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py @@ -62,20 +62,16 @@ def test_valid_data_type_works(self, postgres_backend, test_table_for_data_type) """) # noqa: F541 assert result is not None - def test_malicious_data_type_rejected_at_dialect_level(self, postgres_backend): - """Test that malicious data type is rejected at dialect level before DB execution.""" + def test_malicious_data_type_rejected_at_construction(self, postgres_backend): + """Test that malicious data type string is rejected at ColumnDefinition construction.""" from rhosocial.activerecord.backend.expression.statements import ColumnDefinition - dialect = postgres_backend.dialect - - # This should raise ValueError before reaching the database - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255); DROP TABLE users--", - ) - - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_column_definition(col_def) + # ColumnDefinition now rejects string data_type values at construction time + with pytest.raises(TypeError, match="data_type must be a DataType instance"): + ColumnDefinition( + name="test_col", + data_type="VARCHAR(255); DROP TABLE users--", + ) class TestPostgresExcludeConstraintSecurityIntegration: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py index 19e40de9..40fc7ee7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py @@ -20,7 +20,9 @@ from rhosocial.activerecord.backend.expression.statements.ddl_table import IndexDefinition from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateFulltextIndexExpression, CreateIndexExpression, + DropFulltextIndexExpression, DropIndexExpression, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -472,8 +474,17 @@ def test_get_supported_index_types(self): assert types == ["BTREE", "HASH", "GIST", "GIN", "SPGIST", "BRIN"] -class TestFulltextNotSupported: - """PostgreSQL does not support MySQL-style FULLTEXT indexes.""" +class TestFulltextDdlNotSupportedSearchSupported: + """PostgreSQL full-text search via tsvector/tsquery, not MySQL-style DDL. + + PostgreSQL does NOT support ``CREATE FULLTEXT INDEX`` (MySQL-style) — + it uses ``CREATE INDEX ... USING GIN (to_tsvector(...))`` instead. + However, full-text *search querying* IS supported through + ``tsvector @@ tsquery`` operators, so the dialect reports + ``supports_fulltext_search()`` True independently of the DDL capability. + """ + + # --- DDL capability (MySQL-style CREATE FULLTEXT INDEX) --- def test_supports_fulltext_index(self): assert PostgresDialect().supports_fulltext_index() is False @@ -481,23 +492,110 @@ def test_supports_fulltext_index(self): def test_supports_fulltext_parser(self): assert PostgresDialect().supports_fulltext_parser() is False - def test_supports_fulltext_boolean_mode(self): - assert PostgresDialect().supports_fulltext_boolean_mode() is False - def test_supports_fulltext_query_expansion(self): assert PostgresDialect().supports_fulltext_query_expansion() is False - def test_format_fulltext_match_raises(self): - with pytest.raises(UnsupportedFeatureError): - PostgresDialect().format_fulltext_match(["col"], "search") + # --- Query capability (tsvector @@ tsquery) --- + + def test_supports_fulltext_search(self): + assert PostgresDialect().supports_fulltext_search() is True + + def test_supports_fulltext_boolean_mode(self): + # to_tsquery() supports &, |, ! — equivalent to MySQL BOOLEAN MODE. + assert PostgresDialect().supports_fulltext_boolean_mode() is True + + def test_format_fulltext_match_natural_language(self): + sql, params = PostgresDialect().format_fulltext_match(["col"], "search") + assert "to_tsvector" in sql + assert "plainto_tsquery" in sql + assert "@@" in sql + assert params == ("search",) + + def test_format_fulltext_match_boolean(self): + sql, params = PostgresDialect().format_fulltext_match(["col"], "a & b", mode="BOOLEAN") + assert "to_tsvector" in sql + assert "to_tsquery" in sql + assert "plainto_tsquery" not in sql + assert params == ("a & b",) + + def test_format_fulltext_match_phrase(self): + sql, params = PostgresDialect().format_fulltext_match(["col"], "cat dog", mode="PHRASE") + assert "phraseto_tsquery" in sql + assert "plainto_tsquery" not in sql + assert params == ("cat dog",) + + def test_format_fulltext_match_unknown_mode_uses_plain(self): + sql, params = PostgresDialect().format_fulltext_match(["col"], "search", mode="CUSTOM") + assert "plainto_tsquery" in sql + assert params == ("search",) + + def test_format_fulltext_match_multi_column(self): + sql, params = PostgresDialect().format_fulltext_match(["a", "b"], "search") + assert " || " in sql + assert "to_tsvector" in sql + assert params == ("search",) + + def test_format_create_fulltext_index_statement_uses_gin(self): + dialect = PostgresDialect() + expr = CreateFulltextIndexExpression( + dialect, + index_name="idx_ft", + table_name="articles", + columns=["body"], + ) + sql, params = dialect.format_create_fulltext_index_statement(expr) + assert sql.startswith("CREATE INDEX") + assert "USING GIN" in sql + assert "to_tsvector" in sql + assert "articles" in sql + assert params == () + + def test_format_create_fulltext_index_statement_if_not_exists(self): + dialect = PostgresDialect() + expr = CreateFulltextIndexExpression( + dialect, + index_name="idx_ft", + table_name="articles", + columns=["body"], + if_not_exists=True, + ) + sql, _ = dialect.format_create_fulltext_index_statement(expr) + assert "IF NOT EXISTS" in sql - def test_format_create_fulltext_index_statement_raises(self): - with pytest.raises(UnsupportedFeatureError): - PostgresDialect().format_create_fulltext_index_statement(None) + def test_format_create_fulltext_index_statement_multi_column(self): + dialect = PostgresDialect() + expr = CreateFulltextIndexExpression( + dialect, + index_name="idx_ft", + table_name="articles", + columns=["title", "body"], + ) + sql, _ = dialect.format_create_fulltext_index_statement(expr) + assert " || " in sql + assert "to_tsvector" in sql + assert "title" in sql and "body" in sql + + def test_format_drop_fulltext_index_statement(self): + dialect = PostgresDialect() + expr = DropFulltextIndexExpression( + dialect, + index_name="idx_ft", + table_name="articles", + ) + sql, _ = dialect.format_drop_fulltext_index_statement(expr) + assert sql.startswith("DROP INDEX") + assert "idx_ft" in sql - def test_format_drop_fulltext_index_statement_raises(self): - with pytest.raises(UnsupportedFeatureError): - PostgresDialect().format_drop_fulltext_index_statement(None) + def test_format_drop_fulltext_index_statement_if_exists(self): + dialect = PostgresDialect() + expr = DropFulltextIndexExpression( + dialect, + index_name="idx_ft", + table_name="articles", + if_exists=True, + ) + sql, _ = dialect.format_drop_fulltext_index_statement(expr) + assert "IF EXISTS" in sql class TestCreateIndexExpression: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py new file mode 100644 index 00000000..d464e4dd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py @@ -0,0 +1,491 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py +"""Unit tests for PostgreSQL Row-Level Security POLICY DDL. + +Tests cover: +- ``PostgresPolicyMixin`` capability switches across PG versions +- ``format_create_policy_statement`` for all FOR commands, AS clause + variants, role lists (identifiers + reserved role keywords), USING / + WITH CHECK expression parameter passing, command/expression + incompatibilities, and version gates. +- ``format_alter_policy_statement`` for the RENAME form and the REPLACE + form (independent clauses, mutual-exclusivity, REPLACE-no-clause error). +- ``format_drop_policy_statement`` for ``IF EXISTS`` / ``CASCADE`` / + ``RESTRICT``, their mutual exclusion, and version gates. + +These tests are pure SQL-string / params assertions — no active DB. +""" +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.postgres import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.policy import ( + AlterPolicyMode, + PostgresAlterPolicyExpression, + PostgresCreatePolicyExpression, + PostgresDropPolicyExpression, + PolicyCommand, + PolicyType, +) +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl.policy import ( + PostgresPolicyMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols import ( + PostgresPolicySupport, +) + + +# --------------------------------------------------------------------------- # +# Capability switches +# --------------------------------------------------------------------------- # +class TestPolicyFeatureDetection: + """Test capability switches across PG versions.""" + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ((10, 0, 0), True), + ((14, 0, 0), True), + ]) + def test_supports_create_policy(self, ver, expected): + assert PostgresDialect(ver).supports_create_policy() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 5, 0), False), + ((9, 6, 0), False), + ((10, 0, 0), True), + ((14, 0, 0), True), + ]) + def test_supports_policy_restrictive(self, ver, expected): + assert PostgresDialect(ver).supports_policy_restrictive() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 5, 0), False), + ((10, 0, 0), True), + ]) + def test_supports_explicit_permissive_keyword(self, ver, expected): + assert PostgresDialect(ver).supports_explicit_permissive_keyword() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ((14, 0, 0), True), + ]) + def test_supports_drop_policy_if_exists(self, ver, expected): + assert PostgresDialect(ver).supports_drop_policy_if_exists() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ]) + def test_supports_alter_policy_rename(self, ver, expected): + assert PostgresDialect(ver).supports_alter_policy_rename() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 4, 0), False), + ((9, 5, 0), True), + ]) + def test_supports_alter_policy_replace(self, ver, expected): + assert PostgresDialect(ver).supports_alter_policy_replace() is expected + + @pytest.mark.parametrize("ver,expected", [ + ((9, 5, 0), False), + ((14, 0, 0), False), + ((15, 0, 0), True), + ((18, 0, 0), True), + ]) + def test_supports_current_role_keyword(self, ver, expected): + assert PostgresDialect(ver).supports_current_role_keyword() is expected + + +# --------------------------------------------------------------------------- # +# Protocol conformance +# --------------------------------------------------------------------------- # +class TestPolicyProtocolConformance: + """PostgresDialect must satisfy PostgresPolicySupport Protocol.""" + + @pytest.mark.parametrize("ver", [(9, 5, 0), (10, 0, 0), (14, 0, 0), (18, 0, 0)]) + def test_isinstance_protocol(self, ver): + assert isinstance(PostgresDialect(ver), PostgresPolicySupport) + + def test_protocol_runtime_checkable(self): + # Protocol must be runtime_checkable (decorated) + assert hasattr(PostgresPolicySupport, "_is_protocol") + # PyPy / CPython alternative: explicit isinstance check + assert isinstance(PostgresDialect((14, 0, 0)), PostgresPolicySupport) + + +# --------------------------------------------------------------------------- # +# CREATE POLICY +# --------------------------------------------------------------------------- # +class TestFormatCreatePolicyStatement: + """Test CREATE POLICY statement formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect((14, 0, 0)) + + def test_basic_create(self, dialect): + """Bare statement — only name + table required.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="orders" + ) + sql, params = expr.to_sql() + assert sql == 'CREATE POLICY "p1" ON "orders"' + assert params == () + + def test_create_with_schema(self, dialect): + """Schema-qualified table reference.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="orders", schema="analytics" + ) + sql, _ = expr.to_sql() + assert sql == 'CREATE POLICY "p1" ON "analytics"."orders"' + + def test_create_permissive_implicit_omitted_on_95(self): + """AS PERMISSIVE keyword must be silently omitted on PG 9.5.""" + d = PostgresDialect((9, 5, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", policy_type=PolicyType.PERMISSIVE + ) + sql, _ = expr.to_sql() + assert "AS" not in sql + assert sql == 'CREATE POLICY "p1" ON "t1"' + + def test_create_permissive_keyword_emitted_on_10(self): + """AS PERMISSIVE keyword emitted on PG 10+.""" + d = PostgresDialect((10, 0, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", policy_type=PolicyType.PERMISSIVE + ) + sql, _ = expr.to_sql() + assert "AS PERMISSIVE" in sql + + def test_create_restrictive_requires_10(self): + """RESTRICTIVE rejects on PG 9.5.""" + d = PostgresDialect((9, 5, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", policy_type=PolicyType.RESTRICTIVE + ) + with pytest.raises(UnsupportedFeatureError) as ei: + expr.to_sql() + assert "RESTRICTIVE" in str(ei.value) + assert "10" in str(ei.value) + + def test_create_restrictive_ok_10(self, dialect): + """RESTRICTIVE allowed on PG 10+.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="t1", policy_type=PolicyType.RESTRICTIVE + ) + sql, _ = expr.to_sql() + assert "AS RESTRICTIVE" in sql + + @pytest.mark.parametrize("cmd,expected_kw", [ + (PolicyCommand.ALL, "FOR ALL"), + (PolicyCommand.SELECT, "FOR SELECT"), + (PolicyCommand.INSERT, "FOR INSERT"), + (PolicyCommand.UPDATE, "FOR UPDATE"), + (PolicyCommand.DELETE, "FOR DELETE"), + ]) + def test_create_all_commands(self, dialect, cmd, expected_kw): + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="t1", command=cmd + ) + sql, _ = expr.to_sql() + assert expected_kw in sql + + def test_create_roles_identifiers_quoted(self, dialect): + """Plain role names are identifiers — wrapped by format_identifier.""" + expr = PostgresCreatePolicyExpression( + dialect, name="p1", table_name="t1", roles=["analyst", "reporter"] + ) + sql, _ = expr.to_sql() + assert 'TO "analyst", "reporter"' in sql + + def test_create_roles_reserved_keywords_verbatim(self, dialect): + """PUBLIC / CURRENT_USER / SESSION_USER emitted verbatim (no quoting).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + roles=["PUBLIC", "CURRENT_USER", "SESSION_USER"], + ) + sql, _ = expr.to_sql() + # Reserved role keywords must NOT be double-quoted + assert "TO PUBLIC, CURRENT_USER, SESSION_USER" in sql + assert '"PUBLIC"' not in sql + assert '"CURRENT_USER"' not in sql + + def test_create_roles_current_role_requires_15(self): + """CURRENT_ROLE keyword requires PG 15; rejected on 14.""" + d = PostgresDialect((14, 0, 0)) + expr = PostgresCreatePolicyExpression( + d, name="p1", table_name="t1", roles=["CURRENT_ROLE"] + ) + with pytest.raises(UnsupportedFeatureError) as ei: + expr.to_sql() + assert "CURRENT_ROLE" in str(ei.value) + assert "15" in str(ei.value) + + def test_create_using_with_param_passing(self, dialect): + """USING expression parameters are passed through to_sql().""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.SELECT, + using=Column(dialect, "user_id") == Literal(dialect, 1), + ) + sql, params = expr.to_sql() + assert "USING (" in sql + assert "%s" in sql + assert params == (1,) + + def test_create_with_check(self, dialect): + """WITH CHECK expression for UPDATE.""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.UPDATE, + with_check=Column(dialect, "status") == Literal(dialect, "active"), + ) + sql, params = expr.to_sql() + assert "WITH CHECK (" in sql + assert params == ("active",) + + def test_create_insert_with_using_rejected(self, dialect): + """INSERT command + USING is invalid (INSERT has no USING semantics).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.INSERT, + using=Column(dialect, "user_id") == Literal(dialect, 1), + ) + with pytest.raises(ValueError, match="INSERT.*USING"): + expr.to_sql() + + def test_create_select_with_check_rejected(self, dialect): + """SELECT command + WITH CHECK is invalid (SELECT has no new rows).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.SELECT, + with_check=Column(dialect, "user_id") == Literal(dialect, 1), + ) + with pytest.raises(ValueError, match="SELECT.*WITH CHECK"): + expr.to_sql() + + def test_create_delete_with_check_rejected(self, dialect): + """DELETE command + WITH CHECK is invalid (DELETE has no new rows).""" + expr = PostgresCreatePolicyExpression( + dialect, + name="p1", table_name="t1", + command=PolicyCommand.DELETE, + with_check=Column(dialect, "user_id") == Literal(dialect, 1), + ) + with pytest.raises(ValueError, match="DELETE.*WITH CHECK"): + expr.to_sql() + + def test_create_full_combination(self, dialect): + """Full statement with all clauses.""" + expr = PostgresCreatePolicyExpression( + dialect, + name="user_select_own", + table_name="orders", + schema="public", + policy_type=PolicyType.RESTRICTIVE, + command=PolicyCommand.UPDATE, + roles=["app_user", "PUBLIC"], + using=Column(dialect, "owner") == Literal(dialect, "alice"), + with_check=Column(dialect, "owner").is_not_null(), + ) + sql, params = expr.to_sql() + # All clauses present in canonical order + assert sql.startswith('CREATE POLICY "user_select_own"') + assert 'ON "public"."orders"' in sql + assert "AS RESTRICTIVE" in sql + assert "FOR UPDATE" in sql + assert 'TO "app_user", PUBLIC' in sql + assert "USING (" in sql + assert "WITH CHECK (" in sql + # USING uses the literal "alice"; WITH CHECK IS NOT NULL yields no params + assert params == ("alice",) + + def test_create_rejected_on_94(self): + """Whole CREATE POLICY statement rejected before PG 9.5.""" + d = PostgresDialect((9, 4, 0)) + expr = PostgresCreatePolicyExpression(d, name="p1", table_name="t1") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +# --------------------------------------------------------------------------- # +# ALTER POLICY +# --------------------------------------------------------------------------- # +class TestFormatAlterPolicyStatement: + """Test ALTER POLICY statement formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect((14, 0, 0)) + + def test_alter_rename(self, dialect): + """Form 1: RENAME TO.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", new_name="p1_v2" + ) + assert expr.mode is AlterPolicyMode.RENAME + sql, params = expr.to_sql() + assert sql == 'ALTER POLICY "p1" ON "t1" RENAME TO "p1_v2"' + assert params == () + + def test_alter_rename_with_schema(self, dialect): + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + schema="my_schema", new_name="p1_v2", + ) + sql, _ = expr.to_sql() + assert 'ON "my_schema"."t1" RENAME TO "p1_v2"' in sql + + def test_alter_rename_rejects_replace_clauses(self, dialect): + """new_name is mutually exclusive with roles/using/with_check.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", new_name="p1_v2", + roles=["app_user"], + ) + with pytest.raises(ValueError, match="RENAME.*mutually exclusive"): + expr.to_sql() + + def test_alter_replace_roles_only(self, dialect): + """Form 2: only TO clause replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", roles=["app_user", "PUBLIC"] + ) + assert expr.mode is AlterPolicyMode.REPLACE + sql, _ = expr.to_sql() + assert sql == 'ALTER POLICY "p1" ON "t1" TO "app_user", PUBLIC' + + def test_alter_replace_using_only(self, dialect): + """Form 2: only USING clause replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + using=Column(dialect, "user_id") == Literal(dialect, 42), + ) + sql, params = expr.to_sql() + assert "USING (" in sql + assert params == (42,) + + def test_alter_replace_with_check_only(self, dialect): + """Form 2: only WITH CHECK clause replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + with_check=Column(dialect, "status") == Literal(dialect, "active"), + ) + sql, params = expr.to_sql() + assert "WITH CHECK (" in sql + assert params == ("active",) + + def test_alter_replace_full(self, dialect): + """Form 2: all three clauses (TO/USING/WITH CHECK) replaced.""" + expr = PostgresAlterPolicyExpression( + dialect, name="p1", table_name="t1", + roles=["app_user"], + using=Column(dialect, "a") == Literal(dialect, 1), + with_check=Column(dialect, "b") == Literal(dialect, "x"), + ) + sql, params = expr.to_sql() + assert 'TO "app_user"' in sql + assert "USING (" in sql + assert "WITH CHECK (" in sql + assert params == (1, "x") + + def test_alter_replace_no_clause_rejected(self, dialect): + """Form 2 requires at least one of TO/USING/WITH CHECK.""" + expr = PostgresAlterPolicyExpression(dialect, name="p1", table_name="t1") + assert expr.mode is AlterPolicyMode.REPLACE + with pytest.raises(ValueError, match="requires at least one"): + expr.to_sql() + + def test_alter_rejected_on_94(self): + """ALTER POLICY totally rejected before PG 9.5.""" + d = PostgresDialect((9, 4, 0)) + expr = PostgresAlterPolicyExpression( + d, name="p1", table_name="t1", new_name="p2" + ) + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +# --------------------------------------------------------------------------- # +# DROP POLICY +# --------------------------------------------------------------------------- # +class TestFormatDropPolicyStatement: + """Test DROP POLICY statement formatting.""" + + @pytest.fixture + def dialect(self): + return PostgresDialect((14, 0, 0)) + + def test_drop_basic(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1" + ) + sql, params = expr.to_sql() + assert sql == 'DROP POLICY "p1" ON "t1"' + assert params == () + + def test_drop_with_schema(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", schema="rhosocial" + ) + sql, _ = expr.to_sql() + assert sql == 'DROP POLICY "p1" ON "rhosocial"."t1"' + + def test_drop_if_exists(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", if_exists=True + ) + sql, _ = expr.to_sql() + assert "DROP POLICY IF EXISTS" in sql + + def test_drop_cascade(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", cascade=True + ) + sql, _ = expr.to_sql() + assert sql.endswith("CASCADE") + + def test_drop_restrict(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", restrict=True + ) + sql, _ = expr.to_sql() + assert sql.endswith("RESTRICT") + + def test_drop_cascade_and_restrict_mutually_exclusive(self, dialect): + expr = PostgresDropPolicyExpression( + dialect, name="p1", table_name="t1", + cascade=True, restrict=True, + ) + with pytest.raises(ValueError, match="CASC.*RESTRICT"): + expr.to_sql() + + def test_drop_rejected_on_94(self): + d = PostgresDialect((9, 4, 0)) + expr = PostgresDropPolicyExpression(d, name="p1", table_name="t1") + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + +# --------------------------------------------------------------------------- # +# Mixin import sanity (defensive) +# --------------------------------------------------------------------------- # +class TestPolicyMixinImport: + """Mixin class exists and is re-exported through the expected package path.""" + + def test_mixin_importable(self): + from rhosocial.activerecord.backend.impl.postgres.mixins import ( + PostgresPolicyMixin as M1, + ) + from rhosocial.activerecord.backend.impl.postgres.mixins.ddl import ( + PostgresPolicyMixin as M2, + ) + assert M1 is M2 is PostgresPolicyMixin diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py index be4e111c..c36e307f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -24,8 +24,10 @@ WhereClause, OrderByClause, GroupByHavingClause, LimitOffsetClause, ) from rhosocial.activerecord.backend.expression.core import Column -from rhosocial.activerecord.backend.expression.query_parts import JoinExpression, JoinType -from rhosocial.activerecord.backend.expression.literals import Identifier +from rhosocial.activerecord.backend.expression.query_parts import JoinExpression +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, TextType, +) GRAPH_NAME = "social_graph" @@ -42,48 +44,48 @@ def social_data(postgres_backend): backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) people_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", "TEXT"), - ColumnDefinition("email", "TEXT"), - ColumnDefinition("city", "TEXT"), + ColumnDefinition("name", TextType()), + ColumnDefinition("email", TextType()), + ColumnDefinition("city", TextType()), ] backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) follows_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", "INTEGER", + ColumnDefinition("follower_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("followed_id", "INTEGER", + ColumnDefinition("followed_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("since", "TEXT"), + ColumnDefinition("since", TextType()), ] backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) posts_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("author_id", "INTEGER", + ColumnDefinition("author_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("content", "TEXT"), - ColumnDefinition("created_at", "TEXT"), + ColumnDefinition("content", TextType()), + ColumnDefinition("created_at", TextType()), ] backend.execute(*CreateTableExpression(dialect, "posts", posts_cols).to_sql()) likes_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", "INTEGER", + ColumnDefinition("user_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("post_id", "INTEGER", + ColumnDefinition("post_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("posts", ["id"]))]), - ColumnDefinition("created_at", "TEXT"), + ColumnDefinition("created_at", TextType()), ] backend.execute(*CreateTableExpression(dialect, "likes", likes_cols).to_sql()) @@ -324,24 +326,24 @@ async def async_social_data(self, async_postgres_backend): await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) people_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", "TEXT"), - ColumnDefinition("email", "TEXT"), - ColumnDefinition("city", "TEXT"), + ColumnDefinition("name", TextType()), + ColumnDefinition("email", TextType()), + ColumnDefinition("city", TextType()), ] await backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) follows_cols = [ - ColumnDefinition("id", "INTEGER", + ColumnDefinition("id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", "INTEGER", + ColumnDefinition("follower_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("followed_id", "INTEGER", + ColumnDefinition("followed_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("since", "TEXT"), + ColumnDefinition("since", TextType()), ] await backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py new file mode 100644 index 00000000..b05c6fb1 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py @@ -0,0 +1,510 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py +""" +Recursive CTE graph traversal tests — PGQ quantified path alternative. + +These tests verify that recursive CTE + SetOperationExpression + UNION ALL +correctly simulates PGQ quantified path patterns (not natively supported +in PostgreSQL 19). + +Scenarios: +1. Social Network: N-degree friend recommendations +2. Anti-Money Laundering: Fund source tracing +""" +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, + InsertExpression, + ValuesSource, + DropTableExpression, + QueryExpression, + TableExpression, + CTEExpression, + WithQueryExpression, + SetOperationExpression, + FunctionCall, + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + VarCharType, + DecimalType, +) +from rhosocial.activerecord.backend.expression.core import Literal, Column +from rhosocial.activerecord.backend.expression.predicates import BetweenPredicate +from rhosocial.activerecord.backend.expression.query_parts import ( + WhereClause, + OrderByClause, + GroupByHavingClause, + JoinExpression, +) + + +@pytest.fixture +def social_network_data(postgres_backend): + """Fixture: create users + follows tables with test data.""" + backend = postgres_backend + dialect = backend.dialect + + for t in ("follows", "users"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + backend.execute(*CreateTableExpression(dialect, "users", [ + ColumnDefinition("id", IntegerType(), constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ]).to_sql()) + + backend.execute(*CreateTableExpression(dialect, "follows", [ + ColumnDefinition("id", IntegerType(), constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("follower_id", IntegerType()), + ColumnDefinition("followed_id", IntegerType()), + ]).to_sql()) + + backend.execute(*InsertExpression(dialect, "users", columns=["id", "name"], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice")], + [Literal(dialect, 2), Literal(dialect, "Bob")], + [Literal(dialect, 3), Literal(dialect, "Charlie")], + [Literal(dialect, 4), Literal(dialect, "Diana")], + [Literal(dialect, 5), Literal(dialect, "Eve")], + ])).to_sql()) + + backend.execute(*InsertExpression(dialect, "follows", columns=["id", "follower_id", "followed_id"], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2)], + [Literal(dialect, 2), Literal(dialect, 2), Literal(dialect, 3)], + [Literal(dialect, 3), Literal(dialect, 1), Literal(dialect, 3)], + [Literal(dialect, 4), Literal(dialect, 4), Literal(dialect, 1)], + ])).to_sql()) + + yield + + for t in ("follows", "users"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + +@pytest.fixture +def aml_data(postgres_backend): + """Fixture: create accounts + transactions tables with test data.""" + backend = postgres_backend + dialect = backend.dialect + + for t in ("transactions", "accounts"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + backend.execute(*CreateTableExpression(dialect, "accounts", [ + ColumnDefinition("id", IntegerType(), constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("account_holder", VarCharType(100)), + ColumnDefinition("account_type", VarCharType(20)), + ]).to_sql()) + + backend.execute(*CreateTableExpression(dialect, "transactions", [ + ColumnDefinition("id", IntegerType(), constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("source_account_id", IntegerType()), + ColumnDefinition("target_account_id", IntegerType()), + ColumnDefinition("amount", DecimalType(12, 2)), + ]).to_sql()) + + backend.execute(*InsertExpression(dialect, "accounts", columns=["id", "account_holder", "account_type"], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice Smith"), Literal(dialect, "checking")], + [Literal(dialect, 2), Literal(dialect, "Bob Corp"), Literal(dialect, "business")], + [Literal(dialect, 3), Literal(dialect, "Charlie Ltd"), Literal(dialect, "business")], + [Literal(dialect, 4), Literal(dialect, "Diana Offshore"), Literal(dialect, "offshore")], + [Literal(dialect, 5), Literal(dialect, "Eve Holding"), Literal(dialect, "offshore")], + ])).to_sql()) + + backend.execute(*InsertExpression(dialect, "transactions", columns=[ + "id", "source_account_id", "target_account_id", "amount"], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 4), Literal(dialect, 3), Literal(dialect, 500000)], + [Literal(dialect, 2), Literal(dialect, 5), Literal(dialect, 3), Literal(dialect, 300000)], + [Literal(dialect, 3), Literal(dialect, 3), Literal(dialect, 2), Literal(dialect, 600000)], + [Literal(dialect, 4), Literal(dialect, 2), Literal(dialect, 1), Literal(dialect, 450000)], + ])).to_sql()) + + yield + + for t in ("transactions", "accounts"): + backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + +class TestSocialNetworkTraversal: + """Recursive CTE for N-degree friend recommendations.""" + + def _build_traversal_cte(self, dialect): + """Build recursive CTE expression for social network traversal.""" + base = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Literal(dialect, 0).as_("depth"), + ], + from_=TableExpression(dialect, "users"), + where=WhereClause(dialect, condition=Column(dialect, "name") == Literal(dialect, "Alice")), + ) + + recursive_join = JoinExpression( + dialect=dialect, + left_table=TableExpression(dialect, "traversal", alias="t"), + right_table=TableExpression(dialect, "follows", alias="f"), + join_type="INNER JOIN", + condition=Column(dialect, "id", table="t") == Column(dialect, "follower_id", table="f"), + ).inner_join( + right_table=TableExpression(dialect, "users", alias="u"), + condition=Column(dialect, "followed_id", table="f") == Column(dialect, "id", table="u"), + ) + + recursive = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id", table="u"), + Column(dialect, "name", table="u"), + (Column(dialect, "depth", table="t") + Literal(dialect, 1)).as_("depth"), + ], + from_=recursive_join, + where=WhereClause(dialect, condition=Column(dialect, "depth", table="t") < Literal(dialect, 4)), + ) + + union = SetOperationExpression( + dialect=dialect, + left=base, + right=recursive, + operation="UNION ALL", + ) + + return CTEExpression( + dialect=dialect, + name="traversal", + query=union, + columns=["id", "name", "depth"], + ) + + def test_friend_recommendation_depth_1_to_3(self, postgres_backend, social_network_data): + """Q1: Friend recommendations at depths 1-3 (simulate {1,3}).""" + dialect = postgres_backend.dialect + cte = self._build_traversal_cte(dialect) + + main = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "depth"), + ], + from_=TableExpression(dialect, "traversal"), + where=WhereClause(dialect, condition=BetweenPredicate( + dialect, Column(dialect, "depth"), Literal(dialect, 1), Literal(dialect, 3), + )), + order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "name")]), + ) + + with_query = WithQueryExpression( + dialect=dialect, + ctes=[cte], + main_query=main, + recursive=True, + ) + + sql, params = with_query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names_at_depth = {r["name"]: r["depth"] for r in rows} + + # Alice follows Bob (depth 1) and Charlie (depth 1) + assert names_at_depth.get("Bob") == 1 + # Charlie also appears at depth 2 (via Bob), dict stores last occurrence + assert names_at_depth.get("Charlie") == 2 + + def test_friend_recommendation_depth_2(self, postgres_backend, social_network_data): + """Q2: Only depth=2 (simulate {2,2}).""" + dialect = postgres_backend.dialect + cte = self._build_traversal_cte(dialect) + + main = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "depth"), + ], + from_=TableExpression(dialect, "traversal"), + where=WhereClause(dialect, condition=Column(dialect, "depth") == Literal(dialect, 2)), + order_by=OrderByClause(dialect, [Column(dialect, "name")]), + ) + + with_query = WithQueryExpression( + dialect=dialect, + ctes=[cte], + main_query=main, + recursive=True, + ) + + sql, params = with_query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names = [r["name"] for r in rows] + assert "Charlie" in names + assert len(names) == 1 + + def test_exclude_start_node(self, postgres_backend, social_network_data): + """Q3: Exclude Alice herself by filtering depth >= 1.""" + dialect = postgres_backend.dialect + cte = self._build_traversal_cte(dialect) + + main = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "name"), + Column(dialect, "depth"), + ], + from_=TableExpression(dialect, "traversal"), + where=WhereClause(dialect, condition=Column(dialect, "depth") >= Literal(dialect, 1)), + order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "name")]), + ) + + with_query = WithQueryExpression( + dialect=dialect, + ctes=[cte], + main_query=main, + recursive=True, + ) + + sql, params = with_query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + names = [r["name"] for r in rows] + assert "Alice" not in names + assert len(names) == 3 # Bob, Charlie (depth=1), Charlie (depth=2) + + +class TestAMLFundTracing: + """Recursive CTE for fund source tracing (AML).""" + + def _build_aml_cte(self, dialect): + """Build recursive CTE expression for fund tracing.""" + base_join = JoinExpression( + dialect=dialect, + left_table=TableExpression(dialect, "transactions", alias="tx"), + right_table=TableExpression(dialect, "accounts", alias="a"), + join_type="INNER JOIN", + condition=Column(dialect, "source_account_id", table="tx") == Column(dialect, "id", table="a"), + ) + + base = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id", table="a"), + Column(dialect, "account_holder", table="a"), + Column(dialect, "account_type", table="a"), + Column(dialect, "amount", table="tx"), + Literal(dialect, 1).as_("depth"), + ], + from_=base_join, + where=WhereClause( + dialect, + condition=Column(dialect, "target_account_id", table="tx") + == Literal(dialect, 1), + ), + ) + + recursive_join = JoinExpression( + dialect=dialect, + left_table=TableExpression(dialect, "fund_trace", alias="tr"), + right_table=TableExpression(dialect, "transactions", alias="tx"), + join_type="INNER JOIN", + condition=Column(dialect, "target_account_id", table="tx") == Column(dialect, "id", table="tr"), + ).inner_join( + right_table=TableExpression(dialect, "accounts", alias="a"), + condition=Column(dialect, "source_account_id", table="tx") == Column(dialect, "id", table="a"), + ) + + recursive = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id", table="a"), + Column(dialect, "account_holder", table="a"), + Column(dialect, "account_type", table="a"), + Column(dialect, "amount", table="tx"), + (Column(dialect, "depth", table="tr") + Literal(dialect, 1)).as_("depth"), + ], + from_=recursive_join, + where=WhereClause(dialect, condition=Column(dialect, "depth", table="tr") < Literal(dialect, 5)), + ) + + union = SetOperationExpression( + dialect=dialect, + left=base, + right=recursive, + operation="UNION ALL", + ) + + return CTEExpression( + dialect=dialect, + name="fund_trace", + query=union, + columns=["id", "account_holder", "account_type", "amount", "depth"], + ) + + def test_fund_trace_chain(self, postgres_backend, aml_data): + """Trace fund sources for account ACC-001.""" + dialect = postgres_backend.dialect + cte = self._build_aml_cte(dialect) + + main = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "account_holder"), + Column(dialect, "depth"), + ], + from_=TableExpression(dialect, "fund_trace"), + where=WhereClause(dialect, condition=Column(dialect, "depth") >= Literal(dialect, 1)), + order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "id")]), + ) + + with_query = WithQueryExpression( + dialect=dialect, + ctes=[cte], + main_query=main, + recursive=True, + ) + + sql, params = with_query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + account_names = [r["account_holder"] for r in rows] + # Direct source: Bob Corp (depth 1) + assert "Bob Corp" in account_names + # Indirect: Charlie Ltd (depth 2) -> Bob Corp + assert "Charlie Ltd" in account_names + # Indirect: Diana Offshore (depth 3) -> Charlie Ltd + assert "Diana Offshore" in account_names + + def test_fund_trace_depth_filter(self, postgres_backend, aml_data): + """Filter fund sources by minimum depth.""" + dialect = postgres_backend.dialect + cte = self._build_aml_cte(dialect) + + main = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "id"), + Column(dialect, "account_holder"), + Column(dialect, "depth"), + ], + from_=TableExpression(dialect, "fund_trace"), + where=WhereClause(dialect, condition=Column(dialect, "depth") >= Literal(dialect, 2)), + order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "id")]), + ) + + with_query = WithQueryExpression( + dialect=dialect, + ctes=[cte], + main_query=main, + recursive=True, + ) + + sql, params = with_query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + depths = [r["depth"] for r in rows] + assert all(d >= 2 for d in depths) + # Depth 2: Charlie Ltd (via Bob Corp) + assert any(r["account_holder"] == "Charlie Ltd" for r in rows) + + def test_fund_trace_aggregation(self, postgres_backend, aml_data): + """Aggregate total amount by depth level.""" + dialect = postgres_backend.dialect + cte = self._build_aml_cte(dialect) + + agg_main = QueryExpression( + dialect=dialect, + select=[ + Column(dialect, "depth"), + FunctionCall(dialect, "SUM", Column(dialect, "amount"), alias="total"), + ], + from_=TableExpression(dialect, "fund_trace"), + group_by_having=GroupByHavingClause( + dialect, group_by=[Column(dialect, "depth")] + ), + order_by=OrderByClause(dialect, [Column(dialect, "depth")]), + ) + + with_query = WithQueryExpression( + dialect=dialect, + ctes=[cte], + main_query=agg_main, + recursive=True, + ) + + sql, params = with_query.to_sql() + rows = postgres_backend.fetch_all(sql, params) + totals = {r["depth"]: float(r["total"]) for r in rows} + # Depth 1: Bob Corp sent 450000 to Alice Smith + assert totals.get(1) == 450000 + # Depth 2: Charlie Ltd sent 600000 to Bob Corp + assert totals.get(2) == 600000 + # Depth 3: Diana Offshore (500000) + Eve Holding (300000) sent to Charlie Ltd + assert totals.get(3) == 800000 + + +class TestAsyncRecursiveCTEGraph: + """Async versions of recursive CTE graph tests.""" + + @pytest_asyncio.fixture + async def async_social_network_data(self, async_postgres_backend): + backend = async_postgres_backend + dialect = backend.dialect + + for t in ("follows", "users"): + await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + await backend.execute(*CreateTableExpression(dialect, "users", [ + ColumnDefinition("id", IntegerType(), constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("name", VarCharType(100)), + ]).to_sql()) + + await backend.execute(*CreateTableExpression(dialect, "follows", [ + ColumnDefinition("id", IntegerType(), constraints=[ + ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition("follower_id", IntegerType()), + ColumnDefinition("followed_id", IntegerType()), + ]).to_sql()) + + await backend.execute(*InsertExpression(dialect, "users", columns=["id", "name"], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, "Alice")], + [Literal(dialect, 2), Literal(dialect, "Bob")], + ])).to_sql()) + + await backend.execute(*InsertExpression(dialect, "follows", columns=["id", "follower_id", "followed_id"], + source=ValuesSource(dialect, [ + [Literal(dialect, 1), Literal(dialect, 1), Literal(dialect, 2)], + ])).to_sql()) + + yield + + for t in ("follows", "users"): + await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) + + @pytest.mark.asyncio + async def test_async_single_hop(self, async_postgres_backend, async_social_network_data): + """Async: Alice's 1-hop friends.""" + dialect = async_postgres_backend.dialect + base = QueryExpression( + dialect=dialect, + select=[Column(dialect, "id"), Column(dialect, "name"), Literal(dialect, 0).as_("depth")], + from_=TableExpression(dialect, "users"), + where=WhereClause(dialect, condition=Column(dialect, "name") == Literal(dialect, "Alice")), + ) + result = base.to_sql() + sql, params = result + rows = await async_postgres_backend.fetch_all(sql, params) + assert len(rows) == 1 + assert rows[0]["name"] == "Alice" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py index 2b9afaff..f8485637 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py @@ -8,6 +8,9 @@ import pytest # noqa: F401 from rhosocial.activerecord.backend.expression import CreateTableExpression, ColumnDefinition from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, VarCharType, +) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -204,10 +207,10 @@ def test_like_ignores_columns(self): """Test that LIKE syntax ignores columns parameter.""" dialect = PostgresDialect() columns = [ - ColumnDefinition("id", "INTEGER", constraints=[ + ColumnDefinition("id", IntegerType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", "VARCHAR(255)") + ColumnDefinition("name", VarCharType(255)) ] create_expr = CreateTableExpression( dialect=dialect, @@ -258,10 +261,10 @@ def test_fallback_to_base_when_no_like(self): """Test that base implementation is used when LIKE is not specified.""" dialect = PostgresDialect() columns = [ - ColumnDefinition("id", "INTEGER", constraints=[ + ColumnDefinition("id", IntegerType(), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", "VARCHAR(255)", constraints=[ + ColumnDefinition("name", VarCharType(255), constraints=[ ColumnConstraint(ColumnConstraintType.NOT_NULL) ]) ] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cursor_pollution.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cursor_pollution.py new file mode 100644 index 00000000..c174b2ce --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cursor_pollution.py @@ -0,0 +1,161 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_cursor_pollution.py +""" +PostgreSQL cursor result set pollution tests. + +Verifies that after get_server_version() executes SELECT version() +and closes the cursor, subsequent queries on the same connection +do not see polluted cursor.description. +""" + +import logging + +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.feature, pytest.mark.backend] + + +class TestPostgresCursorPollution: + """Cursor pollution: sync PostgreSQL backend.""" + + def test_get_server_version_then_query(self, postgres_backend): + """get_server_version() then a normal query.""" + backend = postgres_backend + version = backend.get_server_version() + assert version is not None + + cursor = backend._get_cursor() + cursor.execute("SELECT 1 AS marker") + rows = cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "marker", ( + f"cursor.description polluted! expected 'marker', got: {col_name}" + ) + cursor.close() + + def test_introspect_and_adapt_then_query(self, postgres_backend): + """introspect_and_adapt() then a normal query.""" + backend = postgres_backend + backend.introspect_and_adapt() + + cursor = backend._get_cursor() + cursor.execute("SELECT 'ok' AS status") + rows = cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "status", ( + f"cursor.description polluted! expected 'status', got: {col_name}" + ) + cursor.close() + + def test_high_frequency_version_query_cycle(self, postgres_backend): + """Repeated get_server_version → query cycle to expose state leaks.""" + backend = postgres_backend + + for i in range(200): + backend.get_server_version() + + cursor = backend._get_cursor() + cursor.execute(f"SELECT {i} AS cycle") + rows = cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "cycle", ( + f"Iteration {i}: cursor.description polluted! " + f"expected 'cycle', got: {col_name}" + ) + cursor.close() + + logger.info("PostgreSQL high-frequency version query cycle 200 iterations passed") + + +class TestAsyncPostgresCursorPollution: + """Cursor pollution: async PostgreSQL backend.""" + + @pytest.mark.asyncio + async def test_get_server_version_then_query(self, async_postgres_backend): + """Async get_server_version() then a normal query.""" + backend = async_postgres_backend + version = await backend.get_server_version() + assert version is not None + + cursor = await backend._get_cursor() + await cursor.execute("SELECT 1 AS marker") + rows = await cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "marker", ( + f"Async cursor.description polluted! expected 'marker', got: {col_name}" + ) + await cursor.close() + + @pytest.mark.asyncio + async def test_introspect_and_adapt_then_query(self, async_postgres_backend): + """Async introspect_and_adapt() then a normal query.""" + backend = async_postgres_backend + await backend.introspect_and_adapt() + + cursor = await backend._get_cursor() + await cursor.execute("SELECT 'ok' AS status") + rows = await cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "status", ( + f"Async cursor.description polluted after introspect! " + f"expected 'status', got: {col_name}" + ) + await cursor.close() + + @pytest.mark.asyncio + async def test_context_entry_workflow(self, async_postgres_backend): + """backend.context() workflow (production pattern).""" + backend = async_postgres_backend + + async with backend.context(): + cursor = await backend._get_cursor() + await cursor.execute("SELECT 'ctx_ok' AS ctx_status") + rows = await cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "ctx_status", ( + f"Async cursor.description polluted after context()! " + f"expected 'ctx_status', got: {col_name}" + ) + await cursor.close() + + @pytest.mark.asyncio + async def test_high_frequency_version_query_cycle(self, async_postgres_backend): + """Async high-frequency version query cycle to expose state leaks.""" + backend = async_postgres_backend + + for i in range(200): + await backend.get_server_version() + + cursor = await backend._get_cursor() + await cursor.execute(f"SELECT {i} AS cycle") + rows = await cursor.fetchall() + + assert len(rows) > 0 + assert cursor.description is not None + col_name = cursor.description[0][0] + assert col_name == "cycle", ( + f"Iteration {i}: async cursor.description polluted! " + f"expected 'cycle', got: {col_name}" + ) + await cursor.close() + + logger.info("Async PostgreSQL high-frequency version query cycle 200 iterations passed") \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py index 6f0a7343..95649d01 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime from typing import Any, Iterable import pytest @@ -32,6 +31,12 @@ PostgresDetachPartitionExpression, PostgresPartitionMetadataExpression, ) +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, TextType, TimestampType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) LIST_PARENT = "ar_partition_adv_list_events" @@ -59,9 +64,9 @@ def _create_list_parent_expression(dialect): dialect=dialect, table=LIST_PARENT, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("status", "TEXT NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("status", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -87,9 +92,9 @@ def _create_hash_parent_expression(dialect): dialect=dialect, table=HASH_PARENT, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("bucket", "BIGINT NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("bucket", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -115,9 +120,9 @@ def _create_range_parent_expression(dialect): dialect=dialect, table=RANGE_PARENT, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -313,14 +318,13 @@ def test_default_attach_and_detach_workflow(self, postgres_backend, postgres_ran default_partition_name = "ar_partition_adv_default" # Create a table with matching structure for DEFAULT attachment - from rhosocial.activerecord.backend.expression.statements import DropTableExpression, TableConstraint create_default = CreateTableExpression( dialect=dialect, table=default_partition_name, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], ) postgres_backend.execute(*create_default.to_sql()) @@ -378,8 +382,8 @@ def test_metadata_include_partitions_false_and_schema_filter(self, postgres_back dialect=dialect, table=TableExpression(dialect, SCHEMA_PARENT, schema_name=schema), columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py new file mode 100644 index 00000000..e8a5bdbb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py @@ -0,0 +1,433 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py +"""PostgreSQL EXPLAIN tests for partitioned tables.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import pytest +import pytest_asyncio + +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnDefinition, + ColumnConstraint, + ColumnConstraintType, + CreateTableExpression, + DropTableExpression, + InsertExpression, + Literal, + QueryExpression, + TableExpression, + ValuesSource, + WildcardExpression, +) +from rhosocial.activerecord.backend.expression.statements import ( + ExplainExpression, + ExplainFormat, + ExplainOptions, + PartitionClause, + PartitionStrategy, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.types import BigIntType, TextType, TimestampType +from rhosocial.activerecord.backend.impl.postgres import PostgresExplainResult +from rhosocial.activerecord.backend.impl.postgres.expression import ( + PostgresCreatePartitionExpression, +) + +PARTITION_EXPLAIN_TABLE = "ar_pg_partition_explain_events" +PARTITION_NAMES = { + "p2026_01": ("ar_pg_partition_explain_events_p2026_01", "2026-01-01", "2026-02-01"), + "p2026_02": ("ar_pg_partition_explain_events_p2026_02", "2026-02-01", "2026-03-01"), + "p2026_03": ("ar_pg_partition_explain_events_p2026_03", "2026-03-01", "2026-04-01"), +} + + +def _all_tables(): + tables = [PARTITION_EXPLAIN_TABLE] + for _, name, _, _ in _iter_partitions(): + tables.append(name) + return tables + + +def _iter_partitions(): + for key, (name, from_val, to_val) in PARTITION_NAMES.items(): + yield key, name, from_val, to_val + + +def _drop_table_sql(dialect, table_name: str): + return DropTableExpression(dialect=dialect, table=table_name, if_exists=True).to_sql() + + +def _create_parent_table_expression(dialect): + return CreateTableExpression( + dialect=dialect, + table=PARTITION_EXPLAIN_TABLE, + if_not_exists=False, + columns=[ + ColumnDefinition("id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(6), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tenant_id", BigIntType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType()), + ], + table_constraints=[ + TableConstraint( + TableConstraintType.PRIMARY_KEY, + columns=["id", "created_at"], + ), + ], + partition=PartitionClause( + dialect=dialect, + method=PartitionStrategy.RANGE, + keys=[Column(dialect, "created_at")], + ), + ) + + +def _create_partition_sql(dialect, partition_name: str, from_value: str, to_value: str): + expr = PostgresCreatePartitionExpression( + dialect=dialect, + partition_name=partition_name, + parent_table=PARTITION_EXPLAIN_TABLE, + partition_type="RANGE", + partition_values={"from": from_value, "to": to_value}, + ) + return expr.to_sql() + + +def _seed_rows_expression(dialect): + rows = [ + [1, datetime(2026, 1, 15), 100, "jan"], + [2, datetime(2026, 2, 15), 100, "feb"], + [3, datetime(2026, 3, 15), 200, "mar"], + ] + return InsertExpression( + dialect=dialect, + into=PARTITION_EXPLAIN_TABLE, + columns=["id", "created_at", "tenant_id", "payload"], + source=ValuesSource( + dialect, + [[Literal(dialect, value) for value in row] for row in rows], + ), + ) + + +def _range_query_expression(dialect, start, end): + return QueryExpression( + dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, PARTITION_EXPLAIN_TABLE), + where=(Column(dialect, "created_at") >= Literal(dialect, start)) + & (Column(dialect, "created_at") < Literal(dialect, end)), + ) + + +def _full_scan_query_expression(dialect): + return QueryExpression( + dialect, + select=[WildcardExpression(dialect)], + from_=TableExpression(dialect, PARTITION_EXPLAIN_TABLE), + ) + + +def _json_explain_sql(dialect, query_expr): + expr = ExplainExpression( + dialect, + query_expr, + options=ExplainOptions(format=ExplainFormat.JSON), + ) + return expr.to_sql() + + +def _setup_partition_table(backend): + dialect = backend.dialect + for table in _all_tables(): + backend.execute(*_drop_table_sql(dialect, table)) + backend.execute(*_create_parent_table_expression(dialect).to_sql()) + for _, name, from_val, to_val in _iter_partitions(): + backend.execute(*_create_partition_sql(dialect, name, from_val, to_val)) + backend.execute(*_seed_rows_expression(dialect).to_sql()) + + +async def _async_setup_partition_table(backend): + dialect = backend.dialect + for table in _all_tables(): + await backend.execute(*_drop_table_sql(dialect, table)) + await backend.execute(*_create_parent_table_expression(dialect).to_sql()) + for _, name, from_val, to_val in _iter_partitions(): + await backend.execute(*_create_partition_sql(dialect, name, from_val, to_val)) + await backend.execute(*_seed_rows_expression(dialect).to_sql()) + + +def _teardown_partition_table(backend): + dialect = backend.dialect + for table in reversed(_all_tables()): + try: + backend.execute(*_drop_table_sql(dialect, table)) + except Exception: + pass + + +async def _async_teardown_partition_table(backend): + dialect = backend.dialect + for table in reversed(_all_tables()): + try: + await backend.execute(*_drop_table_sql(dialect, table)) + except Exception: + pass + + +def _flatten_plan_text(plan: Any, lines: list) -> None: + """Recursively extract text plan lines from a JSON EXPLAIN result.""" + if isinstance(plan, list): + for item in plan: + _flatten_plan_text(item, lines) + elif isinstance(plan, dict): + if "Plan" in plan: + _flatten_plan_text(plan["Plan"], lines) + for key, value in plan.items(): + if key in ("Plans", "Subplans"): + for sub in value: + _flatten_plan_text(sub, lines) + elif key == "Relation Name": + lines.append(value) + elif isinstance(plan, str): + lines.append(plan) + + +def _parse_json_plan(result: PostgresExplainResult) -> Any: + """Extract the JSON plan from a PostgresExplainResult. + + psycopg's JSON adapter may deserialize the column value automatically, + so we accept both parsed (list/dict) and unparsed (str) values. + """ + raw = result.raw_rows + if raw and isinstance(raw[0], dict) and "QUERY PLAN" in raw[0]: + plan_data = raw[0]["QUERY PLAN"] + if isinstance(plan_data, (list, dict)): + return plan_data + import json + return json.loads(plan_data) + if raw and isinstance(raw[0], tuple): + plan_data = raw[0][0] + if isinstance(plan_data, (list, dict)): + return plan_data + import json + return json.loads(plan_data) + return {} + + +def _extract_relation_names(result: PostgresExplainResult) -> list[str]: + """Extract partition relation names from a JSON-format EXPLAIN result.""" + plan = _parse_json_plan(result) + names: list[str] = [] + _flatten_plan_text(plan, names) + return names + + +def _assert_range_partition_support(dialect): + if not dialect.supports_partitioned_table_creation(): + pytest.skip("PostgreSQL scenario does not support declarative partitioning") + version = dialect.get_server_version() + if version and version[0] < 11: + pytest.skip("PostgreSQL 10 does not support primary keys on partitioned tables") + + +@pytest.fixture +def pg_partition_explain_backend(postgres_backend): + """Create a range-partitioned table for EXPLAIN tests.""" + _assert_range_partition_support(postgres_backend.dialect) + _setup_partition_table(postgres_backend) + yield postgres_backend + _teardown_partition_table(postgres_backend) + + +@pytest_asyncio.fixture +async def async_pg_partition_explain_backend(async_postgres_backend): + """Async counterpart for partition explain tests.""" + _assert_range_partition_support(async_postgres_backend.dialect) + await _async_setup_partition_table(async_postgres_backend) + yield async_postgres_backend + await _async_teardown_partition_table(async_postgres_backend) + + +def _combined_plan_text(result: PostgresExplainResult) -> str: + """Join all lines of the EXPLAIN TEXT output into a single string.""" + lines = [] + for row in result.raw_rows: + if isinstance(row, dict): + for val in row.values(): + lines.append(str(val)) + elif isinstance(row, tuple): + for val in row: + lines.append(str(val)) + else: + lines.append(str(row)) + return "\n".join(lines) + + +class TestPostgresPartitionExplain: + """Synchronous EXPLAIN tests for PostgreSQL partition pruning.""" + + def test_explain_partition_pruning_on_range_predicate( + self, + pg_partition_explain_backend, + ): + """Range predicate on partition key should prune unrelated partitions.""" + dialect = pg_partition_explain_backend.dialect + query = _range_query_expression( + dialect, + datetime(2026, 2, 1), + datetime(2026, 3, 1), + ) + result = pg_partition_explain_backend.explain(query) + assert isinstance(result, PostgresExplainResult) + assert len(result.raw_rows) > 0 + + def test_explain_json_reports_target_partition( + self, + pg_partition_explain_backend, + ): + """EXPLAIN FORMAT JSON should expose the pruned target partition name.""" + dialect = pg_partition_explain_backend.dialect + query = _range_query_expression( + dialect, + datetime(2026, 2, 1), + datetime(2026, 3, 1), + ) + sql, params = _json_explain_sql(dialect, query) + rows = pg_partition_explain_backend.fetch_all(sql, params) + result = PostgresExplainResult(raw_rows=rows, sql=sql, duration=0.0) + names = _extract_relation_names(result) + + expected = PARTITION_NAMES["p2026_02"][0] + assert expected in names, f"Expected partition {expected} in query plan, got {names}" + + def test_explain_json_full_scan_lists_scan_method( + self, + pg_partition_explain_backend, + ): + """Full scan on partitioned table should show an Append or Seq Scan plan.""" + dialect = pg_partition_explain_backend.dialect + sql, params = _json_explain_sql(dialect, _full_scan_query_expression(dialect)) + rows = pg_partition_explain_backend.fetch_all(sql, params) + result = PostgresExplainResult(raw_rows=rows, sql=sql, duration=0.0) + plan_text = str(result.raw_rows) + assert "Append" in plan_text or "Seq Scan" in plan_text + + def test_explain_with_verbose_contains_partition_info( + self, + pg_partition_explain_backend, + ): + """EXPLAIN VERBOSE should expose partition-related details.""" + dialect = pg_partition_explain_backend.dialect + query = _range_query_expression( + dialect, + datetime(2026, 1, 1), + datetime(2026, 2, 1), + ) + verbose_expr = ExplainExpression( + dialect, + query, + options=ExplainOptions(verbose=True), + ) + sql, params = verbose_expr.to_sql() + rows = pg_partition_explain_backend.fetch_all(sql, params) + result = PostgresExplainResult(raw_rows=rows, sql=sql, duration=0.0) + plan = _combined_plan_text(result) + expected = PARTITION_NAMES["p2026_01"][0] + assert expected in plan, f"Expected partition {expected} in verbose plan, got {plan}" + + def test_explain_full_scan_returns_rows( + self, + pg_partition_explain_backend, + ): + """Basic EXPLAIN on full scan should return a non-empty result.""" + dialect = pg_partition_explain_backend.dialect + result = pg_partition_explain_backend.explain( + _full_scan_query_expression(dialect) + ) + assert isinstance(result, PostgresExplainResult) + assert len(result.raw_rows) > 0 + + +class TestAsyncPostgresPartitionExplain: + """Asynchronous EXPLAIN tests for PostgreSQL partition pruning.""" + + @pytest.mark.asyncio + async def test_explain_partition_pruning_on_range_predicate( + self, + async_pg_partition_explain_backend, + ): + """Range predicate on partition key should prune unrelated partitions.""" + dialect = async_pg_partition_explain_backend.dialect + query = _range_query_expression( + dialect, + datetime(2026, 2, 1), + datetime(2026, 3, 1), + ) + result = await async_pg_partition_explain_backend.explain(query) + assert isinstance(result, PostgresExplainResult) + assert len(result.raw_rows) > 0 + + @pytest.mark.asyncio + async def test_explain_json_reports_target_partition( + self, + async_pg_partition_explain_backend, + ): + """EXPLAIN FORMAT JSON should expose the pruned target partition name.""" + dialect = async_pg_partition_explain_backend.dialect + query = _range_query_expression( + dialect, + datetime(2026, 2, 1), + datetime(2026, 3, 1), + ) + sql, params = _json_explain_sql(dialect, query) + rows = await async_pg_partition_explain_backend.fetch_all(sql, params) + result = PostgresExplainResult(raw_rows=rows, sql=sql, duration=0.0) + names = _extract_relation_names(result) + expected = PARTITION_NAMES["p2026_02"][0] + assert expected in names, f"Expected partition {expected} in query plan, got {names}" + + @pytest.mark.asyncio + async def test_explain_with_verbose_contains_partition_info( + self, + async_pg_partition_explain_backend, + ): + """EXPLAIN VERBOSE should expose partition-related details.""" + dialect = async_pg_partition_explain_backend.dialect + query = _range_query_expression( + dialect, + datetime(2026, 1, 1), + datetime(2026, 2, 1), + ) + verbose_expr = ExplainExpression( + dialect, + query, + options=ExplainOptions(verbose=True), + ) + sql, params = verbose_expr.to_sql() + rows = await async_pg_partition_explain_backend.fetch_all(sql, params) + result = PostgresExplainResult(raw_rows=rows, sql=sql, duration=0.0) + plan = _combined_plan_text(result) + expected = PARTITION_NAMES["p2026_01"][0] + assert expected in plan, f"Expected partition {expected} in verbose plan, got {plan}" + + @pytest.mark.asyncio + async def test_explain_full_scan_returns_rows( + self, + async_pg_partition_explain_backend, + ): + """Basic EXPLAIN on full scan should return a non-empty result.""" + dialect = async_pg_partition_explain_backend.dialect + result = await async_pg_partition_explain_backend.explain( + _full_scan_query_expression(dialect) + ) + assert isinstance(result, PostgresExplainResult) + assert len(result.raw_rows) > 0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py index 5ded526f..fed659ae 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -51,6 +51,12 @@ ensure_extension_installed, async_ensure_extension_installed, ) +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, TextType, TimestampType, +) +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, ColumnConstraintType, +) PARTITION_TABLES = ( @@ -76,9 +82,9 @@ def _create_partitioned_parent_sql(dialect, table_name: str): dialect=dialect, table=table_name, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ColumnDefinition("payload", "TEXT"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType()), ], partition=PartitionClause( dialect=dialect, @@ -200,10 +206,10 @@ def _create_production_parent_sql(dialect): dialect=dialect, table=PRODUCTION_PARTITION_TABLE, columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP(6) NOT NULL"), - ColumnDefinition("tenant_id", "BIGINT NOT NULL"), - ColumnDefinition("payload", "TEXT NOT NULL"), + ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("created_at", TimestampType(6), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ TableConstraint( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index fe763442..dca80715 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -17,6 +17,7 @@ TableConstraint, TableConstraintType, ) +from rhosocial.activerecord.backend.expression.types import VarCharType from typing import Tuple, Any # noqa: F401 @@ -41,25 +42,23 @@ def test_postgres_validate_data_type(dialect): def test_postgres_format_column_definition_data_type_validation(dialect): - """Test column definition validates data_type.""" + """Test column definition formats data_type correctly.""" col_def = ColumnDefinition( name="test_col", - data_type="VARCHAR(255)", + data_type=VarCharType(255), ) sql, params = dialect.format_column_definition(col_def) assert "VARCHAR(255)" in sql -def test_postgres_format_column_definition_data_type_rejects_injection(dialect): - """Test that malicious data_type is rejected.""" - col_def = ColumnDefinition( - name="test_col", - data_type="VARCHAR(255); DROP TABLE users--", - ) - - with pytest.raises(ValueError, match="Invalid data type"): - dialect.format_column_definition(col_def) +def test_postgres_column_definition_rejects_string_data_type(dialect): + """Test that ColumnDefinition rejects string data_type at construction.""" + with pytest.raises(TypeError, match="data_type must be a DataType instance"): + ColumnDefinition( + name="test_col", + data_type="VARCHAR(255); DROP TABLE users--", + ) def test_postgres_format_default_constraint_string_escaping(dialect): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py new file mode 100644 index 00000000..c7c286f9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py @@ -0,0 +1,435 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py +"""Pure-function tests for PostgreSQL DataType formatting and parsing. + +These tests exercise ``PostgresTypeFormatSupportMixin`` (the +``PostgresDialect.format_data_type`` and ``PostgresDialect.parse_type`` +paths) without requiring a live PostgreSQL server. +""" + +import pytest + +from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BigIntType, + BlobType, + BooleanType, + CharType, + CustomType, + DateType, + DateTimeType, + DecimalType, + DoubleType, + FloatType, + IntegerType, + IntType, + IntervalType, + JsonBType, + JsonType, + RealType, + SmallIntType, + TextType, + TimeType, + TimeTzType, + TimestampType, + TimestampTzType, + TinyIntType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCharacterVaryingType, + PostgresCidrType, + PostgresCircleType, + PostgresCitextType, + PostgresCubeType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresLtreeType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRasterType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, +) + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(15, 0, 0)) + + +# --------------------------------------------------------------------------- +# format_data_type — PostgreSQL-specific types +# --------------------------------------------------------------------------- + +FORMAT_CASES = [ + (PostgresByteaType(), "BYTEA"), + (PostgresSmallSerialType(), "SMALLSERIAL"), + (PostgresSerialType(), "SERIAL"), + (PostgresBigSerialType(), "BIGSERIAL"), + (PostgresUUIDType(), "UUID"), + (PostgresXMLType(), "XML"), + (PostgresTSVectorType(), "TSVECTOR"), + (PostgresTSQueryType(), "TSQUERY"), + (PostgresJsonPathType(), "JSONPATH"), + (PostgresBitType(), "BIT"), + (PostgresBitType(n=8), "BIT(8)"), + (PostgresVarBitType(), "VARBIT"), + (PostgresVarBitType(n=16), "VARBIT(16)"), + (PostgresInetType(), "INET"), + (PostgresCidrType(), "CIDR"), + (PostgresMacAddrType(), "MACADDR"), + (PostgresMacAddr8Type(), "MACADDR8"), + (PostgresPointType(), "POINT"), + (PostgresLineType(), "LINE"), + (PostgresLineSegmentType(), "LSEG"), + (PostgresBoxType(), "BOX"), + (PostgresPathType(), "PATH"), + (PostgresPolygonType(), "POLYGON"), + (PostgresCircleType(), "CIRCLE"), + (PostgresMoneyType(), "MONEY"), + (PostgresInt4RangeType(), "INT4RANGE"), + (PostgresInt8RangeType(), "INT8RANGE"), + (PostgresNumRangeType(), "NUMRANGE"), + (PostgresTsRangeType(), "TSRANGE"), + (PostgresTsTzRangeType(), "TSTZRANGE"), + (PostgresDateRangeType(), "DATERANGE"), + (PostgresInt4MultirangeType(), "INT4MULTIRANGE"), + (PostgresInt8MultirangeType(), "INT8MULTIRANGE"), + (PostgresNumMultirangeType(), "NUMMULTIRANGE"), + (PostgresTsMultirangeType(), "TSMULTIRANGE"), + (PostgresTsTzMultirangeType(), "TSTZMULTIRANGE"), + (PostgresDateMultirangeType(), "DATEMULTIRANGE"), + (PostgresOIDType(), "OID"), + (PostgresRegClassType(), "REGCLASS"), + (PostgresRegTypeType(), "REGTYPE"), + (PostgresXIDType(), "XID"), + (PostgresXID8Type(), "XID8"), + (PostgresCIDType(), "CID"), + (PostgresTIDType(), "TID"), + (PostgresPgLSNType(), "PG_LSN"), + (PostgresHstoreType(), "HSTORE"), + (PostgresGeometryType(), "GEOMETRY"), + (PostgresGeographyType(), "GEOGRAPHY"), + (PostgresVectorType(dim=384), "VECTOR(384)"), + (PostgresCitextType(), "CITEXT"), + (PostgresCubeType(), "CUBE"), + (PostgresLtreeType(), "LTREE"), + (PostgresRasterType(), "RASTER"), + (PostgresCharacterVaryingType(), "CHARACTER VARYING"), + (PostgresCharacterVaryingType(length=100), "CHARACTER VARYING(100)"), +] + + +@pytest.mark.parametrize("data_type,expected", FORMAT_CASES) +def test_format_postgres_specific_types(dialect, data_type, expected): + sql, params = dialect.format_data_type(data_type) + assert sql == expected + assert params == () + + +# --------------------------------------------------------------------------- +# format_data_type — core types with PostgreSQL specialisation +# --------------------------------------------------------------------------- + +CORE_FORMAT_CASES = [ + (TinyIntType(), "SMALLINT"), + (SmallIntType(), "SMALLINT"), + (IntType(), "INTEGER"), + (IntegerType(), "INTEGER"), + (BigIntType(), "BIGINT"), + (FloatType(), "REAL"), + (FloatType(precision=8), "FLOAT(8)"), + (RealType(), "REAL"), + (DoubleType(), "DOUBLE PRECISION"), + (DecimalType(), "DECIMAL"), + (DecimalType(precision=10), "DECIMAL(10)"), + (DecimalType(precision=10, scale=2), "DECIMAL(10,2)"), + (CharType(), "CHAR"), + (CharType(length=5), "CHAR(5)"), + (VarCharType(), "VARCHAR"), + (VarCharType(length=50), "VARCHAR(50)"), + (TextType(), "TEXT"), + (BooleanType(), "BOOLEAN"), + (BlobType(), "BYTEA"), + (DateType(), "DATE"), + (TimeType(), "TIME"), + (TimeType(precision=6), "TIME(6)"), + (TimeTzType(), "TIME WITH TIME ZONE"), + (TimeTzType(precision=6), "TIME(6) WITH TIME ZONE"), + (DateTimeType(), "TIMESTAMP"), + (TimestampType(), "TIMESTAMP"), + (TimestampType(precision=3), "TIMESTAMP(3)"), + (TimestampTzType(), "TIMESTAMP WITH TIME ZONE"), + (TimestampTzType(precision=3), "TIMESTAMP(3) WITH TIME ZONE"), + (IntervalType(), "INTERVAL"), + (IntervalType(fields="DAY TO SECOND"), "INTERVAL DAY TO SECOND"), + (JsonType(), "JSON"), + (JsonBType(), "JSONB"), +] + + +@pytest.mark.parametrize("data_type,expected", CORE_FORMAT_CASES) +def test_format_core_types(dialect, data_type, expected): + sql, params = dialect.format_data_type(data_type) + assert sql == expected + assert params == () + + +def test_format_array_type(dialect): + sql, _ = dialect.format_data_type(ArrayType(IntegerType(), dimensions=1)) + assert sql == "INTEGER[]" + + sql, _ = dialect.format_data_type(ArrayType(VarCharType(10), dimensions=3)) + assert sql == "VARCHAR(10)[][][]" + + +def test_format_array_type_multidimensional(dialect): + sql, _ = dialect.format_data_type(ArrayType(IntegerType(), dimensions=2)) + assert sql == "INTEGER[][]" + + +def test_format_array_type_with_postgres_element(dialect): + sql, _ = dialect.format_data_type(ArrayType(PostgresUUIDType(), dimensions=1)) + assert sql == "UUID[]" + + +def test_format_array_type_unsupported_element_raises(dialect): + with pytest.raises(TypeError, match="is not supported"): + dialect.format_data_type(ArrayType(CustomType("X"), dimensions=1)) + + +def test_format_unregistered_type_raises(dialect): + with pytest.raises(TypeError, match="does not support"): + dialect.format_data_type(CustomType("X")) + + +# --------------------------------------------------------------------------- +# parse_type — round-trip families +# --------------------------------------------------------------------------- + +PARSE_CASES = [ + ("SMALLINT", SmallIntType, {}), + ("INT2", SmallIntType, {}), + ("INT", IntegerType, {}), + ("INTEGER", IntegerType, {}), + ("INT4", IntegerType, {}), + ("BIGINT", BigIntType, {}), + ("INT8", BigIntType, {}), + ("SMALLSERIAL", PostgresSmallSerialType, {}), + ("SERIAL2", PostgresSmallSerialType, {}), + ("SERIAL", PostgresSerialType, {}), + ("SERIAL4", PostgresSerialType, {}), + ("BIGSERIAL", PostgresBigSerialType, {}), + ("SERIAL8", PostgresBigSerialType, {}), + ("REAL", RealType, {}), + ("FLOAT4", RealType, {}), + ("DOUBLE", DoubleType, {}), + ("DOUBLE PRECISION", DoubleType, {}), + ("FLOAT", FloatType, {}), + ("FLOAT(8)", FloatType, {"precision": 8}), + ("DECIMAL", DecimalType, {}), + ("NUMERIC", DecimalType, {}), + ("DECIMAL(10)", DecimalType, {"precision": 10}), + ("DECIMAL(10,2)", DecimalType, {"precision": 10, "scale": 2}), + ("NUMERIC(12,4)", DecimalType, {"precision": 12, "scale": 4}), + ("CHAR", CharType, {}), + ("CHAR(5)", CharType, {"length": 5}), + ("CHARACTER", CharType, {}), + ("CHARACTER(5)", CharType, {"length": 5}), + ("VARCHAR", VarCharType, {}), + ("VARCHAR(50)", VarCharType, {"length": 50}), + ("CHARACTER VARYING", VarCharType, {}), + ("CHARACTER VARYING(100)", VarCharType, {"length": 100}), + ("TEXT", TextType, {}), + ("BYTEA", PostgresByteaType, {}), + ("BLOB", PostgresByteaType, {}), + ("DATE", DateType, {}), + ("DATETIME", DateTimeType, {}), + ("TIMESTAMP", TimestampType, {}), + ("TIMESTAMP(3)", TimestampType, {"precision": 3}), + ("TIMESTAMPTZ", TimestampTzType, {}), + ("TIMESTAMP(3) WITH TIME ZONE", TimestampTzType, {"precision": 3}), + ("TIME", TimeType, {}), + ("TIME(6)", TimeType, {"precision": 6}), + ("TIMETZ", TimeTzType, {}), + ("TIME(6) WITH TIME ZONE", TimeTzType, {"precision": 6}), + ("INTERVAL", IntervalType, {}), + ("INTERVAL DAY TO SECOND", IntervalType, {"fields": "DAY TO SECOND"}), + ("JSON", JsonType, {}), + ("JSONB", JsonBType, {}), + ("JSONPATH", PostgresJsonPathType, {}), + ("UUID", PostgresUUIDType, {}), + ("BOOLEAN", BooleanType, {}), + ("BOOL", BooleanType, {}), + ("BIT", PostgresBitType, {}), + ("BIT(8)", PostgresBitType, {"n": 8}), + ("VARBIT", PostgresVarBitType, {}), + ("VARBIT(16)", PostgresVarBitType, {"n": 16}), + ("INET", PostgresInetType, {}), + ("CIDR", PostgresCidrType, {}), + ("MACADDR", PostgresMacAddrType, {}), + ("MACADDR8", PostgresMacAddr8Type, {}), + ("POINT", PostgresPointType, {}), + ("LINE", PostgresLineType, {}), + ("LSEG", PostgresLineSegmentType, {}), + ("BOX", PostgresBoxType, {}), + ("PATH", PostgresPathType, {}), + ("POLYGON", PostgresPolygonType, {}), + ("CIRCLE", PostgresCircleType, {}), + ("INT4RANGE", PostgresInt4RangeType, {}), + ("INT8RANGE", PostgresInt8RangeType, {}), + ("NUMRANGE", PostgresNumRangeType, {}), + ("TSRANGE", PostgresTsRangeType, {}), + ("TSTZRANGE", PostgresTsTzRangeType, {}), + ("DATERANGE", PostgresDateRangeType, {}), + ("INT4MULTIRANGE", PostgresInt4MultirangeType, {}), + ("INT8MULTIRANGE", PostgresInt8MultirangeType, {}), + ("NUMMULTIRANGE", PostgresNumMultirangeType, {}), + ("TSMULTIRANGE", PostgresTsMultirangeType, {}), + ("TSTZMULTIRANGE", PostgresTsTzMultirangeType, {}), + ("DATEMULTIRANGE", PostgresDateMultirangeType, {}), + ("OID", PostgresOIDType, {}), + ("REGCLASS", PostgresRegClassType, {}), + ("REGTYPE", PostgresRegTypeType, {}), + ("XID", PostgresXIDType, {}), + ("XID8", PostgresXID8Type, {}), + ("CID", PostgresCIDType, {}), + ("TID", PostgresTIDType, {}), + ("TSVECTOR", PostgresTSVectorType, {}), + ("TSQUERY", PostgresTSQueryType, {}), + ("MONEY", PostgresMoneyType, {}), + ("XML", PostgresXMLType, {}), + ("PG_LSN", PostgresPgLSNType, {}), + ("HSTORE", PostgresHstoreType, {}), + ("GEOGRAPHY", PostgresGeographyType, {}), + ("GEOMETRY", PostgresGeometryType, {}), + ("VECTOR", PostgresVectorType, {"dim": 0}), + ("VECTOR(768)", PostgresVectorType, {"dim": 768}), +] + + +@pytest.mark.parametrize("raw,expected_cls,expected_attrs", PARSE_CASES) +def test_parse_type(dialect, raw, expected_cls, expected_attrs): + result = dialect.parse_type(raw) + assert type(result) is expected_cls + for attr, value in expected_attrs.items(): + assert getattr(result, attr) == value + + +def test_parse_type_array_bracket_suffix(dialect): + result = dialect.parse_type("INTEGER[]") + assert type(result) is PostgresArrayType + assert type(result.element_type) is IntegerType + assert result.dimensions == 1 + + +def test_parse_type_array_multidimensional_brackets(dialect): + result = dialect.parse_type("INTEGER[][]") + assert type(result) is PostgresArrayType + assert type(result.element_type) is IntegerType + + +def test_parse_type_array_with_dimension_bracket(dialect): + result = dialect.parse_type("VARCHAR(10)[3]") + assert type(result) is PostgresArrayType + assert type(result.element_type) is VarCharType + assert result.element_type.length == 10 + + +def test_parse_type_malformed_bracket_suffix_falls_back(dialect): + result = dialect.parse_type("FOO[abc]") + assert type(result) is CustomType + + +def test_parse_type_array_keyword(dialect): + result = dialect.parse_type("INTEGER ARRAY") + assert type(result) is PostgresArrayType + assert type(result.element_type) is IntegerType + + +def test_parse_type_array_keyword_with_dimension(dialect): + result = dialect.parse_type("INTEGER ARRAY[3]") + assert type(result) is PostgresArrayType + assert type(result.element_type) is IntegerType + + +def test_parse_type_array_nested_element(dialect): + result = dialect.parse_type("UUID[]") + assert type(result) is PostgresArrayType + assert type(result.element_type) is PostgresUUIDType + + +def test_parse_type_unknown_falls_back_to_custom(dialect): + result = dialect.parse_type("FOOBARBAZ") + assert type(result) is CustomType + assert result.raw == "FOOBARBAZ" + + +def test_parse_type_case_insensitive(dialect): + result = dialect.parse_type(" varchar(25) ") + assert type(result) is VarCharType + assert result.length == 25 + + +# --------------------------------------------------------------------------- +# supports_data_types +# --------------------------------------------------------------------------- + + +def test_supports_data_types_registers_all_postgres_types(dialect): + supported = dialect.supports_data_types() + assert isinstance(supported, list) + assert len(supported) > 0 + classes = {cls for cls, _ in supported} + for expected in ( + PostgresSerialType, + PostgresByteaType, + PostgresUUIDType, + PostgresVectorType, + PostgresTsRangeType, + ArrayType, + JsonBType, + IntegerType, + ): + assert expected in classes diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py new file mode 100644 index 00000000..e8a96295 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py @@ -0,0 +1,104 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py +"""Tests for PostgreSQL-specific DataType subclasses (pure, no DB). + +Covers the ``synonyms()`` registrations, equality/hash semantics and +array-type equivalence used by introspection and schema comparison. +""" + +from rhosocial.activerecord.backend.expression.types import ( + IntegerType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + PostgresBigSerialType, + PostgresBitType, + PostgresByteaType, + PostgresCharacterVaryingType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresVarBitType, + PostgresVectorType, +) + + +class TestPostgresTypeSynonyms: + def test_character_varying_synonyms(self): + assert PostgresCharacterVaryingType.synonyms() == {"VarCharType"} + + def test_bytea_synonyms(self): + assert PostgresByteaType.synonyms() == {"BlobType"} + + def test_small_serial_synonyms(self): + assert PostgresSmallSerialType.synonyms() == {"SmallSerialType", "SmallIntType"} + + def test_serial_synonyms(self): + assert PostgresSerialType.synonyms() == {"SerialType", "IntegerType"} + + def test_big_serial_synonyms(self): + assert PostgresBigSerialType.synonyms() == {"BigSerialType", "BigIntType"} + + +class TestPostgresBitTypeEquality: + def test_equal(self): + assert PostgresBitType(8) == PostgresBitType(8) + + def test_not_equal_values(self): + assert PostgresBitType(8) != PostgresBitType(16) + + def test_not_equal_types(self): + assert PostgresBitType(8) != PostgresVarBitType(8) + assert PostgresBitType(8) != "not a type" + + def test_none_vs_value(self): + assert PostgresBitType(None) != PostgresBitType(8) + + def test_hash(self): + assert hash(PostgresBitType(8)) == hash((type(PostgresBitType(8)), 8)) + + +class TestPostgresVarBitTypeEquality: + def test_equal(self): + assert PostgresVarBitType(16) == PostgresVarBitType(16) + + def test_not_equal_values(self): + assert PostgresVarBitType(16) != PostgresVarBitType(32) + + def test_not_equal_types(self): + assert PostgresVarBitType(16) != PostgresBitType(16) + assert PostgresVarBitType(16) != object() + + def test_hash(self): + assert hash(PostgresVarBitType(16)) == hash((type(PostgresVarBitType(16)), 16)) + + +class TestPostgresVectorTypeEquality: + def test_equal(self): + assert PostgresVectorType(384) == PostgresVectorType(384) + + def test_not_equal_values(self): + assert PostgresVectorType(384) != PostgresVectorType(768) + + def test_not_equal_types(self): + assert PostgresVectorType(384) != PostgresBitType(8) + assert PostgresVectorType(384) != "vector" + + def test_hash(self): + assert hash(PostgresVectorType(384)) == hash((type(PostgresVectorType(384)), 384)) + + +class TestPostgresArrayType: + def test_is_equivalent_matching_element(self): + arr1 = PostgresArrayType(IntegerType(), dimensions=1) + arr2 = PostgresArrayType(IntegerType(), dimensions=3) + assert arr1.is_equivalent(arr2) + + def test_is_equivalent_non_array(self): + arr = PostgresArrayType(IntegerType(), dimensions=1) + assert arr.is_equivalent(IntegerType()) is False + assert arr.is_equivalent(None) is False + + def test_is_equivalent_different_element(self): + arr1 = PostgresArrayType(IntegerType(), dimensions=1) + arr2 = PostgresArrayType(VarCharType(), dimensions=1) + assert arr1.is_equivalent(arr2) is False diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations.py similarity index 57% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations.py index e0936eda..8e76ce45 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_bulk_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations.py @@ -8,4 +8,6 @@ async_bulk_user_class, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.basic.test_bulk_operations import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.bulk_crud.test_bulk_operations import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.bulk_crud.test_bulk_operations_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations_async.py new file mode 100644 index 00000000..2a67aa45 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/bulk_crud/test_bulk_operations_async.py +"""Bridge file for async feature.basic.bulk_crud.test_bulk_operations_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.bulk_crud.test_bulk_operations_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py index 92417f78..1f6cb9a6 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/conftest.py @@ -3,7 +3,28 @@ Pytest configuration for basic feature tests. This file imports fixtures from the corresponding testsuite, making them -available to the tests in this directory. +available to the tests in this directory. It overrides ``type_adapter_fixtures`` +so the scenario name is passed to the provider. Without this, the sync provider +always creates the ``type_adapter_tests`` table on the first registered scenario, +which collides with the async provider when both scenarios run in parallel. """ -from rhosocial.activerecord.testsuite.feature.basic.conftest import * # noqa: F403 +import pytest + +from rhosocial.activerecord.testsuite.feature.basic.conftest import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.basic.conftest import ( # noqa: F401 + SCENARIO_PARAMS_SYNC, + PROVIDER_KEY_SYNC, +) +from rhosocial.activerecord.testsuite.core.registry import get_provider_registry + + +@pytest.fixture(scope="function", params=SCENARIO_PARAMS_SYNC) +def type_adapter_fixtures(request): + scenario = request.param + provider_registry = get_provider_registry() + provider_class = provider_registry.get_provider(PROVIDER_KEY_SYNC) + provider = provider_class() + model = provider.setup_type_adapter_model_and_schema(scenario) + yield model + provider.cleanup_after_test(scenario) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py index 8f7e8cf3..b5ecbf1d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context.py @@ -12,3 +12,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_context import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_context_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context_async.py new file mode 100644 index 00000000..8d360f89 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_context_async.py +"""Bridge file for async feature.basic.connection.test_active_record_context_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_context_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py index eb4e162b..c9488510 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud.py @@ -12,3 +12,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_crud import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_crud_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud_async.py new file mode 100644 index 00000000..a826a9e8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_active_record_crud_async.py +"""Bridge file for async feature.basic.connection.test_active_record_crud_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.connection.test_active_record_crud_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py new file mode 100644 index 00000000..95ffd60b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths.py @@ -0,0 +1,213 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/connection/test_pool_transaction_paths.py +"""White-box contracts for `pool.transaction()` dispatch branches. + +`BackendPool.transaction()` (python-activerecord +`connection/pool/sync_pool.py:540-598`) and its async counterpart +(`connection/pool/async_pool.py:579-638`) dispatch on context state +across three exclusive branches: + + Path 1 Already inside a transaction context -> yield same backend + Path 2 Inside a connection-only context -> begin/commit on that + Path 3 No existing context -> acquire + begin + release + +These tests verify each branch's observable contract: backend identity, +token discipline via `context.*` helpers, and the cleanup path after an +exception. They are scoped to the pool layer; the underlying +TransactionManager state machine is exercised separately by +`test_savepoint_api.py` (T0-1). + +Every test in this module MUST have an async twin in +`test_pool_transaction_paths_async.py`. +""" +import pytest + +from rhosocial.activerecord.connection.pool import ( + get_current_transaction_backend, + get_current_connection_backend, + get_current_backend, +) + + +class TestSyncPath1ExistingTransactionIsReused: + """Path 1 (sync_pool.py:561-565): inside a transaction, no new begin/commit. + + The inner `pool.transaction()` MUST yield the same backend instance + and MUST NOT call begin/commit/rollback. Observable effect: the outer + transaction remains active after the inner block exits, and the outer + commit (not the inner) is what persists the data. + """ + + def test_inner_pool_transaction_yields_same_backend(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.transaction() as outer: + with pool.transaction() as inner: + assert inner is outer + + def test_inner_exits_does_not_commit_outer(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.transaction() as outer: + mgr = outer.transaction_manager + assert mgr.is_active + with pool.transaction() as _: + # still inside, level must not have changed by Path 1 + assert mgr.transaction_level == 1 + # After inner exits, outer still active at level 1 + assert mgr.is_active + assert mgr.transaction_level == 1 + + def test_path1_does_not_register_a_new_transaction_backend(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.transaction() as outer: + with pool.transaction() as _inner: + # ctx variable still resolves to exactly `outer` + assert get_current_transaction_backend() is outer + + +class TestSyncPath2ConnectionOnlyBranch: + """Path 2 (sync_pool.py:567-582): inside a connection-only context. + + A `with pool.connection()` block is open. `pool.transaction()` is + entered inside that block. The transaction MUST operate on the same + connection (no acquire), begin/commit issued on it, and after exit + the connection-only context MUST remain usable and TX token MUST be + cleared. + """ + + def test_transaction_reuses_the_connection_backend(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.connection() as conn: + with pool.transaction() as tx_backend: + assert tx_backend is conn + + def test_tx_token_set_during_path2_and_cleared_after(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.connection(): + assert get_current_transaction_backend() is None + with pool.transaction() as tx: + assert get_current_transaction_backend() is tx + # After exit, transaction token must be cleared even though + # the connection-only token persists. + assert get_current_transaction_backend() is None + # Connection token keeps pointing at the connection. + assert get_current_connection_backend() is tx + + def test_path2_commits_visible_after_exit(self, sync_pool_and_model): + pool, model = sync_pool_and_model + with pool.connection(): + with pool.transaction(): + instance = model(name="Path2User", email="p2@test.local") + instance.save() + # Connection-only context exited; transaction must already be committed. + with pool.connection() as _check: + assert model.query().where("name = ?", ("Path2User",)).count() == 1 + + def test_path2_rollback_on_exception_leaves_no_tx_token(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pytest.raises(RuntimeError): + with pool.connection() as conn: + with pool.transaction() as tx: + assert tx is conn + raise RuntimeError("boom") + # After the entire nested context unwinds, nothing is bound. + assert get_current_transaction_backend() is None + assert get_current_connection_backend() is None + + def test_path2_failure_does_not_break_outer_connection(self, sync_pool_and_model): + """After Path 2 rollback, the connection backend remains reusable.""" + pool, model = sync_pool_and_model + with pool.connection() as conn: + try: + with pool.transaction() as _tx: + raise RuntimeError("intentional") + except RuntimeError: + pass + # The same connection ctx is still active, and writes outside + # any nested transaction should auto-commit normally. + assert not conn.transaction_manager.is_active + instance = model(name="AfterFailure", email="af@test.local") + instance.save() + with pool.connection() as _check: + assert model.query().where("name = ?", ("AfterFailure",)).count() == 1 + + +class TestSyncPath3AcquireBranch: + """Path 3 (sync_pool.py:584-598): no existing context, full acquire cycle.""" + + def test_path3_yields_a_backend_bound_to_both_tx_and_conn_tokens(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.transaction() as backend: + assert get_current_transaction_backend() is backend + assert get_current_connection_backend() is backend + # get_current_backend resolves transaction first. + assert get_current_backend() is backend + + def test_path3_tokens_cleared_after_exit(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.transaction(): + pass + assert get_current_transaction_backend() is None + assert get_current_connection_backend() is None + assert get_current_backend() is None + + def test_path3_commit_on_clean_exit(self, sync_pool_and_model): + pool, model = sync_pool_and_model + with pool.transaction() as _backend: + model(name="P3Ok", email="p3ok@test.local").save() + with pool.connection() as _check: + assert model.query().where("name = ?", ("P3Ok",)).count() == 1 + + def test_path3_rollback_on_exception(self, sync_pool_and_model): + pool, model = sync_pool_and_model + with pytest.raises(RuntimeError): + with pool.transaction() as _backend: + model(name="P3Fail", email="p3fail@test.local").save() + raise RuntimeError("intentional") + with pool.connection() as _check: + assert model.query().where("name = ?", ("P3Fail",)).count() == 0 + + def test_path3_tokens_cleared_after_exception(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pytest.raises(RuntimeError): + with pool.transaction(): + raise RuntimeError("intentional") + assert get_current_transaction_backend() is None + assert get_current_connection_backend() is None + + +class TestSyncPathPrecedence: + """The three branches are mutually exclusive and checked in order + (transaction > connection > acquire). This precedence matters because + path 1 never opens a savepoint on the outer TX: were path 2 to run + when already inside a transaction, a second begin() would be issued + on the same backend, which is a real semantic bug that the order + guards against. + """ + + def test_transaction_context_beats_connection_only(self, sync_pool_and_model): + """When both a transaction and connection token are set, transaction + wins. Since `pool.transaction()` itself also sets the connection + token (line 587), this verifies the precedence by checking identity. + """ + pool, _model = sync_pool_and_model + with pool.transaction() as tx: + # Path 3 set both tokens to the same backend. A nested call + # MUST take Path 1 (transaction wins), not Path 2. + with pool.transaction() as inner: + assert inner is tx + + def test_no_savepoint_created_when_reentering_via_pool(self, sync_pool_and_model): + """Path 1 must not create a savepoint. + + Pool-level nesting reuses the outer transaction; the + TransactionManager's savepoint list must remain empty at the + outer level. This is the critical guard against the historical + bug where pool NESTED would double-begin on the same backend. + """ + pool, _model = sync_pool_and_model + with pool.transaction() as outer: + mgr = outer.transaction_manager + saved_sp = list(mgr._active_savepoints) + with pool.transaction(): + # No new savepoint should have been registered. + assert mgr._active_savepoints == saved_sp + assert mgr.transaction_level == 1 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths_async.py new file mode 100644 index 00000000..a9240fe8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/connection/test_pool_transaction_paths_async.py @@ -0,0 +1,149 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/connection/test_pool_transaction_paths_async.py +"""Async white-box contracts for `pool.transaction()` dispatch branches. + +Mirror of test_pool_transaction_paths.py exercising AsyncBackendPool +(`connection/pool/async_pool.py:579-638`). Every test MUST have a sync +twin in test_pool_transaction_paths.py to guarantee the sync/async +symmetry required by the project code style. +""" +import pytest + +from rhosocial.activerecord.connection.pool import ( + get_current_async_transaction_backend, + get_current_async_connection_backend, + get_current_async_backend, +) + + +class TestAsyncPath1ExistingTransactionIsReused: + async def test_inner_pool_transaction_yields_same_backend(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction() as outer: + async with pool.transaction() as inner: + assert inner is outer + + async def test_inner_exits_does_not_commit_outer(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction() as outer: + mgr = outer.transaction_manager + assert mgr.is_active + async with pool.transaction() as _: + assert mgr.transaction_level == 1 + assert mgr.is_active + assert mgr.transaction_level == 1 + + async def test_path1_does_not_register_a_new_transaction_backend(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction() as outer: + async with pool.transaction() as _inner: + assert get_current_async_transaction_backend() is outer + + +class TestAsyncPath2ConnectionOnlyBranch: + async def test_transaction_reuses_the_connection_backend(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.connection() as conn: + async with pool.transaction() as tx_backend: + assert tx_backend is conn + + async def test_tx_token_set_during_path2_and_cleared_after(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.connection(): + assert get_current_async_transaction_backend() is None + async with pool.transaction() as tx: + assert get_current_async_transaction_backend() is tx + assert get_current_async_transaction_backend() is None + assert get_current_async_connection_backend() is tx + + async def test_path2_commits_visible_after_exit(self, async_pool_and_model): + pool, model = async_pool_and_model + async with pool.connection(): + async with pool.transaction() as _backend: + instance = model(name="Path2User", email="p2@test.local") + await instance.save() + async with pool.connection() as _check: + assert await model.query().where("name = ?", ("Path2User",)).count() == 1 + + async def test_path2_rollback_on_exception_leaves_no_tx_token(self, async_pool_and_model): + pool, _model = async_pool_and_model + with pytest.raises(RuntimeError): + async with pool.connection() as conn: + async with pool.transaction() as tx: + assert tx is conn + raise RuntimeError("boom") + assert get_current_async_transaction_backend() is None + assert get_current_async_connection_backend() is None + + async def test_path2_failure_does_not_break_outer_connection(self, async_pool_and_model): + pool, model = async_pool_and_model + async with pool.connection() as conn: + try: + async with pool.transaction() as _tx: + raise RuntimeError("intentional") + except RuntimeError: + pass + assert not conn.transaction_manager.is_active + instance = model(name="AfterFailure", email="af@test.local") + await instance.save() + async with pool.connection() as _check: + assert await model.query().where("name = ?", ("AfterFailure",)).count() == 1 + + +class TestAsyncPath3AcquireBranch: + async def test_path3_yields_a_backend_bound_to_both_tx_and_conn_tokens(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction() as backend: + assert get_current_async_transaction_backend() is backend + assert get_current_async_connection_backend() is backend + assert get_current_async_backend() is backend + + async def test_path3_tokens_cleared_after_exit(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction(): + pass + assert get_current_async_transaction_backend() is None + assert get_current_async_connection_backend() is None + assert get_current_async_backend() is None + + async def test_path3_commit_on_clean_exit(self, async_pool_and_model): + pool, model = async_pool_and_model + async with pool.transaction() as _backend: + u = model(name="P3Ok", email="p3ok@test.local") + await u.save() + async with pool.connection() as _check: + assert await model.query().where("name = ?", ("P3Ok",)).count() == 1 + + async def test_path3_rollback_on_exception(self, async_pool_and_model): + pool, model = async_pool_and_model + with pytest.raises(RuntimeError): + async with pool.transaction() as _backend: + u = model(name="P3Fail", email="p3fail@test.local") + await u.save() + raise RuntimeError("intentional") + async with pool.connection() as _check: + assert await model.query().where("name = ?", ("P3Fail",)).count() == 0 + + async def test_path3_tokens_cleared_after_exception(self, async_pool_and_model): + pool, _model = async_pool_and_model + with pytest.raises(RuntimeError): + async with pool.transaction(): + raise RuntimeError("intentional") + assert get_current_async_transaction_backend() is None + assert get_current_async_connection_backend() is None + + +class TestAsyncPathPrecedence: + async def test_transaction_context_beats_connection_only(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction() as tx: + async with pool.transaction() as inner: + assert inner is tx + + async def test_no_savepoint_created_when_reentering_via_pool(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.transaction() as outer: + mgr = outer.transaction_manager + saved_sp = list(mgr._active_savepoints) + async with pool.transaction(): + assert mgr._active_savepoints == saved_sp + assert mgr.transaction_level == 1 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud.py new file mode 100644 index 00000000..1f34b0b8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud.py @@ -0,0 +1,6 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud.py +""" +Bridge file for composite PK CRUD tests from the testsuite. +""" +from rhosocial.activerecord.testsuite.feature.basic.crud.test_composite_pk_crud import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.crud.test_composite_pk_crud_async import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud_async.py new file mode 100644 index 00000000..5bf67d8f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_composite_pk_crud_async.py +"""Bridge file for async feature.basic.crud.test_composite_pk_crud_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.crud.test_composite_pk_crud_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud.py similarity index 87% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud.py index c434da79..ffc5439b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_crud.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud.py @@ -27,4 +27,6 @@ ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.test_crud import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.crud.test_crud import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.crud.test_crud_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud_async.py new file mode 100644 index 00000000..b8ffe646 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/crud/test_crud_async.py +"""Bridge file for async feature.basic.crud.test_crud_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.crud.test_crud_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py new file mode 100644 index 00000000..7cc74555 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py @@ -0,0 +1,10 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/conftest.py +""" +Pytest configuration for the basic/ddl ALTER TABLE tests. + +This file imports fixtures from the corresponding testsuite subtopic, making +them (and the ``requires_protocol`` protocol-check autouse fixture) available +to the tests in this directory. +""" + +from rhosocial.activerecord.testsuite.feature.basic.ddl.conftest import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py new file mode 100644 index 00000000..6602a2f9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic.py @@ -0,0 +1,9 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists.py +""" +ALTER TABLE IF [NOT] EXISTS tests (sync) for the PostgreSQL backend. + +Thin bridge that runs the shared testsuite contract against the PostgreSQL +dialect, which supports all three modifiers (PG >= 9.6). +""" + +from rhosocial.activerecord.testsuite.feature.basic.ddl.test_alter_table_if_exists import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py new file mode 100644 index 00000000..b23bfe37 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_basic_async.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/ddl/test_alter_table_if_exists_async.py +""" +ALTER TABLE IF [NOT] EXISTS tests (async) for the PostgreSQL backend. + +Async mirror of ``test_alter_table_if_exists.py``. +""" + +from rhosocial.activerecord.testsuite.feature.basic.ddl.test_alter_table_if_exists_async import * # noqa: E501, F401, F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py new file mode 100644 index 00000000..540631b5 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py @@ -0,0 +1,331 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/fields/test_derived_field.py +import pytest +from typing import ClassVar, Optional +from typing_extensions import Annotated + +from rhosocial.activerecord.base import DerivedField, UseColumn +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.expression import Column, Literal + + +class TestDerivedFieldDeclaration: + + def test_derived_fields_registered(self, product_class): + assert "discounted_price" in product_class.__derived_fields__ + assert "total_value" in product_class.__derived_fields__ + + def test_descriptor_class_access(self, product_class): + assert isinstance(product_class.discounted_price, DerivedField) + + def test_derived_field_with_static_expression(self, product_class): + """Test DerivedField constructed with a pre-built expression object (non-callable path).""" + dialect = product_class.backend().dialect + static_expr = Column(dialect, "price") * Literal(dialect, 2) + df = DerivedField(static_expr) + resolved = df.resolve(dialect) + assert resolved is static_expr + + def test_descriptor_instance_default_none(self, product_class): + p = product_class(name="x", price=10.0, quantity=1) + assert p.discounted_price is None + assert p.total_value is None + + def test_derived_field_names_mapping(self, product_class): + names = product_class.__derived_field_names__ + df = product_class.__derived_fields__["discounted_price"] + assert names[id(df)] == "discounted_price" + + +class TestDerivedFieldQuery: + + def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + p.save() + return p + + def test_find_all_derived_true(self, product_class): + self._insert(product_class, "A", 100.0, 5) + results = product_class.find_all(derived=True) + assert len(results) == 1 + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(500.0) + + def test_find_all_derived_list_by_name(self, product_class): + self._insert(product_class, "B", 50.0, 3) + results = product_class.find_all(derived=["total_value"]) + assert len(results) == 1 + assert results[0].total_value == pytest.approx(150.0) + assert results[0].discounted_price is None + + def test_find_all_derived_list_all(self, product_class): + self._insert(product_class, "C", 200.0, 2) + results = product_class.find_all(derived=["discounted_price", "total_value"]) + assert results[0].discounted_price == pytest.approx(180.0) + assert results[0].total_value == pytest.approx(400.0) + + def test_find_one_derived(self, product_class): + p = self._insert(product_class, "D", 80.0, 4) + result = product_class.find_one(p.id, derived=True) + assert result.discounted_price == pytest.approx(72.0) + + def test_find_one_or_fail_derived(self, product_class): + p = self._insert(product_class, "D2", 80.0, 4) + result = product_class.find_one_or_fail(p.id, derived=True) + assert result.discounted_price == pytest.approx(72.0) + assert result.total_value == pytest.approx(320.0) + + def test_find_one_or_fail_derived_list(self, product_class): + p = self._insert(product_class, "D3", 80.0, 4) + result = product_class.find_one_or_fail(p.id, derived=["discounted_price", "total_value"]) + assert result.discounted_price == pytest.approx(72.0) + assert result.total_value == pytest.approx(320.0) + + def test_find_all_derived_false_default(self, product_class): + self._insert(product_class, "E", 60.0, 1) + results = product_class.find_all() + assert results[0].discounted_price is None + assert results[0].total_value is None + + def test_find_all_derived_all(self, product_class): + self._insert(product_class, "F", 100.0, 5) + results = product_class.find_all(derived="all") + assert len(results) == 1 + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(500.0) + + def test_find_one_derived_all(self, product_class): + p = self._insert(product_class, "G", 80.0, 4) + result = product_class.find_one(p.id, derived="all") + assert result.discounted_price == pytest.approx(72.0) + assert result.total_value == pytest.approx(320.0) + + def test_find_one_or_fail_derived_all(self, product_class): + p = self._insert(product_class, "H", 60.0, 3) + result = product_class.find_one_or_fail(p.id, derived="all") + assert result.discounted_price == pytest.approx(54.0) + assert result.total_value == pytest.approx(180.0) + + +class TestDerivedFieldFormA: + """Tests for Form A declaration: ClassVar[DerivedField] = DerivedField(...).""" + + def test_form_a_fields_registered(self, product_form_a_class): + assert "discounted_price" in product_form_a_class.__derived_fields__ + assert "total_value" in product_form_a_class.__derived_fields__ + + def test_form_a_query(self, product_form_a_class): + p = product_form_a_class(name="FA", price=100.0, quantity=3) + p.save() + results = product_form_a_class.find_all(derived=True) + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(300.0) + + def test_form_a_source_id_mapping(self, product_form_a_class): + df = product_form_a_class.__derived_fields__["discounted_price"] + assert df._source_id is not None + assert product_form_a_class.__derived_field_names__[df._source_id] == "discounted_price" + + +class TestDerivedFieldDictForm: + """Tests for derived=dict form.""" + + def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + p.save() + return p + + def test_derived_dict_with_lambda(self, product_class): + self._insert(product_class, "D1", 100.0, 4) + results = product_class.find_all( + derived={"my_discount": lambda d: Column(d, "price") * Literal(d, 0.8)} + ) + assert results[0].__dict__["my_discount"] == pytest.approx(80.0) + + def test_derived_dict_with_derived_field_instance(self, product_class): + self._insert(product_class, "D2", 50.0, 2) + df = product_class.__derived_fields__["total_value"] + results = product_class.find_all( + derived={"tv": df} + ) + assert results[0].__dict__["tv"] == pytest.approx(100.0) + + + def test_derived_dict_with_expression_object(self, product_class): + self._insert(product_class, "D3", 80.0, 5) + dialect = product_class.backend().dialect + expr = Column(dialect, "price") * Literal(dialect, 0.5) + results = product_class.find_all( + derived={"half_price": expr} + ) + assert results[0].__dict__["half_price"] == pytest.approx(40.0) + + +class TestDerivedFieldLookupByInstance: + """Tests for derived=[DerivedField instance] form.""" + + def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + p.save() + return p + + def test_lookup_by_derived_field_instance(self, product_class): + self._insert(product_class, "L1", 200.0, 1) + df = product_class.__derived_fields__["discounted_price"] + results = product_class.find_all(derived=[df]) + assert results[0].discounted_price == pytest.approx(180.0) + + +class TestExtraDerived: + + def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + p.save() + return p + + def test_extra_derived_basic(self, product_class): + self._insert(product_class, "F", 100.0, 10) + results = product_class.find_all( + extra_derived={"triple_price": lambda d: Column(d, "price") * Literal(d, 3)} + ) + assert len(results) == 1 + assert results[0].__dict__["triple_price"] == pytest.approx(300.0) + + def test_extra_derived_conflict_raises(self, product_class): + self._insert(product_class, "G", 50.0, 2) + with pytest.raises(ValueError, match="conflicts with a declared derived field"): + product_class.find_all( + extra_derived={"discounted_price": lambda d: Column(d, "price") * Literal(d, 0.5)} + ) + + def test_derived_and_extra_derived_together(self, product_class): + self._insert(product_class, "H", 40.0, 5) + results = product_class.find_all( + derived=True, + extra_derived={"double_qty": lambda d: Column(d, "quantity") * Literal(d, 2)} + ) + assert results[0].discounted_price == pytest.approx(36.0) + assert results[0].total_value == pytest.approx(200.0) + assert results[0].__dict__["double_qty"] == pytest.approx(10.0) + + +class TestDerivedFieldWithProxy: + """Tests for DerivedField using FieldProxy in expressions.""" + + def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + p.save() + return p + + def test_proxy_derived_query(self, product_with_proxy_class): + self._insert(product_with_proxy_class, "P1", 100.0, 4) + results = product_with_proxy_class.find_all(derived=True) + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(400.0) + + def test_proxy_derived_all_fields(self, product_with_proxy_class): + self._insert(product_with_proxy_class, "P2", 200.0, 3) + results = product_with_proxy_class.find_all( + derived=["discounted_price", "total_value"] + ) + assert results[0].discounted_price == pytest.approx(180.0) + assert results[0].total_value == pytest.approx(600.0) + + def test_proxy_derived_not_in_model_fields(self, product_with_proxy_class): + assert "discounted_price" not in product_with_proxy_class.model_fields + assert "total_value" not in product_with_proxy_class.model_fields + + def test_proxy_derived_not_in_dirty_fields(self, product_with_proxy_class): + self._insert(product_with_proxy_class, "P3", 50.0, 2) + instance = product_with_proxy_class.find_all(derived=True)[0] + instance.__dict__["discounted_price"] = 999.0 + assert "discounted_price" not in instance.dirty_fields + + def test_proxy_derived_read_only(self, product_with_proxy_class): + self._insert(product_with_proxy_class, "P4", 80.0, 1) + instance = product_with_proxy_class.find_all(derived=True)[0] + with pytest.raises(AttributeError): + instance.discounted_price = 123.0 + + def test_proxy_derived_not_saved_to_db(self, product_with_proxy_class): + p = self._insert(product_with_proxy_class, "P5", 60.0, 2) + instance = product_with_proxy_class.find_one(p.id, derived=True) + assert instance.discounted_price == pytest.approx(54.0) + instance.name = "P5_modified" + instance.save() + fresh = product_with_proxy_class.find_one(p.id, derived=True) + assert fresh.name == "P5_modified" + assert fresh.discounted_price == pytest.approx(54.0) + + +class TestDerivedFieldWithUseColumnAndAdapter: + """Tests for DerivedField with UseColumn and UseAdapter annotations.""" + + def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + p.save() + return p + + def test_use_column_alias_in_select(self, product_with_column_and_adapter_class): + """UseColumn controls the SELECT alias; result maps back to Python field name.""" + self._insert(product_with_column_and_adapter_class, "UC1", 100.0, 5) + results = product_with_column_and_adapter_class.find_all(derived=True) + # discounted_price has UseColumn("disc") — alias is "disc" in SQL, + # but the value is accessible via the Python attribute name + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_int == 500 + + def test_use_adapter_from_database(self, product_with_column_and_adapter_class): + """UseAdapter applies from_database conversion on the derived field value.""" + self._insert(product_with_column_and_adapter_class, "UA1", 33.3, 3) + results = product_with_column_and_adapter_class.find_all(derived=["total_int"]) + # 33.3 * 3 = 99.9, adapter rounds to int → 100 + assert results[0].total_int == 100 + assert isinstance(results[0].total_int, int) + + def test_use_column_field_not_in_model_fields(self, product_with_column_and_adapter_class): + assert "discounted_price" not in product_with_column_and_adapter_class.model_fields + assert "total_int" not in product_with_column_and_adapter_class.model_fields + + def test_use_column_column_name_stored(self, product_with_column_and_adapter_class): + df = product_with_column_and_adapter_class.__derived_fields__["discounted_price"] + assert df.column_name == "disc" + + def test_use_adapter_stored(self, product_with_column_and_adapter_class): + df = product_with_column_and_adapter_class.__derived_fields__["total_int"] + assert df.adapter is not None + + def test_use_column_and_adapter_together(self, product_with_column_and_adapter_class): + """Both UseColumn and UseAdapter can coexist on the same derived field.""" + self._insert(product_with_column_and_adapter_class, "CA1", 50.0, 4) + results = product_with_column_and_adapter_class.find_all( + derived=["discounted_price", "total_int"] + ) + assert results[0].discounted_price == pytest.approx(45.0) + assert results[0].total_int == 200 + + +class TestDerivedFieldColumnConflict: + """Tests that column name conflicts between regular and derived fields are detected.""" + + def test_use_column_conflict_raises(self): + """UseColumn on derived field must not duplicate a regular field's column name.""" + with pytest.raises(TypeError, match="conflicts with a regular field's column name"): + class ConflictModel(ActiveRecord): + __table_name__ = "conflict" + id: Optional[int] = None + discount_rate: Annotated[float, UseColumn("disc")] + discounted: ClassVar[Annotated[float, DerivedField( + lambda d: Column(d, "price") * Literal(d, 0.9), + ), UseColumn("disc")]] + + def test_use_column_no_conflict_different_names(self): + """Different column names should not conflict.""" + class NoConflict(ActiveRecord): + __table_name__ = "no_conflict" + id: Optional[int] = None + discount_rate: Annotated[float, UseColumn("rate")] + discounted: ClassVar[Annotated[float, DerivedField( + lambda d: Column(d, "price") * Literal(d, 0.9), + ), UseColumn("disc")]] + + assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py new file mode 100644 index 00000000..8720acd9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py @@ -0,0 +1,331 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/fields/test_derived_field_async.py +import pytest +from typing import ClassVar, Optional +from typing_extensions import Annotated + +from rhosocial.activerecord.base import DerivedField, UseColumn +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.backend.expression import Column, Literal + + +class TestAsyncDerivedFieldDeclaration: + + async def test_derived_fields_registered(self, async_product_class): + assert "discounted_price" in async_product_class.__derived_fields__ + assert "total_value" in async_product_class.__derived_fields__ + + async def test_descriptor_class_access(self, async_product_class): + assert isinstance(async_product_class.discounted_price, DerivedField) + + async def test_derived_field_with_static_expression(self, async_product_class): + """Test DerivedField constructed with a pre-built expression object (non-callable path).""" + dialect = async_product_class.backend().dialect + static_expr = Column(dialect, "price") * Literal(dialect, 2) + df = DerivedField(static_expr) + resolved = df.resolve(dialect) + assert resolved is static_expr + + async def test_descriptor_instance_default_none(self, async_product_class): + p = async_product_class(name="x", price=10.0, quantity=1) + assert p.discounted_price is None + assert p.total_value is None + + async def test_derived_field_names_mapping(self, async_product_class): + names = async_product_class.__derived_field_names__ + df = async_product_class.__derived_fields__["discounted_price"] + assert names[id(df)] == "discounted_price" + + +class TestAsyncDerivedFieldQuery: + + async def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + await p.save() + return p + + async def test_find_all_derived_true(self, async_product_class): + await self._insert(async_product_class, "A", 100.0, 5) + results = await async_product_class.find_all(derived=True) + assert len(results) == 1 + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(500.0) + + async def test_find_all_derived_list_by_name(self, async_product_class): + await self._insert(async_product_class, "B", 50.0, 3) + results = await async_product_class.find_all(derived=["total_value"]) + assert len(results) == 1 + assert results[0].total_value == pytest.approx(150.0) + assert results[0].discounted_price is None + + async def test_find_all_derived_list_all(self, async_product_class): + await self._insert(async_product_class, "C", 200.0, 2) + results = await async_product_class.find_all(derived=["discounted_price", "total_value"]) + assert results[0].discounted_price == pytest.approx(180.0) + assert results[0].total_value == pytest.approx(400.0) + + async def test_find_one_derived(self, async_product_class): + p = await self._insert(async_product_class, "D", 80.0, 4) + result = await async_product_class.find_one(p.id, derived=True) + assert result.discounted_price == pytest.approx(72.0) + + async def test_find_one_or_fail_derived(self, async_product_class): + p = await self._insert(async_product_class, "D2", 80.0, 4) + result = await async_product_class.find_one_or_fail(p.id, derived=True) + assert result.discounted_price == pytest.approx(72.0) + assert result.total_value == pytest.approx(320.0) + + async def test_find_one_or_fail_derived_list(self, async_product_class): + p = await self._insert(async_product_class, "D3", 80.0, 4) + result = await async_product_class.find_one_or_fail(p.id, derived=["discounted_price", "total_value"]) + assert result.discounted_price == pytest.approx(72.0) + assert result.total_value == pytest.approx(320.0) + + async def test_find_all_derived_false_default(self, async_product_class): + await self._insert(async_product_class, "E", 60.0, 1) + results = await async_product_class.find_all() + assert results[0].discounted_price is None + assert results[0].total_value is None + + async def test_find_all_derived_all(self, async_product_class): + await self._insert(async_product_class, "F", 100.0, 5) + results = await async_product_class.find_all(derived="all") + assert len(results) == 1 + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(500.0) + + async def test_find_one_derived_all(self, async_product_class): + p = await self._insert(async_product_class, "G", 80.0, 4) + result = await async_product_class.find_one(p.id, derived="all") + assert result.discounted_price == pytest.approx(72.0) + assert result.total_value == pytest.approx(320.0) + + async def test_find_one_or_fail_derived_all(self, async_product_class): + p = await self._insert(async_product_class, "H", 60.0, 3) + result = await async_product_class.find_one_or_fail(p.id, derived="all") + assert result.discounted_price == pytest.approx(54.0) + assert result.total_value == pytest.approx(180.0) + + +class TestAsyncDerivedFieldFormA: + """Tests for Form A declaration: ClassVar[DerivedField] = DerivedField(...).""" + + async def test_form_a_fields_registered(self, async_product_form_a_class): + assert "discounted_price" in async_product_form_a_class.__derived_fields__ + assert "total_value" in async_product_form_a_class.__derived_fields__ + + async def test_form_a_query(self, async_product_form_a_class): + p = async_product_form_a_class(name="FA", price=100.0, quantity=3) + await p.save() + results = await async_product_form_a_class.find_all(derived=True) + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(300.0) + + async def test_form_a_source_id_mapping(self, async_product_form_a_class): + df = async_product_form_a_class.__derived_fields__["discounted_price"] + assert df._source_id is not None + assert async_product_form_a_class.__derived_field_names__[df._source_id] == "discounted_price" + + +class TestAsyncDerivedFieldDictForm: + """Tests for derived=dict form.""" + + async def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + await p.save() + return p + + async def test_derived_dict_with_lambda(self, async_product_class): + await self._insert(async_product_class, "D1", 100.0, 4) + results = await async_product_class.find_all( + derived={"my_discount": lambda d: Column(d, "price") * Literal(d, 0.8)} + ) + assert results[0].__dict__["my_discount"] == pytest.approx(80.0) + + async def test_derived_dict_with_derived_field_instance(self, async_product_class): + await self._insert(async_product_class, "D2", 50.0, 2) + df = async_product_class.__derived_fields__["total_value"] + results = await async_product_class.find_all( + derived={"tv": df} + ) + assert results[0].__dict__["tv"] == pytest.approx(100.0) + + + async def test_derived_dict_with_expression_object(self, async_product_class): + await self._insert(async_product_class, "D3", 80.0, 5) + dialect = async_product_class.backend().dialect + expr = Column(dialect, "price") * Literal(dialect, 0.5) + results = await async_product_class.find_all( + derived={"half_price": expr} + ) + assert results[0].__dict__["half_price"] == pytest.approx(40.0) + + +class TestAsyncDerivedFieldLookupByInstance: + """Tests for derived=[DerivedField instance] form.""" + + async def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + await p.save() + return p + + async def test_lookup_by_derived_field_instance(self, async_product_class): + await self._insert(async_product_class, "L1", 200.0, 1) + df = async_product_class.__derived_fields__["discounted_price"] + results = await async_product_class.find_all(derived=[df]) + assert results[0].discounted_price == pytest.approx(180.0) + + +class TestAsyncExtraDerived: + + async def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + await p.save() + return p + + async def test_extra_derived_basic(self, async_product_class): + await self._insert(async_product_class, "F", 100.0, 10) + results = await async_product_class.find_all( + extra_derived={"triple_price": lambda d: Column(d, "price") * Literal(d, 3)} + ) + assert len(results) == 1 + assert results[0].__dict__["triple_price"] == pytest.approx(300.0) + + async def test_extra_derived_conflict_raises(self, async_product_class): + await self._insert(async_product_class, "G", 50.0, 2) + with pytest.raises(ValueError, match="conflicts with a declared derived field"): + await async_product_class.find_all( + extra_derived={"discounted_price": lambda d: Column(d, "price") * Literal(d, 0.5)} + ) + + async def test_derived_and_extra_derived_together(self, async_product_class): + await self._insert(async_product_class, "H", 40.0, 5) + results = await async_product_class.find_all( + derived=True, + extra_derived={"double_qty": lambda d: Column(d, "quantity") * Literal(d, 2)} + ) + assert results[0].discounted_price == pytest.approx(36.0) + assert results[0].total_value == pytest.approx(200.0) + assert results[0].__dict__["double_qty"] == pytest.approx(10.0) + + +class TestAsyncDerivedFieldWithProxy: + """Tests for DerivedField using FieldProxy in expressions.""" + + async def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + await p.save() + return p + + async def test_proxy_derived_query(self, async_product_with_proxy_class): + await self._insert(async_product_with_proxy_class, "P1", 100.0, 4) + results = await async_product_with_proxy_class.find_all(derived=True) + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_value == pytest.approx(400.0) + + async def test_proxy_derived_all_fields(self, async_product_with_proxy_class): + await self._insert(async_product_with_proxy_class, "P2", 200.0, 3) + results = await async_product_with_proxy_class.find_all( + derived=["discounted_price", "total_value"] + ) + assert results[0].discounted_price == pytest.approx(180.0) + assert results[0].total_value == pytest.approx(600.0) + + async def test_proxy_derived_not_in_model_fields(self, async_product_with_proxy_class): + assert "discounted_price" not in async_product_with_proxy_class.model_fields + assert "total_value" not in async_product_with_proxy_class.model_fields + + async def test_proxy_derived_not_in_dirty_fields(self, async_product_with_proxy_class): + await self._insert(async_product_with_proxy_class, "P3", 50.0, 2) + instance = (await async_product_with_proxy_class.find_all(derived=True))[0] + instance.__dict__["discounted_price"] = 999.0 + assert "discounted_price" not in instance.dirty_fields + + async def test_proxy_derived_read_only(self, async_product_with_proxy_class): + await self._insert(async_product_with_proxy_class, "P4", 80.0, 1) + instance = (await async_product_with_proxy_class.find_all(derived=True))[0] + with pytest.raises(AttributeError): + instance.discounted_price = 123.0 + + async def test_proxy_derived_not_saved_to_db(self, async_product_with_proxy_class): + p = await self._insert(async_product_with_proxy_class, "P5", 60.0, 2) + instance = await async_product_with_proxy_class.find_one(p.id, derived=True) + assert instance.discounted_price == pytest.approx(54.0) + instance.name = "P5_modified" + await instance.save() + fresh = await async_product_with_proxy_class.find_one(p.id, derived=True) + assert fresh.name == "P5_modified" + assert fresh.discounted_price == pytest.approx(54.0) + + +class TestAsyncDerivedFieldWithUseColumnAndAdapter: + """Tests for DerivedField with UseColumn and UseAdapter annotations.""" + + async def _insert(self, Model, name, price, quantity): + p = Model(name=name, price=price, quantity=quantity) + await p.save() + return p + + async def test_use_column_alias_in_select(self, async_product_with_column_and_adapter_class): + """UseColumn controls the SELECT alias; result maps back to Python field name.""" + await self._insert(async_product_with_column_and_adapter_class, "UC1", 100.0, 5) + results = await async_product_with_column_and_adapter_class.find_all(derived=True) + # discounted_price has UseColumn("disc") — alias is "disc" in SQL, + # but the value is accessible via the Python attribute name + assert results[0].discounted_price == pytest.approx(90.0) + assert results[0].total_int == 500 + + async def test_use_adapter_from_database(self, async_product_with_column_and_adapter_class): + """UseAdapter applies from_database conversion on the derived field value.""" + await self._insert(async_product_with_column_and_adapter_class, "UA1", 33.3, 3) + results = await async_product_with_column_and_adapter_class.find_all(derived=["total_int"]) + # 33.3 * 3 = 99.9, adapter rounds to int → 100 + assert results[0].total_int == 100 + assert isinstance(results[0].total_int, int) + + async def test_use_column_field_not_in_model_fields(self, async_product_with_column_and_adapter_class): + assert "discounted_price" not in async_product_with_column_and_adapter_class.model_fields + assert "total_int" not in async_product_with_column_and_adapter_class.model_fields + + async def test_use_column_column_name_stored(self, async_product_with_column_and_adapter_class): + df = async_product_with_column_and_adapter_class.__derived_fields__["discounted_price"] + assert df.column_name == "disc" + + async def test_use_adapter_stored(self, async_product_with_column_and_adapter_class): + df = async_product_with_column_and_adapter_class.__derived_fields__["total_int"] + assert df.adapter is not None + + async def test_use_column_and_adapter_together(self, async_product_with_column_and_adapter_class): + """Both UseColumn and UseAdapter can coexist on the same derived field.""" + await self._insert(async_product_with_column_and_adapter_class, "CA1", 50.0, 4) + results = await async_product_with_column_and_adapter_class.find_all( + derived=["discounted_price", "total_int"] + ) + assert results[0].discounted_price == pytest.approx(45.0) + assert results[0].total_int == 200 + + +class TestAsyncDerivedFieldColumnConflict: + """Tests that column name conflicts between regular and derived fields are detected.""" + + async def test_use_column_conflict_raises(self): + """UseColumn on derived field must not duplicate a regular field's column name.""" + with pytest.raises(TypeError, match="conflicts with a regular field's column name"): + class ConflictModel(ActiveRecord): + __table_name__ = "conflict" + id: Optional[int] = None + discount_rate: Annotated[float, UseColumn("disc")] + discounted: ClassVar[Annotated[float, DerivedField( + lambda d: Column(d, "price") * Literal(d, 0.9), + ), UseColumn("disc")]] + + async def test_use_column_no_conflict_different_names(self): + """Different column names should not conflict.""" + class NoConflict(ActiveRecord): + __table_name__ = "no_conflict" + id: Optional[int] = None + discount_rate: Annotated[float, UseColumn("rate")] + discounted: ClassVar[Annotated[float, DerivedField( + lambda d: Column(d, "price") * Literal(d, 0.9), + ), UseColumn("disc")]] + + assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic.py new file mode 100644 index 00000000..cb781f9d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic.py @@ -0,0 +1,5 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic.py +""" +Bridge file for derived field tests from the testsuite. +""" +from rhosocial.activerecord.testsuite.feature.basic.fields.test_derived_field import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic_async.py new file mode 100644 index 00000000..e6cf4fff --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_basic_async.py +"""Bridge file for async derived field basic tests from the testsuite.""" +from rhosocial.activerecord.testsuite.feature.basic.fields.test_derived_field_async import * # noqa: F401, F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures.py new file mode 100644 index 00000000..a35d9746 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures.py @@ -0,0 +1,6 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures.py +""" +Bridge file for example basic fixtures tests from the testsuite. +""" +from rhosocial.activerecord.testsuite.feature.basic.fields.test_example_basic_fixtures import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_example_basic_fixtures_async import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures_async.py new file mode 100644 index 00000000..cef55fc3 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_example_basic_fixtures_async.py +"""Bridge file for async feature.basic.fields.test_example_basic_fixtures_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.fields.test_example_basic_fixtures_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping.py similarity index 81% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping.py index e5503f88..87b920c3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_field_column_mapping.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping.py @@ -20,4 +20,6 @@ ) # The test classes contain the actual test cases. -from rhosocial.activerecord.testsuite.feature.basic.test_field_column_mapping import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_field_column_mapping import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_field_column_mapping_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping_async.py new file mode 100644 index 00000000..d46911b2 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_field_column_mapping_async.py +"""Bridge file for async feature.basic.fields.test_field_column_mapping_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.fields.test_field_column_mapping_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields.py similarity index 84% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields.py index ed3bdd00..13b13fb8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_fields.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields.py @@ -20,4 +20,6 @@ ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.test_fields import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_fields import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_fields_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields_async.py new file mode 100644 index 00000000..2dace749 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_fields_async.py +"""Bridge file for async feature.basic.fields.test_fields_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.fields.test_fields_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py new file mode 100644 index 00000000..a6c6df92 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py310.py @@ -0,0 +1,810 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py310.py +""" +Python 3.10+ fixture model definitions. + +This file contains model classes using Python 3.10+ syntax features: +- `X | Y` syntax instead of `Optional[X]` or `Union[X, Y]` +- `Type | None` instead of `Optional[Type]` + +Note: This file should only be imported and used in Python 3.10+ environments. +""" +import re +from datetime import date, time, datetime +from decimal import Decimal +from typing import Optional, Type, Literal, Union, Any, Dict, List, Set +import json + +from pydantic import EmailStr, Field, field_validator, model_validator + +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter +from rhosocial.activerecord.base.fields import UseAdapter, UseColumn +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.backend.errors import ValidationError +from rhosocial.activerecord.field import CompositePKMixin, TimestampMixin, UUIDMixin, IntegerPKMixin +from typing import Annotated, ClassVar + + +# Declare that this module requires Python 3.10+ +__requires_python__ = (3, 10) + + +class TypeCase(UUIDMixin, ActiveRecord): + """A model with a wide variety of data types to test database type handling. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "type_cases" + + username: str + email: str + tiny_int: int | None + small_int: int | None + big_int: int | None + float_val: float | None + double_val: float | None + decimal_val: Decimal | None + char_val: str | None + varchar_val: str | None + text_val: str | None + date_val: date | None + time_val: time | None + timestamp_val: datetime | None + blob_val: bytes | None + json_val: dict | None + array_val: list | None + is_active: bool = True + + +class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): + """A model with a wide variety of data types to test database type handling. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() + + username: str + email: str + tiny_int: int | None + small_int: int | None + big_int: int | None + float_val: float | None + double_val: float | None + decimal_val: Decimal | None + char_val: str | None + varchar_val: str | None + text_val: str | None + date_val: date | None + time_val: time | None + timestamp_val: datetime | None + blob_val: bytes | None + json_val: dict | None + array_val: list | None + is_active: bool = True + + +class TypeTestModel(UUIDMixin, ActiveRecord): + """Model class for testing various field types. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "type_tests" + + # UUID primary key provided by UUIDMixin + string_field: str = Field(default="test string") + int_field: int = Field(default=42) + float_field: float = Field(default=3.14) + decimal_field: Decimal = Field(default=Decimal("10.99")) + bool_field: bool = Field(default=True) + datetime_field: datetime = Field(default_factory=datetime.now) + json_field: dict | None = None + nullable_field: str | None = Field(default=None) + + +class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): + """Model class for testing various field types. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() + + # UUID primary key provided by UUIDMixin + string_field: str = Field(default="test string") + int_field: int = Field(default=42) + float_field: float = Field(default=3.14) + decimal_field: Decimal = Field(default=Decimal("10.99")) + bool_field: bool = Field(default=True) + datetime_field: datetime = Field(default_factory=datetime.now) + json_field: dict | None = None + nullable_field: str | None = Field(default=None) + + +class User(IntegerPKMixin, TimestampMixin, ActiveRecord): + """A standard User model for general CRUD operation testing. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + + # The IntegerPKMixin is expected to handle the `id` field. + id: int | None = None + username: str + email: EmailStr + age: int | None = Field(..., ge=0, le=100) + balance: float = 0.0 + is_active: bool = True + + +class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """A standard User model for general CRUD operation testing. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + + # The IntegerPKMixin is expected to handle the `id` field. + id: int | None = None + username: str + email: EmailStr + age: int | None = Field(..., ge=0, le=100) + balance: float = 0.0 + is_active: bool = True + + +class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): + """A User model with specific, custom field validators to test the framework's + validation handling. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = None + balance: float | None = 0.0 + credit_score: int + status: Literal['active', 'inactive', 'banned', 'pending', 'suspended'] = 'active' + is_active: bool | None = True + + @field_validator('username') + def validate_username(cls, value): + """A custom validator that rejects usernames containing '123'.""" + if re.search(r'123', value): + raise ValidationError("Username must not contain '123'.") + return value + + @field_validator('credit_score') + def validate_credit_score(cls, value): + """A custom validator that ensures credit_score is within a specific range.""" + if not (0 <= value <= 800): + raise ValidationError("Credit score must be between 0 and 800.") + return value + + +class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): + """A User model with specific, custom field validators to test the framework's + validation handling. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = None + balance: float | None = 0.0 + credit_score: int + status: Literal['active', 'inactive', 'banned', 'pending', 'suspended'] = 'active' + is_active: bool | None = True + + @field_validator('username') + def validate_username(cls, value): + """A custom validator that rejects usernames containing '123'.""" + if re.search(r'123', value): + raise ValidationError("Username must not contain '123'.") + return value + + @field_validator('credit_score') + def validate_credit_score(cls, value): + """A custom validator that ensures credit_score is within a specific range.""" + if not (0 <= value <= 800): + raise ValidationError("Credit score must be between 0 and 800.") + return value + + +class ValidatedUser(IntegerPKMixin, ActiveRecord): + """User model for validation testing. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "validated_users" + + id: int | None = None + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + age: int | None = Field(None, ge=0, le=150) + + @field_validator('username') + def validate_username(cls, v: str) -> str: + if len(v.strip()) != len(v): + raise ValidationError("Username cannot have leading or trailing spaces") + if not v.isalnum(): + raise ValidationError("Username must be alphanumeric") + return v + + @classmethod + def validate_record(cls, instance: 'ValidatedUser') -> None: + """Business rule validation""" + if instance.age is not None and instance.age < 13: + raise ValidationError("User must be at least 13 years old") + + +class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): + """User model for validation testing. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + age: int | None = Field(None, ge=0, le=150) + + @field_validator('username') + def validate_username(cls, v: str) -> str: + if len(v.strip()) != len(v): + raise ValidationError("Username cannot have leading or trailing spaces") + if not v.isalnum(): + raise ValidationError("Username must be alphanumeric") + return v + + @classmethod + def validate_record(cls, instance) -> None: + """Business rule validation""" + if instance.age is not None and instance.age < 13: + raise ValidationError("User must be at least 13 years old") + + +# Keep this mixin I/O-free: fields, validators, and the FieldProxy descriptor are shared by sync/async models. +# FieldProxy builds expressions from configured model metadata; concrete models still own table metadata and I/O bases. +class PydanticValidatedFieldsMixin: + """Shared Pydantic fields and validators for sync/async contract tests.""" + + c: ClassVar[FieldProxy] = FieldProxy() + code: str = Field( + ..., + pattern=r"^[A-Z]{3}-\d{3}$", + title="Validation code", + description="Business code used by Pydantic compatibility tests.", + json_schema_extra={"active_record_test": "pydantic-native"}, + ) + quantity: int = Field(..., ge=1, le=999) + step_count: int = Field(..., gt=0, lt=100, multiple_of=5) + price: Decimal = Field(..., ge=Decimal("0.01")) + start_at: datetime + end_at: datetime + status: Literal["draft", "active", "archived"] = "draft" + normalized_name: str = Field(..., min_length=1, max_length=50) + created_token: str = Field(default_factory=lambda: "generated-token") + + @field_validator("normalized_name") + def normalize_name(cls, value: str) -> str: + return value.strip().lower() + + @model_validator(mode="after") + def validate_period(self): + if self.end_at <= self.start_at: + raise ValueError("end_at must be after start_at") + return self + + +class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): + """Model for Pydantic native validation contract tests.""" + __table_name__ = "pydantic_validated_models" + + id: int | None = None + + +class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): + """Async model for Pydantic native validation contract tests.""" + __table_name__ = "pydantic_validated_models" + + id: int | None = None + + +class YesOrNoBooleanAdapter(BaseSQLTypeAdapter): + """Converts Python's True/False to 'yes'/'no' strings.""" + def _do_to_database(self, value: bool, target_type: Type, options: Dict[str, Any] | None = None) -> str: + return "yes" if value else "no" + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> bool: + return value == "yes" + + +class TypeAdapterTest(ActiveRecord): + """Model for testing various type adapter scenarios. + + 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 + # Fields for testing implicit X | None handling + optional_name: str | None = None + optional_age: int | None = None + last_login: datetime | None = None + is_premium: bool | None = None + # Field for testing unsupported Union (str | int syntax) + unsupported_union: str | int = 0 + # Fields for testing explicit adapter annotation + custom_bool: Annotated[bool, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + optional_custom_bool: Annotated[bool | None, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + + +class AsyncTypeAdapterTest(AsyncActiveRecord): + """Model for testing various type adapter scenarios. + + 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 + name: str + # Fields for testing implicit X | None handling + optional_name: str | None = None + optional_age: int | None = None + last_login: datetime | None = None + is_premium: bool | None = None + # Field for testing unsupported Union (str | int syntax) + unsupported_union: str | int = 0 + # Fields for testing explicit adapter annotation + custom_bool: Annotated[bool, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + optional_custom_bool: Annotated[bool | None, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + + +class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): + """User model with custom column name mappings for testing in basic feature. + + 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 + + # Python field: user_name, Database column: username + user_name: Annotated[str, UseColumn("username")] + + # Python field: email_address, Database column: email + email_address: Annotated[str, UseColumn("email")] + + # Python field: creation_date, which maps to the 'created_at' column. + creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + +class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """User model with custom column name mappings for testing in basic feature. + + 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 + user_id: Annotated[int | None, UseColumn("id")] = None + + # Python field: user_name, Database column: username + user_name: Annotated[str, UseColumn("username")] + + # Python field: email_address, Database column: email + email_address: Annotated[str, UseColumn("email")] + + # Python field: creation_date, which maps to the 'created_at' column. + creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + +class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Post model with custom column name mappings for testing in basic feature. + + 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 + + # Python field: author_id, Database column: author + author_id: Annotated[int, UseColumn("author")] + + # Python field: post_title, Database column: title + post_title: Annotated[str, UseColumn("title")] + + # Python field: post_content, Database column: content + post_content: Annotated[str, UseColumn("content")] + + # Python field: publication_time, maps to 'published_at' column + publication_time: Annotated[datetime | None, UseColumn("published_at")] = None + + # Python field: is_published, Database column: published + is_published: Annotated[bool, UseColumn("published")] + + +class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Post model with custom column name mappings for testing in basic feature. + + 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 + post_id: Annotated[int | None, UseColumn("id")] = None + + # Python field: author_id, Database column: author + author_id: Annotated[int, UseColumn("author")] + + # Python field: post_title, Database column: title + post_title: Annotated[str, UseColumn("title")] + + # Python field: post_content, Database column: content + post_content: Annotated[str, UseColumn("content")] + + # Python field: publication_time, maps to 'published_at' column + publication_time: Annotated[datetime | None, UseColumn("published_at")] = None + + # Python field: is_published, Database column: published + is_published: Annotated[bool, UseColumn("published")] + + +class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Comment model with custom column name mappings for testing in basic feature. + + 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 + + # Python field: post_id, Database column: post_ref + post_id: Annotated[int, UseColumn("post_ref")] + + # Python field: author_id, Database column: author + author_id: Annotated[int, UseColumn("author")] + + # Python field: comment_text, Database column: text + comment_text: Annotated[str, UseColumn("text")] + + # Python field: comment_creation_date, maps to 'created_at' column. + comment_creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + # Python field: is_approved, Database column: approved + is_approved: Annotated[bool, UseColumn("approved")] + + +class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Comment model with custom column name mappings for testing in basic feature. + + 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 + comment_id: Annotated[int | None, UseColumn("id")] = None + + # Python field: post_id, Database column: post_ref + post_id: Annotated[int, UseColumn("post_ref")] + + # Python field: author_id, Database column: author + author_id: Annotated[int, UseColumn("author")] + + # Python field: comment_text, Database column: text + comment_text: Annotated[str, UseColumn("text")] + + # Python field: comment_creation_date, maps to 'created_at' column. + comment_creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + # Python field: is_approved, Database column: approved + is_approved: Annotated[bool, UseColumn("approved")] + + +class IntToStringAdapter(BaseSQLTypeAdapter): + """A simple type adapter that converts an integer for the DB to a string for Python.""" + + def __init__(self): + super().__init__() + self._register_type(str, int) + + def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: + return int(value) + + def _do_from_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: + return str(value) + + +class ColumnMappingModel(ActiveRecord): + """A model demonstrating various valid uses of UseColumn and UseAdapter, + including mapping the primary key attribute. + + 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) + name: str # Default mapping + item_count: Annotated[int, UseColumn("item_total")] # Mapped to 'item_total' + notes: Annotated[str, UseAdapter(IntToStringAdapter(), target_db_type=int), UseColumn("remarks")] + + +class AsyncColumnMappingModel(AsyncActiveRecord): + """A model demonstrating various valid uses of UseColumn and UseAdapter, + including mapping the primary key attribute. + + 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. + item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) + name: str # Default mapping + item_count: Annotated[int, UseColumn("item_total")] # Mapped to 'item_total' + notes: Annotated[str, UseAdapter(IntToStringAdapter(), target_db_type=int), UseColumn("remarks")] + + +class ListToStringAdapter(BaseSQLTypeAdapter): + """Converts a Python list of strings to a single comma-separated string for the DB.""" + + def __init__(self): + super().__init__() + self._register_type(list, str) + + def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: + if value is None: + return None + return ",".join(value) + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> list[str] | None: + if value is None: + return None + + if isinstance(value, str): + if value.startswith('[') and value.endswith(']'): + import ast + try: + parsed_value = ast.literal_eval(value) + if isinstance(parsed_value, list): + return parsed_value + else: + return [parsed_value] if parsed_value is not None else [] + except (ValueError, SyntaxError): + pass + + stripped_value = value.strip().strip('"\'') + if stripped_value.startswith('[') and stripped_value.endswith(']'): + import ast + try: + parsed_value = ast.literal_eval(stripped_value) + if isinstance(parsed_value, list): + return parsed_value + except (ValueError, SyntaxError): + pass + + if value: + return [item.strip().strip('"\'') for item in value.split(',')] + else: + return [] + + return [value] if value is not None else [] + + +class JsonToStringAdapter(BaseSQLTypeAdapter): + """Converts a Python dictionary to a JSON string for the DB.""" + + def __init__(self): + super().__init__() + self._register_type(dict, str) + + def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: + if value is None: + return None + return json.dumps(value) + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> dict | None: + if value is None: + return None + return json.loads(value) + + +class MixedAnnotationModel(ActiveRecord): + """A model with various combinations of annotations to test field mapping and adapters. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "mixed_annotation_items" + __primary_key__ = "id" + + # 1. Standard Python type + name: str + + # 2. Field with a different column name + item_id: Annotated[int, UseColumn("id")] + + # 3. Field using a type adapter + tags: Annotated[list[str], UseAdapter(ListToStringAdapter(), str)] + + # 4. Field with both a different column name and a type adapter + metadata: Annotated[dict | None, UseColumn("meta"), UseAdapter(JsonToStringAdapter(), str)] = None + + # 5. Field that is nullable + description: str | None = None + + # 6. Field with a default value + status: str = "active" + + +class AsyncMixedAnnotationModel(AsyncActiveRecord): + """A model with various combinations of annotations to test field mapping and adapters. + + 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 + name: str + + # 2. Field with a different column name + item_id: Annotated[int, UseColumn("id")] + + # 3. Field using a type adapter + tags: Annotated[list[str], UseAdapter(ListToStringAdapter(), str)] + + # 4. Field with both a different column name and a type adapter + metadata: Annotated[dict | None, UseColumn("meta"), UseAdapter(JsonToStringAdapter(), str)] = None + + # 5. Field that is nullable + description: str | None = None + + # 6. Field with a default value + status: str = "active" + +# ============================================================================= +# Composite Primary Key Models +# ============================================================================= + +class OrderItem(CompositePKMixin, ActiveRecord): + """Composite PK (order_id, product_id), PK provided by application.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_id: int + product_id: int + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): + """Async variant of OrderItem.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_id: int + product_id: int + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class StoreInventory(CompositePKMixin, ActiveRecord): + """Triple-column composite PK.""" + __table_name__ = "store_inventory" + __primary_key__ = ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() + + store_id: int + product_id: int + batch_id: str + stock: int = 0 + + +class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): + """Async variant of StoreInventory.""" + __table_name__ = "store_inventory" + __primary_key__ = ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() + + store_id: int + product_id: int + batch_id: str + stock: int = 0 + + +class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Single-column auto-increment PK -- backward compatibility control group.""" + __table_name__ = "orders" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + total: Decimal = Decimal("0.00") + + +class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Async variant of Order.""" + __table_name__ = "orders" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + total: Decimal = Decimal("0.00") + + +class MappedOrderItem(CompositePKMixin, ActiveRecord): + """Composite PK with UseColumn mapping -- Python field names differ from DB column names.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + + order_ref: Annotated[int, UseColumn("order_id")] + product_ref: Annotated[int, UseColumn("product_id")] + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): + """Async variant of MappedOrderItem.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_ref: Annotated[int, UseColumn("order_id")] + product_ref: Annotated[int, UseColumn("product_id")] + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class BulkUser(IntegerPKMixin, ActiveRecord): + __table_name__ = "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + age: int = 0 + email: str = "" + + +class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): + __table_name__ = "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + age: int = 0 + email: str = "" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py new file mode 100644 index 00000000..f912576e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py311.py @@ -0,0 +1,781 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py311.py +""" +Python 3.11+ fixture model definitions. + +This file contains model classes using Python 3.11+ syntax features: +- `Self` type for methods that return an instance of the same class +- `Never` type for functions that never return +- Exception groups (not used in models) + +Note: This file should only be imported and used in Python 3.11+ environments. +""" +from __future__ import annotations + +import copy +import re +from datetime import date, time, datetime +from decimal import Decimal +from typing import Optional, Type, Literal, Union, Any, Dict, List, Self, Set +import json + +from pydantic import EmailStr, Field, field_validator, model_validator + +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter +from rhosocial.activerecord.base.fields import UseAdapter, UseColumn +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.backend.errors import ValidationError +from rhosocial.activerecord.field import CompositePKMixin, TimestampMixin, UUIDMixin, IntegerPKMixin +from typing import Annotated, ClassVar + + +# Declare that this module requires Python 3.11+ +__requires_python__ = (3, 11) + + +class TypeCase(UUIDMixin, ActiveRecord): + """A model with a wide variety of data types to test database type handling. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "type_cases" + + username: str + email: str + tiny_int: int | None + small_int: int | None + big_int: int | None + float_val: float | None + double_val: float | None + decimal_val: Decimal | None + char_val: str | None + varchar_val: str | None + text_val: str | None + date_val: date | None + time_val: time | None + timestamp_val: datetime | None + blob_val: bytes | None + json_val: dict | None + array_val: list | None + is_active: bool = True + + def clone(self) -> Self: + """Create a deep copy of this instance. + + Uses Python 3.11+ Self type for accurate return type annotation. + """ + return copy.deepcopy(self) + + +class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): + """A model with a wide variety of data types to test database type handling. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() + + username: str + email: str + tiny_int: int | None + small_int: int | None + big_int: int | None + float_val: float | None + double_val: float | None + decimal_val: Decimal | None + char_val: str | None + varchar_val: str | None + text_val: str | None + date_val: date | None + time_val: time | None + timestamp_val: datetime | None + blob_val: bytes | None + json_val: dict | None + array_val: list | None + is_active: bool = True + + def clone(self) -> Self: + """Create a deep copy of this instance. + + Uses Python 3.11+ Self type for accurate return type annotation. + """ + return copy.deepcopy(self) + + +class TypeTestModel(UUIDMixin, ActiveRecord): + """Model class for testing various field types. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "type_tests" + + # UUID primary key provided by UUIDMixin + string_field: str = Field(default="test string") + int_field: int = Field(default=42) + float_field: float = Field(default=3.14) + decimal_field: Decimal = Field(default=Decimal("10.99")) + bool_field: bool = Field(default=True) + datetime_field: datetime = Field(default_factory=datetime.now) + json_field: dict | None = None + nullable_field: str | None = Field(default=None) + + +class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): + """Model class for testing various field types. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() + + # UUID primary key provided by UUIDMixin + string_field: str = Field(default="test string") + int_field: int = Field(default=42) + float_field: float = Field(default=3.14) + decimal_field: Decimal = Field(default=Decimal("10.99")) + bool_field: bool = Field(default=True) + datetime_field: datetime = Field(default_factory=datetime.now) + json_field: dict | None = None + nullable_field: str | None = Field(default=None) + + +class User(IntegerPKMixin, TimestampMixin, ActiveRecord): + """A standard User model for general CRUD operation testing. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = Field(..., ge=0, le=100) + balance: float = 0.0 + is_active: bool = True + + def with_balance(self, new_balance: float) -> Self: + """Return a new instance with updated balance. + + Uses Python 3.11+ Self type for fluent interface pattern. + """ + new_user = self.clone() + new_user.balance = new_balance + return new_user + + def activate(self) -> Self: + """Activate the user and return self. + + Uses Python 3.11+ Self type for chainable method pattern. + """ + self.is_active = True + return self + + def deactivate(self) -> Self: + """Deactivate the user and return self. + + Uses Python 3.11+ Self type for chainable method pattern. + """ + self.is_active = False + return self + + +class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """A standard User model for general CRUD operation testing. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = Field(..., ge=0, le=100) + balance: float = 0.0 + is_active: bool = True + + def with_balance(self, new_balance: float) -> Self: + """Return a new instance with updated balance.""" + new_user = copy.deepcopy(self) + new_user.balance = new_balance + return new_user + + +class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): + """A User model with specific, custom field validators to test the framework's + validation handling. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = None + balance: float | None = 0.0 + credit_score: int + status: Literal['active', 'inactive', 'banned', 'pending', 'suspended'] = 'active' + is_active: bool | None = True + + @field_validator('username') + def validate_username(cls, value): + """A custom validator that rejects usernames containing '123'.""" + if re.search(r'123', value): + raise ValidationError("Username must not contain '123'.") + return value + + @field_validator('credit_score') + def validate_credit_score(cls, value): + """A custom validator that ensures credit_score is within a specific range.""" + if not (0 <= value <= 800): + raise ValidationError("Credit score must be between 0 and 800.") + return value + + +class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): + """A User model with specific, custom field validators to test the framework's + validation handling. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = None + balance: float | None = 0.0 + credit_score: int + status: Literal['active', 'inactive', 'banned', 'pending', 'suspended'] = 'active' + is_active: bool | None = True + + @field_validator('username') + def validate_username(cls, value): + """A custom validator that rejects usernames containing '123'.""" + if re.search(r'123', value): + raise ValidationError("Username must not contain '123'.") + return value + + @field_validator('credit_score') + def validate_credit_score(cls, value): + """A custom validator that ensures credit_score is within a specific range.""" + if not (0 <= value <= 800): + raise ValidationError("Credit score must be between 0 and 800.") + return value + + +class ValidatedUser(IntegerPKMixin, ActiveRecord): + """User model for validation testing. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "validated_users" + + id: int | None = None + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + age: int | None = Field(None, ge=0, le=150) + + @field_validator('username') + def validate_username(cls, v: str) -> str: + if len(v.strip()) != len(v): + raise ValidationError("Username cannot have leading or trailing spaces") + if not v.isalnum(): + raise ValidationError("Username must be alphanumeric") + return v + + @classmethod + def validate_record(cls, instance: Self) -> None: + """Business rule validation using Self type for instance parameter.""" + if instance.age is not None and instance.age < 13: + raise ValidationError("User must be at least 13 years old") + + +class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): + """User model for validation testing. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + age: int | None = Field(None, ge=0, le=150) + + @field_validator('username') + def validate_username(cls, v: str) -> str: + if len(v.strip()) != len(v): + raise ValidationError("Username cannot have leading or trailing spaces") + if not v.isalnum(): + raise ValidationError("Username must be alphanumeric") + return v + + @classmethod + def validate_record(cls, instance: Self) -> None: + """Business rule validation using Self type.""" + if instance.age is not None and instance.age < 13: + raise ValidationError("User must be at least 13 years old") + + +# Keep this mixin I/O-free: fields, validators, and the FieldProxy descriptor are shared by sync/async models. +# FieldProxy builds expressions from configured model metadata; concrete models still own table metadata and I/O bases. +class PydanticValidatedFieldsMixin: + """Shared Pydantic fields and validators for sync/async contract tests.""" + + c: ClassVar[FieldProxy] = FieldProxy() + code: str = Field( + ..., + pattern=r"^[A-Z]{3}-\d{3}$", + title="Validation code", + description="Business code used by Pydantic compatibility tests.", + json_schema_extra={"active_record_test": "pydantic-native"}, + ) + quantity: int = Field(..., ge=1, le=999) + step_count: int = Field(..., gt=0, lt=100, multiple_of=5) + price: Decimal = Field(..., ge=Decimal("0.01")) + start_at: datetime + end_at: datetime + status: Literal["draft", "active", "archived"] = "draft" + normalized_name: str = Field(..., min_length=1, max_length=50) + created_token: str = Field(default_factory=lambda: "generated-token") + + @field_validator("normalized_name") + def normalize_name(cls, value: str) -> str: + return value.strip().lower() + + @model_validator(mode="after") + def validate_period(self): + if self.end_at <= self.start_at: + raise ValueError("end_at must be after start_at") + return self + + +class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): + """Model for Pydantic native validation contract tests.""" + __table_name__ = "pydantic_validated_models" + + id: int | None = None + + +class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): + """Async model for Pydantic native validation contract tests.""" + __table_name__ = "pydantic_validated_models" + + id: int | None = None + + +class YesOrNoBooleanAdapter(BaseSQLTypeAdapter): + """Converts Python's True/False to 'yes'/'no' strings.""" + def _do_to_database(self, value: bool, target_type: Type, options: Dict[str, Any] | None = None) -> str: + return "yes" if value else "no" + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> bool: + return value == "yes" + + +class TypeAdapterTest(ActiveRecord): + """Model for testing various type adapter scenarios. + + Python 3.11+ version using | syntax. + """ + __table_name__ = 'type_adapter_tests' + __primary_key__ = 'id' + + id: int | None = None + name: str + optional_name: str | None = None + optional_age: int | None = None + last_login: datetime | None = None + is_premium: bool | None = None + unsupported_union: str | int = 0 + custom_bool: Annotated[bool, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + optional_custom_bool: Annotated[bool | None, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + + +class AsyncTypeAdapterTest(AsyncActiveRecord): + """Model for testing various type adapter scenarios. + + Python 3.11+ version using | syntax. + """ + __table_name__ = 'type_adapter_tests' + __primary_key__ = 'id' + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + optional_name: str | None = None + optional_age: int | None = None + last_login: datetime | None = None + is_premium: bool | None = None + unsupported_union: str | int = 0 + custom_bool: Annotated[bool, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + optional_custom_bool: Annotated[bool | None, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + + +class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): + """User model with custom column name mappings. + + 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")] + email_address: Annotated[str, UseColumn("email")] + creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + def update_email(self, new_email: str) -> Self: + """Update email and return self for chaining.""" + self.email_address = new_email + return self + + +class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """User model with custom column name mappings. + + Python 3.11+ version using | syntax. + """ + __table_name__ = "users" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + user_id: Annotated[int | None, UseColumn("id")] = None + user_name: Annotated[str, UseColumn("username")] + email_address: Annotated[str, UseColumn("email")] + creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + +class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Post model with custom column name mappings. + + 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")] + post_title: Annotated[str, UseColumn("title")] + post_content: Annotated[str, UseColumn("content")] + publication_time: Annotated[datetime | None, UseColumn("published_at")] = None + is_published: Annotated[bool, UseColumn("published")] + + +class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Post model with custom column name mappings. + + Python 3.11+ version using | syntax. + """ + __table_name__ = "posts" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + post_id: Annotated[int | None, UseColumn("id")] = None + author_id: Annotated[int, UseColumn("author")] + post_title: Annotated[str, UseColumn("title")] + post_content: Annotated[str, UseColumn("content")] + publication_time: Annotated[datetime | None, UseColumn("published_at")] = None + is_published: Annotated[bool, UseColumn("published")] + + +class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Comment model with custom column name mappings. + + 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")] + author_id: Annotated[int, UseColumn("author")] + comment_text: Annotated[str, UseColumn("text")] + comment_creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + is_approved: Annotated[bool, UseColumn("approved")] + + +class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Comment model with custom column name mappings. + + Python 3.11+ version using | syntax. + """ + __table_name__ = "comments" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + comment_id: Annotated[int | None, UseColumn("id")] = None + post_id: Annotated[int, UseColumn("post_ref")] + author_id: Annotated[int, UseColumn("author")] + comment_text: Annotated[str, UseColumn("text")] + comment_creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + is_approved: Annotated[bool, UseColumn("approved")] + + +class IntToStringAdapter(BaseSQLTypeAdapter): + """A simple type adapter that converts an integer for the DB to a string for Python.""" + + def __init__(self): + super().__init__() + self._register_type(str, int) + + def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: + return int(value) + + def _do_from_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: + return str(value) + + +class ColumnMappingModel(ActiveRecord): + """A model demonstrating various valid uses of UseColumn and UseAdapter. + + 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 + item_count: Annotated[int, UseColumn("item_total")] + notes: Annotated[str, UseAdapter(IntToStringAdapter(), target_db_type=int), UseColumn("remarks")] + + +class AsyncColumnMappingModel(AsyncActiveRecord): + """A model demonstrating various valid uses of UseColumn and UseAdapter. + + 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) + name: str + item_count: Annotated[int, UseColumn("item_total")] + notes: Annotated[str, UseAdapter(IntToStringAdapter(), target_db_type=int), UseColumn("remarks")] + + +class ListToStringAdapter(BaseSQLTypeAdapter): + """Converts a Python list of strings to a single comma-separated string for the DB.""" + + def __init__(self): + super().__init__() + self._register_type(list, str) + + def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: + if value is None: + return None + return ",".join(value) + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> list[str] | None: + if value is None: + return None + + if isinstance(value, str): + if value.startswith('[') and value.endswith(']'): + import ast + try: + parsed_value = ast.literal_eval(value) + if isinstance(parsed_value, list): + return parsed_value + else: + return [parsed_value] if parsed_value is not None else [] + except (ValueError, SyntaxError): + pass + + stripped_value = value.strip().strip('"\'') + if stripped_value.startswith('[') and stripped_value.endswith(']'): + import ast + try: + parsed_value = ast.literal_eval(stripped_value) + if isinstance(parsed_value, list): + return parsed_value + except (ValueError, SyntaxError): + pass + + if value: + return [item.strip().strip('"\'') for item in value.split(',')] + else: + return [] + + return [value] if value is not None else [] + + +class JsonToStringAdapter(BaseSQLTypeAdapter): + """Converts a Python dictionary to a JSON string for the DB.""" + + def __init__(self): + super().__init__() + self._register_type(dict, str) + + def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: + if value is None: + return None + return json.dumps(value) + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> dict | None: + if value is None: + return None + return json.loads(value) + + +class MixedAnnotationModel(ActiveRecord): + """A model with various combinations of annotations to test field mapping and adapters. + + Python 3.11+ version using | syntax. + """ + __table_name__ = "mixed_annotation_items" + __primary_key__ = "id" + + name: str + item_id: Annotated[int, UseColumn("id")] + tags: Annotated[list[str], UseAdapter(ListToStringAdapter(), str)] + metadata: Annotated[dict | None, UseColumn("meta"), UseAdapter(JsonToStringAdapter(), str)] = None + description: str | None = None + status: str = "active" + + def with_status(self, new_status: str) -> Self: + """Return a new instance with updated status. + + Uses Python 3.11+ Self type. + """ + new_instance = copy.deepcopy(self) + new_instance.status = new_status + return new_instance + + +class AsyncMixedAnnotationModel(AsyncActiveRecord): + """A model with various combinations of annotations to test field mapping and adapters. + + Python 3.11+ version using | syntax. + """ + __table_name__ = "mixed_annotation_items" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + name: str + item_id: Annotated[int, UseColumn("id")] + tags: Annotated[list[str], UseAdapter(ListToStringAdapter(), str)] + metadata: Annotated[dict | None, UseColumn("meta"), UseAdapter(JsonToStringAdapter(), str)] = None + description: str | None = None + status: str = "active" + +# ============================================================================= +# Composite Primary Key Models +# ============================================================================= + +class OrderItem(CompositePKMixin, ActiveRecord): + """Composite PK (order_id, product_id), PK provided by application.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_id: int + product_id: int + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): + """Async variant of OrderItem.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_id: int + product_id: int + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class StoreInventory(CompositePKMixin, ActiveRecord): + """Triple-column composite PK.""" + __table_name__ = "store_inventory" + __primary_key__ = ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() + + store_id: int + product_id: int + batch_id: str + stock: int = 0 + + +class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): + """Async variant of StoreInventory.""" + __table_name__ = "store_inventory" + __primary_key__ = ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() + + store_id: int + product_id: int + batch_id: str + stock: int = 0 + + +class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Single-column auto-increment PK -- backward compatibility control group.""" + __table_name__ = "orders" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + total: Decimal = Decimal("0.00") + + +class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Async variant of Order.""" + __table_name__ = "orders" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + total: Decimal = Decimal("0.00") + + +class MappedOrderItem(CompositePKMixin, ActiveRecord): + """Composite PK with UseColumn mapping -- Python field names differ from DB column names.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + + order_ref: Annotated[int, UseColumn("order_id")] + product_ref: Annotated[int, UseColumn("product_id")] + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): + """Async variant of MappedOrderItem.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_ref: Annotated[int, UseColumn("order_id")] + product_ref: Annotated[int, UseColumn("product_id")] + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class BulkUser(IntegerPKMixin, ActiveRecord): + __table_name__ = "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + age: int = 0 + email: str = "" + + +class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): + __table_name__ = "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + age: int = 0 + email: str = "" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py new file mode 100644 index 00000000..c0178057 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fixtures/models_py312.py @@ -0,0 +1,815 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/fixtures/models_py312.py +""" +Python 3.12+ fixture model definitions. + +This file contains model classes using Python 3.12+ syntax features: +- Type parameter syntax (PEP 695): `class Model[T]:` instead of `Generic[T]` +- `@override` decorator for inheritance safety +- Enhanced f-string expressions + +Note: This file should only be imported and used in Python 3.12+ environments. +""" +from __future__ import annotations + +import copy +import re +from datetime import date, time, datetime +from decimal import Decimal +from typing import Optional, Type, Literal, Union, Any, Dict, List, Self, Set, override +import json + +from pydantic import EmailStr, Field, field_validator, model_validator + +from rhosocial.activerecord.backend.type_adapter import BaseSQLTypeAdapter +from rhosocial.activerecord.base.fields import UseAdapter, UseColumn +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.backend.errors import ValidationError +from rhosocial.activerecord.field import CompositePKMixin, TimestampMixin, UUIDMixin, IntegerPKMixin +from typing import Annotated, ClassVar + + +# Declare that this module requires Python 3.12+ +__requires_python__ = (3, 12) + + +class TypeCase(UUIDMixin, ActiveRecord): + """A model with a wide variety of data types to test database type handling. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "type_cases" + + username: str + email: str + tiny_int: int | None + small_int: int | None + big_int: int | None + float_val: float | None + double_val: float | None + decimal_val: Decimal | None + char_val: str | None + varchar_val: str | None + text_val: str | None + date_val: date | None + time_val: time | None + timestamp_val: datetime | None + blob_val: bytes | None + json_val: dict | None + array_val: list | None + is_active: bool = True + + def clone(self) -> Self: + """Create a deep copy of this instance.""" + return copy.deepcopy(self) + + +class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): + """A model with a wide variety of data types to test database type handling. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() + + username: str + email: str + tiny_int: int | None + small_int: int | None + big_int: int | None + float_val: float | None + double_val: float | None + decimal_val: Decimal | None + char_val: str | None + varchar_val: str | None + text_val: str | None + date_val: date | None + time_val: time | None + timestamp_val: datetime | None + blob_val: bytes | None + json_val: dict | None + array_val: list | None + is_active: bool = True + + def clone(self) -> Self: + """Create a deep copy of this instance.""" + return copy.deepcopy(self) + + +class TypeTestModel(UUIDMixin, ActiveRecord): + """Model class for testing various field types. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "type_tests" + + # UUID primary key provided by UUIDMixin + string_field: str = Field(default="test string") + int_field: int = Field(default=42) + float_field: float = Field(default=3.14) + decimal_field: Decimal = Field(default=Decimal("10.99")) + bool_field: bool = Field(default=True) + datetime_field: datetime = Field(default_factory=datetime.now) + json_field: dict | None = None + nullable_field: str | None = Field(default=None) + + +class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): + """Model class for testing various field types. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() + + # UUID primary key provided by UUIDMixin + string_field: str = Field(default="test string") + int_field: int = Field(default=42) + float_field: float = Field(default=3.14) + decimal_field: Decimal = Field(default=Decimal("10.99")) + bool_field: bool = Field(default=True) + datetime_field: datetime = Field(default_factory=datetime.now) + json_field: dict | None = None + nullable_field: str | None = Field(default=None) + + +class User(IntegerPKMixin, TimestampMixin, ActiveRecord): + """A standard User model for general CRUD operation testing. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = Field(..., ge=0, le=100) + balance: float = 0.0 + is_active: bool = True + + def with_balance(self, new_balance: float) -> Self: + """Return a new instance with updated balance.""" + new_user = self.clone() + new_user.balance = new_balance + return new_user + + def activate(self) -> Self: + """Activate the user and return self for chaining.""" + self.is_active = True + return self + + def deactivate(self) -> Self: + """Deactivate the user and return self for chaining.""" + self.is_active = False + return self + + +class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """A standard User model for general CRUD operation testing. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = Field(..., ge=0, le=100) + balance: float = 0.0 + is_active: bool = True + + def with_balance(self, new_balance: float) -> Self: + """Return a new instance with updated balance.""" + new_user = copy.deepcopy(self) + new_user.balance = new_balance + return new_user + + +class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): + """A User model with specific, custom field validators to test the framework's + validation handling. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = None + balance: float | None = 0.0 + credit_score: int + status: Literal['active', 'inactive', 'banned', 'pending', 'suspended'] = 'active' + is_active: bool | None = True + + @field_validator('username') + def validate_username(cls, value): + """A custom validator that rejects usernames containing '123'.""" + if re.search(r'123', value): + raise ValidationError("Username must not contain '123'.") + return value + + @field_validator('credit_score') + def validate_credit_score(cls, value): + """A custom validator that ensures credit_score is within a specific range.""" + if not (0 <= value <= 800): + raise ValidationError("Credit score must be between 0 and 800.") + return value + + +class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): + """A User model with specific, custom field validators to test the framework's + validation handling. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str + email: EmailStr + age: int | None = None + balance: float | None = 0.0 + credit_score: int + status: Literal['active', 'inactive', 'banned', 'pending', 'suspended'] = 'active' + is_active: bool | None = True + + @field_validator('username') + def validate_username(cls, value): + """A custom validator that rejects usernames containing '123'.""" + if re.search(r'123', value): + raise ValidationError("Username must not contain '123'.") + return value + + @field_validator('credit_score') + def validate_credit_score(cls, value): + """A custom validator that ensures credit_score is within a specific range.""" + if not (0 <= value <= 800): + raise ValidationError("Credit score must be between 0 and 800.") + return value + + +class ValidatedUser(IntegerPKMixin, ActiveRecord): + """User model for validation testing. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "validated_users" + + id: int | None = None + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + age: int | None = Field(None, ge=0, le=150) + + @field_validator('username') + @override + def validate_username(cls, v: str) -> str: + if len(v.strip()) != len(v): + raise ValidationError("Username cannot have leading or trailing spaces") + if not v.isalnum(): + raise ValidationError("Username must be alphanumeric") + return v + + @classmethod + def validate_record(cls, instance: Self) -> None: + """Business rule validation using Self type.""" + if instance.age is not None and instance.age < 13: + raise ValidationError("User must be at least 13 years old") + + +class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): + """User model for validation testing. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + username: str = Field(..., min_length=3, max_length=50) + email: EmailStr + age: int | None = Field(None, ge=0, le=150) + + @field_validator('username') + @override + def validate_username(cls, v: str) -> str: + if len(v.strip()) != len(v): + raise ValidationError("Username cannot have leading or trailing spaces") + if not v.isalnum(): + raise ValidationError("Username must be alphanumeric") + return v + + @classmethod + def validate_record(cls, instance: Self) -> None: + """Business rule validation using Self type.""" + if instance.age is not None and instance.age < 13: + raise ValidationError("User must be at least 13 years old") + + +# Keep this mixin I/O-free: fields, validators, and the FieldProxy descriptor are shared by sync/async models. +# FieldProxy builds expressions from configured model metadata; concrete models still own table metadata and I/O bases. +class PydanticValidatedFieldsMixin: + """Shared Pydantic fields and validators for sync/async contract tests.""" + + c: ClassVar[FieldProxy] = FieldProxy() + code: str = Field( + ..., + pattern=r"^[A-Z]{3}-\d{3}$", + title="Validation code", + description="Business code used by Pydantic compatibility tests.", + json_schema_extra={"active_record_test": "pydantic-native"}, + ) + quantity: int = Field(..., ge=1, le=999) + step_count: int = Field(..., gt=0, lt=100, multiple_of=5) + price: Decimal = Field(..., ge=Decimal("0.01")) + start_at: datetime + end_at: datetime + status: Literal["draft", "active", "archived"] = "draft" + normalized_name: str = Field(..., min_length=1, max_length=50) + created_token: str = Field(default_factory=lambda: "generated-token") + + @field_validator("normalized_name") + def normalize_name(cls, value: str) -> str: + return value.strip().lower() + + @model_validator(mode="after") + def validate_period(self): + if self.end_at <= self.start_at: + raise ValueError("end_at must be after start_at") + return self + + +class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): + """Model for Pydantic native validation contract tests.""" + __table_name__ = "pydantic_validated_models" + + id: int | None = None + + +class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): + """Async model for Pydantic native validation contract tests.""" + __table_name__ = "pydantic_validated_models" + + id: int | None = None + + +class YesOrNoBooleanAdapter(BaseSQLTypeAdapter): + """Converts Python's True/False to 'yes'/'no' strings.""" + def _do_to_database(self, value: bool, target_type: Type, options: Dict[str, Any] | None = None) -> str: + return "yes" if value else "no" + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> bool: + return value == "yes" + + +class TypeAdapterTest(ActiveRecord): + """Model for testing various type adapter scenarios. + + Python 3.12+ version using | syntax. + """ + __table_name__ = 'type_adapter_tests' + __primary_key__ = 'id' + + id: int | None = None + name: str + optional_name: str | None = None + optional_age: int | None = None + last_login: datetime | None = None + is_premium: bool | None = None + unsupported_union: str | int = 0 + custom_bool: Annotated[bool, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + optional_custom_bool: Annotated[bool | None, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + + +class AsyncTypeAdapterTest(AsyncActiveRecord): + """Model for testing various type adapter scenarios. + + Python 3.12+ version using | syntax. + """ + __table_name__ = 'type_adapter_tests' + __primary_key__ = 'id' + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + optional_name: str | None = None + optional_age: int | None = None + last_login: datetime | None = None + is_premium: bool | None = None + unsupported_union: str | int = 0 + custom_bool: Annotated[bool, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + optional_custom_bool: Annotated[bool | None, UseAdapter(YesOrNoBooleanAdapter(), str)] = None + + +class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): + """User model with custom column name mappings. + + 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")] + email_address: Annotated[str, UseColumn("email")] + creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + def update_email(self, new_email: str) -> Self: + """Update email and return self for chaining.""" + self.email_address = new_email + return self + + +class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """User model with custom column name mappings. + + Python 3.12+ version using | syntax. + """ + __table_name__ = "users" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + user_id: Annotated[int | None, UseColumn("id")] = None + user_name: Annotated[str, UseColumn("username")] + email_address: Annotated[str, UseColumn("email")] + creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + + +class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Post model with custom column name mappings. + + 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")] + post_title: Annotated[str, UseColumn("title")] + post_content: Annotated[str, UseColumn("content")] + publication_time: Annotated[datetime | None, UseColumn("published_at")] = None + is_published: Annotated[bool, UseColumn("published")] + + +class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Post model with custom column name mappings. + + Python 3.12+ version using | syntax. + """ + __table_name__ = "posts" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + post_id: Annotated[int | None, UseColumn("id")] = None + author_id: Annotated[int, UseColumn("author")] + post_title: Annotated[str, UseColumn("title")] + post_content: Annotated[str, UseColumn("content")] + publication_time: Annotated[datetime | None, UseColumn("published_at")] = None + is_published: Annotated[bool, UseColumn("published")] + + +class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Comment model with custom column name mappings. + + 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")] + author_id: Annotated[int, UseColumn("author")] + comment_text: Annotated[str, UseColumn("text")] + comment_creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + is_approved: Annotated[bool, UseColumn("approved")] + + +class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Comment model with custom column name mappings. + + Python 3.12+ version using | syntax. + """ + __table_name__ = "comments" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + comment_id: Annotated[int | None, UseColumn("id")] = None + post_id: Annotated[int, UseColumn("post_ref")] + author_id: Annotated[int, UseColumn("author")] + comment_text: Annotated[str, UseColumn("text")] + comment_creation_date: Annotated[datetime | None, UseColumn("created_at")] = None + is_approved: Annotated[bool, UseColumn("approved")] + + +class IntToStringAdapter(BaseSQLTypeAdapter): + """A simple type adapter that converts an integer for the DB to a string for Python.""" + + def __init__(self): + super().__init__() + self._register_type(str, int) + + def _do_to_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: + return int(value) + + def _do_from_database(self, value: Any, target_type: Type, options: Dict[str, Any] | None = None) -> Any: + return str(value) + + +class ColumnMappingModel(ActiveRecord): + """A model demonstrating various valid uses of UseColumn and UseAdapter. + + 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 + item_count: Annotated[int, UseColumn("item_total")] + notes: Annotated[str, UseAdapter(IntToStringAdapter(), target_db_type=int), UseColumn("remarks")] + + +class AsyncColumnMappingModel(AsyncActiveRecord): + """A model demonstrating various valid uses of UseColumn and UseAdapter. + + 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) + name: str + item_count: Annotated[int, UseColumn("item_total")] + notes: Annotated[str, UseAdapter(IntToStringAdapter(), target_db_type=int), UseColumn("remarks")] + + +class ListToStringAdapter(BaseSQLTypeAdapter): + """Converts a Python list of strings to a single comma-separated string for the DB.""" + + def __init__(self): + super().__init__() + self._register_type(list, str) + + def _do_to_database(self, value: List[str], target_type: Type, options: Dict[str, Any] | None = None) -> str | None: + if value is None: + return None + return ",".join(value) + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> list[str] | None: + if value is None: + return None + + if isinstance(value, str): + if value.startswith('[') and value.endswith(']'): + import ast + try: + parsed_value = ast.literal_eval(value) + if isinstance(parsed_value, list): + return parsed_value + else: + return [parsed_value] if parsed_value is not None else [] + except (ValueError, SyntaxError): + pass + + stripped_value = value.strip().strip('"\'') + if stripped_value.startswith('[') and stripped_value.endswith(']'): + import ast + try: + parsed_value = ast.literal_eval(stripped_value) + if isinstance(parsed_value, list): + return parsed_value + except (ValueError, SyntaxError): + pass + + if value: + return [item.strip().strip('"\'') for item in value.split(',')] + else: + return [] + + return [value] if value is not None else [] + + +class JsonToStringAdapter(BaseSQLTypeAdapter): + """Converts a Python dictionary to a JSON string for the DB.""" + + def __init__(self): + super().__init__() + self._register_type(dict, str) + + def _do_to_database(self, value: Dict, target_type: Type, options: Dict[str, Any] | None = None) -> str | None: + if value is None: + return None + return json.dumps(value) + + def _do_from_database(self, value: str, target_type: Type, options: Dict[str, Any] | None = None) -> dict | None: + if value is None: + return None + return json.loads(value) + + +class MixedAnnotationModel(ActiveRecord): + """A model with various combinations of annotations to test field mapping and adapters. + + 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")] + tags: Annotated[list[str], UseAdapter(ListToStringAdapter(), str)] + metadata: Annotated[dict | None, UseColumn("meta"), UseAdapter(JsonToStringAdapter(), str)] = None + description: str | None = None + status: str = "active" + + def with_status(self, new_status: str) -> Self: + """Return a new instance with updated status.""" + new_instance = copy.deepcopy(self) + new_instance.status = new_status + return new_instance + + +class AsyncMixedAnnotationModel(AsyncActiveRecord): + """A model with various combinations of annotations to test field mapping and adapters. + + Python 3.12+ version using | syntax. + """ + __table_name__ = "mixed_annotation_items" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + name: str + item_id: Annotated[int, UseColumn("id")] + tags: Annotated[list[str], UseAdapter(ListToStringAdapter(), str)] + metadata: Annotated[dict | None, UseColumn("meta"), UseAdapter(JsonToStringAdapter(), str)] = None + description: str | None = None + status: str = "active" + + +# Python 3.12+ Type Parameter Syntax Examples (PEP 695) +# These demonstrate the new generic syntax, though they may not be directly +# usable with ActiveRecord due to Pydantic model constraints. + +class GenericContainer[T]: + """Example of Python 3.12+ type parameter syntax. + + Note: This is a demonstration class, not an ActiveRecord model. + The new syntax allows cleaner generic definitions: + + Before (Python 3.8+): + class GenericContainer(Generic[T]): + ... + + After (Python 3.12+): + class GenericContainer[T]: + ... + """ + def __init__(self, value: T): + self._value = value + + def get_value(self) -> T: + return self._value + + def map[U](self, func: callable[[T], U]) -> "GenericContainer[U]": + """Transform the contained value using the provided function. + + Uses Python 3.12+ type parameter syntax for methods. + """ + return GenericContainer(func(self._value)) + + +class TypedResult[T, E]: + """Example of Python 3.12+ multiple type parameters. + + Note: This is a demonstration class, not an ActiveRecord model. + """ + def __init__(self, value: T | None = None, error: E | None = None): + self._value = value + self._error = error + + def is_ok(self) -> bool: + return self._error is None + + def unwrap(self) -> T: + if self._error is not None: + raise ValueError(f"Cannot unwrap error: {self._error}") + return self._value # type: ignore + +# ============================================================================= +# Composite Primary Key Models +# ============================================================================= + +class OrderItem(CompositePKMixin, ActiveRecord): + """Composite PK (order_id, product_id), PK provided by application.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_id: int + product_id: int + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): + """Async variant of OrderItem.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_id: int + product_id: int + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class StoreInventory(CompositePKMixin, ActiveRecord): + """Triple-column composite PK.""" + __table_name__ = "store_inventory" + __primary_key__ = ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() + + store_id: int + product_id: int + batch_id: str + stock: int = 0 + + +class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): + """Async variant of StoreInventory.""" + __table_name__ = "store_inventory" + __primary_key__ = ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() + + store_id: int + product_id: int + batch_id: str + stock: int = 0 + + +class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Single-column auto-increment PK -- backward compatibility control group.""" + __table_name__ = "orders" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + total: Decimal = Decimal("0.00") + + +class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): + """Async variant of Order.""" + __table_name__ = "orders" + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + total: Decimal = Decimal("0.00") + + +class MappedOrderItem(CompositePKMixin, ActiveRecord): + """Composite PK with UseColumn mapping -- Python field names differ from DB column names.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + + order_ref: Annotated[int, UseColumn("order_id")] + product_ref: Annotated[int, UseColumn("product_id")] + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): + """Async variant of MappedOrderItem.""" + __table_name__ = "order_items" + __primary_key__ = ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() + + order_ref: Annotated[int, UseColumn("order_id")] + product_ref: Annotated[int, UseColumn("product_id")] + quantity: int = 1 + unit_price: Decimal = Decimal("0.00") + + +class BulkUser(IntegerPKMixin, ActiveRecord): + __table_name__ = "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + age: int = 0 + email: str = "" + + +class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): + __table_name__ = "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() + + id: int | None = None + name: str + age: int = 0 + email: str = "" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py index 9c3e81ba..6601e6ad 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_basic_mapped_models.py @@ -15,4 +15,6 @@ from rhosocial.activerecord.testsuite.feature.basic.conftest import mapped_models_fixtures # noqa: F401 # The `TestBasicMappedModels` class contains the actual test cases. -from rhosocial.activerecord.testsuite.feature.basic.test_example_basic_fixtures import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_example_basic_fixtures import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.fields.test_example_basic_fixtures_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py deleted file mode 100644 index f9841654..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_type_adapter.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -This is a "bridge" file for the basic features test group, specifically for -type adapter tests. - -Its purpose is to import the generic tests from the `rhosocial-activerecord-testsuite` -package and make them discoverable by `pytest` within this project's test run. -""" - -# Import the fixture that provides the configured model and backend for type adapter tests. -from rhosocial.activerecord.testsuite.feature.basic.conftest import ( - type_adapter_fixtures, # noqa: F401 - async_type_adapter_fixtures, # noqa: F401 -) - -# Import all tests from the generic testsuite file. -from rhosocial.activerecord.testsuite.feature.basic.test_type_adapter import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/conftest.py new file mode 100644 index 00000000..b4dabb9b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/conftest.py @@ -0,0 +1,6 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/conftest.py +""" +Pytest configuration for basic transaction feature tests. +""" + +from rhosocial.activerecord.testsuite.feature.basic.transaction.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py new file mode 100644 index 00000000..a27119c9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_async_owner_task.py @@ -0,0 +1,201 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/transaction/test_async_owner_task.py +"""Async contracts for `AsyncTransactionManager._owner_task` safety warning. + +`AsyncTransactionManager.transaction()` (python-activerecord +`backend/transaction.py:879-943`) tracks which asyncio task currently +owns an active outermost transaction. When a second task enters +`transaction()` on the same backend instance while another task holds +an active outermost transaction, it MUST emit `UserWarning` with a fixed +message text (line 906-915). + +The `_owner_task` lifecycle is bound to `backend.transaction_manager.transaction()` +only. `pool.transaction()` calls `begin_transaction()` / `commit_transaction()` +directly (sync_pool.py:574-576 / async_pool.py:613-615) and does NOT +participate in `_owner_task` tracking. Therefore these tests operate +directly on `backend.transaction_manager` rather than through +`pool.transaction()`. + +Coverage: +- T1 single task nested transaction does not emit warning +- T2 a different task entering while an outer is active emits warning +- T3 warning message text is stable (for log-grep filters) +- T4 owner_task is cleared after outermost transaction exits cleanly +- T5 same task re-entering after clean exit re-acquires ownership +""" +import asyncio +import warnings + +import pytest + + +@pytest.fixture +async def mgr_and_backend(async_pool_and_model): + """Acquire a backend via pool.connection() and yield (backend, mgr). + + Mechanism note: we stub `_do_begin / _do_commit / _do_rollback / + _do_create_savepoint / _do_release_savepoint / _do_rollback_savepoint` + on the async transaction manager with no-ops, because the + `_owner_task` warning branch fires *before* `_do_begin()` is awaited + (transaction.py:905-915 runs synchronously at context-manager entry, + only then does `await self.begin()` execute). Stubbing the I/O layer + lets us exercise the pure state machine — including concurrent + multi-task execution on a real asyncio event loop — without dragging + in SQLite's thread-affinity rules, which otherwise cause spurious + "cannot start a transaction within a transaction" when a second + task reuses the same aiosqlite connection. + """ + pool, _model = async_pool_and_model + cm = pool.connection() + backend = await cm.__aenter__() + mgr = backend.transaction_manager + + async def _noop(*args, **kwargs): + return None + + mgr._do_begin = _noop + mgr._do_commit = _noop + mgr._do_rollback = _noop + mgr._do_create_savepoint = _noop + mgr._do_release_savepoint = _noop + mgr._do_rollback_savepoint = _noop + + try: + yield backend, mgr + finally: + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + + +class TestAsyncOwnerTaskNoWarningSameTask: + async def test_same_task_nested_transaction_no_warning(self, mgr_and_backend): + _backend, mgr = mgr_and_backend + with warnings.catch_warnings(): + warnings.simplefilter("error") + async with mgr.transaction(): + async with mgr.transaction(): + pass + + async def test_same_task_reentry_after_clean_exit_no_warning(self, mgr_and_backend): + _backend, mgr = mgr_and_backend + async with mgr.transaction(): + pass + with warnings.catch_warnings(): + warnings.simplefilter("error") + async with mgr.transaction(): + pass + + +class TestAsyncOwnerTaskCrossTaskWarning: + async def test_cross_task_share_emits_user_warning(self, mgr_and_backend): + """A different task entering transaction() during an active outer + transaction owned by another task MUST emit UserWarning. + + Per backend/transaction.py:906-915, the branch fires when: + self._owner_task is not None and self._owner_task is not current_task + It does NOT raise, so the second task can still proceed - the + system is telling the developer to fix their code. + """ + _backend, mgr = mgr_and_backend + + outer_started = asyncio.Event() + proceed = asyncio.Event() + + async def owner(): + async with mgr.transaction(): + outer_started.set() + await proceed.wait() + + async def second_task(caught): + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + async with mgr.transaction(): + pass + caught.extend(captured) + + caught = [] + owner_task = asyncio.create_task(owner()) + second_t = asyncio.create_task(second_task(caught)) + # Let owner enter its outer transaction first. + await asyncio.wait_for(outer_started.wait(), timeout=5.0) + # Yield once so second task enters transaction() and emits warning. + await asyncio.sleep(0.05) + proceed.set() + await asyncio.gather(owner_task, second_t) + + user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] + assert user_warnings, "expected a UserWarning about shared backend" + + async def test_cross_task_warning_message_is_stable(self, mgr_and_backend): + _backend, mgr = mgr_and_backend + + outer_started = asyncio.Event() + proceed = asyncio.Event() + + async def owner(): + async with mgr.transaction(): + outer_started.set() + await proceed.wait() + + async def second_task(caught): + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + async with mgr.transaction(): + pass + caught.extend(captured) + + caught = [] + owner_task = asyncio.create_task(owner()) + second_t = asyncio.create_task(second_task(caught)) + await asyncio.wait_for(outer_started.wait(), timeout=5.0) + await asyncio.sleep(0.05) + proceed.set() + await asyncio.gather(owner_task, second_t) + + msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] + assert msgs, "no UserWarning captured" + expected = ( + "Shared async backend instance detected: a different async task " + "already holds an active transaction on this backend. Concurrent " + "use of the same backend instance across tasks can lead to " + "transaction state inconsistency. Use separate backend instances " + "per task or ensure serialized access." + ) + assert msgs[0] == expected + + +class TestAsyncOwnerTaskLifecycle: + async def test_owner_task_cleared_after_outermost_exits(self, mgr_and_backend): + """After a clean outermost commit, _owner_task MUST reset to None. + + Per backend/transaction.py:941-943: + if not self.is_active: + self._owner_task = None + """ + _backend, mgr = mgr_and_backend + async with mgr.transaction(): + assert mgr.is_active + async with mgr.transaction(): + pass + assert mgr._owner_task is None + + async def test_owner_task_cleared_after_outermost_rolls_back(self, mgr_and_backend): + _backend, mgr = mgr_and_backend + with pytest.raises(RuntimeError): + async with mgr.transaction(): + async with mgr.transaction(): + raise RuntimeError("force rollback") + assert mgr._owner_task is None + + async def test_owner_task_reacquired_by_same_task_after_clean_exit(self, mgr_and_backend): + """After clean reset, the same task re-entering does not warn and + re-acquires ownership (line 916-917).""" + _backend, mgr = mgr_and_backend + async with mgr.transaction(): + async with mgr.transaction(): + pass + with warnings.catch_warnings(): + warnings.simplefilter("error") + async with mgr.transaction(): + pass diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_savepoint_api.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_savepoint_api.py new file mode 100644 index 00000000..339b69ff --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_savepoint_api.py @@ -0,0 +1,224 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/transaction/test_savepoint_api.py +""" +Black-box contracts for `TransactionManager.savepoint / release / rollback_to`. + +These tests target the three first-class savepoint APIs exposed by +`backend.transaction_manager` (see python-activerecord +`src/rhosocial/activerecord/backend/transaction.py:494-592`): + +- ``savepoint(name=None) -> str`` create a savepoint (auto-named if None) +- ``release(name) -> None`` release a savepoint +- ``rollback_to(name) -> None`` rollback to a savepoint, truncating newer ones + +Scope: pure API-shaped contracts that should hold on any backend whose +dialect advertises ``supports_savepoint()``. Isolation-level phenomena +and dialect-specific SQL formatting are out of scope here (see +test_isolation_behavior.py / test_read_only_mode.py in later tasks). + +Backend providers that do not support savepoints should skip the whole +module via scenario filter rather than per-test markers, since every +test here is grounded on the savepoint primitive. +""" +import pytest + +from rhosocial.activerecord.backend.errors import TransactionError + + +@pytest.fixture +def txn_backend(sync_pool_and_model): + """Yield a backend inside an outer transaction context. + + All savepoint operations require an already-active transaction; opening + one ``with pool.transaction()`` also lets the block cleanup gracefully on + test errors, keeping pool connection state consistent across tests. + """ + pool, _model = sync_pool_and_model + cm = pool.transaction() + backend = cm.__enter__() + try: + yield backend + finally: + try: + cm.__exit__(None, None, None) + except Exception: + pass + + +class TestSavepointCreate: + """``TransactionManager.savepoint()`` creation contracts.""" + + def test_savepoint_autoname_is_returned_and_prefixed(self, txn_backend): + mgr = txn_backend.transaction_manager + name = mgr.savepoint() + assert isinstance(name, str) + assert name # non-empty + assert name in mgr._active_savepoints + + def test_savepoint_explicit_name_is_honored(self, txn_backend): + mgr = txn_backend.transaction_manager + name = mgr.savepoint(name="cust_sp_a") + assert name == "cust_sp_a" + assert "cust_sp_a" in mgr._active_savepoints + + def test_savepoint_increments_active_savepoints(self, txn_backend): + mgr = txn_backend.transaction_manager + before = len(mgr._active_savepoints) + mgr.savepoint(name="sp_first") + mgr.savepoint(name="sp_second") + assert len(mgr._active_savepoints) == before + 2 + assert mgr._active_savepoints[-1] == "sp_second" + assert mgr._active_savepoints[-2] == "sp_first" + + def test_savepoint_seq_autoname_do_not_collide(self, txn_backend): + mgr = txn_backend.transaction_manager + names = {mgr.savepoint() for _ in range(3)} + assert len(names) == 3 + + +class TestRelease: + """``TransactionManager.release()`` contracts.""" + + def test_release_removes_named_savepoint(self, txn_backend): + mgr = txn_backend.transaction_manager + n = mgr.savepoint(name="rel_target") + assert n in mgr._active_savepoints + mgr.release(n) + assert n not in mgr._active_savepoints + + def test_release_does_not_touch_other_savepoints(self, txn_backend): + mgr = txn_backend.transaction_manager + a = mgr.savepoint(name="keep_a") + b = mgr.savepoint(name="rel_b") + c = mgr.savepoint(name="keep_c") + mgr.release(b) + assert a in mgr._active_savepoints + assert c in mgr._active_savepoints + assert b not in mgr._active_savepoints + + def test_release_unknown_name_raises(self, txn_backend): + mgr = txn_backend.transaction_manager + mgr.savepoint(name="real_one") + with pytest.raises(TransactionError): + mgr.release("never_created") + + def test_release_outside_transaction_raises(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.connection() as backend: + mgr = backend.transaction_manager + # No outer transaction active on this fresh connection + assert not mgr.is_active + with pytest.raises(TransactionError): + mgr.release("any_sp") + + +class TestRollbackTo: + """``TransactionManager.rollback_to()`` contracts. + + Per backend/transaction.py:587, rollback_to truncates _active_savepoints + to include the named savepoint (kept) and drop those created after it. + This non-stack rollback-to-named-point behaviour is a deliberate + design choice and a key differentiator versus strictly-stack APIs. + """ + + def test_rollback_to_truncates_newer_savepoints(self, txn_backend): + mgr = txn_backend.transaction_manager + mgr.savepoint(name="lvl1") + mgr.savepoint(name="lvl2") + mgr.savepoint(name="lvl3") + mgr.savepoint(name="lvl4") + mgr.rollback_to("lvl2") + # lvl2 retained; lvl3, lvl4 dropped + assert "lvl2" in mgr._active_savepoints + assert "lvl1" in mgr._active_savepoints + assert "lvl3" not in mgr._active_savepoints + assert "lvl4" not in mgr._active_savepoints + assert mgr._active_savepoints == ["lvl1", "lvl2"] + + def test_rollback_to_oldest_keeps_all_older(self, txn_backend): + mgr = txn_backend.transaction_manager + mgr.savepoint(name="a") + mgr.savepoint(name="b") + mgr.savepoint(name="c") + mgr.rollback_to("a") + assert mgr._active_savepoints == ["a"] + + def test_rollback_to_same_savepoint_keeps_it(self, txn_backend): + """Rolling back to the top savepoint keeps exactly that savepoint. + + This is intentionally different from release() which removes it. + rollback_to(name) keeps ``name`` alive so further rollback_to(name) + can be invoked again without recreating it. + """ + mgr = txn_backend.transaction_manager + top = mgr.savepoint(name="top") + mgr.rollback_to(top) + assert top in mgr._active_savepoints + + def test_rollback_to_unknown_raises(self, txn_backend): + mgr = txn_backend.transaction_manager + mgr.savepoint(name="actual") + with pytest.raises(TransactionError): + mgr.rollback_to("phantom") + + def test_rollback_to_outside_transaction_raises(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.connection() as backend: + mgr = backend.transaction_manager + assert not mgr.is_active + with pytest.raises(TransactionError): + mgr.rollback_to("any_sp") + + +class TestSavepointOutsideTransaction: + """``savepoint()`` itself requires an active transaction.""" + + def test_savepoint_outside_transaction_raises(self, sync_pool_and_model): + pool, _model = sync_pool_and_model + with pool.connection() as backend: + mgr = backend.transaction_manager + assert not mgr.is_active + with pytest.raises(TransactionError): + mgr.savepoint(name="orphan_sp") + + +class TestSavepointDataSemantics: + """``rollback_to`` actually rolls back DML done after the savepoint. + + This is the only test in this module that crosses into data semantics; + keep it minimal so it stays a pure contract: it proves rollback_to is + not merely state accounting but reaches the database. + """ + + def test_rollback_to_discards_writes_after_savepoint(self, sync_pool_and_model): + pool, model = sync_pool_and_model + with pool.transaction() as backend: + mgr = backend.transaction_manager + mgr.savepoint(name="before_changes") + instance = model(name="Alice", email="alice@test.local") + instance.save() + assert model.query().where("name = ?", ("Alice",)).count() == 1 + mgr.rollback_to("before_changes") + # Write made after the savepoint should have been undone. + assert model.query().where("name = ?", ("Alice",)).count() == 0 + + +class TestCommitWithPendingSavepoint: + """Outer commit still succeeds when savepoints are unreleased. + + Per backend/transaction.py:413-420, outermost commit() releases any + lingering savepoint tracking structures and issues a real COMMIT; it + does not require the user to release() every savepoint manually. + """ + + def test_outer_commit_with_unreleased_savepoint_succeeds(self, sync_pool_and_model): + pool, model = sync_pool_and_model + with pool.transaction() as backend: + mgr = backend.transaction_manager + mgr.savepoint(name="left_open") + instance = model(name="Bob", email="bob@test.local") + instance.save() + # No release() call: when the outer context exits, commit() should + # still flush _active_savepoints and COMMIT cleanly. + # Data should be persisted. + with pool.connection() as _backend: + assert model.query().where("name = ?", ("Bob",)).count() == 1 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_savepoint_api_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_savepoint_api_async.py new file mode 100644 index 00000000..352d0639 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/transaction/test_savepoint_api_async.py @@ -0,0 +1,165 @@ +# src/rhosocial/activerecord/testsuite/feature/basic/transaction/test_savepoint_api_async.py +"""Async black-box contracts for savepoint / release / rollback_to. + +Mirror of test_savepoint_api.py exercising AsyncTransactionManager. +Every test in this module MUST have a sync twin in test_savepoint_api.py +to guarantee the sync/async symmetry required by the project's code style. +""" +import pytest + +from rhosocial.activerecord.backend.errors import TransactionError + + +@pytest.fixture +async def async_txn_backend(async_pool_and_model): + """Async fixture: open a transaction and yield the backend.""" + pool, _model = async_pool_and_model + cm = pool.transaction() + backend = await cm.__aenter__() + try: + yield backend + finally: + try: + await cm.__aexit__(None, None, None) + except Exception: + pass + + +class TestAsyncSavepointCreate: + async def test_savepoint_autoname_is_returned_and_prefixed(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + name = await mgr.savepoint() + assert isinstance(name, str) + assert name + assert name in mgr._active_savepoints + + async def test_savepoint_explicit_name_is_honored(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + name = await mgr.savepoint(name="cust_sp_a") + assert name == "cust_sp_a" + assert "cust_sp_a" in mgr._active_savepoints + + async def test_savepoint_increments_active_savepoints(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + before = len(mgr._active_savepoints) + await mgr.savepoint(name="sp_first") + await mgr.savepoint(name="sp_second") + assert len(mgr._active_savepoints) == before + 2 + assert mgr._active_savepoints[-1] == "sp_second" + assert mgr._active_savepoints[-2] == "sp_first" + + async def test_savepoint_seq_autoname_do_not_collide(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + names = {await mgr.savepoint() for _ in range(3)} + assert len(names) == 3 + + +class TestAsyncRelease: + async def test_release_removes_named_savepoint(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + n = await mgr.savepoint(name="rel_target") + assert n in mgr._active_savepoints + await mgr.release(n) + assert n not in mgr._active_savepoints + + async def test_release_does_not_touch_other_savepoints(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + a = await mgr.savepoint(name="keep_a") + b = await mgr.savepoint(name="rel_b") + c = await mgr.savepoint(name="keep_c") + await mgr.release(b) + assert a in mgr._active_savepoints + assert c in mgr._active_savepoints + assert b not in mgr._active_savepoints + + async def test_release_unknown_name_raises(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + await mgr.savepoint(name="real_one") + with pytest.raises(TransactionError): + await mgr.release("never_created") + + async def test_release_outside_transaction_raises(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.connection() as backend: + mgr = backend.transaction_manager + assert not mgr.is_active + with pytest.raises(TransactionError): + await mgr.release("any_sp") + + +class TestAsyncRollbackTo: + async def test_rollback_to_truncates_newer_savepoints(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + await mgr.savepoint(name="lvl1") + await mgr.savepoint(name="lvl2") + await mgr.savepoint(name="lvl3") + await mgr.savepoint(name="lvl4") + await mgr.rollback_to("lvl2") + assert "lvl2" in mgr._active_savepoints + assert "lvl1" in mgr._active_savepoints + assert "lvl3" not in mgr._active_savepoints + assert "lvl4" not in mgr._active_savepoints + assert mgr._active_savepoints == ["lvl1", "lvl2"] + + async def test_rollback_to_oldest_keeps_all_older(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + await mgr.savepoint(name="a") + await mgr.savepoint(name="b") + await mgr.savepoint(name="c") + await mgr.rollback_to("a") + assert mgr._active_savepoints == ["a"] + + async def test_rollback_to_same_savepoint_keeps_it(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + top = await mgr.savepoint(name="top") + await mgr.rollback_to(top) + assert top in mgr._active_savepoints + + async def test_rollback_to_unknown_raises(self, async_txn_backend): + mgr = async_txn_backend.transaction_manager + await mgr.savepoint(name="actual") + with pytest.raises(TransactionError): + await mgr.rollback_to("phantom") + + async def test_rollback_to_outside_transaction_raises(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.connection() as backend: + mgr = backend.transaction_manager + assert not mgr.is_active + with pytest.raises(TransactionError): + await mgr.rollback_to("any_sp") + + +class TestAsyncSavepointOutsideTransaction: + async def test_savepoint_outside_transaction_raises(self, async_pool_and_model): + pool, _model = async_pool_and_model + async with pool.connection() as backend: + mgr = backend.transaction_manager + assert not mgr.is_active + with pytest.raises(TransactionError): + await mgr.savepoint(name="orphan_sp") + + +class TestAsyncSavepointDataSemantics: + async def test_rollback_to_discards_writes_after_savepoint(self, async_pool_and_model): + pool, model = async_pool_and_model + async with pool.transaction() as backend: + mgr = backend.transaction_manager + await mgr.savepoint(name="before_changes") + instance = model(name="Alice", email="alice@test.local") + await instance.save() + assert await model.query().where("name = ?", ("Alice",)).count() == 1 + await mgr.rollback_to("before_changes") + assert await model.query().where("name = ?", ("Alice",)).count() == 0 + + +class TestAsyncCommitWithPendingSavepoint: + async def test_outer_commit_with_unreleased_savepoint_succeeds(self, async_pool_and_model): + pool, model = async_pool_and_model + async with pool.transaction() as backend: + mgr = backend.transaction_manager + await mgr.savepoint(name="left_open") + instance = model(name="Bob", email="bob@test.local") + await instance.save() + async with pool.connection() as _backend: + assert await model.query().where("name = ?", ("Bob",)).count() == 1 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter.py new file mode 100644 index 00000000..d6108e4f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter.py @@ -0,0 +1,18 @@ +""" +This is a "bridge" file for the basic features test group, specifically for +type adapter tests. + +Its purpose is to import the generic tests from the `rhosocial-activerecord-testsuite` +package and make them discoverable by `pytest` within this project's test run. +""" + +# Note: fixtures are provided by the conftest override at +# feature/basic/conftest.py (which passes the scenario name to the provider). +# Do NOT re-import type_adapter_fixtures here -- pytest would register the +# testsuite's original (scenario-ignoring) fixture with this module as the +# most specific baseid, shadowing the conftest override. + +# Import all tests from the generic testsuite file. +from rhosocial.activerecord.testsuite.feature.basic.type_adapter.test_type_adapter import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.type_adapter.test_type_adapter_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter_async.py new file mode 100644 index 00000000..533df4fa --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/type_adapter/test_type_adapter_async.py +"""Bridge file for async feature.basic.type_adapter.test_type_adapter_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.type_adapter.test_type_adapter_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation.py similarity index 71% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation.py index 752b0a1b..03fc5eeb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_pydantic_native_validation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation.py @@ -13,4 +13,6 @@ pydantic_validated_model, # noqa: F401 ) -from rhosocial.activerecord.testsuite.feature.basic.test_pydantic_native_validation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.validation.test_pydantic_native_validation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.validation.test_pydantic_native_validation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation_async.py new file mode 100644 index 00000000..1b5019de --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_pydantic_native_validation_async.py +"""Bridge file for async feature.basic.validation.test_pydantic_native_validation_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.validation.test_pydantic_native_validation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation.py similarity index 83% rename from tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py rename to tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation.py index b522ba20..2c2cd094 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/test_validation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation.py @@ -20,4 +20,6 @@ ) # Import shared tests from testsuite package -from rhosocial.activerecord.testsuite.feature.basic.test_validation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.validation.test_validation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.validation.test_validation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation_async.py new file mode 100644 index 00000000..df784d78 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/validation/test_validation_async.py +"""Bridge file for async feature.basic.validation.test_validation_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.validation.test_validation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/conftest.py new file mode 100644 index 00000000..35e82e19 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/conftest.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/worker/conftest.py +""" +Pytest configuration for basic worker tests. + +This file imports fixtures from the corresponding testsuite, making them +available to the tests in this directory. +""" +from rhosocial.activerecord.testsuite.feature.basic.worker.conftest import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py index 4486e093..a5150e06 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management.py @@ -8,3 +8,5 @@ user_class_for_worker, # noqa: F401 ) from rhosocial.activerecord.testsuite.feature.basic.worker.test_connection_management import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.worker.test_connection_management_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management_async.py new file mode 100644 index 00000000..3340c392 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_connection_management_async.py +"""Bridge file for async feature.basic.worker.test_connection_management_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.worker.test_connection_management_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py index c75a4e8c..287d0828 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud.py @@ -9,3 +9,5 @@ async_user_class_for_worker, # noqa: F401 ) from rhosocial.activerecord.testsuite.feature.basic.worker.test_parallel_crud import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.worker.test_parallel_crud_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud_async.py new file mode 100644 index 00000000..62eedf04 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_parallel_crud_async.py +"""Bridge file for async feature.basic.worker.test_parallel_crud_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.worker.test_parallel_crud_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py index 53d0a0d0..7df4d9df 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle.py @@ -8,3 +8,5 @@ user_class_for_worker, # noqa: F401 ) from rhosocial.activerecord.testsuite.feature.basic.worker.test_worker_pool_lifecycle import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.worker.test_worker_pool_lifecycle_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle_async.py new file mode 100644 index 00000000..15ccdd00 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/basic/worker/test_worker_pool_lifecycle_async.py +"""Bridge file for async feature.basic.worker.test_worker_pool_lifecycle_async tests.""" +from rhosocial.activerecord.testsuite.feature.basic.worker.test_worker_pool_lifecycle_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/conftest.py new file mode 100644 index 00000000..f5d5b2ad --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/conftest.py @@ -0,0 +1,23 @@ +# tests/rhosocial/activerecord_postgres_test/feature/conftest.py +""" +Pytest configuration for top-level feature tests. + +Provides the `fixtures` / `async_fixtures` placeholders consumed by +`test_features{,_async}.py`. The bodies of those tests are `assert True`, +and the @requires_protocol / @requires_functions markers operate on the +test's own requested fixtures (here only `fixtures`), so a stub is enough. +""" + +import pytest + + +@pytest.fixture +def fixtures(): + """Stub fixture for the protocol/function decorator smoke tests.""" + return None + + +@pytest.fixture +async def async_fixtures(): + """Async stub fixture for the protocol/function decorator smoke tests.""" + return None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py310.py b/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py310.py new file mode 100644 index 00000000..0e9dba08 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py310.py @@ -0,0 +1,65 @@ +# src/rhosocial/activerecord/testsuite/feature/events/fixtures/models_py310.py +""" +Python 3.10+ fixture model definitions for events tests. + +This file contains model classes using Python 3.10+ syntax features: +- `X | Y` syntax instead of `Optional[X]` or `Union[X, Y]` +- `Type | None` instead of `Optional[Type]` + +Note: This file should only be imported and used in Python 3.10+ environments. +""" +from typing import Dict, List, Tuple +from datetime import datetime + +from pydantic import Field + +from rhosocial.activerecord.interface import ModelEvent +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin + + +# Declare that this module requires Python 3.10+ +__requires_python__ = (3, 10) + + +class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): + """A model class for testing event mechanisms. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "event_tests" + + id: int | None = None + name: str + status: str = Field(default="draft") + revision: int = Field(default=1) + content: str | None = None + + def __init__(self, **data): + super().__init__(**data) + self._event_logs: list[tuple[ModelEvent, Dict]] = [] # Used to record event triggering history + + def log_event(self, event: ModelEvent, **kwargs): + """Record the trigger history of events""" + self._event_logs.append((event, kwargs)) + + def get_event_logs(self) -> list[tuple[ModelEvent, Dict]]: + """Get the event history""" + return self._event_logs.copy() + + def clear_event_logs(self): + """Empty the history of events""" + self._event_logs.clear() + + +class EventTrackingModel(IntegerPKMixin, ActiveRecord): + """A model for testing event tracking functionality. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "event_tracking_models" + + title: str + content: str + view_count: int = 0 + last_viewed_at: datetime | None = None diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py311.py b/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py311.py new file mode 100644 index 00000000..f48aa467 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py311.py @@ -0,0 +1,84 @@ +# src/rhosocial/activerecord/testsuite/feature/events/fixtures/models_py311.py +""" +Python 3.11+ fixture model definitions for events tests. + +This file contains model classes using Python 3.11+ syntax features: +- `Self` type for methods that return an instance of the same class +- `X | Y` syntax (inherited from 3.10+) + +Note: This file should only be imported and used in Python 3.11+ environments. +""" +from __future__ import annotations + +from typing import Dict, Self +from datetime import datetime + +from pydantic import Field + +from rhosocial.activerecord.interface import ModelEvent +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin + + +# Declare that this module requires Python 3.11+ +__requires_python__ = (3, 11) + + +class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): + """A model class for testing event mechanisms. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "event_tests" + + id: int | None = None + name: str + status: str = Field(default="draft") + revision: int = Field(default=1) + content: str | None = None + + def __init__(self, **data): + super().__init__(**data) + self._event_logs: list[tuple[ModelEvent, Dict]] = [] # Used to record event triggering history + + def log_event(self, event: ModelEvent, **kwargs): + """Record the trigger history of events""" + self._event_logs.append((event, kwargs)) + + def get_event_logs(self) -> list[tuple[ModelEvent, Dict]]: + """Get the event history""" + return self._event_logs.copy() + + def clear_event_logs(self) -> Self: + """Empty the history of events and return self for chaining.""" + self._event_logs.clear() + return self + + def set_status(self, new_status: str) -> Self: + """Set status and return self for chaining.""" + self.status = new_status + return self + + def increment_revision(self) -> Self: + """Increment revision and return self for chaining.""" + self.revision += 1 + return self + + +class EventTrackingModel(IntegerPKMixin, ActiveRecord): + """A model for testing event tracking functionality. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "event_tracking_models" + + title: str + content: str + view_count: int = 0 + last_viewed_at: datetime | None = None + + def record_view(self) -> Self: + """Record a view and return self for chaining.""" + self.view_count += 1 + self.last_viewed_at = datetime.now() + return self diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py312.py b/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py312.py new file mode 100644 index 00000000..f4b82d02 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/fixtures/models_py312.py @@ -0,0 +1,85 @@ +# src/rhosocial/activerecord/testsuite/feature/events/fixtures/models_py312.py +""" +Python 3.12+ fixture model definitions for events tests. + +This file contains model classes using Python 3.12+ syntax features: +- `@override` decorator for inheritance safety +- `Self` type for methods that return an instance of the same class +- `X | Y` syntax (inherited from 3.10+) + +Note: This file should only be imported and used in Python 3.12+ environments. +""" +from __future__ import annotations + +from typing import Dict, Self, override +from datetime import datetime + +from pydantic import Field + +from rhosocial.activerecord.interface import ModelEvent +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin + + +# Declare that this module requires Python 3.12+ +__requires_python__ = (3, 12) + + +class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): + """A model class for testing event mechanisms. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "event_tests" + + id: int | None = None + name: str + status: str = Field(default="draft") + revision: int = Field(default=1) + content: str | None = None + + def __init__(self, **data): + super().__init__(**data) + self._event_logs: list[tuple[ModelEvent, Dict]] = [] # Used to record event triggering history + + def log_event(self, event: ModelEvent, **kwargs): + """Record the trigger history of events""" + self._event_logs.append((event, kwargs)) + + def get_event_logs(self) -> list[tuple[ModelEvent, Dict]]: + """Get the event history""" + return self._event_logs.copy() + + def clear_event_logs(self) -> Self: + """Empty the history of events and return self for chaining.""" + self._event_logs.clear() + return self + + def set_status(self, new_status: str) -> Self: + """Set status and return self for chaining.""" + self.status = new_status + return self + + def increment_revision(self) -> Self: + """Increment revision and return self for chaining.""" + self.revision += 1 + return self + + +class EventTrackingModel(IntegerPKMixin, ActiveRecord): + """A model for testing event tracking functionality. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "event_tracking_models" + + title: str + content: str + view_count: int = 0 + last_viewed_at: datetime | None = None + + def record_view(self) -> Self: + """Record a view and return self for chaining.""" + self.view_count += 1 + self.last_viewed_at = datetime.now() + return self diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py b/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py index 02d222d2..5964c8ba 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers.py @@ -21,3 +21,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.events.test_handlers import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.events.test_handlers_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers_async.py b/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers_async.py new file mode 100644 index 00000000..6b946f79 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/events/test_handlers_async.py +"""Bridge file for async feature.events.test_handlers_async tests.""" +from rhosocial.activerecord.testsuite.feature.events.test_handlers_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py b/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py index 00b29520..43bb6d83 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle.py @@ -21,3 +21,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.events.test_lifecycle import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.events.test_lifecycle_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle_async.py b/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle_async.py new file mode 100644 index 00000000..94312c2a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/events/test_lifecycle_async.py +"""Bridge file for async feature.events.test_lifecycle_async tests.""" +from rhosocial.activerecord.testsuite.feature.events.test_lifecycle_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/interface/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/interface/conftest.py new file mode 100644 index 00000000..52d35dc0 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/interface/conftest.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/interface/conftest.py +""" +Pytest configuration for interface tests. + +This file imports fixtures from the corresponding testsuite, making them +available to the tests in this directory. +""" +from rhosocial.activerecord.testsuite.feature.interface.conftest import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict.py b/tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict.py new file mode 100644 index 00000000..48cf1235 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict.py +""" +Bridge file for ThreadSafeDict interface tests from the testsuite. +""" + +from rhosocial.activerecord.testsuite.feature.interface.test_threadsafe_dict import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.interface.test_threadsafe_dict_async import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict_async.py b/tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict_async.py new file mode 100644 index 00000000..b5e43962 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/interface/test_threadsafe_dict_async.py +"""Bridge file for async feature.interface.test_threadsafe_dict_async tests.""" +from rhosocial.activerecord.testsuite.feature.interface.test_threadsafe_dict_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py310.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py310.py new file mode 100644 index 00000000..c587ccce --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py310.py @@ -0,0 +1,69 @@ +# src/rhosocial/activerecord/testsuite/feature/mixins/fixtures/models_py310.py +""" +Python 3.10+ fixture model definitions for mixins tests. + +This file contains model classes using Python 3.10+ syntax features: +- `X | Y` syntax instead of `Optional[X]` or `Union[X, Y]` +- `Type | None` instead of `Optional[Type]` + +Note: This file should only be imported and used in Python 3.10+ environments. +""" +from datetime import datetime + +from pydantic import Field + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin + + +# Declare that this module requires Python 3.10+ +__requires_python__ = (3, 10) + + +class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Blog post model with timestamps. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "timestamped_posts" + + id: int | None = None + title: str + content: str + + +class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): + """Product model with optimistic locking. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "versioned_products" + + id: int | None = None + name: str + price: float = Field(default=0.0) + + +class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): + """Task model supporting soft deletion. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "tasks" + + id: int | None = None + title: str + is_completed: bool = Field(default=False) + + +class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin, ActiveRecord): + """Article model combining all mixins. + + Python 3.10+ version using | syntax instead of Optional. + """ + __table_name__ = "combined_articles" + + id: int | None = None + title: str + content: str + status: str = Field(default="draft") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py311.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py311.py new file mode 100644 index 00000000..6a8798c3 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py311.py @@ -0,0 +1,102 @@ +# src/rhosocial/activerecord/testsuite/feature/mixins/fixtures/models_py311.py +""" +Python 3.11+ fixture model definitions for mixins tests. + +This file contains model classes using Python 3.11+ syntax features: +- `Self` type for methods that return an instance of the same class +- `X | Y` syntax (inherited from 3.10+) + +Note: This file should only be imported and used in Python 3.11+ environments. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Self + +from pydantic import Field + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin + + +# Declare that this module requires Python 3.11+ +__requires_python__ = (3, 11) + + +class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Blog post model with timestamps. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "timestamped_posts" + + id: int | None = None + title: str + content: str + + def update_content(self, new_content: str) -> Self: + """Update content and return self for chaining.""" + self.content = new_content + return self + + +class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): + """Product model with optimistic locking. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "versioned_products" + + id: int | None = None + name: str + price: float = Field(default=0.0) + + def set_price(self, new_price: float) -> Self: + """Set price and return self for chaining.""" + self.price = new_price + return self + + +class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): + """Task model supporting soft deletion. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "tasks" + + id: int | None = None + title: str + is_completed: bool = Field(default=False) + + def complete(self) -> Self: + """Mark task as completed and return self for chaining.""" + self.is_completed = True + return self + + def uncomplete(self) -> Self: + """Mark task as not completed and return self for chaining.""" + self.is_completed = False + return self + + +class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin, ActiveRecord): + """Article model combining all mixins. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "combined_articles" + + id: int | None = None + title: str + content: str + status: str = Field(default="draft") + + def publish(self) -> Self: + """Publish article and return self for chaining.""" + self.status = "published" + return self + + def archive(self) -> Self: + """Archive article and return self for chaining.""" + self.status = "archived" + return self diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py312.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py312.py new file mode 100644 index 00000000..36ccbe31 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/fixtures/models_py312.py @@ -0,0 +1,127 @@ +# src/rhosocial/activerecord/testsuite/feature/mixins/fixtures/models_py312.py +""" +Python 3.12+ fixture model definitions for mixins tests. + +This file contains model classes using Python 3.12+ syntax features: +- `@override` decorator for inheritance safety +- `Self` type for methods that return an instance of the same class +- `X | Y` syntax (inherited from 3.10+) + +Note: This file should only be imported and used in Python 3.12+ environments. +""" +from __future__ import annotations + +from datetime import datetime +from typing import Self, override + +from pydantic import Field + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.field import IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin + + +# Declare that this module requires Python 3.12+ +__requires_python__ = (3, 12) + + +class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): + """Blog post model with timestamps. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "timestamped_posts" + + id: int | None = None + title: str + content: str + + @override + def save(self, **kwargs) -> Self: + """Save the post and return self for chaining.""" + super().save(**kwargs) + return self + + def update_content(self, new_content: str) -> Self: + """Update content and return self for chaining.""" + self.content = new_content + return self + + +class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): + """Product model with optimistic locking. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "versioned_products" + + id: int | None = None + name: str + price: float = Field(default=0.0) + + @override + def save(self, **kwargs) -> Self: + """Save the product and return self for chaining.""" + super().save(**kwargs) + return self + + def set_price(self, new_price: float) -> Self: + """Set price and return self for chaining.""" + self.price = new_price + return self + + +class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): + """Task model supporting soft deletion. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "tasks" + + id: int | None = None + title: str + is_completed: bool = Field(default=False) + + @override + def save(self, **kwargs) -> Self: + """Save the task and return self for chaining.""" + super().save(**kwargs) + return self + + def complete(self) -> Self: + """Mark task as completed and return self for chaining.""" + self.is_completed = True + return self + + def uncomplete(self) -> Self: + """Mark task as not completed and return self for chaining.""" + self.is_completed = False + return self + + +class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin, ActiveRecord): + """Article model combining all mixins. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "combined_articles" + + id: int | None = None + title: str + content: str + status: str = Field(default="draft") + + @override + def save(self, **kwargs) -> Self: + """Save the article and return self for chaining.""" + super().save(**kwargs) + return self + + def publish(self) -> Self: + """Publish article and return self for chaining.""" + self.status = "published" + return self + + def archive(self) -> Self: + """Archive article and return self for chaining.""" + self.status = "archived" + return self diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py index a63c2d24..c0c81379 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles.py @@ -7,3 +7,5 @@ """ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.mixins.test_combined_articles import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.mixins.test_combined_articles_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles_async.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles_async.py new file mode 100644 index 00000000..1dc7d5c5 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/mixins/test_combined_articles_async.py +"""Bridge file for async feature.mixins.test_combined_articles_async tests.""" +from rhosocial.activerecord.testsuite.feature.mixins.test_combined_articles_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py index 9fd9601a..be5611e5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock.py @@ -7,3 +7,5 @@ """ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.mixins.test_optimistic_lock import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.mixins.test_optimistic_lock_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock_async.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock_async.py new file mode 100644 index 00000000..4c233401 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/mixins/test_optimistic_lock_async.py +"""Bridge file for async feature.mixins.test_optimistic_lock_async tests.""" +from rhosocial.activerecord.testsuite.feature.mixins.test_optimistic_lock_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py index e440e286..40a34336 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete.py @@ -7,3 +7,5 @@ """ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.mixins.test_soft_delete import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.mixins.test_soft_delete_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete_async.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete_async.py new file mode 100644 index 00000000..14371fd5 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/mixins/test_soft_delete_async.py +"""Bridge file for async feature.mixins.test_soft_delete_async tests.""" +from rhosocial.activerecord.testsuite.feature.mixins.test_soft_delete_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py index d776bdbf..64344372 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps.py @@ -7,3 +7,5 @@ """ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.mixins.test_timestamps import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.mixins.test_timestamps_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps_async.py b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps_async.py new file mode 100644 index 00000000..0260c373 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/mixins/test_timestamps_async.py +"""Bridge file for async feature.mixins.test_timestamps_async tests.""" +from rhosocial.activerecord.testsuite.feature.mixins.test_timestamps_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate.py index 75f400d0..deae3685 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_aggregate.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_aggregate import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.aggregate.test_active_query_aggregate import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.aggregate.test_active_query_aggregate_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate_async.py new file mode 100644 index 00000000..c3568677 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_active_query_aggregate_async.py +"""Bridge file for async feature.query.aggregate.test_active_query_aggregate_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.aggregate.test_active_query_aggregate_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries.py index 12c99783..525ba49a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_aggregate_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_aggregate_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.aggregate.test_aggregate_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.aggregate.test_aggregate_queries_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries_async.py new file mode 100644 index 00000000..df180f44 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/aggregate/test_aggregate_queries_async.py +"""Bridge file for async feature.query.aggregate.test_aggregate_queries_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.aggregate.test_aggregate_queries_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic.py similarity index 56% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic.py index c8e67b7d..9baba06b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_basic import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_active_query_basic import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_active_query_basic_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic_async.py new file mode 100644 index 00000000..4bc013b4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_active_query_basic_async.py +"""Bridge file for async feature.query.basic.test_active_query_basic_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.basic.test_active_query_basic_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic.py similarity index 57% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic.py index f56b9765..e2f69cef 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_basic import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_basic import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_basic_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic_async.py new file mode 100644 index 00000000..55d0570f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_basic_async.py +"""Bridge file for async feature.query.basic.test_basic_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.basic.test_basic_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query.py new file mode 100644 index 00000000..20bd64fb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query.py @@ -0,0 +1,6 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query.py +""" +Bridge file for composite PK query tests from the testsuite. +""" +from rhosocial.activerecord.testsuite.feature.query.basic.test_composite_pk_query import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_composite_pk_query_async import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query_async.py new file mode 100644 index 00000000..a4d65b87 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_composite_pk_query_async.py +"""Bridge file for async feature.query.basic.test_composite_pk_query_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.basic.test_composite_pk_query_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_queries.py similarity index 70% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_queries.py index 75205a5d..1fda7a9b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_queries.py @@ -1,8 +1,8 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_sync_queries.py +# tests/rhosocial/activerecord_postgres_test/feature/query/test_queries.py """ Bridge file for synchronous query tests from the testsuite. This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_sync_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_queries import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_queries_async.py similarity index 68% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_queries_async.py index ef4556d1..8e53f6fe 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/basic/test_queries_async.py @@ -1,8 +1,9 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_async_queries.py +# tests/rhosocial/activerecord_postgres_test/feature/query/test_queries_async.py """ Bridge file for async queries tests from the testsuite. This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_async_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_queries_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_postgres_test/feature/query/collation/test_collation_expression.py similarity index 100% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_collation_expression.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/collation/test_collation_expression.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py index 403c1a9e..9fb3d2f7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context.py @@ -12,3 +12,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.query.connection.test_active_query_context import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.connection.test_active_query_context_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context_async.py new file mode 100644 index 00000000..cd8c61c6 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_active_query_context_async.py +"""Bridge file for async feature.query.connection.test_active_query_context_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.connection.test_active_query_context_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py index 1483e319..0b1e6153 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context.py @@ -12,3 +12,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.query.connection.test_cte_query_context import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.connection.test_cte_query_context_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context_async.py new file mode 100644 index 00000000..e8b21313 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_cte_query_context_async.py +"""Bridge file for async feature.query.connection.test_cte_query_context_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.connection.test_cte_query_context_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py index f6acecbf..ef0d192a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context.py @@ -12,3 +12,5 @@ # Import shared tests from testsuite package from rhosocial.activerecord.testsuite.feature.query.connection.test_set_operation_context import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.connection.test_set_operation_context_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context_async.py new file mode 100644 index 00000000..9bc0e34e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/connection/test_set_operation_context_async.py +"""Bridge file for async feature.query.connection.test_set_operation_context_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.connection.test_set_operation_context_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility.py index ce5c074a..b234b681 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cross_database_compatibility.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_cross_database_compatibility import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cross_database.test_cross_database_compatibility import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cross_database.test_cross_database_compatibility_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility_async.py new file mode 100644 index 00000000..1c8d2108 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/cross_database/test_cross_database_compatibility_async.py +"""Bridge file for async feature.query.cross_database.test_cross_database_compatibility_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.cross_database.test_cross_database_compatibility_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte.py new file mode 100644 index 00000000..986a6c4a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte.py @@ -0,0 +1,6 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte.py +""" +Bridge file for composite PK CTE tests from the testsuite. +""" +from rhosocial.activerecord.testsuite.feature.query.cte.test_composite_pk_cte import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cte.test_composite_pk_cte_async import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte_async.py new file mode 100644 index 00000000..46972049 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_composite_pk_cte_async.py +"""Bridge file for async feature.query.cte.test_composite_pk_cte_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.cte.test_composite_pk_cte_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query.py similarity index 57% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query.py index 1ce77f68..6298688f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_active_query.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_cte_query_active_query import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cte.test_cte_query_active_query import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cte.test_cte_query_active_query_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query_async.py new file mode 100644 index 00000000..3429c45a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_active_query_async.py +"""Bridge file for async feature.query.cte.test_cte_query_active_query_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.cte.test_cte_query_active_query_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation.py similarity index 56% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation.py index 2610511f..686d8fc6 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_cte_query_set_operation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_cte_query_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cte.test_cte_query_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.cte.test_cte_query_set_operation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation_async.py new file mode 100644 index 00000000..4bf171be --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/cte/test_cte_query_set_operation_async.py +"""Bridge file for async feature.query.cte.test_cte_query_set_operation_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.cte.test_cte_query_set_operation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching.py new file mode 100644 index 00000000..a7c1e676 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py +""" +Bridge file for eager loading caching tests. +""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_caching import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_caching_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching_async.py new file mode 100644 index 00000000..f946bcec --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_caching_async.py +"""Bridge file for async feature.query.eager_loading.test_eager_loading_caching_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_caching_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested.py new file mode 100644 index 00000000..09efb41a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py +""" +Bridge file for eager loading nested tests. +""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_nested import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_nested_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested_async.py new file mode 100644 index 00000000..b7921aa6 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_nested_async.py +"""Bridge file for async feature.query.eager_loading.test_eager_loading_nested_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_nested_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_scenarios.py new file mode 100644 index 00000000..7b5ca3cd --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_scenarios.py @@ -0,0 +1,2 @@ +"""Bridge file for sync eager loading scenario tests.""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_scenarios import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_scenarios_async.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_scenarios_async.py index 37b45c60..64cae900 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_async.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_scenarios_async.py @@ -2,4 +2,4 @@ """ Bridge file for async eager loading scenario tests. """ -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_scenarios_async import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_scenarios_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all.py new file mode 100644 index 00000000..7571dafa --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py +""" +Bridge file for eager loading with_all tests. +""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_all import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_all_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all_async.py new file mode 100644 index 00000000..197ae5d6 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_all_async.py +"""Bridge file for async feature.query.eager_loading.test_eager_loading_with_all_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_all_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier.py new file mode 100644 index 00000000..c55cb6f8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py +""" +Bridge file for eager loading with_modifier tests. +""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_modifier import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_modifier_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier_async.py new file mode 100644 index 00000000..c558e117 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_modifier_async.py +"""Bridge file for async feature.query.eager_loading.test_eager_loading_with_modifier_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_modifier_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one.py new file mode 100644 index 00000000..8dc3de43 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py +""" +Bridge file for eager loading with_one tests. +""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_one import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_one_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one_async.py new file mode 100644 index 00000000..1ad43fb7 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/eager_loading/test_eager_loading_with_one_async.py +"""Bridge file for async feature.query.eager_loading.test_eager_loading_with_one_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.eager_loading.test_eager_loading_with_one_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py b/tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling.py index d30ddc60..88f48f2c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_error_handling.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_error_handling import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.error_handling.test_error_handling import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.error_handling.test_error_handling_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling_async.py new file mode 100644 index 00000000..ef91b73c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/error_handling/test_error_handling_async.py +"""Bridge file for async feature.query.error_handling.test_error_handling_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.error_handling.test_error_handling_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join.py similarity index 56% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join.py index 4f750e5b..90eb3a08 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_join.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_join import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.joins.test_active_query_join import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.joins.test_active_query_join_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join_async.py new file mode 100644 index 00000000..63a0b532 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_active_query_join_async.py +"""Bridge file for async feature.query.joins.test_active_query_join_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.joins.test_active_query_join_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins.py similarity index 57% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins.py index 56a0a911..a775027d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_joins.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_joins import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.joins.test_joins import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.joins.test_joins_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins_async.py new file mode 100644 index 00000000..f5710f45 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/joins/test_joins_async.py +"""Bridge file for async feature.query.joins.test_joins_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.joins.test_joins_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py b/tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization.py index fd09e85d..5087c2ed 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_logging_data_summarization.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_logging_data_summarization import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.logging.test_logging_data_summarization import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.logging.test_logging_data_summarization_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization_async.py new file mode 100644 index 00000000..1f9e0aa4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/logging/test_logging_data_summarization_async.py +"""Bridge file for async feature.query.logging.test_logging_data_summarization_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.logging.test_logging_data_summarization_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py b/tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization.py index ad5b3b08..115d70f0 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_query_optimization.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_query_optimization import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.optimization.test_query_optimization import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.optimization.test_query_optimization_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization_async.py new file mode 100644 index 00000000..9688b94b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/optimization/test_query_optimization_async.py +"""Bridge file for async feature.query.optimization.test_query_optimization_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.optimization.test_query_optimization_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range.py index af663f25..8498bb08 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_range.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_range import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_active_query_range import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_active_query_range_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range_async.py new file mode 100644 index 00000000..667a3718 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_active_query_range_async.py +"""Bridge file for async feature.query.range_queries.test_active_query_range_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_active_query_range_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries.py index f6027323..fb8bc85e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_range_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_range_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_range_queries_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries_async.py new file mode 100644 index 00000000..c6ef096e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_queries_async.py +"""Bridge file for async feature.query.range_queries.test_range_queries_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_range_queries_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed.py index 62bd6a32..ef05c44b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_range_query_detailed.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_range_query_detailed import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_range_query_detailed import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_range_query_detailed_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed_async.py new file mode 100644 index 00000000..df8937fe --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/range_queries/test_range_query_detailed_async.py +"""Bridge file for async feature.query.range_queries.test_range_query_detailed_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.range_queries.test_range_query_detailed_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation.py index def8f01d..abd6a3f4 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relational_validation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_relational_validation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.relations.test_relational_validation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.relations.test_relational_validation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation_async.py new file mode 100644 index 00000000..9276ed3a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relational_validation_async.py +"""Bridge file for async feature.query.relations.test_relational_validation_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.relations.test_relational_validation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic.py similarity index 56% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic.py index fbf463d4..7a2a089e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_basic.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_relations_basic import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.relations.test_relations_basic import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.relations.test_relations_basic_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic_async.py new file mode 100644 index 00000000..6ff408c0 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_basic_async.py +"""Bridge file for async feature.query.relations.test_relations_basic_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.relations.test_relations_basic_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_with.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_with.py index 9d4a3e0e..8e7d2338 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_sync.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_with.py @@ -2,4 +2,4 @@ """ Bridge file for sync relation 'with' tests. """ -from rhosocial.activerecord.testsuite.feature.query.test_relations_with_sync import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.relations.test_relations_with import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_with_async.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_with_async.py index 854df473..8057becf 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_relations_with_async.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/relations/test_relations_with_async.py @@ -2,4 +2,4 @@ """ Bridge file for async relation 'with' tests. """ -from rhosocial.activerecord.testsuite.feature.query.test_relations_with_async import * # noqa: F401, F403 +from rhosocial.activerecord.testsuite.feature.query.relations.test_relations_with_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation.py index 04171825..d3981b53 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_active_query_set_operation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_active_query_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_active_query_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_active_query_set_operation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation_async.py new file mode 100644 index 00000000..97e7fa6f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_active_query_set_operation_async.py +"""Bridge file for async feature.query.set_operations.test_active_query_set_operation_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_active_query_set_operation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation.py new file mode 100644 index 00000000..8cf2e58c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation.py @@ -0,0 +1,6 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation.py +""" +Bridge file for composite PK set operation tests from the testsuite. +""" +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_composite_pk_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_composite_pk_set_operation_async import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation_async.py new file mode 100644 index 00000000..fe46fbee --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_composite_pk_set_operation_async.py +"""Bridge file for async feature.query.set_operations.test_composite_pk_set_operation_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_composite_pk_set_operation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation.py similarity index 55% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation.py index fc7158fb..a23ca1b3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_set_operation.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_set_operation import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_set_operation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation_async.py new file mode 100644 index 00000000..20f605cb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/set_operations/test_set_operation_async.py +"""Bridge file for async feature.query.set_operations.test_set_operation_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.set_operations.test_set_operation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures.py similarity index 56% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures.py index 79aa6592..162cb400 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_example_query_fixtures.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_example_query_fixtures import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.special.test_example_query_fixtures import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.special.test_example_query_fixtures_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures_async.py new file mode 100644 index 00000000..aa20fa09 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/special/test_example_query_fixtures_async.py +"""Bridge file for async feature.query.special.test_example_query_fixtures_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.special.test_example_query_fixtures_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries.py similarity index 56% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries.py index b9eb5614..eb4efe6f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_special_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_special_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.special.test_special_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.special.test_special_queries_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries_async.py new file mode 100644 index 00000000..4bba9f80 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/special/test_special_queries_async.py +"""Bridge file for async feature.query.special.test_special_queries_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.special.test_special_queries_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py deleted file mode 100644 index 2c23a774..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py +++ /dev/null @@ -1,5 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_caching.py -""" -Bridge file for eager loading caching tests. -""" -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_caching import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py deleted file mode 100644 index c90a39ef..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py +++ /dev/null @@ -1,5 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_nested.py -""" -Bridge file for eager loading nested tests. -""" -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_nested import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py deleted file mode 100644 index ff5df366..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py +++ /dev/null @@ -1,5 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_scenarios_sync.py -""" -Bridge file for sync eager loading scenario tests. -""" -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_scenarios_sync import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py deleted file mode 100644 index c298cef3..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py +++ /dev/null @@ -1,5 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_all.py -""" -Bridge file for eager loading with_all tests. -""" -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_with_all import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py deleted file mode 100644 index 433a7051..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py +++ /dev/null @@ -1,5 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_modifier.py -""" -Bridge file for eager loading with_modifier tests. -""" -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_with_modifier import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py b/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py deleted file mode 100644 index df162324..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py +++ /dev/null @@ -1,5 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/test_eager_loading_with_one.py -""" -Bridge file for eager loading with_one tests. -""" -from rhosocial.activerecord.testsuite.feature.query.test_eager_loading_with_one import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions.py similarity index 54% rename from tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py rename to tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions.py index aa5d6cc7..54ee5de3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/test_window_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions.py @@ -5,4 +5,6 @@ This file imports the generic tests from the testsuite package and makes them discoverable by pytest in this project's test run. """ -from rhosocial.activerecord.testsuite.feature.query.test_window_functions import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.window_functions.test_window_functions import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.window_functions.test_window_functions_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions_async.py new file mode 100644 index 00000000..f69a0000 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/window_functions/test_window_functions_async.py +"""Bridge file for async feature.query.window_functions.test_window_functions_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.window_functions.test_window_functions_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/conftest.py new file mode 100644 index 00000000..e9cd5de9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/conftest.py @@ -0,0 +1,8 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/worker/conftest.py +""" +Pytest configuration for query worker tests. + +This file imports fixtures from the corresponding testsuite, making them +available to the tests in this directory. +""" +from rhosocial.activerecord.testsuite.feature.query.worker.conftest import * # noqa: F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py index 7bfe5ecb..f7927c07 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries.py @@ -9,3 +9,5 @@ async_order_fixtures_for_worker, # noqa: F401 ) from rhosocial.activerecord.testsuite.feature.query.worker.test_parallel_queries import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.worker.test_parallel_queries_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries_async.py new file mode 100644 index 00000000..326c49d9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_parallel_queries_async.py +"""Bridge file for async feature.query.worker.test_parallel_queries_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.worker.test_parallel_queries_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_query_worker_transaction_isolation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_query_worker_transaction_isolation.py new file mode 100644 index 00000000..f32643b9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_query_worker_transaction_isolation.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_query_worker_transaction_isolation.py +""" +Bridge file for transaction isolation tests. + +Imports tests from testsuite and makes them discoverable by pytest. +""" +from rhosocial.activerecord.testsuite.feature.query.worker.test_transaction_isolation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_transaction_isolation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_transaction_isolation_async.py new file mode 100644 index 00000000..f73efd1c --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_transaction_isolation_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_transaction_isolation_async.py +"""Bridge file for async feature.query.worker.test_transaction_isolation_async tests.""" +from rhosocial.activerecord.testsuite.feature.query.worker.test_transaction_isolation_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py b/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py deleted file mode 100644 index c7590e84..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py +++ /dev/null @@ -1,12 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/query/worker/test_worker_transaction_isolation.py -""" -Bridge file for transaction isolation worker tests. - -Imports tests from testsuite and makes them discoverable by pytest. -Note: Renamed to avoid conflict with feature/backend/test_transaction_isolation.py -""" -from rhosocial.activerecord.testsuite.feature.query.worker.conftest import ( - order_fixtures_for_worker, # noqa: F401 - async_order_fixtures_for_worker, # noqa: F401 -) -from rhosocial.activerecord.testsuite.feature.query.worker.test_transaction_isolation import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_base.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_base.py new file mode 100644 index 00000000..1fb8802b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_base.py @@ -0,0 +1,5 @@ +""" +Tests for relation base functionality. +Imports tests from the standardized testsuite. +""" +from rhosocial.activerecord.testsuite.feature.relation.base.test_base import TestRelationDescriptor # noqa: F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_base_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_base_async.py new file mode 100644 index 00000000..5259aa4a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_base_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.base.test_base_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_interfaces.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_interfaces.py new file mode 100644 index 00000000..dce937f9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_interfaces.py @@ -0,0 +1,5 @@ +""" +Tests for relation interfaces. +Imports tests from the standardized testsuite. +""" +from rhosocial.activerecord.testsuite.feature.relation.base.test_interfaces import TestRelationInterfaces # noqa: F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_interfaces_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_interfaces_async.py new file mode 100644 index 00000000..688ea916 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/base/test_interfaces_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.base.test_interfaces_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/boundary/test_boundary.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/boundary/test_boundary.py new file mode 100644 index 00000000..0efbbf89 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/boundary/test_boundary.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.boundary.test_boundary import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.boundary.test_boundary_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/boundary/test_boundary_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/boundary/test_boundary_async.py new file mode 100644 index 00000000..591af194 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/boundary/test_boundary_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.boundary.test_boundary_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache.py similarity index 76% rename from tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py rename to tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache.py index ccd3017a..7e97b7cd 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache.py @@ -7,7 +7,7 @@ # Import the test classes from the testsuite -from rhosocial.activerecord.testsuite.feature.relation.test_cache import TestRelationCache # noqa: F401 +from rhosocial.activerecord.testsuite.feature.relation.cache.test_cache import TestRelationCache # noqa: F401 # This will cause pytest to run all the tests in the imported class diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_async.py new file mode 100644 index 00000000..33f8eccb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.cache.test_cache_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing.py new file mode 100644 index 00000000..ae990e84 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.cache.test_cache_clearing import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.cache.test_cache_clearing_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing_async.py new file mode 100644 index 00000000..e5e95b6a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/cache/test_cache_clearing_async.py +"""Bridge file for async feature.relation.cache.test_cache_clearing_async tests.""" +from rhosocial.activerecord.testsuite.feature.relation.cache.test_cache_clearing_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py index cb3209b8..77f688a5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/conftest.py @@ -28,30 +28,29 @@ async def async_comment_class(async_user_post_comment_classes): return comment -@pytest_asyncio.fixture(scope="function", params=SCENARIO_PARAMS) # noqa: F405 +@pytest_asyncio.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) # noqa: F405 async def async_user_post_comment_classes(request): from rhosocial.activerecord.testsuite.core.registry import get_provider_registry scenario = request.param provider_registry = get_provider_registry() - provider_class = provider_registry.get_provider(PROVIDER_KEY) # noqa: F405 + provider_class = provider_registry.get_provider(PROVIDER_KEY_ASYNC) # noqa: F405 provider = provider_class() - user = provider.setup_async_user_model(scenario) - post = provider.setup_async_post_model(scenario) - comment = provider.setup_async_comment_model(scenario) - await provider._ensure_user_post_comment_async_schema() + user = await provider.setup_user_model(scenario) + post = await provider.setup_post_model(scenario) + comment = await provider.setup_comment_model(scenario) yield user, post, comment - await provider.cleanup_after_test_async(scenario) + await provider.cleanup_after_test(scenario) -@pytest_asyncio.fixture(scope="function", params=SCENARIO_PARAMS) # noqa: F405 +@pytest_asyncio.fixture(scope="function", params=SCENARIO_PARAMS_ASYNC) # noqa: F405 async def async_relation_boundary_context(request): from rhosocial.activerecord.testsuite.core.registry import get_provider_registry scenario = request.param provider_registry = get_provider_registry() - provider_class = provider_registry.get_provider(PROVIDER_KEY) # noqa: F405 + provider_class = provider_registry.get_provider(PROVIDER_KEY_ASYNC) # noqa: F405 provider = provider_class() - owner, profile, post = provider.setup_async_relation_boundary_fixtures(scenario) + owner, profile, post = await provider.setup_relation_boundary_fixtures(scenario) yield provider, scenario, owner, profile, post - await provider.cleanup_after_test_async(scenario) + await provider.cleanup_after_test(scenario) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_derived_field_json.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_derived_field_json.py new file mode 100644 index 00000000..a9d9b50b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_derived_field_json.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.derived.test_relation_derived_field_json import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.derived.test_relation_derived_field_json_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_derived_field_json_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_derived_field_json_async.py new file mode 100644 index 00000000..0bf8a6d2 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_derived_field_json_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.derived.test_relation_derived_field_json_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field.py new file mode 100644 index 00000000..b43dbfb2 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field.py +"""Bridge file for sync feature.relation.derived.test_derived_field tests.""" +from rhosocial.activerecord.testsuite.feature.relation.derived.test_relation_derived_field import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field_async.py new file mode 100644 index 00000000..c26f8585 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/derived/test_relation_derived_field_async.py +"""Bridge file for async feature.relation.derived.test_derived_field_async tests.""" +from rhosocial.activerecord.testsuite.feature.relation.derived.test_relation_derived_field_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat.py new file mode 100644 index 00000000..09df177d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat.py @@ -0,0 +1,4 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py + +from rhosocial.activerecord.testsuite.feature.relation.descriptors.test_descriptor_compat import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.descriptors.test_descriptor_compat_async import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat_async.py new file mode 100644 index 00000000..53052583 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptor_compat_async.py +"""Bridge file for async feature.relation.descriptors.test_descriptor_compat_async tests.""" +from rhosocial.activerecord.testsuite.feature.relation.descriptors.test_descriptor_compat_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptors.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptors.py new file mode 100644 index 00000000..9622ce84 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptors.py @@ -0,0 +1,5 @@ +""" +Tests for relation descriptor functionality. +Imports tests from the standardized testsuite. +""" +from rhosocial.activerecord.testsuite.feature.relation.descriptors.test_descriptors import TestRelationDescriptors # noqa: F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptors_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptors_async.py new file mode 100644 index 00000000..b10f32f3 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/descriptors/test_descriptors_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py + +from rhosocial.activerecord.testsuite.feature.relation.descriptors.test_descriptors_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_nested_relationship_access.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_nested_relationship_access.py new file mode 100644 index 00000000..f9447d3a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_nested_relationship_access.py @@ -0,0 +1,5 @@ +""" +Tests for nested relationship access functionality. +Imports tests from the standardized testsuite. +""" +from rhosocial.activerecord.testsuite.feature.relation.eager_loading.test_nested_relationship_access import TestNestedRelationshipAccess # noqa: E501, F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_nested_relationship_access_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_nested_relationship_access_async.py new file mode 100644 index 00000000..6ea7cbbe --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_nested_relationship_access_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.eager_loading.test_nested_relationship_access_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_with_method.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_with_method.py new file mode 100644 index 00000000..a1ce8686 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_with_method.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.eager_loading.test_with_method import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.eager_loading.test_with_method_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_with_method_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_with_method_async.py new file mode 100644 index 00000000..a2c28563 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/eager_loading/test_with_method_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.eager_loading.test_with_method_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py new file mode 100644 index 00000000..3e1f957f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py @@ -0,0 +1,384 @@ +# src/rhosocial/activerecord/testsuite/feature/relation/fixtures/models.py +""" +Relation model fixtures for the testsuite. + +This module provides model classes for testing relation features: +- Employee/Department: Basic BelongsTo/HasMany relations +- Author/Book/Chapter/Profile: Nested relations with HasOne +- User/Post/Comment: Relations with FieldProxy, DerivedField, and JSON fields +""" +from typing import Optional, ClassVar + +from typing_extensions import Annotated + +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.base import DerivedField, FieldProxy +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.functions import ( + json_extract_text, length, coalesce +) +from rhosocial.activerecord.relation.descriptors import BelongsTo, HasMany, HasOne +from rhosocial.activerecord.relation.async_descriptors import AsyncBelongsTo, AsyncHasMany, AsyncHasOne + + +# ── Basic Relation Models ──────────────────────────────── + +class Employee(ActiveRecord): + __table_name__ = "employees" + + id: Optional[int] = None + username: str + department_id: int + + department: ClassVar[BelongsTo["Department"]] = BelongsTo( + foreign_key="department_id", + inverse_of="employees" + ) + + +class Department(ActiveRecord): + __table_name__ = "departments" + + id: Optional[int] = None + name: str + description: str = "" + + employees: ClassVar[HasMany["Employee"]] = HasMany( + foreign_key="department_id", + inverse_of="department" + ) + + +class Author(ActiveRecord): + __table_name__ = "authors" + + id: Optional[int] = None + name: str + + books: ClassVar[HasMany["Book"]] = HasMany( + foreign_key="author_id", + inverse_of="author" + ) + profile: ClassVar[HasOne["Profile"]] = HasOne( + foreign_key="author_id", + inverse_of="author" + ) + + +class Book(ActiveRecord): + __table_name__ = "books" + + id: Optional[int] = None + title: str + author_id: int + + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="books" + ) + chapters: ClassVar[HasMany["Chapter"]] = HasMany( + foreign_key="book_id", + inverse_of="book" + ) + + +class Chapter(ActiveRecord): + __table_name__ = "chapters" + + id: Optional[int] = None + title: str + book_id: int + + book: ClassVar[BelongsTo["Book"]] = BelongsTo( + foreign_key="book_id", + inverse_of="chapters" + ) + + +class Profile(ActiveRecord): + __table_name__ = "profiles" + + id: Optional[int] = None + bio: str + author_id: int + + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="profile" + ) + + +# ── Advanced Relation Models (with FieldProxy, DerivedField, JSON) ── + +class User(ActiveRecord): + __table_name__ = "users" + + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: Optional[str] = None + settings: Optional[str] = None # JSON: {"language": "zh-CN", "theme": "dark"} + + # DerivedField: display name (coalesce email to name) + display_name: ClassVar[Annotated[str, DerivedField( + lambda d: coalesce(d, Column(d, "email"), Column(d, "name")), + )]] + + # DerivedField (JSON): extract language preference + language: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "settings"), "$.language"), + )]] + + # DerivedField (JSON): extract theme preference + theme: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "settings"), "$.theme"), + )]] + + # Relation + posts: ClassVar[HasMany["Post"]] = HasMany( + foreign_key="user_id", + inverse_of="user" + ) + + +class Post(ActiveRecord): + __table_name__ = "posts" + + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + title: str + body: str + user_id: int + view_count: int = 0 + metadata: Optional[str] = None # JSON: {"tags": ["python", "orm"], "source": "blog"} + + # DerivedField: title length using FieldProxy + title_length: ClassVar[Annotated[int, DerivedField( + lambda d: length(d, Post.c.title), + )]] + + # DerivedField: hotness score + hotness: ClassVar[Annotated[int, DerivedField( + lambda d: Column(d, "view_count") + Literal(d, 1), + )]] + + # DerivedField (JSON): first tag from metadata + first_tag: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "metadata"), "$.tags[0]"), + )]] + + # DerivedField (JSON): source from metadata + source: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "metadata"), "$.source"), + )]] + + # Relations + user: ClassVar[BelongsTo["User"]] = BelongsTo( + foreign_key="user_id", + inverse_of="posts" + ) + comments: ClassVar[HasMany["Comment"]] = HasMany( + foreign_key="post_id", + inverse_of="post" + ) + + +class Comment(ActiveRecord): + __table_name__ = "comments" + + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + body: str + post_id: int + meta: Optional[str] = None # JSON: {"platform": "web", "device": "mobile"} + + # DerivedField: body length using FieldProxy + body_length: ClassVar[Annotated[int, DerivedField( + lambda d: length(d, Comment.c.body), + )]] + + # DerivedField (JSON): platform from meta + platform: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "meta"), "$.platform"), + )]] + + # Relation + post: ClassVar[BelongsTo["Post"]] = BelongsTo( + foreign_key="post_id", + inverse_of="comments" + ) + + +# ── Async Models ───────────────────────────────────────── + +class AsyncUser(AsyncActiveRecord): + __table_name__ = "users" + + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + name: str + email: Optional[str] = None + settings: Optional[str] = None + + display_name: ClassVar[Annotated[str, DerivedField( + lambda d: coalesce(d, Column(d, "email"), Column(d, "name")), + )]] + + language: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "settings"), "$.language"), + )]] + + theme: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "settings"), "$.theme"), + )]] + + posts: ClassVar[AsyncHasMany["AsyncPost"]] = AsyncHasMany( + foreign_key="user_id", + inverse_of="user" + ) + + +class AsyncPost(AsyncActiveRecord): + __table_name__ = "posts" + + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + title: str + body: str + user_id: int + view_count: int = 0 + metadata: Optional[str] = None + + title_length: ClassVar[Annotated[int, DerivedField( + lambda d: length(d, AsyncPost.c.title), + )]] + + hotness: ClassVar[Annotated[int, DerivedField( + lambda d: Column(d, "view_count") + Literal(d, 1), + )]] + + first_tag: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "metadata"), "$.tags[0]"), + )]] + + source: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "metadata"), "$.source"), + )]] + + user: ClassVar[AsyncBelongsTo["AsyncUser"]] = AsyncBelongsTo( + foreign_key="user_id", + inverse_of="posts" + ) + comments: ClassVar[AsyncHasMany["AsyncComment"]] = AsyncHasMany( + foreign_key="post_id", + inverse_of="post" + ) + + +class AsyncComment(AsyncActiveRecord): + __table_name__ = "comments" + + c: ClassVar[FieldProxy] = FieldProxy() + id: Optional[int] = None + body: str + post_id: int + meta: Optional[str] = None + + body_length: ClassVar[Annotated[int, DerivedField( + lambda d: length(d, AsyncComment.c.body), + )]] + + platform: ClassVar[Annotated[str, DerivedField( + lambda d: json_extract_text(d, Column(d, "meta"), "$.platform"), + )]] + + post: ClassVar[AsyncBelongsTo["AsyncPost"]] = AsyncBelongsTo( + foreign_key="post_id", + inverse_of="comments" + ) + + +# ── Relation Boundary Models ────────────────────────────── + +class BoundaryOwner(ActiveRecord): + __table_name__ = "relation_boundary_owners" + + id: Optional[int] = None + name: str + + profile: ClassVar[HasOne["BoundaryProfile"]] = HasOne( + foreign_key="owner_id", + inverse_of="owner" + ) + posts: ClassVar[HasMany["BoundaryPost"]] = HasMany( + foreign_key="owner_id", + inverse_of="owner" + ) + + +class BoundaryProfile(ActiveRecord): + __table_name__ = "relation_boundary_profiles" + + id: Optional[int] = None + bio: str + owner_id: Optional[int] = None + + owner: ClassVar[BelongsTo["BoundaryOwner"]] = BelongsTo( + foreign_key="owner_id", + inverse_of="profile" + ) + + +class BoundaryPost(ActiveRecord): + __table_name__ = "relation_boundary_posts" + + id: Optional[int] = None + title: str + owner_id: Optional[int] = None + + owner: ClassVar[BelongsTo["BoundaryOwner"]] = BelongsTo( + foreign_key="owner_id", + inverse_of="posts" + ) + + +class AsyncBoundaryOwner(AsyncActiveRecord): + __table_name__ = "relation_boundary_owners" + + id: Optional[int] = None + name: str + + profile: ClassVar[AsyncHasOne["AsyncBoundaryProfile"]] = AsyncHasOne( + foreign_key="owner_id", + inverse_of="owner" + ) + posts: ClassVar[AsyncHasMany["AsyncBoundaryPost"]] = AsyncHasMany( + foreign_key="owner_id", + inverse_of="owner" + ) + + +class AsyncBoundaryProfile(AsyncActiveRecord): + __table_name__ = "relation_boundary_profiles" + + id: Optional[int] = None + bio: str + owner_id: Optional[int] = None + + owner: ClassVar[AsyncBelongsTo["AsyncBoundaryOwner"]] = AsyncBelongsTo( + foreign_key="owner_id", + inverse_of="profile" + ) + + +class AsyncBoundaryPost(AsyncActiveRecord): + __table_name__ = "relation_boundary_posts" + + id: Optional[int] = None + title: str + owner_id: Optional[int] = None + + owner: ClassVar[AsyncBelongsTo["AsyncBoundaryOwner"]] = AsyncBelongsTo( + foreign_key="owner_id", + inverse_of="posts" + ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py310.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py310.py new file mode 100644 index 00000000..20e10165 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py310.py @@ -0,0 +1,132 @@ +# src/rhosocial/activerecord/testsuite/feature/relation/fixtures/models_py310.py +""" +Python 3.10+ fixture model definitions for relation tests. + +This file contains model classes using Python 3.10+ syntax features: +- `X | Y` syntax instead of `Optional[X]` or `Union[X, Y]` + +Note: This file should only be imported and used in Python 3.10+ environments. +""" +from typing import ClassVar + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.relation.descriptors import BelongsTo, HasMany, HasOne + + +# Declare that this module requires Python 3.10+ +__requires_python__ = (3, 10) + + +class Employee(ActiveRecord): + """Employee model with department relation. + + Python 3.10+ version. + """ + __table_name__ = "employees" + + id: int | None = None + username: str + department_id: int + + # Define the relation to department + department: ClassVar[BelongsTo["Department"]] = BelongsTo( + foreign_key="department_id", + inverse_of="employees" + ) + + +class Department(ActiveRecord): + """Department model with employees relation. + + Python 3.10+ version. + """ + __table_name__ = "departments" + + id: int | None = None + name: str + description: str = "" + + # Define the relation to employees + employees: ClassVar[HasMany["Employee"]] = HasMany( + foreign_key="department_id", + inverse_of="department" + ) + + +class Author(ActiveRecord): + """Author model with books and profile relations. + + Python 3.10+ version. + """ + __table_name__ = "authors" + + id: int | None = None + name: str + + # Relations + books: ClassVar[HasMany["Book"]] = HasMany( + foreign_key="author_id", + inverse_of="author" + ) + profile: ClassVar[HasOne["Profile"]] = HasOne( + foreign_key="author_id", + inverse_of="author" + ) + + +class Book(ActiveRecord): + """Book model with author and chapters relations. + + Python 3.10+ version. + """ + __table_name__ = "books" + + id: int | None = None + title: str + author_id: int + + # Relations + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="books" + ) + chapters: ClassVar[HasMany["Chapter"]] = HasMany( + foreign_key="book_id", + inverse_of="book" + ) + + +class Chapter(ActiveRecord): + """Chapter model with book relation. + + Python 3.10+ version. + """ + __table_name__ = "chapters" + + id: int | None = None + title: str + book_id: int + + # Relations + book: ClassVar[BelongsTo["Book"]] = BelongsTo( + foreign_key="book_id", + inverse_of="chapters" + ) + + +class Profile(ActiveRecord): + """Profile model with author relation. + + Python 3.10+ version. + """ + __table_name__ = "profiles" + + id: int | None = None + bio: str + author_id: int + + # Relations + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="profile" + ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py311.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py311.py new file mode 100644 index 00000000..a6835c3a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py311.py @@ -0,0 +1,165 @@ +# src/rhosocial/activerecord/testsuite/feature/relation/fixtures/models_py311.py +""" +Python 3.11+ fixture model definitions for relation tests. + +This file contains model classes using Python 3.11+ syntax features: +- `Self` type for methods that return an instance of the same class +- `X | Y` syntax (inherited from 3.10+) + +Note: This file should only be imported and used in Python 3.11+ environments. +""" +from __future__ import annotations + +from typing import ClassVar, Self + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.relation.descriptors import BelongsTo, HasMany, HasOne + + +# Declare that this module requires Python 3.11+ +__requires_python__ = (3, 11) + + +class Employee(ActiveRecord): + """Employee model with department relation. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "employees" + + id: int | None = None + username: str + department_id: int + + # Define the relation to department + department: ClassVar[BelongsTo["Department"]] = BelongsTo( + foreign_key="department_id", + inverse_of="employees" + ) + + def transfer_to(self, new_department_id: int) -> Self: + """Transfer employee to another department and return self for chaining.""" + self.department_id = new_department_id + return self + + +class Department(ActiveRecord): + """Department model with employees relation. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "departments" + + id: int | None = None + name: str + description: str = "" + + # Define the relation to employees + employees: ClassVar[HasMany["Employee"]] = HasMany( + foreign_key="department_id", + inverse_of="department" + ) + + def set_description(self, new_description: str) -> Self: + """Set description and return self for chaining.""" + self.description = new_description + return self + + +class Author(ActiveRecord): + """Author model with books and profile relations. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "authors" + + id: int | None = None + name: str + + # Relations + books: ClassVar[HasMany["Book"]] = HasMany( + foreign_key="author_id", + inverse_of="author" + ) + profile: ClassVar[HasOne["Profile"]] = HasOne( + foreign_key="author_id", + inverse_of="author" + ) + + def rename(self, new_name: str) -> Self: + """Rename author and return self for chaining.""" + self.name = new_name + return self + + +class Book(ActiveRecord): + """Book model with author and chapters relations. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "books" + + id: int | None = None + title: str + author_id: int + + # Relations + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="books" + ) + chapters: ClassVar[HasMany["Chapter"]] = HasMany( + foreign_key="book_id", + inverse_of="book" + ) + + def retitle(self, new_title: str) -> Self: + """Change book title and return self for chaining.""" + self.title = new_title + return self + + +class Chapter(ActiveRecord): + """Chapter model with book relation. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "chapters" + + id: int | None = None + title: str + book_id: int + + # Relations + book: ClassVar[BelongsTo["Book"]] = BelongsTo( + foreign_key="book_id", + inverse_of="chapters" + ) + + def rename(self, new_title: str) -> Self: + """Rename chapter and return self for chaining.""" + self.title = new_title + return self + + +class Profile(ActiveRecord): + """Profile model with author relation. + + Python 3.11+ version using | syntax and Self type. + """ + __table_name__ = "profiles" + + id: int | None = None + bio: str + author_id: int + + # Relations + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="profile" + ) + + def update_bio(self, new_bio: str) -> Self: + """Update bio and return self for chaining.""" + self.bio = new_bio + return self diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py312.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py312.py new file mode 100644 index 00000000..84ce68c8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models_py312.py @@ -0,0 +1,202 @@ +# src/rhosocial/activerecord/testsuite/feature/relation/fixtures/models_py312.py +""" +Python 3.12+ fixture model definitions for relation tests. + +This file contains model classes using Python 3.12+ syntax features: +- `@override` decorator for inheritance safety +- `Self` type for methods that return an instance of the same class +- `X | Y` syntax (inherited from 3.10+) + +Note: This file should only be imported and used in Python 3.12+ environments. +""" +from __future__ import annotations + +from typing import ClassVar, Self, override + +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.relation.descriptors import BelongsTo, HasMany, HasOne + + +# Declare that this module requires Python 3.12+ +__requires_python__ = (3, 12) + + +class Employee(ActiveRecord): + """Employee model with department relation. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "employees" + + id: int | None = None + username: str + department_id: int + + # Define the relation to department + department: ClassVar[BelongsTo["Department"]] = BelongsTo( + foreign_key="department_id", + inverse_of="employees" + ) + + @override + def save(self, **kwargs) -> Self: + """Save the employee and return self for chaining.""" + super().save(**kwargs) + return self + + def transfer_to(self, new_department_id: int) -> Self: + """Transfer employee to another department and return self for chaining.""" + self.department_id = new_department_id + return self + + +class Department(ActiveRecord): + """Department model with employees relation. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "departments" + + id: int | None = None + name: str + description: str = "" + + # Define the relation to employees + employees: ClassVar[HasMany["Employee"]] = HasMany( + foreign_key="department_id", + inverse_of="department" + ) + + @override + def save(self, **kwargs) -> Self: + """Save the department and return self for chaining.""" + super().save(**kwargs) + return self + + def set_description(self, new_description: str) -> Self: + """Set description and return self for chaining.""" + self.description = new_description + return self + + +class Author(ActiveRecord): + """Author model with books and profile relations. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "authors" + + id: int | None = None + name: str + + # Relations + books: ClassVar[HasMany["Book"]] = HasMany( + foreign_key="author_id", + inverse_of="author" + ) + profile: ClassVar[HasOne["Profile"]] = HasOne( + foreign_key="author_id", + inverse_of="author" + ) + + @override + def save(self, **kwargs) -> Self: + """Save the author and return self for chaining.""" + super().save(**kwargs) + return self + + def rename(self, new_name: str) -> Self: + """Rename author and return self for chaining.""" + self.name = new_name + return self + + +class Book(ActiveRecord): + """Book model with author and chapters relations. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "books" + + id: int | None = None + title: str + author_id: int + + # Relations + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="books" + ) + chapters: ClassVar[HasMany["Chapter"]] = HasMany( + foreign_key="book_id", + inverse_of="book" + ) + + @override + def save(self, **kwargs) -> Self: + """Save the book and return self for chaining.""" + super().save(**kwargs) + return self + + def retitle(self, new_title: str) -> Self: + """Change book title and return self for chaining.""" + self.title = new_title + return self + + +class Chapter(ActiveRecord): + """Chapter model with book relation. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "chapters" + + id: int | None = None + title: str + book_id: int + + # Relations + book: ClassVar[BelongsTo["Book"]] = BelongsTo( + foreign_key="book_id", + inverse_of="chapters" + ) + + @override + def save(self, **kwargs) -> Self: + """Save the chapter and return self for chaining.""" + super().save(**kwargs) + return self + + def rename(self, new_title: str) -> Self: + """Rename chapter and return self for chaining.""" + self.title = new_title + return self + + +class Profile(ActiveRecord): + """Profile model with author relation. + + Python 3.12+ version using | syntax, Self type, and @override. + """ + __table_name__ = "profiles" + + id: int | None = None + bio: str + author_id: int + + # Relations + author: ClassVar[BelongsTo["Author"]] = BelongsTo( + foreign_key="author_id", + inverse_of="profile" + ) + + @override + def save(self, **kwargs) -> Self: + """Save the profile and return self for chaining.""" + super().save(**kwargs) + return self + + def update_bio(self, new_bio: str) -> Self: + """Update bio and return self for chaining.""" + self.bio = new_bio + return self diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/integration/test_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/integration/test_integration.py new file mode 100644 index 00000000..eb3e57d4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/integration/test_integration.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.integration.test_integration import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.integration.test_integration_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/integration/test_integration_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/integration/test_integration_async.py new file mode 100644 index 00000000..ef9b2e02 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/integration/test_integration_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.integration.test_integration_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_behavior.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_behavior.py new file mode 100644 index 00000000..8fc621f3 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_behavior.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.modifiers.test_modifier_behavior import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.modifiers.test_modifier_behavior_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_behavior_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_behavior_async.py new file mode 100644 index 00000000..797fa5e3 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_behavior_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.modifiers.test_modifier_behavior_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_warning.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_warning.py new file mode 100644 index 00000000..2b7b7bc8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_warning.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.modifiers.test_modifier_warning import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.modifiers.test_modifier_warning_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_warning_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_warning_async.py new file mode 100644 index 00000000..6e884253 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/modifiers/test_modifier_warning_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.modifiers.test_modifier_warning_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_base.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_base.py deleted file mode 100644 index 2b7e6eb2..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_base.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Tests for relation base functionality. -Imports tests from the standardized testsuite. -""" -from rhosocial.activerecord.testsuite.feature.relation.test_base import TestRelationDescriptor # noqa: F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary.py deleted file mode 100644 index 7e44d5a1..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_boundary import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary_async.py deleted file mode 100644 index 95cbd862..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_boundary_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_boundary_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache_clearing.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache_clearing.py deleted file mode 100644 index 4a4b246f..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_cache_clearing.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_cache_clearing import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field.py deleted file mode 100644 index 21564139..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_derived_field import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_async.py deleted file mode 100644 index af5beacd..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_derived_field_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json.py deleted file mode 100644 index 67251dc4..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_derived_field_json import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json_async.py deleted file mode 100644 index 0130588f..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_derived_field_json_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_derived_field_json_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py deleted file mode 100644 index 47c04e0f..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py +++ /dev/null @@ -1,3 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptor_compat.py - -from rhosocial.activerecord.testsuite.feature.relation.test_descriptor_compat import * # noqa: F401,F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors.py deleted file mode 100644 index 8f5b34e5..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Tests for relation descriptor functionality. -Imports tests from the standardized testsuite. -""" -from rhosocial.activerecord.testsuite.feature.relation.test_descriptors import TestRelationDescriptors # noqa: F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py deleted file mode 100644 index 8fa56f6d..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py +++ /dev/null @@ -1,3 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/relation/test_descriptors_async.py - -from rhosocial.activerecord.testsuite.feature.relation.test_descriptors_async import * # noqa: F401,F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration.py deleted file mode 100644 index ebd76ceb..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_integration import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration_async.py deleted file mode 100644 index 5bc443dc..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_integration_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_integration_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_interfaces.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_interfaces.py deleted file mode 100644 index 2e1fab2c..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_interfaces.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Tests for relation interfaces. -Imports tests from the standardized testsuite. -""" -from rhosocial.activerecord.testsuite.feature.relation.test_interfaces import TestRelationInterfaces # noqa: F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior.py deleted file mode 100644 index bbd9a806..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_modifier_behavior import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior_async.py deleted file mode 100644 index 6538d2b1..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_behavior_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_modifier_behavior_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning.py deleted file mode 100644 index eb29123d..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_modifier_warning import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning_async.py deleted file mode 100644 index b7207a9f..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_modifier_warning_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_modifier_warning_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py deleted file mode 100644 index edb06654..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_nested_relationship_access.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Tests for nested relationship access functionality. -Imports tests from the standardized testsuite. -""" -from rhosocial.activerecord.testsuite.feature.relation.test_nested_relationship_access import TestNestedRelationshipAccess # noqa: E501, F401 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation.py deleted file mode 100644 index 37e7e8e9..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_path_validation import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation_async.py deleted file mode 100644 index 599860af..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_path_validation_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_path_validation_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py deleted file mode 100644 index 0cebf814..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py +++ /dev/null @@ -1,3 +0,0 @@ -# tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py - -from rhosocial.activerecord.testsuite.feature.relation.test_validator import * # noqa: F401,F403 \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method.py deleted file mode 100644 index fdb8bca6..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_with_method import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method_async.py deleted file mode 100644 index de09d14a..00000000 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/test_with_method_async.py +++ /dev/null @@ -1 +0,0 @@ -from rhosocial.activerecord.testsuite.feature.relation.test_with_method_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_path_validation.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_path_validation.py new file mode 100644 index 00000000..b2b0a150 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_path_validation.py @@ -0,0 +1,3 @@ +from rhosocial.activerecord.testsuite.feature.relation.validation.test_path_validation import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.validation.test_path_validation_async import * # noqa: F403 + diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_path_validation_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_path_validation_async.py new file mode 100644 index 00000000..b492478f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_path_validation_async.py @@ -0,0 +1 @@ +from rhosocial.activerecord.testsuite.feature.relation.validation.test_path_validation_async import * # noqa: F401,F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator.py new file mode 100644 index 00000000..47df6d4e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator.py @@ -0,0 +1,4 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/test_validator.py + +from rhosocial.activerecord.testsuite.feature.relation.validation.test_validator import * # noqa: F401,F403 +from rhosocial.activerecord.testsuite.feature.relation.validation.test_validator_async import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator_async.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator_async.py new file mode 100644 index 00000000..799baf9f --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator_async.py @@ -0,0 +1,3 @@ +# tests/rhosocial/activerecord_postgres_test/feature/relation/validation/test_validator_async.py +"""Bridge file for async feature.relation.validation.test_validator_async tests.""" +from rhosocial.activerecord.testsuite.feature.relation.validation.test_validator_async import * # noqa: F401, F403 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/test_features.py b/tests/rhosocial/activerecord_postgres_test/feature/test_features.py new file mode 100644 index 00000000..b84fe49d --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/test_features.py @@ -0,0 +1,51 @@ +# src/rhosocial/activerecord/testsuite/feature/test_features.py +"""Test the protocol-based requirement system.""" + +import pytest +from rhosocial.activerecord.testsuite.utils import ( + requires_functions, + requires_window_functions, +) + + +class TestRequiresProtocolDecorator: + """Test the requires_protocol decorator functionality.""" + + def test_requires_protocol_decorator_basic(self): + """Test that requires_protocol decorator can be applied.""" + # This test just verifies the decorator can be applied + assert True + + def test_requires_protocol_with_method(self): + """Test requires_protocol with specific method name.""" + # This test verifies requires_protocol with method_name argument + assert True + + +class TestRequiresFunctionsDecorator: + """Test the requires_functions decorator functionality.""" + + @requires_functions('json_array_insert', 'jsonb_array_insert') + def test_requires_functions_single(self, fixtures): + """Test requires_functions with single function.""" + # This test will be skipped if json_array_insert is not supported + assert True + + @requires_functions('json_array_insert') + def test_requires_functions_multiple(self, fixtures): + """Test requires_functions with multiple functions.""" + # This test will be skipped if any required function is not supported + assert True + + +class TestConvenienceDecorators: + """Test convenience decorators.""" + + @requires_window_functions() + def test_requires_window_functions(self, fixtures): + """Test requires_window_functions convenience decorator.""" + assert True + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py b/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py new file mode 100644 index 00000000..9baf541b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/test_features_async.py @@ -0,0 +1,53 @@ +# src/rhosocial/activerecord/testsuite/feature/test_features_async.py +"""Test the protocol-based requirement system.""" + +import pytest +from rhosocial.activerecord.testsuite.utils import ( + requires_functions, + requires_window_functions, +) + + +class TestAsyncRequiresProtocolDecorator: + """Test the requires_protocol decorator functionality.""" + + + async def test_requires_protocol_decorator_basic(self): + """Test that requires_protocol decorator can be applied.""" + # This test just verifies the decorator can be applied + assert True + + + async def test_requires_protocol_with_method(self): + """Test requires_protocol with specific method name.""" + # This test verifies requires_protocol with method_name argument + assert True + + +class TestAsyncRequiresFunctionsDecorator: + """Test the requires_functions decorator functionality.""" + + @requires_functions('json_array_insert', 'jsonb_array_insert') + async def test_requires_functions_single(self, fixtures): + """Test requires_functions with single function.""" + # This test will be skipped if json_array_insert is not supported + assert True + + @requires_functions('json_array_insert') + async def test_requires_functions_multiple(self, fixtures): + """Test requires_functions with multiple functions.""" + # This test will be skipped if any required function is not supported + assert True + + +class TestAsyncConvenienceDecorators: + """Test convenience decorators.""" + + @requires_window_functions() + async def test_requires_window_functions(self, fixtures): + """Test requires_window_functions convenience decorator.""" + assert True + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) \ No newline at end of file