From dc0835abfa2374a5a22dff5d1313e379fc1bc4dd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 9 Oct 2026 16:13:40 +0800 Subject: [PATCH 01/15] feat: the typed-columns column-type suggestion table --- .github/workflows/test.yml | 16 +- .../backend/impl/postgres/backend/base.py | 10 +- .../backend/impl/postgres/dialect.py | 30 +- .../impl/postgres/examples/extensions/cube.py | 27 +- .../postgres/examples/extensions/hstore.py | 17 +- .../postgres/examples/extensions/intarray.py | 5 +- .../postgres/examples/extensions/pgvector.py | 11 +- .../postgres/examples/extensions/postgis.py | 5 +- .../examples/named_migrations/expressions.py | 23 +- .../impl/postgres/examples/query/window.py | 8 +- .../examples/query/window_functions.py | 15 +- .../examples/schema_diff/array_type_diff.py | 10 +- .../examples/sqlxml/standard_expressions.py | 8 +- .../impl/postgres/expression/__init__.py | 2 - .../impl/postgres/expression/ddl/domain.py | 56 +- .../backend/impl/postgres/expression/types.py | 995 ++++++++++++++++-- .../impl/postgres/expression/values.py | 709 +++++++++++++ .../functions/address_standardizer.py | 45 +- .../backend/impl/postgres/functions/array.py | 158 ++- .../impl/postgres/functions/bit_string.py | 117 +- .../backend/impl/postgres/functions/citext.py | 33 +- .../backend/impl/postgres/functions/cube.py | 76 +- .../impl/postgres/functions/data_type.py | 11 +- .../impl/postgres/functions/earthdistance.py | 69 +- .../backend/impl/postgres/functions/enum.py | 96 +- .../impl/postgres/functions/fuzzystrmatch.py | 75 +- .../impl/postgres/functions/geometric.py | 119 +-- .../backend/impl/postgres/functions/hstore.py | 279 +++-- .../backend/impl/postgres/functions/hypopg.py | 33 +- .../impl/postgres/functions/intarray.py | 88 +- .../backend/impl/postgres/functions/json.py | 39 +- .../backend/impl/postgres/functions/ltree.py | 98 +- .../impl/postgres/functions/math_enhanced.py | 97 +- .../impl/postgres/functions/network.py | 110 +- .../backend/impl/postgres/functions/orafce.py | 124 +-- .../impl/postgres/functions/pg_cron.py | 52 +- .../impl/postgres/functions/pg_partman.py | 51 +- .../impl/postgres/functions/pg_repack.py | 27 +- .../impl/postgres/functions/pg_surgery.py | 35 +- .../impl/postgres/functions/pg_trgm.py | 55 +- .../impl/postgres/functions/pg_walinspect.py | 41 +- .../impl/postgres/functions/pgaudit.py | 52 +- .../impl/postgres/functions/pgcrypto.py | 91 +- .../impl/postgres/functions/pglogical.py | 71 +- .../impl/postgres/functions/pgrouting.py | 60 +- .../impl/postgres/functions/pgvector.py | 147 ++- .../impl/postgres/functions/postgis.py | 187 ++-- .../impl/postgres/functions/postgis_raster.py | 50 +- .../backend/impl/postgres/functions/range.py | 383 ++++--- .../impl/postgres/functions/tablefunc.py | 81 +- .../impl/postgres/functions/text_search.py | 113 +- .../backend/impl/postgres/functions/uuid.py | 43 +- .../backend/impl/postgres/functions/xml.py | 48 +- .../backend/impl/postgres/mixins/__init__.py | 4 + .../impl/postgres/mixins/column_suggestion.py | 232 ++++ .../backend/impl/postgres/mixins/data_type.py | 11 - .../backend/impl/postgres/mixins/ddl_table.py | 5 +- .../impl/postgres/mixins/expression.py | 10 +- .../postgres/mixins/extensions/uuid_ossp.py | 17 +- .../backend/impl/postgres/mixins/json.py | 24 +- .../impl/postgres/mixins/types/data_type.py | 13 +- .../mixins/types/data_type_formatting.py | 483 ++++++++- .../postgres/mixins/types/jsonb_enhanced.py | 596 ++++++++++- .../backend/impl/postgres/mixins/uuid.py | 107 ++ .../protocols/extensions/uuid_ossp.py | 20 +- .../protocols/types/jsonb_enhanced.py | 80 +- .../backend/impl/postgres/py.typed | 0 .../backend/impl/postgres/schema/differ.py | 13 +- .../impl/postgres/type_values/constants.py | 4 +- .../backend/expression/vector_search_test.py | 4 +- .../extensions/test_cube_integration.py | 44 +- .../extensions/test_hstore_integration.py | 20 +- .../extensions/test_intarray_integration.py | 100 +- .../extensions/test_ltree_integration.py | 80 +- .../extensions/test_orafce_integration.py | 4 +- .../extensions/test_pgvector_integration.py | 66 +- .../extensions/test_postgis_integration.py | 4 +- .../extensions/test_uuid_ossp_integration.py | 5 +- .../backend/postgres/test_cast_integration.py | 580 ++++++++++ .../postgres/test_core_type_mappings.py | 19 +- .../backend/postgres/test_ddl_objects.py | 15 +- .../test_domain_expression_roundtrip.py | 192 ++++ .../postgres/test_extension_type_execution.py | 465 ++++++++ .../test_extension_type_operations.py | 372 +++++++ .../backend/postgres/test_extensions.py | 6 +- .../backend/postgres/test_json_path_modes.py | 613 +++++++++++ ..._postgres_datetime_interval_expressions.py | 3 +- .../test_postgres_hstore_functions.py | 143 +-- .../test_postgres_math_enhanced_functions.py | 85 +- .../test_postgres_pgvector_functions.py | 10 +- .../test_postgres_postgis_functions.py | 95 +- .../backend/postgres/test_type_casting.py | 57 +- .../backend/postgres/test_uuid_values.py | 156 +++ .../backend/test_expression_roundtrip_all.py | 63 +- .../backend/test_postgres_dialect_security.py | 7 +- .../test_postgres_protocol_conformance.py | 1 + .../types/test_column_type_suggestions.py | 348 ++++++ .../types/test_data_type_formatting.py | 201 +++- .../backend/types/test_expression_types.py | 177 +++- .../types/test_postgres_type_protocol.py | 282 ++++- 100 files changed, 8459 insertions(+), 2578 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/expression/values.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/uuid.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/py.typed create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_cast_integration.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_domain_expression_roundtrip.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_execution.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_operations.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_json_path_modes.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_uuid_values.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38081818..1b6940a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,8 +68,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config run: | @@ -279,8 +279,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config run: | @@ -391,8 +391,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config run: | @@ -489,8 +489,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/expression-schema-object-refactor" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config run: | diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py index 7abb647e..48f2c6e0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py @@ -114,6 +114,7 @@ def _register_postgres_adapters(self): from ..adapters.monetary import PostgresMoneyAdapter from ..adapters.network_address import PostgresMacaddrAdapter, PostgresMacaddr8Adapter from ..adapters.object_identifier import PostgresOidAdapter, PostgresXidAdapter, PostgresTidAdapter + from ..adapters.uuid import PostgresUUIDAdapter from ..adapters.pg_lsn import PostgresLsnAdapter from ..adapters.text_search import PostgresTsVectorAdapter, PostgresTsQueryAdapter from ..config import RangeAdapterMode @@ -133,6 +134,11 @@ def _register_postgres_adapters(self): PostgresOidAdapter(), PostgresXidAdapter(), PostgresTidAdapter(), + # Same {uuid.UUID: {str}} pair the core UUIDAdapter registers, so + # this needs allow_override (see the loop below). Unlike + # PostgresJsonPathAdapter it claims no str->str mapping, so it + # cannot shadow the default string handling. + PostgresUUIDAdapter(), # PostgresJsonPathAdapter(), # Not registered: str->str conflict # PostgresXMLAdapter is NOT registered by default # due to str->str type pair conflict @@ -141,7 +147,9 @@ def _register_postgres_adapters(self): for adapter in pg_adapters: for py_type, db_types in adapter.supported_types.items(): for db_type in db_types: - self.adapter_registry.register(adapter, py_type, db_type) + self.adapter_registry.register( + adapter, py_type, db_type, allow_override=True + ) # Register Range adapters based on configuration range_mode = getattr(self.config, "range_adapter_mode", RangeAdapterMode.NATIVE) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index d98c04dc..4cced4ed 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -38,6 +38,7 @@ WindowFunctionMixin, JSONMixin, + UUIDMixin, ArrayMixin, ExplainMixin, @@ -116,7 +117,6 @@ CTESupport, FilterClauseSupport, WindowFunctionSupport, - JSONSupport, ReturningSupport, AdvancedGroupingSupport, ArraySupport, @@ -160,6 +160,7 @@ PostgresDataTypeMixin, PostgresLogicalReplicationMixin, PostgresParallelQueryMixin, + PostgresColumnSuggestionMixin, # Per-feature mixins PostgresCTEMixin, PostgresWindowMixin, @@ -230,6 +231,7 @@ PostgresFullTextSearchMixin, PostgresRangeTypeMixin, PostgresJSONBEnhancedMixin, + PostgresUUIDMixin, PostgresArrayEnhancedMixin, PostgresTypeFormatSupportMixin, # DDL/DML operation mixins (new) @@ -428,7 +430,15 @@ class PostgresDialect( WindowFunctionMixin, PostgresJSONBEnhancedMixin, JSONMixin, - + # PG's UUID answer (version 13+ built-in, or uuid-ossp) must win over + # the core UUIDMixin table, which has a single fixed spelling per operation. + PostgresUUIDMixin, + UUIDMixin, + + # Before ArrayMixin: this answers True where the core answers False, and + # it was listed in the backend block further down, so the core default + # was what every caller actually saw. + PostgresArrayEnhancedMixin, ArrayMixin, ExplainMixin, # Must precede GraphMixin/GraphTableMixin so that the explicit-override @@ -475,6 +485,12 @@ class PostgresDialect( PostgresDataTypeMixin, PostgresLogicalReplicationMixin, PostgresParallelQueryMixin, + # Column-type suggestions. After the DataType mixin above because that is the + # sibling decision -- this one says which operations a value carries, the + # other says how it is spelled. Nothing else in the list can answer for a + # common Python type, so the MRO leaves the placement free; putting the two + # side by side is what makes the separation legible to the next reader. + PostgresColumnSuggestionMixin, # Extension feature mixins PostgresLtreeMixin, PostgresIntarrayMixin, @@ -516,7 +532,6 @@ class PostgresDialect( MultirangeMixin, PostgresFullTextSearchMixin, PostgresRangeTypeMixin, - PostgresArrayEnhancedMixin, PostgresTypeFormatSupportMixin, # DDL/DML operation mixins (new) PostgresExtendedStatisticsMixin, @@ -544,7 +559,14 @@ class PostgresDialect( CTESupport, FilterClauseSupport, WindowFunctionSupport, - JSONSupport, + # JSONSupport is not listed: PostgresJSONBEnhancedSupport derives from it + # below, and listing a base ahead of its own subclass is not a consistent + # MRO. This is the shape every other derived protocol uses here -- + # PostgresTableSupport(TableSupport), PostgresIndexSupport(IndexSupport) -- + # and JSONSupport still reaches the MRO, so isinstance(dialect, JSONSupport) + # is unchanged. Listing both, while each was standalone, also put + # supports_json_type in the MRO four times: two abstract protocol + # declarations, the core mixin default, and the one implementation. ReturningSupport, AdvancedGroupingSupport, ArraySupport, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/cube.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/cube.py index b0af80fb..fe99ee27 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/cube.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/cube.py @@ -69,6 +69,9 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresCubeType, +) # Check if cube extension is available available = dialect.is_extension_available("cube") @@ -136,12 +139,12 @@ source=ValuesSource( dialect, [ - [Literal(dialect, "Widget A"), Literal(dialect, "(0.5, 0.8, 0.2)").cast("cube")], - [Literal(dialect, "Widget B"), Literal(dialect, "(0.3, 0.6, 0.9)").cast("cube")], - [Literal(dialect, "Widget C"), Literal(dialect, "(0.7, 0.4, 0.1)").cast("cube")], - [Literal(dialect, "Widget D"), Literal(dialect, "(0.2, 0.3, 0.7)").cast("cube")], + [Literal(dialect, "Widget A"), Literal(dialect, "(0.5, 0.8, 0.2)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "Widget B"), Literal(dialect, "(0.3, 0.6, 0.9)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "Widget C"), Literal(dialect, "(0.7, 0.4, 0.1)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "Widget D"), Literal(dialect, "(0.2, 0.3, 0.7)").cast(PostgresCubeType(dialect))], # A range entry (min corner, max corner in each dimension) - [Literal(dialect, "Category X"), Literal(dialect, "(0.1,0.2,0.0),(0.5,0.6,0.3)").cast("cube")], + [Literal(dialect, "Category X"), Literal(dialect, "(0.1,0.2,0.0),(0.5,0.6,0.3)").cast(PostgresCubeType(dialect))], ], ), ) @@ -161,7 +164,7 @@ from_=Table(dialect, "products"), where=BinaryExpression( dialect, "@>", - Literal(dialect, "(0.1,0.2,0.0),(0.5,0.6,0.3)").cast("cube"), + Literal(dialect, "(0.1,0.2,0.0),(0.5,0.6,0.3)").cast(PostgresCubeType(dialect)), Column(dialect, "feature_vector"), ), order_by=OrderByClause(dialect, expressions=[Column(dialect, "name")]), @@ -181,7 +184,7 @@ where=BinaryExpression( dialect, "<@", Column(dialect, "feature_vector"), - Literal(dialect, "(0.1,0.2,0.0),(0.5,0.6,0.3)").cast("cube"), + Literal(dialect, "(0.1,0.2,0.0),(0.5,0.6,0.3)").cast(PostgresCubeType(dialect)), ), order_by=OrderByClause(dialect, expressions=[Column(dialect, "name")]), ) @@ -201,7 +204,7 @@ BinaryExpression( dialect, "<->", Column(dialect, "feature_vector"), - Literal(dialect, "(0.5, 0.8, 0.2)").cast("cube"), + Literal(dialect, "(0.5, 0.8, 0.2)").cast(PostgresCubeType(dialect)), ), ).as_("distance") @@ -212,14 +215,14 @@ BinaryExpression( dialect, "<->", Column(dialect, "feature_vector"), - Literal(dialect, "(0.5, 0.8, 0.2)").cast("cube"), + Literal(dialect, "(0.5, 0.8, 0.2)").cast(PostgresCubeType(dialect)), ), ) dist_for_order = BinaryExpression( dialect, "<->", Column(dialect, "feature_vector"), - Literal(dialect, "(0.5, 0.8, 0.2)").cast("cube"), + Literal(dialect, "(0.5, 0.8, 0.2)").cast(PostgresCubeType(dialect)), ) query = QueryExpression( @@ -251,14 +254,14 @@ BinaryExpression( dialect, "<->", Column(dialect, "feature_vector"), - Literal(dialect, "(0.4, 0.5, 0.6)").cast("cube"), + Literal(dialect, "(0.4, 0.5, 0.6)").cast(PostgresCubeType(dialect)), ), ).as_("distance") nn_dist_for_order = BinaryExpression( dialect, "<->", Column(dialect, "feature_vector"), - Literal(dialect, "(0.4, 0.5, 0.6)").cast("cube"), + Literal(dialect, "(0.4, 0.5, 0.6)").cast(PostgresCubeType(dialect)), ) query = QueryExpression( diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/hstore.py index ee9b0f21..574784e8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/hstore.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/hstore.py @@ -65,6 +65,9 @@ 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 ( + PostgresHstoreType, +) # Check if hstore extension is available available = dialect.is_extension_available("hstore") @@ -118,7 +121,7 @@ backend.execute(sql, params) # Example 2: Insert hstore data using InsertExpression + ValuesSource - # Use Literal with .cast("hstore") for type-safe hstore insertion + # Use Literal with .cast(PostgresHstoreType(self.dialect)) for type-safe hstore insertion insert_expr = InsertExpression( dialect=dialect, into=Table(dialect, 'products'), @@ -128,15 +131,15 @@ [ [ Literal(dialect, "Laptop"), - Literal(dialect, "color=>silver, weight=>2.5").cast("hstore"), + Literal(dialect, "color=>silver, weight=>2.5").cast(PostgresHstoreType(dialect)), ], [ Literal(dialect, "Phone"), - Literal(dialect, "color=>black, weight=>0.2").cast("hstore"), + Literal(dialect, "color=>black, weight=>0.2").cast(PostgresHstoreType(dialect)), ], [ Literal(dialect, "Tablet"), - Literal(dialect, "color=>silver, weight=>0.5, brand=>Acme").cast("hstore"), + Literal(dialect, "color=>silver, weight=>0.5, brand=>Acme").cast(PostgresHstoreType(dialect)), ], ], ), @@ -198,7 +201,7 @@ where=BinaryExpression( dialect, "@>", Column(dialect, "attributes"), - Literal(dialect, "color=>silver").cast("hstore"), + Literal(dialect, "color=>silver").cast(PostgresHstoreType(dialect)), ), ) sql, params = query.to_sql() @@ -216,7 +219,7 @@ Subquery(dialect, BinaryExpression( dialect, "||", Column(dialect, "attributes"), - Literal(dialect, "brand=>Acme").cast("hstore"), + Literal(dialect, "brand=>Acme").cast(PostgresHstoreType(dialect)), )).as_("with_brand"), ], from_=Table(dialect, "products"), @@ -315,7 +318,7 @@ "attributes": BinaryExpression( dialect, "||", Column(dialect, "attributes"), - Literal(dialect, "discount=>true").cast("hstore"), + Literal(dialect, "discount=>true").cast(PostgresHstoreType(dialect)), ), }, where=ComparisonPredicate( diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py index 799db067..706b2fb5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py @@ -67,6 +67,7 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import IntegerType # Check if intarray extension is available available = dialect.is_extension_available("intarray") @@ -156,7 +157,7 @@ where=BinaryExpression( dialect, "@>", Column(dialect, "tag_ids"), - Literal(dialect, "{1,3}").cast("integer[]"), + Literal(dialect, "{1,3}").cast(IntegerType(dialect)), ), ) sql, params = query.to_sql() @@ -175,7 +176,7 @@ where=BinaryExpression( dialect, "&&", Column(dialect, "tag_ids"), - Literal(dialect, "{1,6}").cast("integer[]"), + Literal(dialect, "{1,6}").cast(IntegerType(dialect)), ), ) sql, params = query.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py index 12a7be64..f9df95ef 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py @@ -65,6 +65,7 @@ vector_search, create_vector_index, ) +from rhosocial.activerecord.backend.impl.postgres.expression.types import PostgresVectorType # Check if vector extension is available available = dialect.is_extension_available("vector") @@ -123,7 +124,7 @@ backend.execute(sql, params) # Example 2: Insert vector data - # Use Literal with .cast("vector") for type-safe vector insertion. + # Use Literal with .cast(PostgresVectorType(self.dialect)) for type-safe vector insertion. insert_expr = InsertExpression( dialect=dialect, into=Table(dialect, 'documents'), @@ -133,19 +134,19 @@ [ [ Literal(dialect, "cat"), - Literal(dialect, "[1.0, 0.5, 0.2]").cast("vector"), + Literal(dialect, "[1.0, 0.5, 0.2]").cast(PostgresVectorType(dialect)), ], [ Literal(dialect, "dog"), - Literal(dialect, "[0.9, 0.6, 0.3]").cast("vector"), + Literal(dialect, "[0.9, 0.6, 0.3]").cast(PostgresVectorType(dialect)), ], [ Literal(dialect, "car"), - Literal(dialect, "[0.1, 0.2, 0.9]").cast("vector"), + Literal(dialect, "[0.1, 0.2, 0.9]").cast(PostgresVectorType(dialect)), ], [ Literal(dialect, "bicycle"), - Literal(dialect, "[0.15, 0.25, 0.85]").cast("vector"), + Literal(dialect, "[0.15, 0.25, 0.85]").cast(PostgresVectorType(dialect)), ], ], ), diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/postgis.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/postgis.py index 708f7809..083c8e84 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/postgis.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/postgis.py @@ -69,6 +69,9 @@ 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 ( + PostgresGeographyType, +) # Check if postgis extension is available available = dialect.is_extension_available("postgis") @@ -248,7 +251,7 @@ Literal(dialect, 40.7128), ), Literal(dialect, 4326), - ).cast("geography"), + ).cast(PostgresGeographyType(dialect)), Literal(dialect, 500000), ), ) 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 index 438d33d2..e9361033 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -14,11 +14,22 @@ ColumnConstraintType, DropTableExpression, ) +from rhosocial.activerecord.backend.expression.types import VarCharType from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresSerialType, - PostgresCharacterVaryingType, ) +def _character_varying(dialect, length: int = 255) -> VarCharType: + """A ``CHARACTER VARYING(length)`` column type. + + SQL:2016 defines ``VARCHAR`` as the abbreviation of ``CHARACTER VARYING``, so + the two are one type written two ways — the core ``VarCharType`` with its + ``character varying`` spelling, not a PostgreSQL-only class. Drop the + ``spelling=`` argument for plain ``VARCHAR(length)``; both render, and both + compare as the same concept with the same length. + """ + return VarCharType(dialect, length=length, spelling="character varying") + def create_users_table(dialect): """CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(255), email VARCHAR(255)).""" @@ -32,8 +43,8 @@ def create_users_table(dialect): PostgresSerialType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition(dialect, "name", PostgresCharacterVaryingType(dialect, length=255)), - ColumnDefinition(dialect, "email", PostgresCharacterVaryingType(dialect, length=255)), + ColumnDefinition(dialect, "name", _character_varying(dialect)), + ColumnDefinition(dialect, "email", _character_varying(dialect)), ], ) @@ -55,8 +66,8 @@ def create_posts_table(dialect): PostgresSerialType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition(dialect, "title", PostgresCharacterVaryingType(dialect, length=255)), - ColumnDefinition(dialect, "user_id", PostgresCharacterVaryingType(dialect, length=255)), + ColumnDefinition(dialect, "title", _character_varying(dialect)), + ColumnDefinition(dialect, "user_id", _character_varying(dialect)), ], ) @@ -82,7 +93,7 @@ def create_custom_table(dialect, table_name: str = "custom_table"): PostgresSerialType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition(dialect, "value", PostgresCharacterVaryingType(dialect, length=255)), + ColumnDefinition(dialect, "value", _character_varying(dialect)), ], ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py index e3dc2eaf..f54d7f2d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window.py @@ -86,7 +86,7 @@ QueryExpression, Column, WindowSpecification, OrderByClause, ) from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.advanced_functions import WindowFunctionCall +from rhosocial.activerecord.backend.expression.core import FunctionCall query = QueryExpression( dialect=dialect, @@ -94,7 +94,7 @@ Column(dialect, 'salesperson'), Column(dialect, 'region'), Column(dialect, 'amount'), - WindowFunctionCall( + FunctionCall( dialect, 'ROW_NUMBER', window_spec=WindowSpecification( @@ -104,10 +104,10 @@ ), alias='row_num', ), - WindowFunctionCall( + FunctionCall( dialect, 'SUM', - args=[Column(dialect, 'amount')], + Column(dialect, 'amount'), window_spec=WindowSpecification( dialect, partition_by=[Column(dialect, 'region')], diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py index 90032096..3c876140 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/query/window_functions.py @@ -27,18 +27,17 @@ QueryExpression, ) from rhosocial.activerecord.backend.expression.objects import Table -from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.core import Column, FunctionCall from rhosocial.activerecord.backend.expression.advanced_functions import ( - WindowFunctionCall, WindowSpecification, WindowFrameSpecification, ) from rhosocial.activerecord.backend.expression.query_parts import OrderByClause # ROW_NUMBER() - sequential rank without gaps -row_number = WindowFunctionCall( +row_number = FunctionCall( dialect=dialect, - function_name='ROW_NUMBER', + func_name='ROW_NUMBER', window_spec=WindowSpecification( dialect=dialect, partition_by=[Column(dialect, 'department')], @@ -54,9 +53,9 @@ ) # RANK() - rank with gaps (same values get same rank, skip ranks) -rank = WindowFunctionCall( +rank = FunctionCall( dialect=dialect, - function_name='RANK', + func_name='RANK', window_spec=WindowSpecification( dialect=dialect, partition_by=[Column(dialect, 'department')], @@ -66,9 +65,9 @@ ) # DENSE_RANK() - rank without gaps -dense_rank = WindowFunctionCall( +dense_rank = FunctionCall( dialect=dialect, - function_name='DENSE_RANK', + func_name='DENSE_RANK', window_spec=WindowSpecification( dialect=dialect, partition_by=[Column(dialect, 'department')], 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 index 8153e338..d059265a 100644 --- 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 @@ -1,9 +1,13 @@ """ 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. +PostgreSQL normalises every array declaration to one dimension on +storage, so INTEGER[] and INTEGER[][] are the same *column* to the +server: the difference this example constructs by hand cannot be read +back out of a live database. The schema differ compares parsed types by +identity (==), which means a change in declared dimensions IS reported -- +use is_element_type_equivalent() when the question is "do these two array +columns hold the same kind of thing", ignoring dimensions. Supported versions: PostgreSQL 8.4+ (arrays available since PG 8.4) """ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py index 35677780..4c5a4234 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/sqlxml/standard_expressions.py @@ -44,6 +44,10 @@ from rhosocial.activerecord.backend.impl.postgres.backend import PostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.expression.types import TextType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresXMLType, +) SQLParam = Optional[Union[str, int, float, bool]] SQLParams = Tuple[SQLParam, ...] @@ -70,13 +74,13 @@ def __init__( def text_literal(dialect: PostgresDialect, value: str) -> Literal: expr = Literal(dialect, value) - expr.cast("TEXT") + expr.cast(TextType(dialect)) return expr def xml_literal(dialect: PostgresDialect, value: str) -> Literal: expr = Literal(dialect, value) - expr.cast("XML") + expr.cast(PostgresXMLType(dialect)) return expr diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 9206de32..95c744bc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -113,7 +113,6 @@ PostgresBitType, PostgresBoxType, PostgresByteaType, - PostgresCharacterVaryingType, PostgresCIDType, PostgresCircleType, PostgresDateMultirangeType, @@ -263,7 +262,6 @@ "PostgresBitType", "PostgresBoxType", "PostgresByteaType", - "PostgresCharacterVaryingType", "PostgresCIDType", "PostgresCircleType", "PostgresDateMultirangeType", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py index bc7bf21c..badcc221 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -5,7 +5,7 @@ import warnings from enum import Enum -from typing import Any, Dict, List, Optional, Sequence, Union, TYPE_CHECKING +from typing import Any, List, Optional, Sequence, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLPredicate from rhosocial.activerecord.backend.expression.objects import Domain @@ -183,6 +183,21 @@ def __init__(self, dialect: "SQLDialectBase", new_schema: str) -> None: class PostgresCreateDomainExpression(CreateDomainExpression): + """CREATE DOMAIN with PostgreSQL's two mutually exclusive check spellings. + + ``constraints`` is the historical spelling and still accepts the + deprecated raw-SQL form; ``checks`` is the typed spelling and accepts only + ``DomainCheckConstraint`` / ``SQLPredicate``. Supplying both is an error. + + Both spellings reach the *same* rendering state, so the generic + introspection-based ``get_params()`` is the whole serialization story: + ``__init__`` stores the merged item list under the slot belonging to the + parameter the caller actually used and leaves the other slot ``None``, so + ``get_params()`` emits ``constraints=[...], checks=None`` (or the reverse) + and the reconstruction takes the same branch as the original. Rendering + reads :attr:`_constraint_clauses`, never one particular spelling's slot. + """ + def __init__( self, dialect: "SQLDialectBase", @@ -206,13 +221,10 @@ def __init__( if constraints is not None and checks is not None: raise ValueError("constraints and checks are mutually exclusive") if checks is not None: - check_parameter = "checks" constraint_items = list(checks) elif constraints is not None: - check_parameter = "constraints" constraint_items = list(constraints) else: - check_parameter = None constraint_items = [] clauses: List[BaseExpression] = [] normalized_checks: List[DomainCheckConstraint] = [] @@ -256,19 +268,31 @@ def __init__( ) self.schema = resolved_schema self.schema_name = resolved_schema - self.constraints = list(constraint_items) + # Fold the merged item list into the slot named by the parameter the + # caller used, and leave the unused spelling at None ("not supplied"). + # Both spellings are mutually exclusive in __init__, so this is what + # makes the generic get_params() round trip without an override. + if checks is not None: + # self.checks already holds the normalized list assigned by super(). + self.constraints = None + else: + self.constraints = list(constraint_items) if constraints is not None else None + self.checks = None self._constraint_clauses = clauses - self._check_parameter = check_parameter - - def get_params(self) -> Dict[str, Any]: - params = super().get_params() - params.pop("constraints", None) - params.pop("checks", None) - if self._check_parameter == "constraints": - params["constraints"] = list(self.constraints) - elif self._check_parameter == "checks": - params["checks"] = list(self.checks) - return params + + @property + def check_constraints(self) -> List[DomainCheckConstraint]: + """The declared checks, whichever spelling the caller used. + + ``constraints`` and ``checks`` are two names for one list of clauses; + only the spelling that was passed owns the corresponding attribute. + Use this to read them back without caring which one that was. + """ + return [ + clause + for clause in self._constraint_clauses + if isinstance(clause, DomainCheckConstraint) + ] @property def name(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py index afac3ea1..026b190c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -17,352 +17,1087 @@ from __future__ import annotations -from typing import Optional, Set, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.types import ( ArrayType, + EnumType, + BigIntType, BlobType, DataType, - VarCharType, + IntegerType, + SmallIntType, + TextType, + UUIDType, + XmlType, ) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -# --------------------------------------------------------------------------- -# Character varying alias -# --------------------------------------------------------------------------- - -class PostgresCharacterVaryingType(VarCharType): - """PostgreSQL ``CHARACTER VARYING(n)`` — alias for ``VARCHAR(n)``.""" - - name = "postgres_character_varying" - @classmethod - def synonyms(cls) -> Set[str]: - return {'VarCharType'} - - # --------------------------------------------------------------------------- # Binary data: BYTEA # --------------------------------------------------------------------------- class PostgresByteaType(BlobType): - """PostgreSQL ``BYTEA`` — variable-length binary string.""" + """PostgreSQL ``BYTEA`` — variable-length binary string. + + PostgreSQL's own name for the concept core calls ``BlobType``; ``BYTEA`` is + in that concept's ``SPELLINGS`` list, so the storage is identical and only + the written form differs. + """ name = "postgres_bytea" - @classmethod - def synonyms(cls) -> Set[str]: - return {'BlobType'} # --------------------------------------------------------------------------- # Serial (auto-increment) types # --------------------------------------------------------------------------- -class PostgresSmallSerialType(DataType): - """PostgreSQL ``SMALLSERIAL`` — auto-incrementing SMALLINT (2 bytes).""" +class PostgresSmallSerialType(SmallIntType): + """PostgreSQL ``SMALLSERIAL`` — auto-incrementing SMALLINT (2 bytes). + + ``SMALLSERIAL`` is not a storage type of its own: PostgreSQL stores a + ``SMALLINT`` and adds a nextval-backed ``DEFAULT`` plus a sequence. The + auto-increment is a column default, not a property of how the bytes are + stored, so this derives from :class:`SmallIntType` — the width *is* the + type — rather than sitting on ``DataType`` where it would claim to be a + concept no other backend has. + + The name stays ``postgres_smallserial`` because the DDL really does say + ``SMALLSERIAL``, and that is what a schema diff must compare. + """ + + # A SERIAL column has no signedness. The auto-increment is a column + # default over a sequence, not a property of how the bytes are stored, and + # this derives from the integer concept only for its width. Declared empty + # so it does not inherit ``unsigned`` from that concept -- which PostgreSQL + # has no way to render anyway, so the field could only ever be a claim the + # backend cannot honour. + PARAMETERS = () name = "postgres_smallserial" - @classmethod - def synonyms(cls) -> Set[str]: - return {'SmallSerialType', 'SmallIntType'} -class PostgresSerialType(DataType): - """PostgreSQL ``SERIAL`` — auto-incrementing INTEGER (4 bytes).""" +class PostgresSerialType(IntegerType): + """PostgreSQL ``SERIAL`` — auto-incrementing INTEGER (4 bytes). + + The auto-increment is a column default over a sequence, not a distinct + storage type, so the concept is exactly ``INTEGER``; see + :class:`PostgresSmallSerialType`. + """ + + # A SERIAL column has no signedness. The auto-increment is a column + # default over a sequence, not a property of how the bytes are stored, and + # this derives from the integer concept only for its width. Declared empty + # so it does not inherit ``unsigned`` from that concept -- which PostgreSQL + # has no way to render anyway, so the field could only ever be a claim the + # backend cannot honour. + PARAMETERS = () name = "postgres_serial" - @classmethod - def synonyms(cls) -> Set[str]: - return {'SerialType', 'IntegerType'} -class PostgresBigSerialType(DataType): - """PostgreSQL ``BIGSERIAL`` — auto-incrementing BIGINT (8 bytes).""" +class PostgresBigSerialType(BigIntType): + """PostgreSQL ``BIGSERIAL`` — auto-incrementing BIGINT (8 bytes). + + See :class:`PostgresSmallSerialType` for why this is the 8-byte integer + concept rather than a type of its own. + """ + + # A SERIAL column has no signedness. The auto-increment is a column + # default over a sequence, not a property of how the bytes are stored, and + # this derives from the integer concept only for its width. Declared empty + # so it does not inherit ``unsigned`` from that concept -- which PostgreSQL + # has no way to render anyway, so the field could only ever be a claim the + # backend cannot honour. + PARAMETERS = () name = "postgres_bigserial" - @classmethod - def synonyms(cls) -> Set[str]: - return {'BigSerialType', 'BigIntType'} # --------------------------------------------------------------------------- # UUID # --------------------------------------------------------------------------- -class PostgresUUIDType(DataType): - """PostgreSQL ``UUID`` — universally unique identifier.""" +class PostgresEnumColumnType(EnumType): + """A PostgreSQL ENUM type used as a column's storage type. + + A PostgreSQL enum is not an inline list of values the way MySQL's is: it is + a named type created once by ``CREATE TYPE ... AS ENUM`` and then referred + to by name, which is what lets two columns share one set of labels. + + That difference is about **rendering**, not about what the value *is*. The + value is an enum either way, so this derives from the core ``EnumType`` and + ``isinstance(col.data_type, EnumType)`` is true -- which is the question a + caller asking "is this column an enum?" is actually asking. What PostgreSQL + writes into the DDL is the *name*; the labels live in the ``CREATE TYPE`` + that made it. Hence ``type_name``, and hence a formatter that emits the + name rather than an inline ``ENUM('a','b')``. + + The labels are carried as well, so introspection can report them and so + equality compares what the column can actually hold rather than only a name + that two different enums could share. + + Creating the type itself is ``EnumTypeManager.create_type`` or + ``PostgresCreateEnumTypeExpression``; this class is the DataType-shaped door + into that machinery. + + Args: + dialect: The dialect that will render the type reference. + type_name: Name of the created enum type. Required -- a column cannot + state a PostgreSQL enum without it. That requirement is why the + dialect's ``suggested_data_types()["enum"]`` points *here* rather + than claiming the generic ``EnumType`` is renderable. + schema: Optional schema holding it. + values: The labels, for introspection and equality. + """ + + name = "postgres_enum" + type_name: str + schema: Optional[str] + + def __init__( + self, + dialect=None, + type_name: Optional[str] = None, + schema: Optional[str] = None, + values: Optional[list] = None, + ): + if not type_name: + raise ValueError( + "PostgresEnumColumnType requires a type_name: PostgreSQL refers " + "to an enum by the name given to CREATE TYPE, so a column " + "cannot state one without it" + ) + super().__init__(dialect, values=values) + self.type_name = type_name + self.schema = schema + + # Identity is the *named type* plus its labels: two columns pointing at + # different enums are different columns even if the label lists happen to + # match, and two columns pointing at the same enum are the same column even + # if introspection only recovered some of the labels. ``values`` is already + # a tuple (EnumType stores it that way), so a plain read is correct. + PARAMETERS = ("type_name", "schema", "values",) + + +class PostgresUUIDType(UUIDType): + """PostgreSQL ``UUID`` — universally unique identifier. + + PostgreSQL is the reference implementation for this concept: 128 random + bits, stored in the order PostgreSQL documents. Deriving from + :class:`UUIDType` is what lets code written against the core concept work + on this backend unchanged, and what makes "is this a UUID column?" one + question instead of nine. + """ name = "postgres_uuid" + + # --------------------------------------------------------------------------- # XML # --------------------------------------------------------------------------- -class PostgresXMLType(DataType): - """PostgreSQL ``XML`` — XML data type.""" +class PostgresXMLType(XmlType): + """PostgreSQL ``XML`` — an XML document. + + PostgreSQL's ``xml`` stores a document that may be validated against an XML + Schema registered for it, and offers XPath over it — which is why it is the + XML concept and not text that happens to hold tags. The associated schema + is a column attribute, so it does not enter the type's identity. + + :class:`PostgresCitextType` is the interesting contrast: it *is* text, and + deriving from :class:`TextType` says so, which this deliberately does not. + """ name = "postgres_xml" + # --------------------------------------------------------------------------- # Text search # --------------------------------------------------------------------------- class PostgresTSVectorType(DataType): - """PostgreSQL ``TSVECTOR`` — text search document.""" + """PostgreSQL ``TSVECTOR`` — a text search document. + + The *result* of analysing a document with a text-search configuration: a + normalised list of lexemes with positions and weights, which is what + ``@@`` and the ranking functions match against. A different thing from + :class:`TextType` — the lexemes are stemmed and indexed, the document text + is not recoverable from it, and neither comparison nor ordering behaves as + it does for text. + + On ``DataType`` rather than derived from anything in core because SQL:2016 + has no full-text search type; the ``tsvector`` type is PostgreSQL's own. + """ name = "postgres_tsvector" + + class PostgresTSQueryType(DataType): - """PostgreSQL ``TSQUERY`` — text search query.""" + """PostgreSQL ``TSQUERY`` — a text search query. + + The *question* side of :class:`PostgresTSVectorType`: a parsed, normalised + expression such as ``'fat' & 'rat'`` that a ``tsvector`` is tested against. + Kept separate from ``tsvector`` because the two are never the same value + and never the same column. + + On ``DataType`` for the same reason as ``TSVECTOR`` — SQL:2016 has no + full-text search types, so there is no core concept to anchor to. + """ name = "postgres_tsquery" + + # --------------------------------------------------------------------------- # JSON path # --------------------------------------------------------------------------- class PostgresJsonPathType(DataType): - """PostgreSQL ``JSONPATH`` — SQL/JSON path expression (PG 12+).""" + """PostgreSQL ``JSONPATH`` — an SQL/JSON path expression (PG 12+). + + A compiled path such as ``$.b.bc`` over a :class:`JsonType` or + :class:`JsonBType` value. Not part of the JSON *storage* type: a column is + ``jsonb``, and this is the expression evaluated against it. It stays on + ``DataType`` rather than deriving from ``JsonType`` because it holds no JSON + document — deriving would make ``isinstance(path, JsonType)`` true for + something that cannot be stored in a JSON column, which is exactly the kind + of lie the hierarchy exists to avoid. + """ name = "postgres_jsonpath" + # --------------------------------------------------------------------------- # Bit string types # --------------------------------------------------------------------------- class PostgresBitType(DataType): - """PostgreSQL ``BIT(n)`` — fixed-length bit string.""" + """PostgreSQL ``BIT(n)`` — fixed-length bit string of *bits*. + + Not a boolean. ``BIT(8)`` is eight bits, and comparing it to ``true`` or + indexing it as an array of bytes are both mistakes that only a type name + invites — hence a class of its own rather than an ``unsigned`` flag on + :class:`IntegerType`. The same argument keeps it away from + :class:`BooleanType`: ``BIT(1)`` accepts only ``0`` and ``1``, ``BOOLEAN`` + accepts ``TRUE``/``FALSE``/``yes``/``on``/``t``/``f``, and the two do not + cast to each other implicitly in PostgreSQL. + + ``n`` is the declared length in bits. PostgreSQL pads to it, so a shorter + input is not an error — the *value* is length-``n``, whatever was written. + ``n=None`` means the type was declared without one, which PostgreSQL + treats as ``BIT(1)``. +On ``DataType``: no core concept covers a fixed-length bit string. It is not an +integer — ``BIT(8)`` is eight bits, and a ``BIT`` column cannot be compared to +``true`` or indexed as bytes — and it is not a boolean, since PostgreSQL does +not cast between ``BIT(1)`` and ``BOOLEAN``. So there is no concept here to +derive from, and it does not derive from the types it must not be confused +with. + + + Args: + dialect: The dialect this type is bound to. First positional parameter, + as for every ``DataType`` — putting ``n`` first would let a + dialect passed by mistake be silently stored as the length. + n: Declared length in bits. + """ name = "postgres_bit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None): + def __init__(self, dialect: Optional["SQLDialectBase"] = None, + n: Optional[int] = None, + ): super().__init__(dialect) self.n = n - def _type_params(self) -> tuple: - return (self.n,) - + PARAMETERS = ("n",) class PostgresVarBitType(DataType): - """PostgreSQL ``VARBIT(n)`` — variable-length bit string.""" + """PostgreSQL ``VARBIT(n)`` — variable-length bit string of *bits*. + + The same type as :class:`PostgresBitType` with one difference: the value + keeps the length it was given, so a ``VARBIT(8)`` holding three bits stores + three bits rather than being padded. That is why it stays a separate class + instead of a flag on ``PostgresBitType`` — the storage differs, not just the + declaration — and both stay away from the integer types, since a bit string + is not a number in any way a reader should assume. + + Args: + dialect: The dialect this type is bound to. First positional parameter, + as for every ``DataType``. + n: Declared maximum length in bits, or ``None`` for no maximum. + """ name = "postgres_varbit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None): + def __init__(self, dialect: Optional["SQLDialectBase"] = None, + n: Optional[int] = None, + ): super().__init__(dialect) self.n = n - def _type_params(self) -> tuple: - return (self.n,) - + PARAMETERS = ("n",) # --------------------------------------------------------------------------- # Network address types # --------------------------------------------------------------------------- class PostgresInetType(DataType): - """PostgreSQL ``INET`` — IPv4 or IPv6 address.""" + """PostgreSQL ``INET`` — an IPv4 or IPv6 *host* address. + + Holds an address and optionally the network it belongs to + (``192.168.1.5/24``), and ``inet`` containment is bit-prefix containment, + so a bare address is treated as its own ``/32`` or ``/128``. Distinct from + :class:`PostgresCidrType` in that the host bits are kept. + + On ``DataType`` because SQL:2016 has no network address type. Core's type + documentation once listed ``InetType`` as a planned concept; it was never + implemented, and adding it to core is a separate decision (see the + datatype-hierarchy plan's out-of-scope list) rather than something to settle + by inheritance here. + """ name = "postgres_inet" + + class PostgresCidrType(DataType): - """PostgreSQL ``CIDR`` — IPv4 or IPv6 network.""" + """PostgreSQL ``CIDR`` — an IPv4 or IPv6 *network*. + + The same address-and-prefix as :class:`PostgresInetType`, except the host + bits are normalised to zero on input, so the value is always a network + boundary and ``cidr`` arithmetic means the same thing everywhere. That + difference in stored contents is why these are two types and not one with + a flag. + + On ``DataType``: SQL:2016 has no network address type. + """ name = "postgres_cidr" + + class PostgresMacAddrType(DataType): - """PostgreSQL ``MACADDR`` — MAC address (EUI-48).""" + """PostgreSQL ``MACADDR`` — a MAC address, EUI-48. + + Six bytes. Distinct from :class:`PostgresMacAddr8Type` because the stored + length differs, which is what a schema diff has to notice. + + On ``DataType``: SQL:2016 has no MAC address type. See + :class:`PostgresInetType` on why these are not core concepts yet. + """ name = "postgres_macaddr" + + class PostgresMacAddr8Type(DataType): - """PostgreSQL ``MACADDR8`` — MAC address (EUI-64, PG 10+).""" + """PostgreSQL ``MACADDR8`` — a MAC address, EUI-64 (PG 10+). + + Eight bytes, covering the 64-bit space. Separate from ``MACADDR`` for the + stored length alone. + + On ``DataType``: SQL:2016 has no MAC address type. + """ name = "postgres_macaddr8" + # --------------------------------------------------------------------------- # Geometric types # --------------------------------------------------------------------------- class PostgresPointType(DataType): - """PostgreSQL ``POINT`` — geometric point (x, y).""" + """PostgreSQL ``POINT`` — a geometric point ``(x, y)``. + + One of nine geometric primitives in PostgreSQL's built-in ``contrib``-era + geometric type system (``point``/``line``/``lseg``/``box``/``path``/ + ``polygon``/``circle``). Each is its own type because each carries different + coordinates and supports different operators; there is no common "geometry" + value among them. + + On ``DataType`` because SQL:2016 has no geometric types at all. PostGIS's + :class:`PostgresGeometryType` and :class:`PostgresGeographyType` are a + different system again (they carry an SRID and a type tag) and are not + interchangeable with these. + + Nine types across five backends is the largest candidate for a new core + concept, but adding one is a design decision of its own — see the + datatype-hierarchy plan's out-of-scope list. + """ name = "postgres_point" + + class PostgresLineType(DataType): - """PostgreSQL ``LINE`` — infinite line.""" + """PostgreSQL ``LINE`` — an infinite line ``Ax + By + C = 0``. + + Not a segment: it is unbounded in both directions and has no endpoints, so + it is neither :class:`PostgresLineSegmentType` nor + :class:`PostgresPathType`. + + On ``DataType``: SQL:2016 has no geometric types. + """ name = "postgres_line" + + class PostgresLineSegmentType(DataType): - """PostgreSQL ``LSEG`` — line segment.""" + """PostgreSQL ``LSEG`` — a line segment between two points. + + Bounded, with two endpoints, which is what separates it from + :class:`PostgresLineType`. + + On ``DataType``: SQL:2016 has no geometric types. + """ name = "postgres_line_segment" + + class PostgresBoxType(DataType): - """PostgreSQL ``BOX`` — rectangular box.""" + """PostgreSQL ``BOX`` — an axis-aligned rectangle. + + Stored as two opposite corners, which is why it is not a general + quadrilateral the way :class:`PostgresPolygonType` is. + + On ``DataType``: SQL:2016 has no geometric types. + """ name = "postgres_box" + + class PostgresPathType(DataType): - """PostgreSQL ``PATH`` — open or closed geometric path.""" + """PostgreSQL ``PATH`` — an open or closed sequence of points. + + The same structure whether it is open or closed — that flag lives *in* the + value, not in the type — which is why it is one class rather than two. + + On ``DataType``: SQL:2016 has no geometric types. + """ name = "postgres_path" + + class PostgresPolygonType(DataType): - """PostgreSQL ``POLYGON`` — closed geometric polygon.""" + """PostgreSQL ``POLYGON`` — a closed path, with an interior. + + Always closed and always enclosing an area, unlike :class:`PostgresPathType` + which may be either. The distinction is enforced by the type, not by + convention. + + On ``DataType``: SQL:2016 has no geometric types. + """ name = "postgres_polygon" + + class PostgresCircleType(DataType): - """PostgreSQL ``CIRCLE`` — circle (center + radius).""" + """PostgreSQL ``CIRCLE`` — a circle as centre plus radius. + + On ``DataType``: SQL:2016 has no geometric types. + """ name = "postgres_circle" + # --------------------------------------------------------------------------- # Monetary type # --------------------------------------------------------------------------- class PostgresMoneyType(DataType): - """PostgreSQL ``MONEY`` — currency amount.""" + """PostgreSQL ``MONEY`` — a fixed-scale currency amount. + + Deliberately **not** a subclass of :class:`DecimalType`. The two are not + two spellings of one concept; PostgreSQL's own manual separates them, and + the differences are not cosmetic: + + ==================== ========================== ========================== + ``MONEY`` ``NUMERIC`` + ==================== ========================== ========================== + Storage 8 bytes, fixed 2 bytes per 4 decimal + digits, plus 3–8 overhead + Range about 17 integer digits up to 131072 + Scale set by the ``lc_monetary`` declared: ``NUMERIC(p,s)`` + GUC — **not in the schema** + Output format **locale-dependent** locale-independent + ``a / b`` ``double precision`` exact ``numeric`` + ==================== ========================== ========================== + + Two of these are reasons on their own: + + * The **scale is not in the schema**. ``ALTER DATABASE ... SET + lc_monetary`` silently changes what every ``money`` column stores and + prints, with nothing in ``information_schema`` recording it. A schema + snapshot therefore cannot tell you what a ``money`` column holds. + * The **output is locale-dependent**, which the manual warns about + explicitly ("the output of this data type is locale-sensitive"). It + follows that ``schema/differ.py``'s fallback to comparing upper-cased raw + type strings can report a false difference on a ``money`` column across + two environments with different ``lc_monetary`` settings — the type name + is stable, but anything read back out of one is not. + + The **division result type** is the decisive one for this framework: a + type that changes the result type of an operation on it is not the same + concept as one that does not. ``money / money`` yields a ``double + precision``, losing exactness in a way ``numeric / numeric`` does not. + + Upstream agrees: the PostgreSQL Wiki's *Don't Do This: Don't use money* + says to use ``numeric`` with an explicit scale instead. This class exists + because ``money`` columns exist — a dump may contain them — not because it + is a good choice. + + See ``docs/.../datatype-hierarchy.md`` §D6/D7 for why this stays on + ``DataType`` rather than joining ``DecimalType``. + """ name = "postgres_money" + # --------------------------------------------------------------------------- # Range types # --------------------------------------------------------------------------- class PostgresInt4RangeType(DataType): - """PostgreSQL ``INT4RANGE`` — range of integer.""" + """PostgreSQL ``INT4RANGE`` — range of ``integer``. + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + """ name = "postgres_int4range" + class PostgresInt8RangeType(DataType): - """PostgreSQL ``INT8RANGE`` — range of bigint.""" + """PostgreSQL ``INT8RANGE`` — range of ``bigint``. + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + """ name = "postgres_int8range" + class PostgresNumRangeType(DataType): - """PostgreSQL ``NUMRANGE`` — range of numeric.""" + """PostgreSQL ``NUMRANGE`` — range of ``numeric``. + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + """ name = "postgres_numrange" + class PostgresTsRangeType(DataType): - """PostgreSQL ``TSRANGE`` — range of timestamp without time zone.""" + """PostgreSQL ``TSRANGE`` — range of ``timestamp`` without time zone. + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + """ name = "postgres_tsrange" + class PostgresTsTzRangeType(DataType): - """PostgreSQL ``TSTZRANGE`` — range of timestamp with time zone.""" + """PostgreSQL ``TSTZRANGE`` — range of ``timestamp with time zone``. + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + """ name = "postgres_tstzrange" + class PostgresDateRangeType(DataType): - """PostgreSQL ``DATERANGE`` — range of date.""" + """PostgreSQL ``DATERANGE`` — range of ``date``. + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + """ name = "postgres_daterange" + # --------------------------------------------------------------------------- # Multirange types (PG 14+) # --------------------------------------------------------------------------- class PostgresInt4MultirangeType(DataType): - """PostgreSQL ``INT4MULTIRANGE`` — multirange of integer (PG 14+).""" + """PostgreSQL ``INT4MULTIRANGE`` — multirange of ``integer`` (PG 14+). + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + +Separate from the corresponding single range rather than a flag on it: a +multirange holds a *set of non-overlapping ranges*, may be empty, and is +normalised to the coarsest such decomposition. That is a different value, not a +wider version of the same one, so it does not inherit from it. They share no +concept that is not a grouping node, and grouping nodes are not used here — +identity is expressed by inheritance alone. + """ name = "postgres_int4multirange" + class PostgresInt8MultirangeType(DataType): - """PostgreSQL ``INT8MULTIRANGE`` — multirange of bigint (PG 14+).""" + """PostgreSQL ``INT8MULTIRANGE`` — multirange of ``bigint`` (PG 14+). + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + +Separate from the corresponding single range rather than a flag on it: a +multirange holds a *set of non-overlapping ranges*, may be empty, and is +normalised to the coarsest such decomposition. That is a different value, not a +wider version of the same one, so it does not inherit from it. They share no +concept that is not a grouping node, and grouping nodes are not used here — +identity is expressed by inheritance alone. + """ name = "postgres_int8multirange" + class PostgresNumMultirangeType(DataType): - """PostgreSQL ``NUMMULTIRANGE`` — multirange of numeric (PG 14+).""" + """PostgreSQL ``NUMMULTIRANGE`` — multirange of ``numeric`` (PG 14+). + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + +Separate from the corresponding single range rather than a flag on it: a +multirange holds a *set of non-overlapping ranges*, may be empty, and is +normalised to the coarsest such decomposition. That is a different value, not a +wider version of the same one, so it does not inherit from it. They share no +concept that is not a grouping node, and grouping nodes are not used here — +identity is expressed by inheritance alone. + """ name = "postgres_nummultirange" + class PostgresTsMultirangeType(DataType): - """PostgreSQL ``TSMULTIRANGE`` — multirange of timestamp (PG 14+).""" + """PostgreSQL ``TSMULTIRANGE`` — multirange of ``timestamp`` (PG 14+). + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + +Separate from the corresponding single range rather than a flag on it: a +multirange holds a *set of non-overlapping ranges*, may be empty, and is +normalised to the coarsest such decomposition. That is a different value, not a +wider version of the same one, so it does not inherit from it. They share no +concept that is not a grouping node, and grouping nodes are not used here — +identity is expressed by inheritance alone. + """ name = "postgres_tsmultirange" + class PostgresTsTzMultirangeType(DataType): - """PostgreSQL ``TSTZMULTIRANGE`` — multirange of timestamptz (PG 14+).""" + """PostgreSQL ``TSTZMULTIRANGE`` — multirange of ``timestamp with time zone`` (PG 14+). + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + +Separate from the corresponding single range rather than a flag on it: a +multirange holds a *set of non-overlapping ranges*, may be empty, and is +normalised to the coarsest such decomposition. That is a different value, not a +wider version of the same one, so it does not inherit from it. They share no +concept that is not a grouping node, and grouping nodes are not used here — +identity is expressed by inheritance alone. + """ name = "postgres_tstzmultirange" + class PostgresDateMultirangeType(DataType): - """PostgreSQL ``DATEMULTIRANGE`` — multirange of date (PG 14+).""" + """PostgreSQL ``DATEMULTIRANGE`` — multirange of ``date`` (PG 14+). + +On ``DataType``: the core has no interval-of-values concept, and a range is not +a widened scalar. A range carries its own bound semantics — a lower and an +upper bound, ``[`` inclusive and ``(`` exclusive, plus the empty range — and +PostgreSQL *canonicalises* it, so ``numrange '[1,4)'`` comes back as ``[1,3]``. +Deriving from the element type's concept would make ``isinstance(r, IntegerType)`` +true of something that cannot be stored in an integer column at all, which is +precisely the lie this hierarchy exists to prevent. Each element type is its own +concept rather than one class parameterised by element, because the +canonicalisation rules differ between continuous and discrete element types — +and that is behaviour, not a field. + +Separate from the corresponding single range rather than a flag on it: a +multirange holds a *set of non-overlapping ranges*, may be empty, and is +normalised to the coarsest such decomposition. That is a different value, not a +wider version of the same one, so it does not inherit from it. They share no +concept that is not a grouping node, and grouping nodes are not used here — +identity is expressed by inheritance alone. + """ name = "postgres_datemultirange" + # --------------------------------------------------------------------------- # Object identifier types # --------------------------------------------------------------------------- class PostgresOIDType(DataType): - """PostgreSQL ``OID`` — object identifier.""" + """PostgreSQL ``OID`` — an unsigned 32-bit object identifier. + + A row identifier into ``pg_catalog``. One of eight system-catalog types + below, none of which appear in an application schema — they show up when + querying the catalog itself (``SELECT ... FROM pg_class``) and as + ``CAST``/``::`` targets. + + On ``DataType``: SQL:2016 has no object identifier type, and ``OID`` is + specifically PostgreSQL's own. Being an unsigned 32-bit number does *not* + make it :class:`IntegerType` — the value only means anything in relation to + a catalog row, which is precisely what makes it a different concept. + """ name = "postgres_oid" + + class PostgresRegClassType(DataType): - """PostgreSQL ``REGCLASS`` — relation name (OID alias).""" + """PostgreSQL ``REGCLASS`` — a relation, referred to by name. + + Stores an ``oid`` but prints and accepts the relation's name, and resolves + names as they were at insert time — the behaviour that makes it pleasant to + use and wrong to treat as an integer. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_regclass" + + class PostgresRegTypeType(DataType): - """PostgreSQL ``REGTYPE`` — type name (OID alias).""" + """PostgreSQL ``REGTYPE`` — a type, referred to by name. + + The type-valued counterpart of :class:`PostgresRegClassType`, and for the + same reason a separate type rather than a flag on it: what the number means + depends on the catalog. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_regtype" + + class PostgresXIDType(DataType): - """PostgreSQL ``XID`` — transaction ID.""" + """PostgreSQL ``XID`` — a 32-bit transaction identifier. + + A counter, not a timestamp, and it wraps: 32 bits of transactions, which is + why the same database has :class:`PostgresXID8Type` alongside it. Not + usable for ordering over time and not interchangeable with the row + identifier :class:`PostgresOIDType`. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_xid" + + class PostgresXID8Type(DataType): - """PostgreSQL ``XID8`` — 64-bit transaction ID (PG 13+).""" + """PostgreSQL ``XID8`` — a 64-bit transaction identifier (PG 13+). + + :class:`PostgresXIDType` widened to 64 bits so it does not wrap in + practice. Separate because the stored width differs — which is exactly what + a schema diff must see — rather than a flag, since the width is the whole + difference. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_xid8" + + class PostgresCIDType(DataType): - """PostgreSQL ``CID`` — command ID.""" + """PostgreSQL ``CID`` — a command identifier within a transaction. + + Selects among the commands of one statement inside one transaction, so it + means nothing without that context and is not comparable to + :class:`PostgresXIDType`, which identifies the transaction. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_cid" + + class PostgresTIDType(DataType): - """PostgreSQL ``TID`` — tuple ID (page, tuple).""" + """PostgreSQL ``TID`` — a tuple identifier as ``(page, tuple)``. + + A physical row location, which changes when the row is rewritten. Unlike + :class:`PostgresOIDType` it identifies a *version* of a row at a place on + disk, not an object, and it is not stable across updates. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_tid" + + # --------------------------------------------------------------------------- # pg_lsn type # --------------------------------------------------------------------------- class PostgresPgLSNType(DataType): - """PostgreSQL ``PG_LSN`` — WAL log sequence number.""" + """PostgreSQL ``PG_LSN`` — a WAL log sequence number. + + A position in the write-ahead log, printed ``XXXXXXXX/XXXXXXXX`` and + comparable against the values in ``pg_current_wal_lsn()``. Ordered, and + monotonically increasing per server — but a *log position*, not a clock + reading and not an identifier of anything, which is why it does not derive + from :class:`PostgresOIDType` despite both being 64-bit-ish numbers. + + On ``DataType``: a system-catalog type, specific to PostgreSQL. + """ name = "postgres_pg_lsn" + # --------------------------------------------------------------------------- # Extension-provided types (minimal DataType wrappers) # --------------------------------------------------------------------------- class PostgresHstoreType(DataType): - """PostgreSQL ``HSTORE`` — key-value store (hstore extension).""" + """PostgreSQL ``HSTORE`` — a key/value store (hstore extension). + + String keys and string values with operators on the pairs — + ``a->'k'``, ``a ? 'k'``, containment — which is a mapping, not text. + ``JsonType`` is a mapping too, and they stay apart: hstore values are + always strings, JSON has a type system, and PostgreSQL will not implicitly + convert between them. Deriving from ``JsonType`` would claim a column can + hold both. + + On ``DataType``: SQL:2016 has no key/value type, and hstore is an + extension type. + """ name = "postgres_hstore" + + class PostgresGeometryType(DataType): - """PostGIS ``GEOMETRY`` — generic spatial geometry (PostGIS extension). + """PostGIS ``GEOMETRY`` — a generic spatial geometry (PostGIS extension). + + Unlike the nine built-in primitives (:class:`PostgresPointType` and + friends), one ``geometry`` column holds *any* of them, tagged internally, + together with an SRID. Subclass this to fix a specific geometry type and its + dimensionality for production use. - For production use, subclass with SRID support as needed. + Not a superclass of :class:`PostgresPointType` and the rest, and not a + subclass of them: the built-in primitives carry no SRID and are not + PostGIS geometries, so neither holds the other. A common ancestor would be + a "spatial value" family node, which the hierarchy deliberately does not + have — see the datatype-hierarchy plan, decision D1. + + On ``DataType``: SQL:2016 has no spatial types. """ name = "postgres_geometry" + + class PostgresGeographyType(DataType): - """PostGIS ``GEOGRAPHY`` — geodetic spatial type (PostGIS extension).""" + """PostGIS ``GEOGRAPHY`` — a geodetic spatial type (PostGIS extension). + + A ``geometry`` measured on the spheroid: distances and areas come out in + metres and square metres by default, and the value is stored as + longitude/latitude. Distinct from :class:`PostgresGeometryType` because the + measurement semantics differ, not only the storage, so a ``geography`` + distance and a ``geometry`` distance are not the same number. + + On ``DataType``: SQL:2016 has no spatial types. + """ name = "postgres_geography" -class PostgresCitextType(DataType): - """PostgreSQL ``CITEXT`` — case-insensitive text (citext extension).""" + +class PostgresCitextType(TextType): + """PostgreSQL ``CITEXT`` — case-insensitive text (citext extension). + + The case-insensitivity is behaviour, not storage: ``citext`` stores exactly + what ``text`` stores and the comparison operators are the difference. It is + therefore the same *data* concept — character data of unbounded length — + and derives from :class:`TextType`. + + The distinction from :class:`PostgresXMLType` is the point of this pair: + both are "some kind of text" to a reader, only one of them is honestly a + text column, and the hierarchy now says which. + """ name = "postgres_citext" + + class PostgresCubeType(DataType): - """PostgreSQL ``CUBE`` — multi-dimensional cube (cube extension).""" + """PostgreSQL ``CUBE`` — a multi-dimensional cube (cube extension). + + A floating-point n-dimensional point that also supports containment and + distance, so it is a geometric type rather than a number: two cubes + compare as equal only when they cover the same region, which is not how + floats compare. + + On ``DataType``: SQL:2016 has no geometric types, and the ``cube`` + extension's types are its own. See :class:`PostgresPointType`` on why the + nine geometric primitives are not a core concept yet. + """ name = "postgres_cube" + + class PostgresLtreeType(DataType): - """PostgreSQL ``LTREE`` — label tree (ltree extension).""" + """PostgreSQL ``LTREE`` — a hierarchical label path (ltree extension). + + A slash-separated path of labels (``Top.Science.Astronomy``) with + ancestors, descendants and match operators defined over it. Not text: the + value is traversed structurally, and ``@>`` tests sub-path containment + rather than substring containment. + + On ``DataType``: SQL:2016 has no hierarchical label path type; ``ltree`` is + an extension type with its own operators. + """ name = "postgres_ltree" + + +class PostgresLqueryType(DataType): + """PostgreSQL ``LQUERY`` — regular-expression match of an ltree label path. + + Named by the extension itself rather than by what it holds: a value of this + type is a pattern such as ``*.Astronomy.*``, and casting a literal to + ``LQUERY`` is how the library tells PostgreSQL to check it as one. + + On ``DataType`` for the same reason as :class:`PostgresLtreeType` — and it + must stay off ``ltree`` specifically, because a ``lquery`` *matches* a path + rather than being one; deriving from ``LTREE`` would claim it could be + stored in the same column. + """ + + name = "postgres_lquery" + + +class PostgresLtxtqueryType(DataType): + """PostgreSQL ``LTXTQUERY`` — regular-expression match of an ltree label. + + The label counterpart of :class:`PostgresLqueryType`: it matches a single + label rather than a whole path. + + On ``DataType`` for the same reason, and kept distinct from ``LQUERY`` + because a path pattern and a label pattern are matched by different + operators against different things. + """ + + name = "postgres_ltxtquery" + + class PostgresRasterType(DataType): - """PostgreSQL ``RASTER`` — raster (PostGIS raster extension).""" + """PostgreSQL ``RASTER`` — a raster grid (PostGIS raster extension). + + Bands of cells over a world coordinate reference system, stored as + compressed pixel data. A different model from + :class:`PostgresGeometryType` — which is a single vector of coordinates — + and from :class:`PostgresCubeType` — which is a continuous region — so it + derives from none of them. + + On ``DataType``: SQL:2016 has no raster type. + """ name = "postgres_raster" + + class PostgresVectorType(DataType): """pgvector ``VECTOR(n)`` — vector embedding (pgvector extension). +On ``DataType``: the core has no vector-embedding concept, and this is not a +widened scalar either. A ``vector(n)`` column holds *n* ``float4`` components +with a fixed dimension baked into the storage, and the pgvector extension +defines the format; there is nothing in the core for it to be a special case of. +Its dimension is the declaration, so ``dim`` is identity rather than an +``unsigned``-style flag — which is also why it does not derive from the half +precision variant below. + Args: - dim: Number of dimensions. + dialect: The dialect this type is bound to. First positional parameter, + as for every ``DataType`` — with ``dim`` first, a caller following + the documented convention would have their dialect silently stored + as the dimension count. + dim: Number of dimensions. PostgreSQL's own ``vector`` allows 2,000 at + most; the limit is not enforced here because it is the extension's, + not the type's. """ name = "postgres_vector" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + def __init__(self, dialect: Optional["SQLDialectBase"] = None, dim: int = 0): super().__init__(dialect) self.dim = dim - def _type_params(self) -> tuple: - return (self.dim,) - + PARAMETERS = ("dim",) class PostgresHalfvecType(DataType): """pgvector ``HALFVEC(n)`` — half-precision vector (pgvector 0.5.0+). @@ -370,20 +1105,31 @@ class PostgresHalfvecType(DataType): Stores each component as a half-precision float, halving memory compared to ``VECTOR``. Requires the pgvector extension. + Separate from :class:`PostgresVectorType` rather than a flag on it: the + element type is part of the storage format, so the two are different types + in every way that matters to the server, not different declarations of one. +On ``DataType``: as ``PostgresVectorType`` — no core vector concept — and +separate from it rather than a flag on it, because the element width is part of +the on-disk format. A ``halfvec`` stores each component as a half-precision +float, so the same ``dim`` produces a different number of bytes; the server +stores and compares them differently, which makes them different concepts +rather than two declarations of one. + + Args: + dialect: The dialect this type is bound to. First positional parameter, + as for every ``DataType``. dim: Number of dimensions. """ name = "postgres_halfvec" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + def __init__(self, dialect: Optional["SQLDialectBase"] = None, dim: int = 0): super().__init__(dialect) self.dim = dim - def _type_params(self) -> tuple: - return (self.dim,) - + PARAMETERS = ("dim",) class PostgresSparsevecType(DataType): """pgvector ``SPARSEVEC(n)`` — sparse vector (pgvector 0.7.0+). @@ -391,20 +1137,24 @@ class PostgresSparsevecType(DataType): Stores only non-zero components as ``{idx:value,...}/dim``, suitable for high-dimensional sparse embeddings. Requires the pgvector extension. + The storage is a list of ``(index, value)`` pairs, which is why this is not + a flag on :class:`PostgresVectorType`: nothing about the two is shared but + the purpose. + Args: - dim: Number of dimensions. + dialect: The dialect this type is bound to. First positional parameter, + as for every ``DataType``. + dim: Number of dimensions — the index space, not the stored count. """ name = "postgres_sparsevec" dim: int - def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None): + def __init__(self, dialect: Optional["SQLDialectBase"] = None, dim: int = 0): super().__init__(dialect) self.dim = dim - def _type_params(self) -> tuple: - return (self.dim,) - + PARAMETERS = ("dim",) # --------------------------------------------------------------------------- # Array container @@ -413,15 +1163,28 @@ def _type_params(self) -> tuple: 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``. + PostgreSQL **normalises every array declaration to one dimension**: an + ``integer[3][3]`` column introspects as ``integer[]``, with the extents held + separately in ``pg_attribute`` and not in the type at all. That is a fact + about what PostgreSQL stores, and it is why + :meth:`~...types.array.ArrayType.is_element_type_equivalent` — not ``==`` — + is the right tool when asking "do these two array columns hold the same kind + of thing", since ``dimensions`` describes the *declaration* here and is + discarded on the way in. + + Two consequences worth stating, because they are easy to trip over: + + * ``PostgresArrayType(IntegerType(), dimensions=2)`` renders + ``INTEGER[][]`` but is **not** equal to ``PostgresArrayType(IntegerType())``, + and a schema diff against an introspected column will therefore report a + difference that PostgreSQL itself does not have. Use + ``dimensions=1`` — the only value that round-trips — when the source is + a live database. + * ``PostgresArrayType(IntegerType(), dimensions=2)`` and + ``PostgresArrayType(PostgresArrayType(IntegerType()))`` render the same + SQL and compare unequal. Both spell a two-dimensional array; the first is + the portable spelling and the second is the nesting the standard permits. + ``dimensions`` is the one that matches what PostgreSQL reports. """ - name = "postgres_array" - 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 + name = "postgres_array" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py new file mode 100644 index 00000000..5ce7e337 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py @@ -0,0 +1,709 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/values.py +"""Value expressions for PostgreSQL's own types. + +The functions these wrap already existed -- ``network()``, ``range_contains()``, +``hstore_get_value()``, ``ltree_matches()`` -- as free functions in +``impl.postgres.functions``. They were unreachable from a column: a ``cidr`` +column had no ``.network()``, an ``int4range`` had no ``.contains()``, and the +only way in was to import the module and spell out the dialect. + +So each type gets a value expression carrying its own operations. The functions +are not reimplemented -- what these classes add is what the *result* is, which a +bare FunctionCall cannot say. ``network()`` returns an address and so carries +address operations itself; ``masklen()`` returns an integer and deliberately +does not, because calling ``.network()`` on an integer is a type error in every +backend. + +Argument order is taken from the functions rather than from what reads best, +which in two places means the opposite: ``range_contained_by(element, range)`` +puts the range second, and ``ltree_descendant(ancestor, descendant)`` reads +backwards from the method name. Both are wrapped so the method reads naturally +and the call site still matches the function. +""" + +from typing import Callable, TypeVar + +from rhosocial.activerecord.backend.expression.core import SQLValueExpression +from rhosocial.activerecord.backend.expression.operators import BinaryExpression +from rhosocial.activerecord.backend.expression.mixins import ( + AliasableMixin, + ComparisonMixin, + StringPatternPredicateMixin, + TypeCastingMixin, +) +from rhosocial.activerecord.backend.impl.postgres.functions import hstore as _hstore +from rhosocial.activerecord.backend.impl.postgres.functions import ltree as _ltree +from rhosocial.activerecord.backend.impl.postgres.functions import network as _network +from rhosocial.activerecord.backend.impl.postgres.functions import range as _range +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.types import BigIntType, IntegerType + + +def _typed_element(dialect, element_type, element): + """Type *element* the way the multirange's element type expects it. + + The range operators are defined per element type, and a Python ``int`` + arrives as the narrowest integer PostgreSQL has -- ``smallint`` -- which + ``int4multirange @> $1`` rejects with + + operator does not exist: int4multirange @> smallint + + The query is right and the parameter is an integer; only the width is + wrong, and no amount of care at the call site would catch it because + nothing about a Python int says which PostgreSQL type is wanted. The + width is taken from the range's own element type, so an int8range gets + bigint and an int4range gets integer. + + A ``Literal`` wrapping a number is unwrapped and re-typed the same way. + The width is a property of the range, not of how the caller chose to spell + the value, and testing the Python type instead meant that passing an + explicit ``Literal(dialect, 5)`` -- the form everything else in this + codebase now takes -- silently bound smallint again and took the operator + down with it. + """ + if isinstance(element, Literal): + value = getattr(element, "value", None) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return element + element = value + if not isinstance(element, int) or isinstance(element, bool): + return element + name = getattr(element_type, "name", "") + if name == "postgres_array": + # An array compares elementwise, so its element type is the one that + # decides the width. + name = getattr(getattr(element_type, "element_type", None), "name", "") + # Both spellings are accepted: the element type may be a core type named + # "integer" or a PostgreSQL one named "postgres_int4", and both mean the + # same width. Only the width matters here, not which module declared it. + if name in ("postgres_int4", "integer", "int4", "int"): + return Literal(dialect, element).cast(IntegerType(dialect)) + + if name in ("postgres_int8", "bigint", "int8"): + return Literal(dialect, element).cast(BigIntType(dialect)) + return element + + +_T = TypeVar("_T") +_F = TypeVar("_F", bound=Callable[..., _T]) + + +class _ResultMixinBase: + """Shared wrapping for the result-type mixins below. + + Only the Python class changes; the node, and therefore the SQL, is left as + it was. The statement was already correct -- what was missing was the + knowledge of what it yields. + """ + + def _retype(self, cls): + return cls(self._dialect, self) + + +class NetworkResultMixin(_ResultMixinBase): + """State that an expression yields a PostgreSQL address. + + For a column whose type is not among the ones this backend models -- a + domain type of the user's, say -- this is how the address operations become + reachable on it. + """ + + def as_inet(self) -> "NetworkValueExpression": + """This yields an inet or cidr, whatever the data turns out to be.""" + return self._retype(NetworkValueExpression) + + def as_cidr(self) -> "NetworkValueExpression": + """This yields a cidr specifically.""" + return self._retype(NetworkValueExpression) + + +class RangeResultMixin(_ResultMixinBase): + """State that an expression yields a PostgreSQL range or multirange.""" + + def as_range(self) -> "RangeValueExpression": + """This yields a range, so containment and overlap apply.""" + return self._retype(RangeValueExpression) + + def as_multirange(self) -> "RangeValueExpression": + """This yields a multirange, whose containment is a different operator.""" + return self._retype(RangeValueExpression) + + +class HstoreResultMixin(_ResultMixinBase): + """State that an expression yields an hstore document. + + The accessors return values rather than documents, so they are typed as what + they are: ``hstore_get_value(h, k)`` is text, and asking it for keys would + be a type error. + """ + + def as_hstore(self) -> "HstoreValueExpression": + """This yields an hstore, so the key and value accessors apply.""" + return self._retype(HstoreValueExpression) + + +class LtreeResultMixin(_ResultMixinBase): + """State that an expression yields an ltree path or one of its patterns.""" + + def as_ltree(self) -> "LtreeValueExpression": + """This yields an ltree, so the path operations apply.""" + return self._retype(LtreeValueExpression) + + def as_lquery(self) -> "LtreeValueExpression": + """This yields an lquery, the pattern an ltree is matched against.""" + return self._retype(LtreeValueExpression) + + def as_ltxtquery(self) -> "LtreeValueExpression": + """This yields an ltxtquery, the label-matching pattern.""" + return self._retype(LtreeValueExpression) + + +class NetworkValueExpression( + NetworkResultMixin, + AliasableMixin, + ComparisonMixin, + StringPatternPredicateMixin, + TypeCastingMixin, + SQLValueExpression, +): + """An ``inet`` or ``cidr`` address. + + Carries the address operations, so ``col.network()`` is still an address + and can be masked or merged -- the chain does not stop at the first call. + + No family is declared, and that is deliberate rather than missing. + ``value_type_of`` reports a family the core lattice knows, and an inet + address is not one of them, so declaring "network" here would be a claim + nothing reports. The range, hstore and ltree expressions below take the same + view. What identifies an address is its class and the operations on it, not + a tag the core would drop. + """ + + @property + def format_method(self) -> str: + """Render through the wrapped call. + + The wrapper adds operations, not syntax: every one of these renders + exactly the FunctionCall it holds, so it needs no dialect method of its + own and cannot drift from the SQL the function module produces. + """ + return "format_function_call" + + def __init__(self, dialect, call, family=None): + super().__init__(dialect) + self.call = call + self._family = family + + @property + def args(self): + """The wrapped call's arguments, so a factory can take this node. + + The function factories build a FunctionCall from whatever they are + given, and they accept a Column, a Literal or another expression. This + wrapper is neither of those, so without this the second operation in a + chain -- ``col.network().masklen()`` -- would have nothing to pass on. + """ + return self.call.args + + def to_sql(self): + """Render the wrapped call.""" + return self.call.to_sql() + + def _network_op(self, factory: _F, *args) -> _T: + """Call one of ``functions.network`` and hand back what it returns. + + Args: + factory: The function to call, imported by name. A string looked up + with getattr would be invisible to a checker, and the result + would arrive as Any -- every chained call unchecked. + args: Positional arguments forwarded to the factory. + + Returns: + The factory's result. Which of these is an address and which is a + scalar is stated by the method that calls this -- by wrapping the + address ones and declaring the return type -- so there is no list + here to fall out of step with the methods above. + """ + return factory(self._dialect, self, *args) + + # --- parts of an address --- + + def network(self) -> "NetworkValueExpression": + """The network part. ``NETWORK(addr)``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inet_network)) + + def netmask(self) -> "NetworkValueExpression": + """The netmask. ``NETMASK(addr)``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inet_netmask)) + + def masklen(self) -> "SQLValueExpression": + """The netmask length in bits. ``MASKLEN(addr)`` -> integer""" + return self._network_op(_network.inet_masklen) + + def set_mask(self, mask_len: int) -> "NetworkValueExpression": + """Re-mask to *mask_len* bits. ``SET_MASKLEN(addr, n)``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inet_set_mask, mask_len)) + + # --- set operations --- + + def merge(self, other) -> "NetworkValueExpression": + """The smallest network containing both. ``NETMERGE(a, b)``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inet_merge, other)) + + def and_mask(self, other) -> "NetworkValueExpression": + """Bitwise AND of two addresses. ``a & b``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inet_and, other)) + + def or_mask(self, other) -> "NetworkValueExpression": + """Bitwise OR of two addresses. ``a | b``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inet_or, other)) + + def invert(self) -> "NetworkValueExpression": + """The complement. ``~addr``""" + return NetworkValueExpression( + self._dialect, self._network_op(_network.inetnot)) + + def abbrev(self) -> "SQLValueExpression": + """The abbreviated first-octet form. ``ABBREV(addr)``""" + return self._network_op(_network.inet_show) + + +class RangeValueExpression( + RangeResultMixin, + AliasableMixin, + ComparisonMixin, + StringPatternPredicateMixin, + TypeCastingMixin, + SQLValueExpression, +): + """A range or multirange value. + + Carries containment and overlap, which answer questions about the range + rather than deriving one, so they return predicates. The operations that + do derive something -- ``lower``, ``upper`` -- return scalars and are + therefore plain expressions, not more ranges. + + Takes the range's ``element_type`` when it is known, because that is what + an element comparison has to be typed against. + """ + + def __init__(self, dialect, call, family=None, element_type=None): + super().__init__(dialect) + self.call = call + self._family = family + #: The range's element type. Needed because a Python int binds as + #: smallint, and ``int4range @> smallint`` does not exist. + self.element_type = element_type + + @property + def format_method(self) -> str: + """Render through the wrapped call. + + The wrapper adds operations, not syntax: every one of these renders + exactly the FunctionCall it holds, so it needs no dialect method of its + own and cannot drift from the SQL the function module produces. + """ + return "format_function_call" + + @property + def args(self): + """The wrapped call's arguments, so a factory can take this node. + + The function factories build a FunctionCall from whatever they are + given, and they accept a Column, a Literal or another expression. This + wrapper is neither of those, so without this the second operation in a + chain -- ``col.network().masklen()`` -- would have nothing to pass on. + """ + return self.call.args + + def to_sql(self): + """Render the wrapped call.""" + return self.call.to_sql() + + def _range_op(self, factory: _F, *args) -> _T: + """Call a range factory. + + Args: + factory: The function to call, imported by name. + args: Positional arguments forwarded to the factory. + + Returns: + The factory's result. + """ + return factory(self._dialect, self, *args) + + # --- containment --- + + def contains(self, other) -> "BinaryExpression": + """The range holds *other*. ``range_contains(range, x)``""" + return self._range_op( + _range.range_contains, + _typed_element(self._dialect, self.element_type, other)) + + def contains_range(self, other) -> "BinaryExpression": + """The range holds *other* whole. ``range_contains(range1, range2)``""" + return self._range_op(_range.range_contains_range, other) + + def overlaps(self, other) -> "BinaryExpression": + """The two share at least one point. ``range_overlaps(r1, r2)``""" + return self._range_op(_range.range_overlaps, other) + + def adjacent(self, other) -> "BinaryExpression": + """The two touch without overlapping. ``range_adjacent(r1, r2)``""" + return self._range_op(_range.range_adjacent, other) + + def strictly_left_of(self, other) -> "BinaryExpression": + """Entirely below *other*. ``range_strictly_left_of(r1, r2)``""" + return self._range_op(_range.range_strictly_left_of, other) + + def strictly_right_of(self, other) -> "BinaryExpression": + """Entirely above *other*. ``range_strictly_right_of(r1, r2)``""" + return self._range_op(_range.range_strictly_right_of, other) + + def merge(self) -> "SQLValueExpression": + """The smallest range covering every range in a multirange. + + ``range_merge(mr)`` collapses a multirange to one range; it is not a + two-argument union. PostgreSQL has no operator for "join two ranges", + because the answer depends on whether they overlap and what to do about + a gap -- ``range_union`` and ``multirange_merge`` are the two that do + exist, and both are named for what they take. + """ + return self._range_op(_range.range_merge) + + def union(self, other) -> "SQLValueExpression": + """The union of two ranges. ``range_union(r1, r2)``""" + return self._range_op(_range.range_union, other) + + # --- multiranges, which contain differently --- + + def multirange_contains(self, element) -> "BinaryExpression": + """The multirange holds *element*. ``multirange_contains(mr, x)``""" + return self._range_op( + _range.multirange_contains, + _typed_element(self._dialect, self.element_type, element)) + + def multirange_overlaps(self, other) -> "BinaryExpression": + """The multirange shares a point with *other*.""" + return self._range_op(_range.multirange_overlaps, other) + + +class RangeBoundMixin: + """The two operations that take a range's *element* as the subject. + + ``contained_by`` and ``strictly_right_of`` read backwards from the method + name in the underlying functions: the function puts the range second, so a + wrapper that put it first would not compile against it. They live apart + from :class:`RangeValueExpression` because they belong on the value being + compared, not on the range -- ``3 <@ r`` is a question about 3. + """ + + def contained_by(self, range_value): + """This value lies inside *range_value*. ``range_contained_by(x, r)``""" + return _range.range_contained_by(self._dialect, self, range_value) + + def element_strictly_right_of(self, range_value): + """This value is above *range_value* entirely.""" + return _range.range_strictly_right_of(self._dialect, range_value, self) + + +class HstoreValueExpression( + HstoreResultMixin, + AliasableMixin, + ComparisonMixin, + StringPatternPredicateMixin, + TypeCastingMixin, + SQLValueExpression, +): + """An ``hstore`` key/value document. + + The accessors return values rather than hstores, so they are typed as what + they are: ``hstore_get_value(h, k)`` is text and calling an hstore + operation on it would be a type error. Only the key and value lists come + back as documents. + """ + + @property + def format_method(self) -> str: + """Render through the wrapped call. + + The wrapper adds operations, not syntax: every one of these renders + exactly the FunctionCall it holds, so it needs no dialect method of its + own and cannot drift from the SQL the function module produces. + """ + return "format_function_call" + + def __init__(self, dialect, call, family=None): + super().__init__(dialect) + self.call = call + self._family = family + + @property + def args(self): + """The wrapped call's arguments, so a factory can take this node. + + The function factories build a FunctionCall from whatever they are + given, and they accept a Column, a Literal or another expression. This + wrapper is neither of those, so without this the second operation in a + chain -- ``col.network().masklen()`` -- would have nothing to pass on. + """ + return self.call.args + + def to_sql(self): + """Render the wrapped call.""" + return self.call.to_sql() + + def _hstore_op(self, factory: _F, *args) -> _T: + """Call an hstore factory. + + Args: + factory: The function to call, imported by name. + args: Positional arguments forwarded to the factory. + + Returns: + The factory's result. + """ + return factory(self._dialect, self, *args) + + def keys(self) -> "SQLValueExpression": + """The keys, as an array. ``akeys(h)`` + + An array of text rather than a document: nothing can be looked up in it, + and typing it as an hstore would let ``h.keys().get("k")`` check out. + """ + return self._hstore_op(_hstore.hstore_akeys) + + def values(self) -> "SQLValueExpression": + """The values, as an array. ``avals(h)``""" + return self._hstore_op(_hstore.hstore_avals) + + def get(self, key: str) -> "SQLValueExpression": + """The value at *key*, or NULL. ``hstore_get_value(h, k)``""" + return self._hstore_op(_hstore.hstore_get_value, key) + + def get_as_text(self, key: str) -> "SQLValueExpression": + """The value at *key*, as text. ``->(h, k)``""" + return self._hstore_op(_hstore.hstore_get_value_as_text, key) + + def subscript(self, key: str) -> "SQLValueExpression": + """The value at *key*. ``h -> k``""" + return self._hstore_op(_hstore.hstore_subscript_get, key) + + def has_key(self, key: str): + """Whether *key* is present. ``exist(h, k)``""" + return self._hstore_op(_hstore.hstore_exist, key) + + def is_defined(self, key: str): + """Whether *key* is present and not NULL. ``defined(h, k)``""" + return self._hstore_op(_hstore.hstore_defined, key) + + def key_exists(self, key: str): + """Whether *key* is present and non-NULL. ``hstore_key_exists(h, k)``""" + return self._hstore_op(_hstore.hstore_key_exists, key) + + def to_json(self) -> "SQLValueExpression": + """The document as json. ``hstore_to_json(h)``""" + return self._hstore_op(_hstore.hstore_to_json) + + +class LtreeValueExpression( + LtreeResultMixin, + AliasableMixin, + ComparisonMixin, + StringPatternPredicateMixin, + TypeCastingMixin, + SQLValueExpression, +): + """An ``ltree`` label path, or the pattern types that match one. + + One class for all three because none of them carries an operation the + others lack a meaning for: the pattern functions take a tree and a pattern + and answer a question, and the tree functions take a tree and answer with + a tree. + """ + + @property + def format_method(self) -> str: + """Render through the wrapped call. + + The wrapper adds operations, not syntax: every one of these renders + exactly the FunctionCall it holds, so it needs no dialect method of its + own and cannot drift from the SQL the function module produces. + """ + return "format_function_call" + + def __init__(self, dialect, call, family=None): + super().__init__(dialect) + self.call = call + self._family = family + + @property + def args(self): + """The wrapped call's arguments, so a factory can take this node. + + The function factories build a FunctionCall from whatever they are + given, and they accept a Column, a Literal or another expression. This + wrapper is neither of those, so without this the second operation in a + chain -- ``col.network().masklen()`` -- would have nothing to pass on. + """ + return self.call.args + + def to_sql(self): + """Render the wrapped call.""" + return self.call.to_sql() + + def _ltree_op(self, factory: _F, *args) -> _T: + """Call an ltree factory. + + Args: + factory: The function to call, imported by name. + args: Positional arguments forwarded to the factory. + + Returns: + The factory's result. ``subpath`` and ``concat`` are wrapped by the + methods below, which declare that they are paths again; a string + looked up here would make the whole chain Any to a checker. + """ + return factory(self._dialect, self, *args) + + def is_ancestor_of(self, path) -> "BinaryExpression": + """This path is an ancestor of *path*. ``ltree_ancestor(l, path)`` + + Named for what it answers rather than for what it returns: the + underlying ``ltree_ancestor`` puts the ancestor first, and a method + called ``ancestors_of`` would read as returning a list of ancestors + when it returns a predicate about one. + """ + return self._ltree_op(_ltree.ltree_ancestor, path) + + def is_descendant_of(self, path) -> "BinaryExpression": + """This path is below *path*. ``ltree_descendant(tree, path)``""" + return self._ltree_op(_ltree.ltree_descendant, path) + + def matches(self, pattern) -> "BinaryExpression": + """This path matches an ``lquery``. ``ltree_matches(l, q)``""" + return self._ltree_op(_ltree.ltree_matches, pattern) + + def matches_text(self, query) -> "BinaryExpression": + """This path matches an ``ltxtquery``. ``ltree_text_search(l, q)``""" + return self._ltree_op(_ltree.ltree_text_search, query) + + def nlevel(self) -> "SQLValueExpression": + """The number of labels. ``nlevel(l)`` -> integer""" + return self._ltree_op(_ltree.ltree_nlevel) + + def subpath(self, start: int, length=None) -> "LtreeValueExpression": + """A slice of the path. ``subpath(l, off[, len])``""" + return LtreeValueExpression( + self._dialect, self._ltree_op(_ltree.ltree_subpath, start, length)) + + def concat(self, other) -> "LtreeValueExpression": + """Append *other*. ``l || r``""" + return LtreeValueExpression( + self._dialect, self._ltree_op(_ltree.ltree_concat, other)) + + def lca(self, paths) -> "SQLValueExpression": + """The longest common ancestor of the given paths.""" + return self._ltree_op(_ltree.ltree_lca, paths) + + +__all__ = [ + "NetworkValueExpression", + "RangeValueExpression", + "RangeBoundMixin", + "HstoreValueExpression", + "LtreeValueExpression", +] + +# --------------------------------------------------------------------------- +# Typed columns: the only way an extension type's operations are reachable +# --------------------------------------------------------------------------- + +#: DataType name -> value expression carrying its operations. Keyed on the +#: dispatch key, which is what a DataType declares, so a type added here needs +#: no registration elsewhere. +_OPERATION_EXPRESSIONS = { + "postgres_cidr": NetworkValueExpression, + "postgres_inet": NetworkValueExpression, + "postgres_macaddr": NetworkValueExpression, + "postgres_macaddr8": NetworkValueExpression, + "postgres_hstore": HstoreValueExpression, + "postgres_ltree": LtreeValueExpression, + "postgres_lquery": LtreeValueExpression, + "postgres_ltxtquery": LtreeValueExpression, +} +for _range_name in ( + "postgres_int4range", "postgres_int8range", "postgres_numrange", + "postgres_tsrange", "postgres_tstzrange", "postgres_daterange", + "postgres_int4multirange", "postgres_int8multirange", + "postgres_nummultirange", "postgres_tsmultirange", + "postgres_tstzmultirange", "postgres_datemultirange", +): + _OPERATION_EXPRESSIONS[_range_name] = RangeValueExpression + + +def value_expression_for(data_type, dialect): + """Return the value expression whose operations *data_type* offers. + + Args: + data_type: The :class:`DataType` the column holds. + dialect: The dialect that will render the column. + + Returns: + A value expression carrying the type's operations, or ``None`` when + the type has no operation surface of its own -- a ``text`` column is + served by the core's string operations and needs nothing here. + + Raises: + ValueError: If *data_type* is not a DataType. + """ + from rhosocial.activerecord.backend.expression.types._base import DataType + + if not isinstance(data_type, DataType): + raise ValueError( + f"expected a DataType, got {type(data_type).__name__}; the column's " + f"operations are chosen by the type it stores, not by a name." + ) + expression = _OPERATION_EXPRESSIONS.get(data_type.name) + if expression is None: + return None + return expression(dialect, None) + + +def operations_class_for(data_type): + """Return the value expression class whose operations *data_type* offers. + + The class rather than an instance, for a caller that wants to wrap a node + it already has -- ``operations_class_for(dt)(dialect, some_call)``. Pass a + type with no operation surface and this gives ``None``, the same answer + :func:`value_expression_for` gives. + + Args: + data_type: The :class:`DataType` to look up. + + Returns: + A value expression class, or ``None``. + + Note: + Callers that wrap a node should pass ``element_type`` through to the + constructor; a range needs it to type an element comparison, since a + Python int binds as smallint and ``int4range @> smallint`` is not an + operator PostgreSQL has. + """ + from rhosocial.activerecord.backend.expression.types._base import DataType + + if not isinstance(data_type, DataType): + raise ValueError( + f"expected a DataType, got {type(data_type).__name__}; the column's " + f"operations are chosen by the type it stores, not by a name." + ) + cls = _OPERATION_EXPRESSIONS.get(data_type.name) + if cls is None: + return None + return cls diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/address_standardizer.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/address_standardizer.py index c4da3dc5..57a31d6f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/address_standardizer.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/address_standardizer.py @@ -34,7 +34,7 @@ - They return Expression objects (FunctionCall, etc.) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -42,34 +42,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Address Functions ============== def parse_address( dialect: "SQLDialectBase", - address: Union[str, "bases.BaseExpression"], + address: bases.BaseExpression, ) -> core.FunctionCall: """Parse an address string into its component parts. @@ -93,16 +70,16 @@ def parse_address( """ return core.FunctionCall( dialect, "parse_address", - _convert_to_expression(dialect, address), + address if isinstance(address, bases.BaseExpression) else core.Literal(dialect, address), ) def standardize_address( dialect: "SQLDialectBase", - lextab: Union[str, "bases.BaseExpression"], - gaztab: Union[str, "bases.BaseExpression"], - rultab: Union[str, "bases.BaseExpression"], - address: Union[str, "bases.BaseExpression"], + lextab: str, + gaztab: str, + rultab: str, + address: bases.BaseExpression, ) -> core.FunctionCall: """Standardize an address string using lookup tables. @@ -134,10 +111,10 @@ def standardize_address( """ return core.FunctionCall( dialect, "standardize_address", - _convert_to_expression(dialect, lextab), - _convert_to_expression(dialect, gaztab), - _convert_to_expression(dialect, rultab), - _convert_to_expression(dialect, address), + lextab if isinstance(lextab, bases.BaseExpression) else core.Literal(dialect, lextab), + gaztab if isinstance(gaztab, bases.BaseExpression) else core.Literal(dialect, gaztab), + rultab if isinstance(rultab, bases.BaseExpression) else core.Literal(dialect, rultab), + address if isinstance(address, bases.BaseExpression) else core.Literal(dialect, address), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py index d95d945a..cfdc27d9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py @@ -13,7 +13,7 @@ - They return core.FunctionCall expression objects (not raw SQL strings) """ -from typing import Any, Optional, TYPE_CHECKING +from typing import Optional, Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -21,26 +21,9 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Any, -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - If the input is already a BaseExpression, it is returned as-is. - Otherwise, it is wrapped in a core.Literal for parameterized output. - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - def array_agg( dialect: "SQLDialectBase", - expression: Any, + expression: bases.BaseExpression, ) -> core.FunctionCall: """ Aggregate function that collects values into an array. @@ -57,13 +40,13 @@ def array_agg( >>> func.to_sql() ('array_agg(%s)', ('value',)) """ - expr = _convert_to_expression(dialect, expression) + expr = expression if isinstance(expression, bases.BaseExpression) else core.Literal(dialect, expression) return core.FunctionCall(dialect, "array_agg", expr) def array_agg_distinct( dialect: "SQLDialectBase", - expression: Any, + expression: bases.BaseExpression, ) -> core.FunctionCall: """ Aggregate function that collects distinct values into an array. @@ -83,14 +66,14 @@ def array_agg_distinct( >>> func.to_sql() ('array_agg(DISTINCT %s)', ('value',)) """ - expr = _convert_to_expression(dialect, expression) + expr = expression if isinstance(expression, bases.BaseExpression) else core.Literal(dialect, expression) return core.FunctionCall(dialect, "array_agg", expr, is_distinct=True) def array_append( dialect: "SQLDialectBase", - array: Any, - element: Any, + array: bases.BaseExpression, + element: bases.BaseExpression, ) -> core.FunctionCall: """ Append an element to the end of an array. @@ -108,15 +91,15 @@ def array_append( >>> func.to_sql() ('array_append(%s, %s)', ('arr', 3)) """ - arr_expr = _convert_to_expression(dialect, array) - elem_expr = _convert_to_expression(dialect, element) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + elem_expr = element if isinstance(element, bases.BaseExpression) else core.Literal(dialect, element) return core.FunctionCall(dialect, "array_append", arr_expr, elem_expr) def array_cat( dialect: "SQLDialectBase", - array1: Any, - array2: Any, + array1: bases.BaseExpression, + array2: bases.BaseExpression, ) -> core.FunctionCall: """ Concatenate two arrays. @@ -134,14 +117,14 @@ def array_cat( >>> func.to_sql() ('array_cat(%s, %s)', ('arr1', 'arr2')) """ - arr1_expr = _convert_to_expression(dialect, array1) - arr2_expr = _convert_to_expression(dialect, array2) + arr1_expr = array1 if isinstance(array1, bases.BaseExpression) else core.Literal(dialect, array1) + arr2_expr = array2 if isinstance(array2, bases.BaseExpression) else core.Literal(dialect, array2) return core.FunctionCall(dialect, "array_cat", arr1_expr, arr2_expr) def array_dims( dialect: "SQLDialectBase", - array: Any, + array: bases.BaseExpression, ) -> core.FunctionCall: """ Return the dimensions of an array as text. @@ -158,15 +141,15 @@ def array_dims( >>> func.to_sql() ('array_dims(%s)', ('arr',)) """ - arr_expr = _convert_to_expression(dialect, array) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) return core.FunctionCall(dialect, "array_dims", arr_expr) def array_fill( dialect: "SQLDialectBase", - element: Any, - dimensions: Any, - bounds: Optional[Any] = None, + element: bases.BaseExpression, + dimensions: int, + bounds: Optional[Union[int, str]] = None, ) -> core.FunctionCall: """ Return an array filled with a value for specified dimensions. @@ -185,18 +168,18 @@ def array_fill( >>> func.to_sql() ('array_fill(%s, %s)', (0, 'dims')) """ - elem_expr = _convert_to_expression(dialect, element) - dims_expr = _convert_to_expression(dialect, dimensions) + elem_expr = element if isinstance(element, bases.BaseExpression) else core.Literal(dialect, element) + dims_expr = dimensions if isinstance(dimensions, bases.BaseExpression) else core.Literal(dialect, dimensions) args = [elem_expr, dims_expr] if bounds is not None: - bounds_expr = _convert_to_expression(dialect, bounds) + bounds_expr = bounds if isinstance(bounds, bases.BaseExpression) else core.Literal(dialect, bounds) args.append(bounds_expr) return core.FunctionCall(dialect, "array_fill", *args) def array_length( dialect: "SQLDialectBase", - array: Any, + array: bases.BaseExpression, dimension: int = 1, ) -> core.FunctionCall: """ @@ -215,14 +198,14 @@ def array_length( >>> func.to_sql() ('array_length(%s, %s)', ('arr', 1)) """ - arr_expr = _convert_to_expression(dialect, array) - dim_expr = _convert_to_expression(dialect, dimension) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + dim_expr = dimension if isinstance(dimension, bases.BaseExpression) else core.Literal(dialect, dimension) return core.FunctionCall(dialect, "array_length", arr_expr, dim_expr) def array_lower( dialect: "SQLDialectBase", - array: Any, + array: bases.BaseExpression, dimension: int = 1, ) -> core.FunctionCall: """ @@ -241,14 +224,14 @@ def array_lower( >>> func.to_sql() ('array_lower(%s, %s)', ('arr', 1)) """ - arr_expr = _convert_to_expression(dialect, array) - dim_expr = _convert_to_expression(dialect, dimension) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + dim_expr = dimension if isinstance(dimension, bases.BaseExpression) else core.Literal(dialect, dimension) return core.FunctionCall(dialect, "array_lower", arr_expr, dim_expr) def array_ndims( dialect: "SQLDialectBase", - array: Any, + array: bases.BaseExpression, ) -> core.FunctionCall: """ Return the number of dimensions of an array. @@ -265,14 +248,14 @@ def array_ndims( >>> func.to_sql() ('array_ndims(%s)', ('arr',)) """ - arr_expr = _convert_to_expression(dialect, array) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) return core.FunctionCall(dialect, "array_ndims", arr_expr) def array_position( dialect: "SQLDialectBase", - array: Any, - element: Any, + array: bases.BaseExpression, + element: bases.BaseExpression, start: Optional[int] = None, ) -> core.FunctionCall: """ @@ -292,19 +275,19 @@ def array_position( >>> func.to_sql() ('array_position(%s, %s)', ('arr', 'elem')) """ - arr_expr = _convert_to_expression(dialect, array) - elem_expr = _convert_to_expression(dialect, element) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + elem_expr = element if isinstance(element, bases.BaseExpression) else core.Literal(dialect, element) args = [arr_expr, elem_expr] if start is not None: - start_expr = _convert_to_expression(dialect, start) + start_expr = start if isinstance(start, bases.BaseExpression) else core.Literal(dialect, start) args.append(start_expr) return core.FunctionCall(dialect, "array_position", *args) def array_positions( dialect: "SQLDialectBase", - array: Any, - element: Any, + array: bases.BaseExpression, + element: bases.BaseExpression, ) -> core.FunctionCall: """ Return an array of positions of element in array. @@ -322,15 +305,15 @@ def array_positions( >>> func.to_sql() ('array_positions(%s, %s)', ('arr', 'elem')) """ - arr_expr = _convert_to_expression(dialect, array) - elem_expr = _convert_to_expression(dialect, element) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + elem_expr = element if isinstance(element, bases.BaseExpression) else core.Literal(dialect, element) return core.FunctionCall(dialect, "array_positions", arr_expr, elem_expr) def array_prepend( dialect: "SQLDialectBase", - element: Any, - array: Any, + element: bases.BaseExpression, + array: bases.BaseExpression, ) -> core.FunctionCall: """ Prepend an element to the beginning of an array. @@ -348,15 +331,15 @@ def array_prepend( >>> func.to_sql() ('array_prepend(%s, %s)', (1, 'arr')) """ - elem_expr = _convert_to_expression(dialect, element) - arr_expr = _convert_to_expression(dialect, array) + elem_expr = element if isinstance(element, bases.BaseExpression) else core.Literal(dialect, element) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) return core.FunctionCall(dialect, "array_prepend", elem_expr, arr_expr) def array_remove( dialect: "SQLDialectBase", - array: Any, - element: Any, + array: bases.BaseExpression, + element: bases.BaseExpression, ) -> core.FunctionCall: """ Remove all elements equal to the specified value from array. @@ -374,16 +357,16 @@ def array_remove( >>> func.to_sql() ('array_remove(%s, %s)', ('arr', 2)) """ - arr_expr = _convert_to_expression(dialect, array) - elem_expr = _convert_to_expression(dialect, element) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + elem_expr = element if isinstance(element, bases.BaseExpression) else core.Literal(dialect, element) return core.FunctionCall(dialect, "array_remove", arr_expr, elem_expr) def array_replace( dialect: "SQLDialectBase", - array: Any, - from_elem: Any, - to_elem: Any, + array: bases.BaseExpression, + from_elem: bases.BaseExpression, + to_elem: bases.BaseExpression, ) -> core.FunctionCall: """ Replace all occurrences of from_elem with to_elem in array. @@ -402,17 +385,17 @@ def array_replace( >>> func.to_sql() ('array_replace(%s, %s, %s)', ('arr', 2, 4)) """ - arr_expr = _convert_to_expression(dialect, array) - from_expr = _convert_to_expression(dialect, from_elem) - to_expr = _convert_to_expression(dialect, to_elem) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + from_expr = from_elem if isinstance(from_elem, bases.BaseExpression) else core.Literal(dialect, from_elem) + to_expr = to_elem if isinstance(to_elem, bases.BaseExpression) else core.Literal(dialect, to_elem) return core.FunctionCall(dialect, "array_replace", arr_expr, from_expr, to_expr) def array_to_string( dialect: "SQLDialectBase", - array: Any, - delimiter: Any, - null_text: Optional[Any] = None, + array: bases.BaseExpression, + delimiter: bases.BaseExpression, + null_text: Optional[str] = None, ) -> core.FunctionCall: """ Convert array to string using delimiter. @@ -431,18 +414,18 @@ def array_to_string( >>> func.to_sql() ('array_to_string(%s, %s)', ('arr', ',')) """ - arr_expr = _convert_to_expression(dialect, array) - delim_expr = _convert_to_expression(dialect, delimiter) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + delim_expr = delimiter if isinstance(delimiter, bases.BaseExpression) else core.Literal(dialect, delimiter) args = [arr_expr, delim_expr] if null_text is not None: - null_expr = _convert_to_expression(dialect, null_text) + null_expr = null_text if isinstance(null_text, bases.BaseExpression) else core.Literal(dialect, null_text) args.append(null_expr) return core.FunctionCall(dialect, "array_to_string", *args) def array_upper( dialect: "SQLDialectBase", - array: Any, + array: bases.BaseExpression, dimension: int = 1, ) -> core.FunctionCall: """ @@ -461,14 +444,14 @@ def array_upper( >>> func.to_sql() ('array_upper(%s, %s)', ('arr', 1)) """ - arr_expr = _convert_to_expression(dialect, array) - dim_expr = _convert_to_expression(dialect, dimension) + arr_expr = array if isinstance(array, bases.BaseExpression) else core.Literal(dialect, array) + dim_expr = dimension if isinstance(dimension, bases.BaseExpression) else core.Literal(dialect, dimension) return core.FunctionCall(dialect, "array_upper", arr_expr, dim_expr) def unnest( dialect: "SQLDialectBase", - *arrays: Any, + *arrays: bases.BaseExpression, ) -> core.FunctionCall: """ Expand an array into a set of rows. @@ -485,15 +468,18 @@ def unnest( >>> func.to_sql() ('unnest(%s, %s)', ('arr1', 'arr2')) """ - args = [_convert_to_expression(dialect, a) for a in arrays] + args = [ + a if isinstance(a, bases.BaseExpression) else core.Literal(dialect, a) + for a in arrays + ] return core.FunctionCall(dialect, "unnest", *args) def string_to_array( dialect: "SQLDialectBase", - string: Any, - delimiter: Any, - null_text: Optional[Any] = None, + string: bases.BaseExpression, + delimiter: bases.BaseExpression, + null_text: Optional[str] = None, ) -> core.FunctionCall: """ Split string into array using delimiter. @@ -512,11 +498,11 @@ def string_to_array( >>> func.to_sql() ('string_to_array(%s, %s)', ('str', ',')) """ - str_expr = _convert_to_expression(dialect, string) - delim_expr = _convert_to_expression(dialect, delimiter) + str_expr = string if isinstance(string, bases.BaseExpression) else core.Literal(dialect, string) + delim_expr = delimiter if isinstance(delimiter, bases.BaseExpression) else core.Literal(dialect, delimiter) args = [str_expr, delim_expr] if null_text is not None: - null_expr = _convert_to_expression(dialect, null_text) + null_expr = null_text if isinstance(null_text, bases.BaseExpression) else core.Literal(dialect, null_text) args.append(null_expr) return core.FunctionCall(dialect, "string_to_array", *args) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/bit_string.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/bit_string.py index 37a714d5..ba84ba0e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/bit_string.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/bit_string.py @@ -26,7 +26,7 @@ - bit_count(bit_string) : Count set bits (PostgreSQL 14+) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import ( @@ -38,35 +38,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, int, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, integers, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Operators ============== def bit_concat( dialect: "SQLDialectBase", - bit1: Union[str, "bases.BaseExpression"], - bit2: Union[str, "bases.BaseExpression"], + bit1: bases.BaseExpression, + bit2: bases.BaseExpression, ) -> BinaryArithmeticExpression: """Generate SQL for bit string concatenation. @@ -80,15 +57,15 @@ def bit_concat( """ return BinaryArithmeticExpression( dialect, "||", - _convert_to_expression(dialect, bit1), - _convert_to_expression(dialect, bit2), + bit1 if isinstance(bit1, bases.BaseExpression) else core.Literal(dialect, bit1), + bit2 if isinstance(bit2, bases.BaseExpression) else core.Literal(dialect, bit2), ) def bit_and( dialect: "SQLDialectBase", - bit1: Union[str, "bases.BaseExpression"], - bit2: Union[str, "bases.BaseExpression"], + bit1: bases.BaseExpression, + bit2: bases.BaseExpression, ) -> BinaryArithmeticExpression: """Generate SQL for bitwise AND operation. @@ -102,15 +79,15 @@ def bit_and( """ return BinaryArithmeticExpression( dialect, "&", - _convert_to_expression(dialect, bit1), - _convert_to_expression(dialect, bit2), + bit1 if isinstance(bit1, bases.BaseExpression) else core.Literal(dialect, bit1), + bit2 if isinstance(bit2, bases.BaseExpression) else core.Literal(dialect, bit2), ) def bit_or( dialect: "SQLDialectBase", - bit1: Union[str, "bases.BaseExpression"], - bit2: Union[str, "bases.BaseExpression"], + bit1: bases.BaseExpression, + bit2: bases.BaseExpression, ) -> BinaryArithmeticExpression: """Generate SQL for bitwise OR operation. @@ -124,15 +101,15 @@ def bit_or( """ return BinaryArithmeticExpression( dialect, "|", - _convert_to_expression(dialect, bit1), - _convert_to_expression(dialect, bit2), + bit1 if isinstance(bit1, bases.BaseExpression) else core.Literal(dialect, bit1), + bit2 if isinstance(bit2, bases.BaseExpression) else core.Literal(dialect, bit2), ) def bit_xor( dialect: "SQLDialectBase", - bit1: Union[str, "bases.BaseExpression"], - bit2: Union[str, "bases.BaseExpression"], + bit1: bases.BaseExpression, + bit2: bases.BaseExpression, ) -> BinaryArithmeticExpression: """Generate SQL for bitwise XOR operation. @@ -146,14 +123,14 @@ def bit_xor( """ return BinaryArithmeticExpression( dialect, "#", - _convert_to_expression(dialect, bit1), - _convert_to_expression(dialect, bit2), + bit1 if isinstance(bit1, bases.BaseExpression) else core.Literal(dialect, bit1), + bit2 if isinstance(bit2, bases.BaseExpression) else core.Literal(dialect, bit2), ) def bit_not( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], + bit: bases.BaseExpression, ) -> UnaryExpression: """Generate SQL for bitwise NOT operation. @@ -166,15 +143,15 @@ def bit_not( """ return UnaryExpression( dialect, "~", - _convert_to_expression(dialect, bit), + bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit), pos="before", ) def bit_shift_left( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], - n: Union[str, int, "bases.BaseExpression"], + bit: bases.BaseExpression, + n: int, ) -> BinaryArithmeticExpression: """Generate SQL for bitwise left shift operation. @@ -188,15 +165,15 @@ def bit_shift_left( """ return BinaryArithmeticExpression( dialect, "<<", - _convert_to_expression(dialect, bit), - _convert_to_expression(dialect, n), + bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit), + n if isinstance(n, bases.BaseExpression) else core.Literal(dialect, n), ) def bit_shift_right( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], - n: Union[str, int, "bases.BaseExpression"], + bit: bases.BaseExpression, + n: int, ) -> BinaryArithmeticExpression: """Generate SQL for bitwise right shift operation. @@ -210,8 +187,8 @@ def bit_shift_right( """ return BinaryArithmeticExpression( dialect, ">>", - _convert_to_expression(dialect, bit), - _convert_to_expression(dialect, n), + bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit), + n if isinstance(n, bases.BaseExpression) else core.Literal(dialect, n), ) @@ -219,7 +196,7 @@ def bit_shift_right( def bit_length( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], + bit: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL for length function on bit string. @@ -232,12 +209,13 @@ def bit_length( Returns: FunctionCall for length(bit) """ - return core.FunctionCall(dialect, "length", _convert_to_expression(dialect, bit)) + bit_expr = bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit) + return core.FunctionCall(dialect, "length", bit_expr) def bit_length_func( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], + bit: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL for bit_length function on bit string. @@ -248,12 +226,13 @@ def bit_length_func( Returns: FunctionCall for bit_length(bit) """ - return core.FunctionCall(dialect, "bit_length", _convert_to_expression(dialect, bit)) + bit_expr = bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit) + return core.FunctionCall(dialect, "bit_length", bit_expr) def bit_octet_length( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], + bit: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL for octet_length function on bit string. @@ -264,13 +243,14 @@ def bit_octet_length( Returns: FunctionCall for octet_length(bit) """ - return core.FunctionCall(dialect, "octet_length", _convert_to_expression(dialect, bit)) + bit_expr = bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit) + return core.FunctionCall(dialect, "octet_length", bit_expr) def bit_get_bit( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], - n: Union[str, int, "bases.BaseExpression"], + bit: bases.BaseExpression, + n: int, ) -> core.FunctionCall: """Generate SQL for get_bit function. @@ -284,16 +264,16 @@ def bit_get_bit( """ return core.FunctionCall( dialect, "get_bit", - _convert_to_expression(dialect, bit), - _convert_to_expression(dialect, n), + bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit), + n if isinstance(n, bases.BaseExpression) else core.Literal(dialect, n), ) def bit_set_bit( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], - n: Union[str, int, "bases.BaseExpression"], - value: Union[str, int, "bases.BaseExpression"], + bit: bases.BaseExpression, + n: int, + value: int, ) -> core.FunctionCall: """Generate SQL for set_bit function. @@ -308,15 +288,15 @@ def bit_set_bit( """ return core.FunctionCall( dialect, "set_bit", - _convert_to_expression(dialect, bit), - _convert_to_expression(dialect, n), - _convert_to_expression(dialect, value), + bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit), + n if isinstance(n, bases.BaseExpression) else core.Literal(dialect, n), + value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value), ) def bit_count( dialect: "SQLDialectBase", - bit: Union[str, "bases.BaseExpression"], + bit: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL for bit_count function (PostgreSQL 14+). @@ -327,7 +307,8 @@ def bit_count( Returns: FunctionCall for bit_count(bit) """ - return core.FunctionCall(dialect, "bit_count", _convert_to_expression(dialect, bit)) + bit_expr = bit if isinstance(bit, bases.BaseExpression) else core.Literal(dialect, bit) + return core.FunctionCall(dialect, "bit_count", bit_expr) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/citext.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/citext.py index 7e611f88..b20c6914 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/citext.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/citext.py @@ -27,37 +27,14 @@ from typing import Union, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresCitextType, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== citext Functions ============== def citext_literal( @@ -84,8 +61,8 @@ def citext_literal( >>> citext_literal(dialect, 'Hello World') # Produces: 'Hello World'::citext """ - converted = _convert_to_expression(dialect, value) - return converted.cast("citext") + converted = value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value) + return converted.cast(PostgresCitextType(dialect)) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/cube.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/cube.py index 6d440e47..0031eab5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/cube.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/cube.py @@ -45,32 +45,6 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Cube Construction ============== def cube_literal( @@ -108,14 +82,15 @@ def cube_literal( ) return core.FunctionCall( dialect, "cube", - _convert_to_expression(dialect, coordinates), + coordinates if isinstance(coordinates, bases.BaseExpression) + else core.Literal(dialect, coordinates), ) def cube_dimension( dialect: "SQLDialectBase", - dim: Union[int, str, "bases.BaseExpression"], - coord: Union[float, str, "bases.BaseExpression"], + dim: int, + coord: float, ) -> core.FunctionCall: """Generate SQL expression for the cube constructor from dimension and coordinate. @@ -138,8 +113,8 @@ def cube_dimension( """ return core.FunctionCall( dialect, "cube", - _convert_to_expression(dialect, dim), - _convert_to_expression(dialect, coord), + dim if isinstance(dim, bases.BaseExpression) else core.Literal(dialect, dim), + coord if isinstance(coord, bases.BaseExpression) else core.Literal(dialect, coord), ) @@ -147,7 +122,7 @@ def cube_dimension( def cube_size( dialect: "SQLDialectBase", - cube_expr: Union[str, "bases.BaseExpression"], + cube_expr: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for the cube_size function. @@ -168,7 +143,8 @@ def cube_size( """ return core.FunctionCall( dialect, "cube_size", - _convert_to_expression(dialect, cube_expr), + cube_expr if isinstance(cube_expr, bases.BaseExpression) + else core.Literal(dialect, cube_expr), ) @@ -176,8 +152,8 @@ def cube_size( def cube_union( dialect: "SQLDialectBase", - cube1: Union[str, "bases.BaseExpression"], - cube2: Union[str, "bases.BaseExpression"], + cube1: bases.BaseExpression, + cube2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for the cube union operator. @@ -198,15 +174,15 @@ def cube_union( """ return BinaryExpression( dialect, "union", - _convert_to_expression(dialect, cube1), - _convert_to_expression(dialect, cube2), + cube1 if isinstance(cube1, bases.BaseExpression) else core.Literal(dialect, cube1), + cube2 if isinstance(cube2, bases.BaseExpression) else core.Literal(dialect, cube2), ) def cube_inter( dialect: "SQLDialectBase", - cube1: Union[str, "bases.BaseExpression"], - cube2: Union[str, "bases.BaseExpression"], + cube1: bases.BaseExpression, + cube2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for the cube intersection operator. @@ -227,15 +203,15 @@ def cube_inter( """ return BinaryExpression( dialect, "inter", - _convert_to_expression(dialect, cube1), - _convert_to_expression(dialect, cube2), + cube1 if isinstance(cube1, bases.BaseExpression) else core.Literal(dialect, cube1), + cube2 if isinstance(cube2, bases.BaseExpression) else core.Literal(dialect, cube2), ) def cube_contains( dialect: "SQLDialectBase", - cube_expr: Union[str, "bases.BaseExpression"], - target: Union[str, "bases.BaseExpression"], + cube_expr: bases.BaseExpression, + target: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for the cube contains operator (@>). @@ -257,15 +233,16 @@ def cube_contains( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, cube_expr), - _convert_to_expression(dialect, target), + cube_expr if isinstance(cube_expr, bases.BaseExpression) + else core.Literal(dialect, cube_expr), + target if isinstance(target, bases.BaseExpression) else core.Literal(dialect, target), ) def cube_distance( dialect: "SQLDialectBase", - cube_expr: Union[str, "bases.BaseExpression"], - target: Union[str, "bases.BaseExpression"], + cube_expr: bases.BaseExpression, + target: bases.BaseExpression, ) -> BinaryArithmeticExpression: """Generate SQL expression for the cube distance operator (<->). @@ -288,8 +265,9 @@ def cube_distance( """ return BinaryArithmeticExpression( dialect, "<->", - _convert_to_expression(dialect, cube_expr), - _convert_to_expression(dialect, target), + cube_expr if isinstance(cube_expr, bases.BaseExpression) + else core.Literal(dialect, cube_expr), + target if isinstance(target, bases.BaseExpression) else core.Literal(dialect, target), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/data_type.py index cbef72fc..03459f28 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/data_type.py @@ -14,6 +14,10 @@ from typing import Any, List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression.types import CustomType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresXID8Type, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -41,7 +45,7 @@ def xid8_literal( >>> xid8_literal(dialect, 123456789) # Produces: 123456789::xid8 """ - return core.Literal(dialect, value).cast("xid8") + return core.Literal(dialect, value).cast(PostgresXID8Type(dialect)) def array_literal( @@ -76,7 +80,8 @@ def array_literal( """ if not elements: if element_type: - return core.Literal(dialect, "{}").cast(f"{element_type}[]") + return core.Literal(dialect, "{}").cast( + CustomType(dialect, raw=f"{element_type}[]")) return core.Literal(dialect, "{}") formatted = [] @@ -91,7 +96,7 @@ def array_literal( array_str = "{" + ",".join(formatted) + "}" result = core.Literal(dialect, array_str) if element_type: - return result.cast(f"{element_type}[]") + return result.cast(CustomType(dialect, raw=f"{element_type}[]")) return result diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/earthdistance.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/earthdistance.py index 354c1cef..b0417a3b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/earthdistance.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/earthdistance.py @@ -28,7 +28,7 @@ - <@ : Point inside circle containment check (point_inside_circle) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -37,35 +37,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression", float], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, floats, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Functions ============== def ll_to_earth( dialect: "SQLDialectBase", - latitude: Union[float, str, "bases.BaseExpression"], - longitude: Union[float, str, "bases.BaseExpression"], + latitude: float, + longitude: float, ) -> core.FunctionCall: """Convert latitude and longitude to a point on the earth's surface. @@ -88,15 +65,17 @@ def ll_to_earth( """ return core.FunctionCall( dialect, "ll_to_earth", - _convert_to_expression(dialect, latitude), - _convert_to_expression(dialect, longitude), + latitude if isinstance(latitude, bases.BaseExpression) + else core.Literal(dialect, latitude), + longitude if isinstance(longitude, bases.BaseExpression) + else core.Literal(dialect, longitude), ) def earth_distance( dialect: "SQLDialectBase", - point1: Union[str, "bases.BaseExpression"], - point2: Union[str, "bases.BaseExpression"], + point1: bases.BaseExpression, + point2: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the great-circle distance between two points on the earth's surface. @@ -121,15 +100,15 @@ def earth_distance( """ return core.FunctionCall( dialect, "earth_distance", - _convert_to_expression(dialect, point1), - _convert_to_expression(dialect, point2), + point1 if isinstance(point1, bases.BaseExpression) else core.Literal(dialect, point1), + point2 if isinstance(point2, bases.BaseExpression) else core.Literal(dialect, point2), ) def earth_box( dialect: "SQLDialectBase", - center: Union[str, "bases.BaseExpression"], - radius: Union[float, str, "bases.BaseExpression"], + center: bases.BaseExpression, + radius: float, ) -> core.FunctionCall: """Calculate a bounding box for a point and radius on the earth's surface. @@ -151,8 +130,8 @@ def earth_box( """ return core.FunctionCall( dialect, "earth_box", - _convert_to_expression(dialect, center), - _convert_to_expression(dialect, radius), + center if isinstance(center, bases.BaseExpression) else core.Literal(dialect, center), + radius if isinstance(radius, bases.BaseExpression) else core.Literal(dialect, radius), ) @@ -160,9 +139,9 @@ def earth_box( def earthdistance_operator( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - latitude: Union[float, str, "bases.BaseExpression"], - longitude: Union[float, str, "bases.BaseExpression"], + column: bases.BaseExpression, + latitude: float, + longitude: float, operator: str = "<->", ) -> BinaryExpression: """Generate SQL expression for earth distance operator. @@ -189,17 +168,17 @@ def earthdistance_operator( """ return BinaryExpression( dialect, operator, - _convert_to_expression(dialect, column), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), ll_to_earth(dialect, latitude, longitude), ) def point_inside_circle( dialect: "SQLDialectBase", - point: Union[str, "bases.BaseExpression"], - center_lat: Union[float, str, "bases.BaseExpression"], - center_lon: Union[float, str, "bases.BaseExpression"], - radius: Union[float, str, "bases.BaseExpression"], + point: bases.BaseExpression, + center_lat: float, + center_lon: float, + radius: float, ) -> BinaryExpression: """Generate SQL expression for checking if a point is inside a circle. @@ -223,7 +202,7 @@ def point_inside_circle( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, point), + point if isinstance(point, bases.BaseExpression) else core.Literal(dialect, point), earth_box(dialect, ll_to_earth(dialect, center_lat, center_lon), radius), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/enum.py index 75462682..8eb4177a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/enum.py @@ -22,47 +22,27 @@ - enum_ge(e1, e2): Greater than or equal (>=) """ -from typing import Optional, Union, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresRegTypeType, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Functions ============== def enum_range( dialect: "SQLDialectBase", - enum_type: Optional[Union[str, "bases.BaseExpression"]] = None, - enum_value: Optional[Union[str, "bases.BaseExpression"]] = None, - start_value: Optional[Union[str, "bases.BaseExpression"]] = None, - end_value: Optional[Union[str, "bases.BaseExpression"]] = None, + enum_type: Optional[str] = None, + enum_value: Optional[str] = None, + start_value: Optional[str] = None, + end_value: Optional[str] = None, ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL enum_range function. @@ -98,21 +78,25 @@ def enum_range( ('enum_range(%s, %s)', ('pending', 'ready')) """ if start_value is not None and end_value is not None: - start_expr = _convert_to_expression(dialect, start_value) - end_expr = _convert_to_expression(dialect, end_value) + start_expr = start_value if isinstance(start_value, bases.BaseExpression) \ + else core.Literal(dialect, start_value) + end_expr = end_value if isinstance(end_value, bases.BaseExpression) \ + else core.Literal(dialect, end_value) return core.FunctionCall(dialect, "enum_range", start_expr, end_expr) if enum_type is not None: - type_expr = _convert_to_expression(dialect, enum_type) - return core.FunctionCall(dialect, "enum_range", type_expr.cast("regtype")) + type_expr = enum_type if isinstance(enum_type, bases.BaseExpression) \ + else core.Literal(dialect, enum_type) + return core.FunctionCall(dialect, "enum_range", type_expr.cast(PostgresRegTypeType(dialect))) if enum_value is not None: - value_expr = _convert_to_expression(dialect, enum_value) + value_expr = enum_value if isinstance(enum_value, bases.BaseExpression) \ + else core.Literal(dialect, enum_value) return core.FunctionCall(dialect, "enum_range", value_expr) raise ValueError("Must provide enum_type, enum_value, or both start_value and end_value") def enum_first( dialect: "SQLDialectBase", - enum_type_or_value: Union[str, "bases.BaseExpression"], + enum_type_or_value: str, ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL enum_first function. @@ -131,13 +115,14 @@ def enum_first( >>> func.to_sql() ("enum_first(CAST(%s AS regtype))", ('status',)) """ - type_expr = _convert_to_expression(dialect, enum_type_or_value) - return core.FunctionCall(dialect, "enum_first", type_expr.cast("regtype")) + type_expr = enum_type_or_value if isinstance(enum_type_or_value, bases.BaseExpression) \ + else core.Literal(dialect, enum_type_or_value) + return core.FunctionCall(dialect, "enum_first", type_expr.cast(PostgresRegTypeType(dialect))) def enum_last( dialect: "SQLDialectBase", - enum_type_or_value: Union[str, "bases.BaseExpression"], + enum_type_or_value: str, ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL enum_last function. @@ -156,8 +141,9 @@ def enum_last( >>> func.to_sql() ("enum_last(CAST(%s AS regtype))", ('status',)) """ - type_expr = _convert_to_expression(dialect, enum_type_or_value) - return core.FunctionCall(dialect, "enum_last", type_expr.cast("regtype")) + type_expr = enum_type_or_value if isinstance(enum_type_or_value, bases.BaseExpression) \ + else core.Literal(dialect, enum_type_or_value) + return core.FunctionCall(dialect, "enum_last", type_expr.cast(PostgresRegTypeType(dialect))) # ============== Comparison Operators ============== @@ -165,8 +151,8 @@ def enum_last( def enum_lt( dialect: "SQLDialectBase", - e1: Union[str, "bases.BaseExpression"], - e2: Union[str, "bases.BaseExpression"], + e1: bases.BaseExpression, + e2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for enum less-than comparison. @@ -183,15 +169,15 @@ def enum_lt( >>> expr.to_sql() ('%s < %s', ('pending', 'active')) """ - left = _convert_to_expression(dialect, e1) - right = _convert_to_expression(dialect, e2) + left = e1 if isinstance(e1, bases.BaseExpression) else core.Literal(dialect, e1) + right = e2 if isinstance(e2, bases.BaseExpression) else core.Literal(dialect, e2) return BinaryExpression(dialect, "<", left, right) def enum_le( dialect: "SQLDialectBase", - e1: Union[str, "bases.BaseExpression"], - e2: Union[str, "bases.BaseExpression"], + e1: bases.BaseExpression, + e2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for enum less-than-or-equal comparison. @@ -208,15 +194,15 @@ def enum_le( >>> expr.to_sql() ('%s <= %s', ('pending', 'active')) """ - left = _convert_to_expression(dialect, e1) - right = _convert_to_expression(dialect, e2) + left = e1 if isinstance(e1, bases.BaseExpression) else core.Literal(dialect, e1) + right = e2 if isinstance(e2, bases.BaseExpression) else core.Literal(dialect, e2) return BinaryExpression(dialect, "<=", left, right) def enum_gt( dialect: "SQLDialectBase", - e1: Union[str, "bases.BaseExpression"], - e2: Union[str, "bases.BaseExpression"], + e1: bases.BaseExpression, + e2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for enum greater-than comparison. @@ -233,15 +219,15 @@ def enum_gt( >>> expr.to_sql() ('%s > %s', ('pending', 'active')) """ - left = _convert_to_expression(dialect, e1) - right = _convert_to_expression(dialect, e2) + left = e1 if isinstance(e1, bases.BaseExpression) else core.Literal(dialect, e1) + right = e2 if isinstance(e2, bases.BaseExpression) else core.Literal(dialect, e2) return BinaryExpression(dialect, ">", left, right) def enum_ge( dialect: "SQLDialectBase", - e1: Union[str, "bases.BaseExpression"], - e2: Union[str, "bases.BaseExpression"], + e1: bases.BaseExpression, + e2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for enum greater-than-or-equal comparison. @@ -258,8 +244,8 @@ def enum_ge( >>> expr.to_sql() ('%s >= %s', ('pending', 'active')) """ - left = _convert_to_expression(dialect, e1) - right = _convert_to_expression(dialect, e2) + left = e1 if isinstance(e1, bases.BaseExpression) else core.Literal(dialect, e1) + right = e2 if isinstance(e2, bases.BaseExpression) else core.Literal(dialect, e2) return BinaryExpression(dialect, ">=", left, right) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/fuzzystrmatch.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/fuzzystrmatch.py index 0ffdd6a5..52dde358 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/fuzzystrmatch.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/fuzzystrmatch.py @@ -24,7 +24,7 @@ - metaphone: Calculate the Metaphone code for a string """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -32,35 +32,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Levenshtein Distance Functions ============== def levenshtein( dialect: "SQLDialectBase", - source: Union[str, "bases.BaseExpression"], - target: Union[str, "bases.BaseExpression"], + source: bases.BaseExpression, + target: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the Levenshtein distance between two strings. @@ -83,16 +60,16 @@ def levenshtein( """ return core.FunctionCall( dialect, "levenshtein", - _convert_to_expression(dialect, source), - _convert_to_expression(dialect, target), + source if isinstance(source, bases.BaseExpression) else core.Literal(dialect, source), + target if isinstance(target, bases.BaseExpression) else core.Literal(dialect, target), ) def levenshtein_less_equal( dialect: "SQLDialectBase", - source: Union[str, "bases.BaseExpression"], - target: Union[str, "bases.BaseExpression"], - threshold: Union[int, "bases.BaseExpression"], + source: bases.BaseExpression, + target: bases.BaseExpression, + threshold: int, ) -> core.FunctionCall: """Calculate the Levenshtein distance with a maximum threshold. @@ -115,9 +92,10 @@ def levenshtein_less_equal( """ return core.FunctionCall( dialect, "levenshtein_less_equal", - _convert_to_expression(dialect, source), - _convert_to_expression(dialect, target), - _convert_to_expression(dialect, threshold), + source if isinstance(source, bases.BaseExpression) else core.Literal(dialect, source), + target if isinstance(target, bases.BaseExpression) else core.Literal(dialect, target), + threshold if isinstance(threshold, bases.BaseExpression) + else core.Literal(dialect, threshold), ) @@ -125,7 +103,7 @@ def levenshtein_less_equal( def soundex( dialect: "SQLDialectBase", - text: Union[str, "bases.BaseExpression"], + text: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the Soundex code for a string. @@ -144,14 +122,14 @@ def soundex( """ return core.FunctionCall( dialect, "soundex", - _convert_to_expression(dialect, text), + text if isinstance(text, bases.BaseExpression) else core.Literal(dialect, text), ) def difference( dialect: "SQLDialectBase", - source: Union[str, "bases.BaseExpression"], - target: Union[str, "bases.BaseExpression"], + source: bases.BaseExpression, + target: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the difference between two Soundex codes. @@ -172,8 +150,8 @@ def difference( """ return core.FunctionCall( dialect, "difference", - _convert_to_expression(dialect, source), - _convert_to_expression(dialect, target), + source if isinstance(source, bases.BaseExpression) else core.Literal(dialect, source), + target if isinstance(target, bases.BaseExpression) else core.Literal(dialect, target), ) @@ -181,8 +159,8 @@ def difference( def metaphone( dialect: "SQLDialectBase", - text: Union[str, "bases.BaseExpression"], - max_length: Union[int, "bases.BaseExpression"], + text: bases.BaseExpression, + max_length: int, ) -> core.FunctionCall: """Calculate the Metaphone code for a string. @@ -203,14 +181,15 @@ def metaphone( """ return core.FunctionCall( dialect, "metaphone", - _convert_to_expression(dialect, text), - _convert_to_expression(dialect, max_length), + text if isinstance(text, bases.BaseExpression) else core.Literal(dialect, text), + max_length if isinstance(max_length, bases.BaseExpression) + else core.Literal(dialect, max_length), ) def dmetaphone( dialect: "SQLDialectBase", - text: Union[str, "bases.BaseExpression"], + text: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the primary Double Metaphone code for a string. @@ -230,13 +209,13 @@ def dmetaphone( """ return core.FunctionCall( dialect, "dmetaphone", - _convert_to_expression(dialect, text), + text if isinstance(text, bases.BaseExpression) else core.Literal(dialect, text), ) def dmetaphone_alt( dialect: "SQLDialectBase", - text: Union[str, "bases.BaseExpression"], + text: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the alternate Double Metaphone code for a string. @@ -257,7 +236,7 @@ def dmetaphone_alt( """ return core.FunctionCall( dialect, "dmetaphone_alt", - _convert_to_expression(dialect, text), + text if isinstance(text, bases.BaseExpression) else core.Literal(dialect, text), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/geometric.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/geometric.py index 5f04cbe1..21ceefd7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/geometric.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/geometric.py @@ -27,7 +27,7 @@ - npoints(geom) : Number of points in a path or polygon """ -from typing import Any, Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -36,35 +36,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Operators ============== def geometry_distance( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL distance operator expression. @@ -80,15 +57,15 @@ def geometry_distance( """ return BinaryExpression( dialect, "<->", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_contains( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL contains operator expression. @@ -104,15 +81,15 @@ def geometry_contains( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_contained_by( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL contained by operator expression. @@ -128,15 +105,15 @@ def geometry_contained_by( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_overlaps( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL overlaps operator expression. @@ -152,15 +129,15 @@ def geometry_overlaps( """ return BinaryExpression( dialect, "&&", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_strictly_left( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL strictly left operator expression. @@ -176,15 +153,15 @@ def geometry_strictly_left( """ return BinaryExpression( dialect, "<<", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_strictly_right( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL strictly right operator expression. @@ -200,15 +177,15 @@ def geometry_strictly_right( """ return BinaryExpression( dialect, ">>", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_not_extend_right( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL does not extend to the right operator. @@ -224,15 +201,15 @@ def geometry_not_extend_right( """ return BinaryExpression( dialect, "&<", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) def geometry_not_extend_left( dialect: "SQLDialectBase", - geom1: Any, - geom2: Any, + geom1: bases.BaseExpression, + geom2: bases.BaseExpression, ) -> BinaryExpression: """Generate PostgreSQL does not extend to the left operator. @@ -248,8 +225,8 @@ def geometry_not_extend_left( """ return BinaryExpression( dialect, "&>", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1 if isinstance(geom1, bases.BaseExpression) else core.Literal(dialect, geom1), + geom2 if isinstance(geom2, bases.BaseExpression) else core.Literal(dialect, geom2), ) @@ -257,7 +234,7 @@ def geometry_not_extend_left( def geometry_area( dialect: "SQLDialectBase", - geom: Any, + geom: bases.BaseExpression, ) -> core.FunctionCall: """Generate PostgreSQL area function expression. @@ -270,12 +247,13 @@ def geometry_area( Returns: FunctionCall for area(geom) """ - return core.FunctionCall(dialect, "area", _convert_to_expression(dialect, geom)) + geom_expr = geom if isinstance(geom, bases.BaseExpression) else core.Literal(dialect, geom) + return core.FunctionCall(dialect, "area", geom_expr) def geometry_center( dialect: "SQLDialectBase", - geom: Any, + geom: bases.BaseExpression, ) -> core.FunctionCall: """Generate PostgreSQL center function expression. @@ -288,12 +266,13 @@ def geometry_center( Returns: FunctionCall for center(geom) """ - return core.FunctionCall(dialect, "center", _convert_to_expression(dialect, geom)) + geom_expr = geom if isinstance(geom, bases.BaseExpression) else core.Literal(dialect, geom) + return core.FunctionCall(dialect, "center", geom_expr) def geometry_length( dialect: "SQLDialectBase", - geom: Any, + geom: bases.BaseExpression, ) -> core.FunctionCall: """Generate PostgreSQL length function expression. @@ -306,12 +285,13 @@ def geometry_length( Returns: FunctionCall for length(geom) """ - return core.FunctionCall(dialect, "length", _convert_to_expression(dialect, geom)) + geom_expr = geom if isinstance(geom, bases.BaseExpression) else core.Literal(dialect, geom) + return core.FunctionCall(dialect, "length", geom_expr) def geometry_width( dialect: "SQLDialectBase", - geom: Any, + geom: bases.BaseExpression, ) -> core.FunctionCall: """Generate PostgreSQL width function expression. @@ -324,12 +304,13 @@ def geometry_width( Returns: FunctionCall for width(geom) """ - return core.FunctionCall(dialect, "width", _convert_to_expression(dialect, geom)) + geom_expr = geom if isinstance(geom, bases.BaseExpression) else core.Literal(dialect, geom) + return core.FunctionCall(dialect, "width", geom_expr) def geometry_height( dialect: "SQLDialectBase", - geom: Any, + geom: bases.BaseExpression, ) -> core.FunctionCall: """Generate PostgreSQL height function expression. @@ -342,12 +323,13 @@ def geometry_height( Returns: FunctionCall for height(geom) """ - return core.FunctionCall(dialect, "height", _convert_to_expression(dialect, geom)) + geom_expr = geom if isinstance(geom, bases.BaseExpression) else core.Literal(dialect, geom) + return core.FunctionCall(dialect, "height", geom_expr) def geometry_npoints( dialect: "SQLDialectBase", - geom: Any, + geom: bases.BaseExpression, ) -> core.FunctionCall: """Generate PostgreSQL npoints function expression. @@ -360,7 +342,8 @@ def geometry_npoints( Returns: FunctionCall for npoints(geom) """ - return core.FunctionCall(dialect, "npoints", _convert_to_expression(dialect, geom)) + geom_expr = geom if isinstance(geom, bases.BaseExpression) else core.Literal(dialect, geom) + return core.FunctionCall(dialect, "npoints", geom_expr) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py index 5a953f47..8a62094e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py @@ -39,50 +39,47 @@ from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression from rhosocial.activerecord.backend.impl.postgres.type_values.hstore import PostgresHstore +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresHstoreType, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - -def _convert_to_expression( +def hstore_literal( dialect: "SQLDialectBase", - expr: Union[PostgresHstore, Dict[str, Optional[str]], str, "bases.BaseExpression"], + data: Union[PostgresHstore, Dict[str, Optional[str]], str], ) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports PostgresHstore objects, Dict, strings, and existing - BaseExpression objects. + """Build a typed hstore literal from domain data. - For PostgresHstore and Dict inputs, generates a typed hstore literal - expression with ::hstore cast. + The one place a Python value becomes an hstore expression, and it is called + rather than inferred: a factory that received ``{"a": "1"}`` and had to + guess whether that was a document or the name of a column holding one could + only ever guess, and a dict is never a column name, so the guess happened to + work -- which is not the same as being right, and cost every factory the + ability to say what it accepts. Args: - dialect: The SQL dialect instance - expr: Value to convert + dialect: The SQL dialect instance. + data: A mapping, a :class:`PostgresHstore`, or the hstore text itself. Returns: - BaseExpression representing the value + A literal cast to ``hstore``, or the text unchanged as a literal. """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, PostgresHstore): - literal = core.Literal(dialect, expr.to_postgres_string()) - return literal.cast("hstore") - elif isinstance(expr, dict): - hstore = PostgresHstore(data=expr) - literal = core.Literal(dialect, hstore.to_postgres_string()) - return literal.cast("hstore") - elif isinstance(expr, str): - return core.Literal(dialect, expr) + if isinstance(data, PostgresHstore): + text = data.to_postgres_string() + elif isinstance(data, dict): + text = PostgresHstore(data=data).to_postgres_string() else: - return core.Literal(dialect, expr) + return core.Literal(dialect, data) + return core.Literal(dialect, text).cast(PostgresHstoreType(dialect)) # ============== Constructors ============== def hstore_from_record( dialect: "SQLDialectBase", - record: Union[str, "bases.BaseExpression"], + record: "bases.BaseExpression", ) -> core.FunctionCall: """Construct hstore from a record/row. @@ -93,13 +90,13 @@ def hstore_from_record( Returns: FunctionCall for hstore(record) """ - return core.FunctionCall(dialect, "hstore", _convert_to_expression(dialect, record)) + return core.FunctionCall(dialect, "hstore", record) def hstore_from_key_value( dialect: "SQLDialectBase", - key: Union[str, "bases.BaseExpression"], - value: Union[str, "bases.BaseExpression"], + key: "bases.BaseExpression", + value: "bases.BaseExpression", ) -> core.FunctionCall: """Construct hstore from a single key/value pair. @@ -113,8 +110,8 @@ def hstore_from_key_value( """ return core.FunctionCall( dialect, "hstore", - _convert_to_expression(dialect, key), - _convert_to_expression(dialect, value), + key, + value, ) @@ -122,7 +119,7 @@ def hstore_from_key_value( def hstore_akeys( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Get all keys from hstore as a text array. @@ -133,12 +130,12 @@ def hstore_akeys( Returns: FunctionCall for akeys(hstore) """ - return core.FunctionCall(dialect, "akeys", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "akeys", hstore_expr) def hstore_skeys( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Get all keys from hstore as a set. @@ -149,12 +146,12 @@ def hstore_skeys( Returns: FunctionCall for skeys(hstore) """ - return core.FunctionCall(dialect, "skeys", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "skeys", hstore_expr) def hstore_avals( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Get all values from hstore as a text array. @@ -165,12 +162,12 @@ def hstore_avals( Returns: FunctionCall for avals(hstore) """ - return core.FunctionCall(dialect, "avals", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "avals", hstore_expr) def hstore_svals( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Get all values from hstore as a set. @@ -181,12 +178,12 @@ def hstore_svals( Returns: FunctionCall for svals(hstore) """ - return core.FunctionCall(dialect, "svals", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "svals", hstore_expr) def hstore_each( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Get all key/value pairs from hstore as a set of (key, value) records. @@ -197,14 +194,14 @@ def hstore_each( Returns: FunctionCall for each(hstore) """ - return core.FunctionCall(dialect, "each", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "each", hstore_expr) # ============== Conversion Functions ============== def hstore_to_array( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Convert hstore to text array (alternating keys and values). @@ -215,12 +212,12 @@ def hstore_to_array( Returns: FunctionCall for hstore_to_array(hstore) """ - return core.FunctionCall(dialect, "hstore_to_array", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "hstore_to_array", hstore_expr) def hstore_to_matrix( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Convert hstore to 2D text array (key/value pairs as rows). @@ -231,12 +228,12 @@ def hstore_to_matrix( Returns: FunctionCall for hstore_to_matrix(hstore) """ - return core.FunctionCall(dialect, "hstore_to_matrix", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "hstore_to_matrix", hstore_expr) def hstore_to_json( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Convert hstore to JSON (values as strings). @@ -247,12 +244,12 @@ def hstore_to_json( Returns: FunctionCall for hstore_to_json(hstore) """ - return core.FunctionCall(dialect, "hstore_to_json", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "hstore_to_json", hstore_expr) def hstore_to_jsonb( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Convert hstore to JSONB (values as strings). @@ -263,12 +260,12 @@ def hstore_to_jsonb( Returns: FunctionCall for hstore_to_jsonb(hstore) """ - return core.FunctionCall(dialect, "hstore_to_jsonb", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "hstore_to_jsonb", hstore_expr) def hstore_to_json_loose( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Convert hstore to JSON with loose type inference. @@ -281,12 +278,12 @@ def hstore_to_json_loose( Returns: FunctionCall for hstore_to_json_loose(hstore) """ - return core.FunctionCall(dialect, "hstore_to_json_loose", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "hstore_to_json_loose", hstore_expr) def hstore_to_jsonb_loose( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Convert hstore to JSONB with loose type inference. @@ -299,15 +296,15 @@ def hstore_to_jsonb_loose( Returns: FunctionCall for hstore_to_jsonb_loose(hstore) """ - return core.FunctionCall(dialect, "hstore_to_jsonb_loose", _convert_to_expression(dialect, hstore_expr)) + return core.FunctionCall(dialect, "hstore_to_jsonb_loose", hstore_expr) # ============== Subset Functions ============== def hstore_slice( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - keys: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + keys: "bases.BaseExpression", ) -> core.FunctionCall: """Extract subset of hstore by keys. @@ -321,8 +318,8 @@ def hstore_slice( """ return core.FunctionCall( dialect, "slice", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, keys), + hstore_expr, + keys, ) @@ -330,8 +327,8 @@ def hstore_slice( def hstore_exist( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> core.FunctionCall: """Check if key exists in hstore (including NULL values). @@ -345,15 +342,15 @@ def hstore_exist( """ return core.FunctionCall( dialect, "exist", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) def hstore_defined( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> core.FunctionCall: """Check if key exists and has a non-NULL value. @@ -367,8 +364,8 @@ def hstore_defined( """ return core.FunctionCall( dialect, "defined", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) @@ -376,8 +373,8 @@ def hstore_defined( def hstore_delete( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> core.FunctionCall: """Delete a key from hstore. @@ -391,15 +388,15 @@ def hstore_delete( """ return core.FunctionCall( dialect, "delete", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) def hstore_delete_keys( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - keys: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + keys: "bases.BaseExpression", ) -> core.FunctionCall: """Delete multiple keys from hstore. @@ -413,15 +410,15 @@ def hstore_delete_keys( """ return core.FunctionCall( dialect, "delete", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, keys), + hstore_expr, + keys, ) def hstore_delete_pairs( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - pairs: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + pairs: "bases.BaseExpression", ) -> core.FunctionCall: """Delete matching key/value pairs from hstore. @@ -435,8 +432,8 @@ def hstore_delete_pairs( """ return core.FunctionCall( dialect, "delete", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, pairs), + hstore_expr, + pairs, ) @@ -444,8 +441,8 @@ def hstore_delete_pairs( def hstore_populate_record( dialect: "SQLDialectBase", - record: Union[str, "bases.BaseExpression"], - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + record: "bases.BaseExpression", + hstore_expr: "bases.BaseExpression", ) -> core.FunctionCall: """Update a record/row with values from hstore. @@ -459,8 +456,8 @@ def hstore_populate_record( """ return core.FunctionCall( dialect, "populate_record", - _convert_to_expression(dialect, record), - _convert_to_expression(dialect, hstore_expr), + record, + hstore_expr, ) @@ -468,8 +465,8 @@ def hstore_populate_record( def hstore_get_value( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> BinaryExpression: """Get value by key (operator ->). @@ -485,15 +482,15 @@ def hstore_get_value( """ return BinaryExpression( dialect, "->", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) def hstore_get_value_as_text( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> BinaryExpression: """Get value by key as text (operator ->>). @@ -509,15 +506,15 @@ def hstore_get_value_as_text( """ return BinaryExpression( dialect, "->>", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) def hstore_get_values( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - keys: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + keys: "bases.BaseExpression", ) -> BinaryExpression: """Get values by multiple keys (operator -> text[]). @@ -531,15 +528,15 @@ def hstore_get_values( """ return BinaryExpression( dialect, "->", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, keys), + hstore_expr, + keys, ) def hstore_concat( dialect: "SQLDialectBase", - left: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - right: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + left: "bases.BaseExpression", + right: "bases.BaseExpression", ) -> BinaryExpression: """Concatenate two hstores (operator ||). @@ -553,15 +550,15 @@ def hstore_concat( """ return BinaryExpression( dialect, "||", - _convert_to_expression(dialect, left), - _convert_to_expression(dialect, right), + left, + right, ) def hstore_key_exists( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> BinaryExpression: """Check if key exists (operator ?). @@ -575,15 +572,15 @@ def hstore_key_exists( """ return BinaryExpression( dialect, "?", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) def hstore_all_keys_exist( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - keys: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + keys: "bases.BaseExpression", ) -> BinaryExpression: """Check if all keys exist (operator ?&). @@ -597,15 +594,15 @@ def hstore_all_keys_exist( """ return BinaryExpression( dialect, "?&", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, keys), + hstore_expr, + keys, ) def hstore_any_key_exists( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - keys: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + keys: "bases.BaseExpression", ) -> BinaryExpression: """Check if any key exists (operator ?|). @@ -619,15 +616,15 @@ def hstore_any_key_exists( """ return BinaryExpression( dialect, "?|", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, keys), + hstore_expr, + keys, ) def hstore_contains( dialect: "SQLDialectBase", - left: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - right: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + left: "bases.BaseExpression", + right: "bases.BaseExpression", ) -> BinaryExpression: """Check if left hstore contains right (operator @>). @@ -641,15 +638,15 @@ def hstore_contains( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, left), - _convert_to_expression(dialect, right), + left, + right, ) def hstore_contained_by( dialect: "SQLDialectBase", - left: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - right: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + left: "bases.BaseExpression", + right: "bases.BaseExpression", ) -> BinaryExpression: """Check if left hstore is contained by right (operator <@). @@ -663,15 +660,15 @@ def hstore_contained_by( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, left), - _convert_to_expression(dialect, right), + left, + right, ) def hstore_subtract_key( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> BinaryExpression: """Delete key from hstore (operator - text). @@ -685,15 +682,15 @@ def hstore_subtract_key( """ return BinaryExpression( dialect, "-", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, key), + hstore_expr, + key, ) def hstore_subtract_keys( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - keys: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + keys: "bases.BaseExpression", ) -> BinaryExpression: """Delete multiple keys from hstore (operator - text[]). @@ -707,15 +704,15 @@ def hstore_subtract_keys( """ return BinaryExpression( dialect, "-", - _convert_to_expression(dialect, hstore_expr), - _convert_to_expression(dialect, keys), + hstore_expr, + keys, ) def hstore_subtract_pairs( dialect: "SQLDialectBase", - left: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - right: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + left: "bases.BaseExpression", + right: "bases.BaseExpression", ) -> BinaryExpression: """Delete matching pairs from hstore (operator - hstore). @@ -729,14 +726,14 @@ def hstore_subtract_pairs( """ return BinaryExpression( dialect, "-", - _convert_to_expression(dialect, left), - _convert_to_expression(dialect, right), + left, + right, ) def hstore_to_array_operator( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> BinaryExpression: """Convert hstore to alternating key/value array (operator %%). @@ -754,13 +751,13 @@ def hstore_to_array_operator( return BinaryExpression( dialect, "%", core.Literal(dialect, ""), - _convert_to_expression(dialect, hstore_expr), + hstore_expr, ) def hstore_to_matrix_operator( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", ) -> BinaryExpression: """Convert hstore to 2D key/value array (operator %#). @@ -778,14 +775,14 @@ def hstore_to_matrix_operator( return BinaryExpression( dialect, "%#", core.Literal(dialect, ""), - _convert_to_expression(dialect, hstore_expr), + hstore_expr, ) def hstore_record_update( dialect: "SQLDialectBase", - record: Union[str, "bases.BaseExpression"], - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], + record: "bases.BaseExpression", + hstore_expr: "bases.BaseExpression", ) -> BinaryExpression: """Update record fields from hstore (operator #=). @@ -799,8 +796,8 @@ def hstore_record_update( """ return BinaryExpression( dialect, "#=", - _convert_to_expression(dialect, record), - _convert_to_expression(dialect, hstore_expr), + record, + hstore_expr, ) @@ -808,8 +805,8 @@ def hstore_record_update( def hstore_subscript_get( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", ) -> core.FunctionCall: """Get value by key using subscript syntax. @@ -831,9 +828,9 @@ def hstore_subscript_get( def hstore_subscript_set( dialect: "SQLDialectBase", - hstore_expr: Union[PostgresHstore, Dict, str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], - value: Union[str, "bases.BaseExpression"], + hstore_expr: "bases.BaseExpression", + key: "bases.BaseExpression", + value: "bases.BaseExpression", ) -> core.FunctionCall: """Set value by key using subscript syntax. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/hypopg.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/hypopg.py index 67b7bb29..b592d1fa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/hypopg.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/hypopg.py @@ -27,7 +27,7 @@ - They return Expression objects (FunctionCall, etc.) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -35,34 +35,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, int, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, integers, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Hypothetical Index Functions ============== def hypopg_create_index( dialect: "SQLDialectBase", - index_def: Union[str, "bases.BaseExpression"], + index_def: str, ) -> core.FunctionCall: """Create a hypothetical index. @@ -87,7 +64,7 @@ def hypopg_create_index( """ return core.FunctionCall( dialect, "hypopg_create_index", - _convert_to_expression(dialect, index_def), + index_def if isinstance(index_def, bases.BaseExpression) else core.Literal(dialect, index_def), ) @@ -138,7 +115,7 @@ def hypopg_show_indexes( def hypopg_estimate_size( dialect: "SQLDialectBase", - index_id: Union[int, "bases.BaseExpression"], + index_id: int, ) -> core.FunctionCall: """Estimate the disk size of a hypothetical index. @@ -161,7 +138,7 @@ def hypopg_estimate_size( """ return core.FunctionCall( dialect, "hypopg_relation_size", - _convert_to_expression(dialect, index_id), + index_id if isinstance(index_id, bases.BaseExpression) else core.Literal(dialect, index_id), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/intarray.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/intarray.py index adc54173..9f4e1ad7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/intarray.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/intarray.py @@ -33,7 +33,7 @@ - They do not concatenate SQL strings directly """ -from typing import Optional, Union, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -42,38 +42,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== intarray Operators ============== def intarray_contains( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - values: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + values: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for the intarray contains operator (@>). @@ -96,15 +70,15 @@ def intarray_contains( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, values), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + values if isinstance(values, bases.BaseExpression) else core.Literal(dialect, values), ) def intarray_contained_by( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - values: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + values: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for the intarray contained-by operator (<@). @@ -126,15 +100,15 @@ def intarray_contained_by( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, values), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + values if isinstance(values, bases.BaseExpression) else core.Literal(dialect, values), ) def intarray_overlaps( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - values: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + values: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for the intarray overlap operator (&&). @@ -156,8 +130,8 @@ def intarray_overlaps( """ return BinaryExpression( dialect, "&&", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, values), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + values if isinstance(values, bases.BaseExpression) else core.Literal(dialect, values), ) @@ -165,8 +139,8 @@ def intarray_overlaps( def intarray_idx( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - value: Union[int, str, "bases.BaseExpression"], + column: bases.BaseExpression, + value: int, ) -> core.FunctionCall: """Generate SQL expression for the idx function. @@ -187,16 +161,16 @@ def intarray_idx( """ return core.FunctionCall( dialect, "idx", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, value), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value), ) def intarray_subarray( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - start: Union[int, str, "bases.BaseExpression"], - length: Optional[Union[int, str, "bases.BaseExpression"]] = None, + column: bases.BaseExpression, + start: int, + length: Optional[int] = None, ) -> core.FunctionCall: """Generate SQL expression for the subarray function. @@ -220,17 +194,17 @@ def intarray_subarray( # Generates SQL: subarray(tags, 2) """ args = [ - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, start), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + start if isinstance(start, bases.BaseExpression) else core.Literal(dialect, start), ] if length is not None: - args.append(_convert_to_expression(dialect, length)) + args.append(length if isinstance(length, bases.BaseExpression) else core.Literal(dialect, length)) return core.FunctionCall(dialect, "subarray", *args) def intarray_uniq( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for the uniq function. @@ -250,13 +224,13 @@ def intarray_uniq( """ return core.FunctionCall( dialect, "uniq", - _convert_to_expression(dialect, column), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), ) def intarray_sort( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for the sort function. @@ -275,15 +249,15 @@ def intarray_sort( """ return core.FunctionCall( dialect, "sort", - _convert_to_expression(dialect, column), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), ) def intarray_operator( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, operator: str, - value: Union[str, "bases.BaseExpression"], + value: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for a generic intarray operator. @@ -310,8 +284,8 @@ def intarray_operator( """ return BinaryExpression( dialect, operator, - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, value), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/json.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/json.py index 377b961b..e0a08d13 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/json.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/json.py @@ -25,29 +25,6 @@ from ..type_values.json import PostgresJsonPath -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - def _convert_jsonpath( dialect: "SQLDialectBase", path: Union["PostgresJsonPath", str, "bases.BaseExpression"], @@ -197,7 +174,7 @@ def json_path_filter(path: Union["PostgresJsonPath", str], condition: str) -> "P def jsonb_path_query( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, path: Union["PostgresJsonPath", str, "bases.BaseExpression"], vars: Optional[Dict[str, Any]] = None, silent: bool = False, @@ -230,7 +207,7 @@ def jsonb_path_query( >>> func.to_sql() ('jsonb_path_query(%s, %s, %s)', ('data', '$.items[*]', True)) """ - col_expr = _convert_to_expression(dialect, column) + col_expr = column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column) path_expr = _convert_jsonpath(dialect, path) args = [col_expr, path_expr] if vars is not None: @@ -243,7 +220,7 @@ def jsonb_path_query( def jsonb_path_query_first( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, path: Union["PostgresJsonPath", str, "bases.BaseExpression"], vars: Optional[Dict[str, Any]] = None, silent: bool = False, @@ -272,7 +249,7 @@ def jsonb_path_query_first( >>> func.to_sql() ('jsonb_path_query_first(%s, %s, %s)', ('data', '$.name', '{"lang": "en"}')) """ - col_expr = _convert_to_expression(dialect, column) + col_expr = column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column) path_expr = _convert_jsonpath(dialect, path) args = [col_expr, path_expr] if vars is not None: @@ -285,7 +262,7 @@ def jsonb_path_query_first( def jsonb_path_exists( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, path: Union["PostgresJsonPath", str, "bases.BaseExpression"], vars: Optional[Dict[str, Any]] = None, silent: bool = False, @@ -314,7 +291,7 @@ def jsonb_path_exists( >>> func.to_sql() ('jsonb_path_exists(%s, %s, %s)', ('data', '$.items[*]', '{"min": 5}')) """ - col_expr = _convert_to_expression(dialect, column) + col_expr = column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column) path_expr = _convert_jsonpath(dialect, path) args = [col_expr, path_expr] if vars is not None: @@ -327,7 +304,7 @@ def jsonb_path_exists( def jsonb_path_match( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, path: Union["PostgresJsonPath", str, "bases.BaseExpression"], vars: Optional[Dict[str, Any]] = None, silent: bool = False, @@ -359,7 +336,7 @@ def jsonb_path_match( >>> func.to_sql() ('jsonb_path_match(%s, %s, %s)', ('data', '$.count > $threshold', '{"threshold": 5}')) """ - col_expr = _convert_to_expression(dialect, column) + col_expr = column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column) path_expr = _convert_jsonpath(dialect, path) args = [col_expr, path_expr] if vars is not None: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/ltree.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/ltree.py index 3406cdb8..86deceda 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/ltree.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/ltree.py @@ -39,41 +39,19 @@ - They do not concatenate SQL strings directly """ -from typing import Optional, Union, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresLqueryType, + PostgresLtxtqueryType, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Literal Constructors ============== def ltree_literal( @@ -120,7 +98,7 @@ def lquery_literal( >>> lquery_literal(dialect, '*.Astronomy.*') # Generates SQL: '*.Astronomy.*'::lquery """ - return core.Literal(dialect, pattern).cast("lquery") + return core.Literal(dialect, pattern).cast(PostgresLqueryType(dialect)) def ltxtquery_literal( @@ -143,15 +121,15 @@ def ltxtquery_literal( >>> ltxtquery_literal(dialect, 'Science & Astronomy') # Generates SQL: 'Science & Astronomy'::ltxtquery """ - return core.Literal(dialect, query).cast("ltxtquery") + return core.Literal(dialect, query).cast(PostgresLtxtqueryType(dialect)) # ============== ltree Operators ============== def ltree_ancestor( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - path: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + path: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for ltree ancestor operator (@>). @@ -172,15 +150,15 @@ def ltree_ancestor( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, path), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + path if isinstance(path, bases.BaseExpression) else core.Literal(dialect, path), ) def ltree_descendant( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - path: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + path: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for ltree descendant operator (<@). @@ -201,15 +179,15 @@ def ltree_descendant( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, path), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + path if isinstance(path, bases.BaseExpression) else core.Literal(dialect, path), ) def ltree_matches( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - pattern: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + pattern: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for ltree lquery match operator (~). @@ -229,15 +207,15 @@ def ltree_matches( """ return BinaryExpression( dialect, "~", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, pattern), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + pattern if isinstance(pattern, bases.BaseExpression) else core.Literal(dialect, pattern), ) def ltree_text_search( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - query: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + query: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for ltree ltxtquery match operator (@). @@ -257,15 +235,15 @@ def ltree_text_search( """ return BinaryExpression( dialect, "@", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, query), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def ltree_concat( dialect: "SQLDialectBase", - left: Union[str, "bases.BaseExpression"], - right: Union[str, "bases.BaseExpression"], + left: bases.BaseExpression, + right: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for ltree concatenation operator (||). @@ -285,8 +263,8 @@ def ltree_concat( """ return BinaryExpression( dialect, "||", - _convert_to_expression(dialect, left), - _convert_to_expression(dialect, right), + left if isinstance(left, bases.BaseExpression) else core.Literal(dialect, left), + right if isinstance(right, bases.BaseExpression) else core.Literal(dialect, right), ) @@ -294,7 +272,7 @@ def ltree_concat( def ltree_nlevel( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for nlevel function. @@ -313,14 +291,16 @@ def ltree_nlevel( >>> ltree_nlevel(dialect, ltree_literal(dialect, 'Top.Science.Astronomy')) # Returns 3 (number of labels) """ - return core.FunctionCall(dialect, "nlevel", _convert_to_expression(dialect, expr)) + return core.FunctionCall( + dialect, "nlevel", + expr if isinstance(expr, bases.BaseExpression) else core.Literal(dialect, expr)) def ltree_subpath( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], - start: Union[int, "bases.BaseExpression"], - length: Optional[Union[int, "bases.BaseExpression"]] = None, + expr: bases.BaseExpression, + start: int, + length: Optional[int] = None, ) -> core.FunctionCall: """Generate SQL expression for subpath function. @@ -344,17 +324,17 @@ def ltree_subpath( # Generates SQL: subpath(path_col, 1) """ args = [ - _convert_to_expression(dialect, expr), - _convert_to_expression(dialect, start), + expr if isinstance(expr, bases.BaseExpression) else core.Literal(dialect, expr), + start if isinstance(start, bases.BaseExpression) else core.Literal(dialect, start), ] if length is not None: - args.append(_convert_to_expression(dialect, length)) + args.append(length if isinstance(length, bases.BaseExpression) else core.Literal(dialect, length)) return core.FunctionCall(dialect, "subpath", *args) def ltree_lca( dialect: "SQLDialectBase", - *paths: Union[str, "bases.BaseExpression"], + *paths: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for lca (lowest common ancestor) function. @@ -373,7 +353,7 @@ def ltree_lca( # Generates SQL: lca('Top.Science.Astronomy', 'Top.Science.Physics') # Returns: 'Top.Science' """ - args = [_convert_to_expression(dialect, path) for path in paths] + args = [path if isinstance(path, bases.BaseExpression) else core.Literal(dialect, path) for path in paths] return core.FunctionCall(dialect, "lca", *args) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py index 0f47c41b..eb333ddf 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py @@ -16,38 +16,9 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """ - Helper function to convert an input value to an appropriate BaseExpression. - - Args: - dialect: The SQL dialect instance - expr: The expression to convert - - Returns: - A BaseExpression instance - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, (int, float)): - return core.Literal(dialect, expr) - elif isinstance(expr, str): - # Try to parse as number first - try: - return core.Literal(dialect, float(expr) if '.' in expr else int(expr)) - except ValueError: - # Not a number, treat as column name - return core.Column(dialect, expr) - else: - return core.Column(dialect, expr) - - def round_( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: "bases.BaseExpression", precision: int = 0, ) -> "core.FunctionCall": """ @@ -69,15 +40,15 @@ def round_( Version: All PostgreSQL versions """ - target_expr = _convert_to_expression(dialect, expr) + target_expr = expr precision_expr = core.Literal(dialect, precision) return core.FunctionCall(dialect, "ROUND", target_expr, precision_expr) def pow( dialect: "SQLDialectBase", - base: Union[str, "bases.BaseExpression"], - exponent: Union[str, "bases.BaseExpression"], + base: "bases.BaseExpression", + exponent: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a POW function call. @@ -98,15 +69,15 @@ def pow( Version: All PostgreSQL versions """ - base_expr = _convert_to_expression(dialect, base) - exp_expr = _convert_to_expression(dialect, exponent) + base_expr = base + exp_expr = exponent return core.FunctionCall(dialect, "POW", base_expr, exp_expr) def power( dialect: "SQLDialectBase", - base: Union[str, "bases.BaseExpression"], - exponent: Union[str, "bases.BaseExpression"], + base: "bases.BaseExpression", + exponent: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a POWER function call (alias for POW). @@ -127,14 +98,14 @@ def power( Version: All PostgreSQL versions """ - base_expr = _convert_to_expression(dialect, base) - exp_expr = _convert_to_expression(dialect, exponent) + base_expr = base + exp_expr = exponent return core.FunctionCall(dialect, "POWER", base_expr, exp_expr) def sqrt( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a SQRT function call. @@ -154,14 +125,14 @@ def sqrt( Version: All PostgreSQL versions """ - target_expr = _convert_to_expression(dialect, expr) + target_expr = expr return core.FunctionCall(dialect, "SQRT", target_expr) def mod( dialect: "SQLDialectBase", - dividend: Union[str, "bases.BaseExpression"], - divisor: Union[str, "bases.BaseExpression"], + dividend: "bases.BaseExpression", + divisor: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a MOD function call. @@ -182,14 +153,14 @@ def mod( Version: All PostgreSQL versions """ - dividend_expr = _convert_to_expression(dialect, dividend) - divisor_expr = _convert_to_expression(dialect, divisor) + dividend_expr = dividend + divisor_expr = divisor return core.FunctionCall(dialect, "MOD", dividend_expr, divisor_expr) def ceil( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a CEIL function call. @@ -209,13 +180,13 @@ def ceil( Version: All PostgreSQL versions """ - target_expr = _convert_to_expression(dialect, expr) + target_expr = expr return core.FunctionCall(dialect, "CEIL", target_expr) def floor( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a FLOOR function call. @@ -235,13 +206,13 @@ def floor( Version: All PostgreSQL versions """ - target_expr = _convert_to_expression(dialect, expr) + target_expr = expr return core.FunctionCall(dialect, "FLOOR", target_expr) def trunc( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: "bases.BaseExpression", precision: int = 0, ) -> "core.FunctionCall": """ @@ -264,16 +235,16 @@ def trunc( Version: All PostgreSQL versions """ - target_expr = _convert_to_expression(dialect, expr) + target_expr = expr precision_expr = core.Literal(dialect, precision) return core.FunctionCall(dialect, "TRUNC", target_expr, precision_expr) def max_( dialect: "SQLDialectBase", - expr1: Union[str, "bases.BaseExpression"], - expr2: Union[str, "bases.BaseExpression"], - *more: Union[str, "bases.BaseExpression"], + expr1: "bases.BaseExpression", + expr2: "bases.BaseExpression", + *more: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a GREATEST or MAX function call. @@ -299,17 +270,17 @@ def max_( Version: All PostgreSQL versions """ - exprs = [_convert_to_expression(dialect, expr1), _convert_to_expression(dialect, expr2)] + exprs = [expr1, expr2] for m in more: - exprs.append(_convert_to_expression(dialect, m)) + exprs.append(m) return core.FunctionCall(dialect, "GREATEST", *exprs) def min_( dialect: "SQLDialectBase", - expr1: Union[str, "bases.BaseExpression"], - expr2: Union[str, "bases.BaseExpression"], - *more: Union[str, "bases.BaseExpression"], + expr1: "bases.BaseExpression", + expr2: "bases.BaseExpression", + *more: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates a LEAST or MIN function call. @@ -335,15 +306,15 @@ def min_( Version: All PostgreSQL versions """ - exprs = [_convert_to_expression(dialect, expr1), _convert_to_expression(dialect, expr2)] + exprs = [expr1, expr2] for m in more: - exprs.append(_convert_to_expression(dialect, m)) + exprs.append(m) return core.FunctionCall(dialect, "LEAST", *exprs) def avg( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], + expr: "bases.BaseExpression", ) -> "core.FunctionCall": """ Creates an AVG aggregate function call. @@ -362,7 +333,7 @@ def avg( Version: All PostgreSQL versions """ - target_expr = _convert_to_expression(dialect, expr) + target_expr = expr return core.FunctionCall(dialect, "AVG", target_expr) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py index 04a25fea..e1d94543 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py @@ -31,7 +31,7 @@ - inetnot(a): Bitwise NOT (~a) """ -from typing import Any, Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression, UnaryExpression @@ -40,29 +40,6 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Session Information Functions ============== def inet_client_addr(dialect: "SQLDialectBase") -> core.FunctionCall: @@ -141,8 +118,8 @@ def inet_server_port(dialect: "SQLDialectBase") -> core.FunctionCall: def inet_merge( dialect: "SQLDialectBase", - net1: Any, - net2: Any, + net1: str, + net2: str, ) -> core.FunctionCall: """ Compute the smallest network that includes both input networks. @@ -162,8 +139,8 @@ def inet_merge( """ return core.FunctionCall( dialect, "inet_merge", - _convert_to_expression(dialect, net1), - _convert_to_expression(dialect, net2), + net1 if isinstance(net1, bases.BaseExpression) else core.Literal(dialect, net1), + net2 if isinstance(net2, bases.BaseExpression) else core.Literal(dialect, net2), ) @@ -171,8 +148,8 @@ def inet_merge( def inet_and( dialect: "SQLDialectBase", - addr1: Any, - addr2: Any, + addr1: str, + addr2: str, ) -> BinaryExpression: """ Compute bitwise AND of two IP addresses. @@ -192,15 +169,15 @@ def inet_and( """ return BinaryExpression( dialect, "&", - _convert_to_expression(dialect, addr1), - _convert_to_expression(dialect, addr2), + addr1 if isinstance(addr1, bases.BaseExpression) else core.Literal(dialect, addr1), + addr2 if isinstance(addr2, bases.BaseExpression) else core.Literal(dialect, addr2), ) def inet_or( dialect: "SQLDialectBase", - addr1: Any, - addr2: Any, + addr1: str, + addr2: str, ) -> BinaryExpression: """ Compute bitwise OR of two IP addresses. @@ -220,14 +197,14 @@ def inet_or( """ return BinaryExpression( dialect, "|", - _convert_to_expression(dialect, addr1), - _convert_to_expression(dialect, addr2), + addr1 if isinstance(addr1, bases.BaseExpression) else core.Literal(dialect, addr1), + addr2 if isinstance(addr2, bases.BaseExpression) else core.Literal(dialect, addr2), ) def inetnot( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> UnaryExpression: """ Compute bitwise NOT of an IP address. @@ -246,7 +223,7 @@ def inetnot( """ return UnaryExpression( dialect, "~", - _convert_to_expression(dialect, addr), + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), pos="before", ) @@ -255,8 +232,8 @@ def inetnot( def inet_set_mask( dialect: "SQLDialectBase", - addr: Any, - mask_len: Any, + addr: str, + mask_len: int, ) -> core.FunctionCall: """ Set the mask length for an IP address. @@ -276,14 +253,14 @@ def inet_set_mask( """ return core.FunctionCall( dialect, "set_masklen", - _convert_to_expression(dialect, addr), - _convert_to_expression(dialect, mask_len), + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + mask_len if isinstance(mask_len, bases.BaseExpression) else core.Literal(dialect, mask_len), ) def inet_masklen( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> core.FunctionCall: """ Return the mask length of an IP address. @@ -300,12 +277,15 @@ def inet_masklen( >>> func.to_sql() ('masklen(%s)', ('192.168.1.1/24',)) """ - return core.FunctionCall(dialect, "masklen", _convert_to_expression(dialect, addr)) + return core.FunctionCall( + dialect, "masklen", + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + ) def inet_netmask( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> core.FunctionCall: """ Return the network mask of an IP address. @@ -322,12 +302,15 @@ def inet_netmask( >>> func.to_sql() ('netmask(%s)', ('192.168.1.1/24',)) """ - return core.FunctionCall(dialect, "netmask", _convert_to_expression(dialect, addr)) + return core.FunctionCall( + dialect, "netmask", + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + ) def inet_network( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> core.FunctionCall: """ Extract the network part of an IP address. @@ -344,12 +327,15 @@ def inet_network( >>> func.to_sql() ('network(%s)', ('192.168.1.1/24',)) """ - return core.FunctionCall(dialect, "network", _convert_to_expression(dialect, addr)) + return core.FunctionCall( + dialect, "network", + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + ) def inet_recv( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> core.FunctionCall: """ Internal function: receive an IP address from a binary string. @@ -366,12 +352,15 @@ def inet_recv( >>> func.to_sql() ('recv(%s)', ('binary_value',)) """ - return core.FunctionCall(dialect, "recv", _convert_to_expression(dialect, addr)) + return core.FunctionCall( + dialect, "recv", + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + ) def inet_show( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> core.FunctionCall: """ Return the IP address as text. @@ -388,12 +377,15 @@ def inet_show( >>> func.to_sql() ('text(%s)', ('192.168.1.1',)) """ - return core.FunctionCall(dialect, "text", _convert_to_expression(dialect, addr)) + return core.FunctionCall( + dialect, "text", + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + ) def cidr_netmask( dialect: "SQLDialectBase", - addr: Any, + addr: str, ) -> core.FunctionCall: """ Return the broadcast address of the network. @@ -410,12 +402,15 @@ def cidr_netmask( >>> func.to_sql() ('broadcast(%s)', ('192.168.1.0/24',)) """ - return core.FunctionCall(dialect, "broadcast", _convert_to_expression(dialect, addr)) + return core.FunctionCall( + dialect, "broadcast", + addr if isinstance(addr, bases.BaseExpression) else core.Literal(dialect, addr), + ) def macaddr8_set7bit( dialect: "SQLDialectBase", - mac: Any, + mac: str, ) -> core.FunctionCall: """ Set the 7th bit of the MAC address (marking it as a multicast address). @@ -432,7 +427,10 @@ def macaddr8_set7bit( >>> func.to_sql() ('macaddr8_set7bit(%s)', ('08:00:2b:01:02:03',)) """ - return core.FunctionCall(dialect, "macaddr8_set7bit", _convert_to_expression(dialect, mac)) + return core.FunctionCall( + dialect, "macaddr8_set7bit", + mac if isinstance(mac, bases.BaseExpression) else core.Literal(dialect, mac), + ) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/orafce.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/orafce.py index efd3f39f..1aaa83b6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/orafce.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/orafce.py @@ -40,36 +40,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase from ..mixins.extensions.orafce import PostgresOrafceMixin - -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, int, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, integers, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Date Functions ============== def add_months( dialect: "SQLDialectBase", - date_expr: Union[str, "bases.BaseExpression"], - months: Union[int, "bases.BaseExpression"], + date_expr: bases.BaseExpression, + months: int, ) -> core.FunctionCall: """Add months to a date. @@ -92,14 +68,14 @@ def add_months( return core.FunctionCall( dialect, dialect.orafce_function_name("ADD_MONTHS"), - _convert_to_expression(dialect, date_expr), - _convert_to_expression(dialect, months), + date_expr if isinstance(date_expr, bases.BaseExpression) else core.Literal(dialect, date_expr), + months if isinstance(months, bases.BaseExpression) else core.Literal(dialect, months), ) def last_day( dialect: "SQLDialectBase", - date_expr: Union[str, "bases.BaseExpression"], + date_expr: bases.BaseExpression, ) -> core.FunctionCall: """Return the last day of the month for a given date. @@ -120,7 +96,7 @@ def last_day( return core.FunctionCall( dialect, dialect.orafce_function_name("LAST_DAY"), - _convert_to_expression(dialect, date_expr), + date_expr if isinstance(date_expr, bases.BaseExpression) else core.Literal(dialect, date_expr), ) @@ -151,15 +127,15 @@ def months_between( return core.FunctionCall( dialect, dialect.orafce_function_name("MONTHS_BETWEEN"), - _convert_to_expression(dialect, date1), - _convert_to_expression(dialect, date2), + date1 if isinstance(date1, bases.BaseExpression) else core.Literal(dialect, date1), + date2 if isinstance(date2, bases.BaseExpression) else core.Literal(dialect, date2), ) def next_day( dialect: "SQLDialectBase", - date_expr: Union[str, "bases.BaseExpression"], - day: Union[str, "bases.BaseExpression"], + date_expr: bases.BaseExpression, + day: str, ) -> core.FunctionCall: """Return the date of the next specified day of the week after a given date. @@ -181,8 +157,8 @@ def next_day( return core.FunctionCall( dialect, dialect.orafce_function_name("NEXT_DAY"), - _convert_to_expression(dialect, date_expr), - _convert_to_expression(dialect, day), + date_expr if isinstance(date_expr, bases.BaseExpression) else core.Literal(dialect, date_expr), + day if isinstance(day, bases.BaseExpression) else core.Literal(dialect, day), ) @@ -190,8 +166,8 @@ def next_day( def nvl( dialect: "SQLDialectBase", - expr1: Union[str, "bases.BaseExpression"], - expr2: Union[str, "bases.BaseExpression"], + expr1: bases.BaseExpression, + expr2: bases.BaseExpression, ) -> core.FunctionCall: """Return expr2 if expr1 is NULL, otherwise return expr1. @@ -213,16 +189,16 @@ def nvl( return core.FunctionCall( dialect, dialect.orafce_function_name("NVL"), - _convert_to_expression(dialect, expr1), - _convert_to_expression(dialect, expr2), + expr1 if isinstance(expr1, bases.BaseExpression) else core.Literal(dialect, expr1), + expr2 if isinstance(expr2, bases.BaseExpression) else core.Literal(dialect, expr2), ) def nvl2( dialect: "SQLDialectBase", - expr1: Union[str, "bases.BaseExpression"], - expr2: Union[str, "bases.BaseExpression"], - expr3: Union[str, "bases.BaseExpression"], + expr1: bases.BaseExpression, + expr2: bases.BaseExpression, + expr3: bases.BaseExpression, ) -> core.FunctionCall: """Return expr2 if expr1 is NOT NULL, otherwise return expr3. @@ -245,9 +221,9 @@ def nvl2( return core.FunctionCall( dialect, dialect.orafce_function_name("NVL2"), - _convert_to_expression(dialect, expr1), - _convert_to_expression(dialect, expr2), - _convert_to_expression(dialect, expr3), + expr1 if isinstance(expr1, bases.BaseExpression) else core.Literal(dialect, expr1), + expr2 if isinstance(expr2, bases.BaseExpression) else core.Literal(dialect, expr2), + expr3 if isinstance(expr3, bases.BaseExpression) else core.Literal(dialect, expr3), ) @@ -255,9 +231,9 @@ def nvl2( def decode( dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], - *matches: Union[str, "bases.BaseExpression"], - default: Optional[Union[str, "bases.BaseExpression"]] = None, + expr: bases.BaseExpression, + *matches: bases.BaseExpression, + default: Optional[bases.BaseExpression] = None, ) -> core.FunctionCall: """Oracle-compatible DECODE conditional expression. @@ -283,11 +259,11 @@ def decode( >>> decode(dialect, 'status', "'active'", '1', "'inactive'", '0', default='-1') >>> decode(dialect, 'color', "'red'", "'R'", "'blue'", "'B'") """ - args = [_convert_to_expression(dialect, expr)] + args = [expr if isinstance(expr, bases.BaseExpression) else core.Literal(dialect, expr)] for m in matches: - args.append(_convert_to_expression(dialect, m)) + args.append(m if isinstance(m, bases.BaseExpression) else core.Literal(dialect, m)) if default is not None: - args.append(_convert_to_expression(dialect, default)) + args.append(default if isinstance(default, bases.BaseExpression) else core.Literal(dialect, default)) return core.FunctionCall( dialect, dialect.orafce_function_name("DECODE"), @@ -299,8 +275,8 @@ def decode( def orafce_trunc( dialect: "SQLDialectBase", - value: Union[str, "bases.BaseExpression"], - format: Optional[Union[str, "bases.BaseExpression"]] = None, + value: bases.BaseExpression, + format: Optional[str] = None, ) -> core.FunctionCall: """Truncate a date or number to the specified precision. @@ -323,9 +299,9 @@ def orafce_trunc( >>> trunc(dialect, 'created_at', "'MONTH'") >>> trunc(dialect, '123.456') """ - args = [_convert_to_expression(dialect, value)] + args = [value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value)] if format is not None: - args.append(_convert_to_expression(dialect, format)) + args.append(format if isinstance(format, bases.BaseExpression) else core.Literal(dialect, format)) return core.FunctionCall( dialect, dialect.orafce_function_name("TRUNC"), @@ -335,8 +311,8 @@ def orafce_trunc( def orafce_round( dialect: "SQLDialectBase", - value: Union[str, "bases.BaseExpression"], - format: Optional[Union[str, "bases.BaseExpression"]] = None, + value: bases.BaseExpression, + format: Optional[str] = None, ) -> core.FunctionCall: """Round a date or number to the specified precision. @@ -359,9 +335,9 @@ def orafce_round( >>> round(dialect, 'created_at', "'MONTH'") >>> round(dialect, '123.456') """ - args = [_convert_to_expression(dialect, value)] + args = [value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value)] if format is not None: - args.append(_convert_to_expression(dialect, format)) + args.append(format if isinstance(format, bases.BaseExpression) else core.Literal(dialect, format)) return core.FunctionCall( dialect, dialect.orafce_function_name("ROUND"), @@ -373,10 +349,10 @@ def orafce_round( def instr( dialect: "SQLDialectBase", - string_expr: Union[str, "bases.BaseExpression"], - substring_expr: Union[str, "bases.BaseExpression"], - position: Union[int, "bases.BaseExpression"] = 1, - occurrence: Union[int, "bases.BaseExpression"] = 1, + string_expr: bases.BaseExpression, + substring_expr: bases.BaseExpression, + position: int = 1, + occurrence: int = 1, ) -> core.FunctionCall: """Find the position of a substring within a string. @@ -401,18 +377,18 @@ def instr( return core.FunctionCall( dialect, dialect.orafce_function_name("INSTR"), - _convert_to_expression(dialect, string_expr), - _convert_to_expression(dialect, substring_expr), - _convert_to_expression(dialect, position), - _convert_to_expression(dialect, occurrence), + string_expr if isinstance(string_expr, bases.BaseExpression) else core.Literal(dialect, string_expr), + substring_expr if isinstance(substring_expr, bases.BaseExpression) else core.Literal(dialect, substring_expr), + position if isinstance(position, bases.BaseExpression) else core.Literal(dialect, position), + occurrence if isinstance(occurrence, bases.BaseExpression) else core.Literal(dialect, occurrence), ) def substr( dialect: "SQLDialectBase", - string_expr: Union[str, "bases.BaseExpression"], - position: Union[int, "bases.BaseExpression"], - length: Optional[Union[int, "bases.BaseExpression"]] = None, + string_expr: bases.BaseExpression, + position: int, + length: Optional[int] = None, ) -> core.FunctionCall: """Extract a substring from a string. @@ -436,11 +412,11 @@ def substr( >>> substr(dialect, 'description', '10') """ args = [ - _convert_to_expression(dialect, string_expr), - _convert_to_expression(dialect, position), + string_expr if isinstance(string_expr, bases.BaseExpression) else core.Literal(dialect, string_expr), + position if isinstance(position, bases.BaseExpression) else core.Literal(dialect, position), ] if length is not None: - args.append(_convert_to_expression(dialect, length)) + args.append(length if isinstance(length, bases.BaseExpression) else core.Literal(dialect, length)) return core.FunctionCall( dialect, dialect.orafce_function_name("SUBSTR"), diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_cron.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_cron.py index aaede880..a86f0f48 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_cron.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_cron.py @@ -25,7 +25,7 @@ - They do not concatenate SQL strings directly """ -from typing import Optional, Union, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -33,39 +33,13 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Job Scheduling ============== def cron_schedule( dialect: "SQLDialectBase", - schedule: Union[str, "bases.BaseExpression"], - command: Union[str, "bases.BaseExpression"], - comment: Optional[Union[str, "bases.BaseExpression"]] = None, + schedule: str, + command: str, + comment: Optional[str] = None, ) -> core.FunctionCall: """Schedule a new cron job. @@ -98,20 +72,20 @@ def cron_schedule( if comment is not None: return core.FunctionCall( dialect, "cron.schedule", - _convert_to_expression(dialect, schedule), - _convert_to_expression(dialect, command), - _convert_to_expression(dialect, comment), + schedule if isinstance(schedule, bases.BaseExpression) else core.Literal(dialect, schedule), + command if isinstance(command, bases.BaseExpression) else core.Literal(dialect, command), + comment if isinstance(comment, bases.BaseExpression) else core.Literal(dialect, comment), ) return core.FunctionCall( dialect, "cron.schedule", - _convert_to_expression(dialect, schedule), - _convert_to_expression(dialect, command), + schedule if isinstance(schedule, bases.BaseExpression) else core.Literal(dialect, schedule), + command if isinstance(command, bases.BaseExpression) else core.Literal(dialect, command), ) def cron_unschedule( dialect: "SQLDialectBase", - job_id: Union[int, "bases.BaseExpression"], + job_id: int, ) -> core.FunctionCall: """Remove a scheduled cron job. @@ -131,13 +105,13 @@ def cron_unschedule( """ return core.FunctionCall( dialect, "cron.unschedule", - _convert_to_expression(dialect, job_id), + job_id if isinstance(job_id, bases.BaseExpression) else core.Literal(dialect, job_id), ) def cron_run( dialect: "SQLDialectBase", - job_id: Union[int, "bases.BaseExpression"], + job_id: int, ) -> core.FunctionCall: """Run a scheduled cron job immediately. @@ -158,7 +132,7 @@ def cron_run( """ return core.FunctionCall( dialect, "cron.run", - _convert_to_expression(dialect, job_id), + job_id if isinstance(job_id, bases.BaseExpression) else core.Literal(dialect, job_id), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_partman.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_partman.py index b746a0dc..4242e38d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_partman.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_partman.py @@ -32,7 +32,7 @@ - They return Expression objects (FunctionCall, etc.) """ -from typing import Union, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -40,38 +40,15 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, int, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, integers, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Partition Management Functions ============== def create_parent( dialect: "SQLDialectBase", - parent_table: Union[str, "bases.BaseExpression"], - control: Union[str, "bases.BaseExpression"], - interval: Union[str, "bases.BaseExpression"], - partition_type: Union[str, "bases.BaseExpression"] = "native", - premake: Union[int, "bases.BaseExpression"] = 4, + parent_table: str, + control: str, + interval: str, + partition_type: str = "native", + premake: int = 4, ) -> core.FunctionCall: """Create a partition set for a parent table. @@ -107,17 +84,17 @@ def create_parent( """ return core.FunctionCall( dialect, "create_parent", - _convert_to_expression(dialect, parent_table), - _convert_to_expression(dialect, control), - _convert_to_expression(dialect, interval), - _convert_to_expression(dialect, partition_type), - _convert_to_expression(dialect, premake), + parent_table if isinstance(parent_table, bases.BaseExpression) else core.Literal(dialect, parent_table), + control if isinstance(control, bases.BaseExpression) else core.Literal(dialect, control), + interval if isinstance(interval, bases.BaseExpression) else core.Literal(dialect, interval), + partition_type if isinstance(partition_type, bases.BaseExpression) else core.Literal(dialect, partition_type), + premake if isinstance(premake, bases.BaseExpression) else core.Literal(dialect, premake), ) def run_maintenance( dialect: "SQLDialectBase", - parent_table: Optional[Union[str, "bases.BaseExpression"]] = None, + parent_table: Optional[str] = None, ) -> core.FunctionCall: """Run partition maintenance to create new partitions and drop old ones. @@ -146,7 +123,9 @@ def run_maintenance( """ args = [] if parent_table is not None: - args.append(_convert_to_expression(dialect, parent_table)) + args.append( + parent_table if isinstance(parent_table, bases.BaseExpression) + else core.Literal(dialect, parent_table)) return core.FunctionCall(dialect, "run_maintenance", *args) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_repack.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_repack.py index d68bbb5d..acdac8c4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_repack.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_repack.py @@ -34,37 +34,14 @@ - They return Expression objects (FunctionCall, etc.) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING -from rhosocial.activerecord.backend.expression import bases, core +from rhosocial.activerecord.backend.expression import core if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Extension Status Functions ============== def repack_version( diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_surgery.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_surgery.py index 82f0bf85..97b8b9c2 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_surgery.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_surgery.py @@ -46,34 +46,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, int, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, integers, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Heap Surgery Functions ============== def heap_force_freeze( dialect: "SQLDialectBase", - reloid: Union[str, "bases.BaseExpression"], + reloid: str, tids: Union[str, "bases.BaseExpression"], ) -> core.FunctionCall: """Force-freeze tuples on a heap relation. @@ -107,14 +84,14 @@ def heap_force_freeze( """ return core.FunctionCall( dialect, "heap_force_freeze", - _convert_to_expression(dialect, reloid), - _convert_to_expression(dialect, tids), + reloid if isinstance(reloid, bases.BaseExpression) else core.Literal(dialect, reloid), + tids if isinstance(tids, bases.BaseExpression) else core.Literal(dialect, tids), ) def heap_force_kill( dialect: "SQLDialectBase", - reloid: Union[str, "bases.BaseExpression"], + reloid: str, tids: Union[str, "bases.BaseExpression"], ) -> core.FunctionCall: """Force-kill tuples on a heap relation. @@ -148,8 +125,8 @@ def heap_force_kill( """ return core.FunctionCall( dialect, "heap_force_kill", - _convert_to_expression(dialect, reloid), - _convert_to_expression(dialect, tids), + reloid if isinstance(reloid, bases.BaseExpression) else core.Literal(dialect, reloid), + tids if isinstance(tids, bases.BaseExpression) else core.Literal(dialect, tids), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_trgm.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_trgm.py index 7138f136..1c7e21ea 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_trgm.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_trgm.py @@ -25,7 +25,7 @@ - !% : Text is not similar (negated similarity threshold) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -34,37 +34,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Similarity Functions ============== def similarity( dialect: "SQLDialectBase", - text1: Union[str, "bases.BaseExpression"], - text2: Union[str, "bases.BaseExpression"], + text1: bases.BaseExpression, + text2: bases.BaseExpression, ) -> core.FunctionCall: """Calculate the similarity between two texts. @@ -91,15 +66,15 @@ def similarity( """ return core.FunctionCall( dialect, "similarity", - _convert_to_expression(dialect, text1), - _convert_to_expression(dialect, text2), + text1 if isinstance(text1, bases.BaseExpression) else core.Literal(dialect, text1), + text2 if isinstance(text2, bases.BaseExpression) else core.Literal(dialect, text2), ) def word_similarity( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - query: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + query: str, ) -> core.FunctionCall: """Calculate the word similarity between a document column and a query. @@ -126,8 +101,8 @@ def word_similarity( """ return core.FunctionCall( dialect, "word_similarity", - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, query), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) @@ -135,7 +110,7 @@ def word_similarity( def show_trgm( dialect: "SQLDialectBase", - text: Union[str, "bases.BaseExpression"], + text: bases.BaseExpression, ) -> core.FunctionCall: """Show the trigrams of a text string. @@ -158,7 +133,7 @@ def show_trgm( """ return core.FunctionCall( dialect, "show_trgm", - _convert_to_expression(dialect, text), + text if isinstance(text, bases.BaseExpression) else core.Literal(dialect, text), ) @@ -166,8 +141,8 @@ def show_trgm( def similarity_operator( dialect: "SQLDialectBase", - column: Union[str, "bases.BaseExpression"], - text: Union[str, "bases.BaseExpression"], + column: bases.BaseExpression, + text: bases.BaseExpression, negate: bool = False, ) -> BinaryExpression: """Generate SQL expression for the similarity operator (%). @@ -199,8 +174,8 @@ def similarity_operator( operator = "!%" if negate else "%" return BinaryExpression( dialect, operator, - _convert_to_expression(dialect, column), - _convert_to_expression(dialect, text), + column if isinstance(column, bases.BaseExpression) else core.Literal(dialect, column), + text if isinstance(text, bases.BaseExpression) else core.Literal(dialect, text), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_walinspect.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_walinspect.py index 73b7e455..99339f67 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_walinspect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pg_walinspect.py @@ -32,7 +32,7 @@ - They return Expression objects (FunctionCall, etc.) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -40,35 +40,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, int, bool, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings, integers, booleans, and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== WAL Inspection Functions ============== def pg_get_wal_records_info( dialect: "SQLDialectBase", - start_lsn: Union[str, "bases.BaseExpression"], - end_lsn: Union[str, "bases.BaseExpression"], + start_lsn: str, + end_lsn: str, ) -> core.FunctionCall: """Get information about WAL records in a LSN range. @@ -94,15 +71,15 @@ def pg_get_wal_records_info( """ return core.FunctionCall( dialect, "pg_get_wal_records_info", - _convert_to_expression(dialect, start_lsn), - _convert_to_expression(dialect, end_lsn), + start_lsn if isinstance(start_lsn, bases.BaseExpression) else core.Literal(dialect, start_lsn), + end_lsn if isinstance(end_lsn, bases.BaseExpression) else core.Literal(dialect, end_lsn), ) def pg_get_wal_blocks_info( dialect: "SQLDialectBase", - start_lsn: Union[str, "bases.BaseExpression"], - end_lsn: Union[str, "bases.BaseExpression"], + start_lsn: str, + end_lsn: str, ) -> core.FunctionCall: """Get information about WAL block references in a LSN range. @@ -128,8 +105,8 @@ def pg_get_wal_blocks_info( """ return core.FunctionCall( dialect, "pg_get_wal_blocks_info", - _convert_to_expression(dialect, start_lsn), - _convert_to_expression(dialect, end_lsn), + start_lsn if isinstance(start_lsn, bases.BaseExpression) else core.Literal(dialect, start_lsn), + end_lsn if isinstance(end_lsn, bases.BaseExpression) else core.Literal(dialect, end_lsn), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgaudit.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgaudit.py index a21ad855..161fd209 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgaudit.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgaudit.py @@ -29,7 +29,7 @@ - They do not concatenate SQL strings directly """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -37,37 +37,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Configuration Functions ============== def pgaudit_set_role( dialect: "SQLDialectBase", - role: Union[str, "bases.BaseExpression"], + role: str, ) -> core.FunctionCall: """Set the pgaudit.role configuration parameter. @@ -95,15 +69,15 @@ def pgaudit_set_role( """ return core.FunctionCall( dialect, "set_config", - _convert_to_expression(dialect, "pgaudit.role"), - _convert_to_expression(dialect, role), - _convert_to_expression(dialect, "false"), + core.Literal(dialect, "pgaudit.role"), + role if isinstance(role, bases.BaseExpression) else core.Literal(dialect, role), + core.Literal(dialect, "false"), ) def pgaudit_log_level( dialect: "SQLDialectBase", - level: Union[str, "bases.BaseExpression"], + level: str, ) -> core.FunctionCall: """Set the pgaudit.log_level configuration parameter. @@ -134,15 +108,15 @@ def pgaudit_log_level( """ return core.FunctionCall( dialect, "set_config", - _convert_to_expression(dialect, "pgaudit.log_level"), - _convert_to_expression(dialect, level), - _convert_to_expression(dialect, "false"), + core.Literal(dialect, "pgaudit.log_level"), + level if isinstance(level, bases.BaseExpression) else core.Literal(dialect, level), + core.Literal(dialect, "false"), ) def pgaudit_include_catalog( dialect: "SQLDialectBase", - include: Union[bool, "bases.BaseExpression"], + include: bool, ) -> core.FunctionCall: """Set the pgaudit.log_catalog configuration parameter. @@ -176,9 +150,9 @@ def pgaudit_include_catalog( value = include return core.FunctionCall( dialect, "set_config", - _convert_to_expression(dialect, "pgaudit.log_catalog"), - _convert_to_expression(dialect, value), - _convert_to_expression(dialect, "false"), + core.Literal(dialect, "pgaudit.log_catalog"), + value if isinstance(value, bases.BaseExpression) else core.Literal(dialect, value), + core.Literal(dialect, "false"), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgcrypto.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgcrypto.py index 3462cf4f..5d6f7212 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgcrypto.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgcrypto.py @@ -26,7 +26,7 @@ - They return Expression objects (FunctionCall, BinaryExpression, etc.) """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -34,29 +34,6 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Salt Generation ============== def gen_salt( @@ -89,7 +66,7 @@ def gen_salt( """ return core.FunctionCall( dialect, "gen_salt", - _convert_to_expression(dialect, algorithm), + algorithm if isinstance(algorithm, bases.BaseExpression) else core.Literal(dialect, algorithm), ) @@ -97,8 +74,8 @@ def gen_salt( def crypt( dialect: "SQLDialectBase", - password: Union[str, "bases.BaseExpression"], - salt: Union[str, "bases.BaseExpression"], + password: bases.BaseExpression, + salt: bases.BaseExpression, ) -> core.FunctionCall: """Hash a password using the crypt() function. @@ -119,8 +96,8 @@ def crypt( """ return core.FunctionCall( dialect, "crypt", - _convert_to_expression(dialect, password), - _convert_to_expression(dialect, salt), + password if isinstance(password, bases.BaseExpression) else core.Literal(dialect, password), + salt if isinstance(salt, bases.BaseExpression) else core.Literal(dialect, salt), ) @@ -128,8 +105,8 @@ def crypt( def encrypt( dialect: "SQLDialectBase", - data: Union[str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + data: bases.BaseExpression, + key: bases.BaseExpression, algorithm: str = "aes", ) -> core.FunctionCall: """Encrypt data using the encrypt() function. @@ -164,16 +141,16 @@ def encrypt( """ return core.FunctionCall( dialect, "encrypt", - _convert_to_expression(dialect, data), - _convert_to_expression(dialect, key), - _convert_to_expression(dialect, algorithm), + data if isinstance(data, bases.BaseExpression) else core.Literal(dialect, data), + key if isinstance(key, bases.BaseExpression) else core.Literal(dialect, key), + algorithm if isinstance(algorithm, bases.BaseExpression) else core.Literal(dialect, algorithm), ) def decrypt( dialect: "SQLDialectBase", - data: Union[str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + data: bases.BaseExpression, + key: bases.BaseExpression, algorithm: str = "aes", ) -> core.FunctionCall: """Decrypt data using the decrypt() function. @@ -196,9 +173,9 @@ def decrypt( """ return core.FunctionCall( dialect, "decrypt", - _convert_to_expression(dialect, data), - _convert_to_expression(dialect, key), - _convert_to_expression(dialect, algorithm), + data if isinstance(data, bases.BaseExpression) else core.Literal(dialect, data), + key if isinstance(key, bases.BaseExpression) else core.Literal(dialect, key), + algorithm if isinstance(algorithm, bases.BaseExpression) else core.Literal(dialect, algorithm), ) @@ -206,7 +183,7 @@ def decrypt( def gen_random_bytes( dialect: "SQLDialectBase", - length: Union[int, "bases.BaseExpression"], + length: int, ) -> core.FunctionCall: """Generate random bytes. @@ -225,7 +202,7 @@ def gen_random_bytes( """ return core.FunctionCall( dialect, "gen_random_bytes", - _convert_to_expression(dialect, length), + length if isinstance(length, bases.BaseExpression) else core.Literal(dialect, length), ) @@ -233,8 +210,8 @@ def gen_random_bytes( def hmac( dialect: "SQLDialectBase", - data: Union[str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + data: bases.BaseExpression, + key: bases.BaseExpression, algorithm: str = "sha256", ) -> core.FunctionCall: """Compute HMAC (Hash-based Message Authentication Code). @@ -266,15 +243,15 @@ def hmac( """ return core.FunctionCall( dialect, "hmac", - _convert_to_expression(dialect, data), - _convert_to_expression(dialect, key), - _convert_to_expression(dialect, algorithm), + data if isinstance(data, bases.BaseExpression) else core.Literal(dialect, data), + key if isinstance(key, bases.BaseExpression) else core.Literal(dialect, key), + algorithm if isinstance(algorithm, bases.BaseExpression) else core.Literal(dialect, algorithm), ) def digest( dialect: "SQLDialectBase", - data: Union[str, "bases.BaseExpression"], + data: bases.BaseExpression, algorithm: str = "sha256", ) -> core.FunctionCall: """Compute a hash digest. @@ -304,8 +281,8 @@ def digest( """ return core.FunctionCall( dialect, "digest", - _convert_to_expression(dialect, data), - _convert_to_expression(dialect, algorithm), + data if isinstance(data, bases.BaseExpression) else core.Literal(dialect, data), + algorithm if isinstance(algorithm, bases.BaseExpression) else core.Literal(dialect, algorithm), ) @@ -313,8 +290,8 @@ def digest( def pgp_sym_encrypt( dialect: "SQLDialectBase", - data: Union[str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + data: bases.BaseExpression, + key: bases.BaseExpression, ) -> core.FunctionCall: """Encrypt data with a symmetric PGP key. @@ -338,15 +315,15 @@ def pgp_sym_encrypt( """ return core.FunctionCall( dialect, "pgp_sym_encrypt", - _convert_to_expression(dialect, data), - _convert_to_expression(dialect, key), + data if isinstance(data, bases.BaseExpression) else core.Literal(dialect, data), + key if isinstance(key, bases.BaseExpression) else core.Literal(dialect, key), ) def pgp_sym_decrypt( dialect: "SQLDialectBase", - data: Union[str, "bases.BaseExpression"], - key: Union[str, "bases.BaseExpression"], + data: bases.BaseExpression, + key: bases.BaseExpression, ) -> core.FunctionCall: """Decrypt data with a symmetric PGP key. @@ -366,8 +343,8 @@ def pgp_sym_decrypt( """ return core.FunctionCall( dialect, "pgp_sym_decrypt", - _convert_to_expression(dialect, data), - _convert_to_expression(dialect, key), + data if isinstance(data, bases.BaseExpression) else core.Literal(dialect, data), + key if isinstance(key, bases.BaseExpression) else core.Literal(dialect, key), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pglogical.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pglogical.py index 2c918f19..a24a0b5b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pglogical.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pglogical.py @@ -35,38 +35,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Node Management ============== def pglogical_create_node( dialect: "SQLDialectBase", - node_name: Union[str, "bases.BaseExpression"], - dsn: Union[str, "bases.BaseExpression"], + node_name: str, + dsn: str, ) -> core.FunctionCall: """Create a pglogical node for logical replication. @@ -88,8 +62,8 @@ def pglogical_create_node( """ return core.FunctionCall( dialect, "pglogical.create_node", - _convert_to_expression(dialect, node_name), - _convert_to_expression(dialect, dsn), + node_name if isinstance(node_name, bases.BaseExpression) else core.Literal(dialect, node_name), + dsn if isinstance(dsn, bases.BaseExpression) else core.Literal(dialect, dsn), ) @@ -97,7 +71,7 @@ def pglogical_create_node( def pglogical_create_publication( dialect: "SQLDialectBase", - pub_name: Union[str, "bases.BaseExpression"], + pub_name: str, tables: Union[str, List[str], "bases.BaseExpression"], replication_sets: Optional[Union[List[str], "bases.BaseExpression"]] = None, ) -> core.FunctionCall: @@ -124,11 +98,13 @@ def pglogical_create_publication( >>> pglogical_create_publication(dialect, 'my_pub', '{users, orders}', '{default}') """ args = [ - _convert_to_expression(dialect, pub_name), - _convert_to_expression(dialect, tables), + pub_name if isinstance(pub_name, bases.BaseExpression) else core.Literal(dialect, pub_name), + tables if isinstance(tables, bases.BaseExpression) else core.Literal(dialect, tables), ] if replication_sets is not None: - args.append(_convert_to_expression(dialect, replication_sets)) + args.append( + replication_sets if isinstance(replication_sets, bases.BaseExpression) + else core.Literal(dialect, replication_sets)) return core.FunctionCall(dialect, "pglogical.create_publication", *args) @@ -136,8 +112,8 @@ def pglogical_create_publication( def pglogical_create_subscription( dialect: "SQLDialectBase", - sub_name: Union[str, "bases.BaseExpression"], - provider_dsn: Union[str, "bases.BaseExpression"], + sub_name: str, + provider_dsn: str, publication_names: Union[str, List[str], "bases.BaseExpression"], replication_sets: Optional[Union[List[str], "bases.BaseExpression"]] = None, ) -> core.FunctionCall: @@ -165,18 +141,21 @@ def pglogical_create_subscription( ... dialect, 'my_sub', 'host=provider.db dbname=mydb', '{my_pub}', '{default}') """ args = [ - _convert_to_expression(dialect, sub_name), - _convert_to_expression(dialect, provider_dsn), - _convert_to_expression(dialect, publication_names), + sub_name if isinstance(sub_name, bases.BaseExpression) else core.Literal(dialect, sub_name), + provider_dsn if isinstance(provider_dsn, bases.BaseExpression) else core.Literal(dialect, provider_dsn), + publication_names if isinstance(publication_names, bases.BaseExpression) + else core.Literal(dialect, publication_names), ] if replication_sets is not None: - args.append(_convert_to_expression(dialect, replication_sets)) + args.append( + replication_sets if isinstance(replication_sets, bases.BaseExpression) + else core.Literal(dialect, replication_sets)) return core.FunctionCall(dialect, "pglogical.create_subscription", *args) def pglogical_show_subscription_status( dialect: "SQLDialectBase", - sub_name: Optional[Union[str, "bases.BaseExpression"]] = None, + sub_name: Optional[str] = None, ) -> core.FunctionCall: """Show the status of pglogical subscriptions. @@ -199,7 +178,7 @@ def pglogical_show_subscription_status( if sub_name is not None: return core.FunctionCall( dialect, "pglogical.show_subscription_status", - _convert_to_expression(dialect, sub_name), + sub_name if isinstance(sub_name, bases.BaseExpression) else core.Literal(dialect, sub_name), ) return core.FunctionCall( dialect, "pglogical.show_subscription_status", @@ -208,8 +187,8 @@ def pglogical_show_subscription_status( def pglogical_alter_subscription_synchronize( dialect: "SQLDialectBase", - sub_name: Union[str, "bases.BaseExpression"], - truncate: Optional[Union[bool, "bases.BaseExpression"]] = None, + sub_name: str, + truncate: Optional[bool] = None, ) -> core.FunctionCall: """Synchronize a pglogical subscription. @@ -231,10 +210,10 @@ def pglogical_alter_subscription_synchronize( >>> pglogical_alter_subscription_synchronize(dialect, 'my_sub', truncate=True) """ args = [ - _convert_to_expression(dialect, sub_name), + sub_name if isinstance(sub_name, bases.BaseExpression) else core.Literal(dialect, sub_name), ] if truncate is not None: - args.append(_convert_to_expression(dialect, truncate)) + args.append(truncate if isinstance(truncate, bases.BaseExpression) else core.Literal(dialect, truncate)) return core.FunctionCall( dialect, "pglogical.alter_subscription_synchronize", *args, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgrouting.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgrouting.py index 079e9288..adbecdf1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgrouting.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgrouting.py @@ -25,7 +25,7 @@ - They do not concatenate SQL strings directly """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -33,40 +33,14 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Routing Functions ============== def pgr_dijkstra( dialect: "SQLDialectBase", - edges_sql: Union[str, "bases.BaseExpression"], - start_vid: Union[int, "bases.BaseExpression"], - end_vid: Union[int, "bases.BaseExpression"], - directed: Union[bool, "bases.BaseExpression"] = True, + edges_sql: str, + start_vid: int, + end_vid: int, + directed: bool = True, ) -> core.FunctionCall: """Calculate the shortest path using Dijkstra's algorithm. @@ -102,19 +76,19 @@ def pgr_dijkstra( """ return core.FunctionCall( dialect, "pgr_dijkstra", - _convert_to_expression(dialect, edges_sql), - _convert_to_expression(dialect, start_vid), - _convert_to_expression(dialect, end_vid), - _convert_to_expression(dialect, directed), + edges_sql if isinstance(edges_sql, bases.BaseExpression) else core.Literal(dialect, edges_sql), + start_vid if isinstance(start_vid, bases.BaseExpression) else core.Literal(dialect, start_vid), + end_vid if isinstance(end_vid, bases.BaseExpression) else core.Literal(dialect, end_vid), + directed if isinstance(directed, bases.BaseExpression) else core.Literal(dialect, directed), ) def pgr_astar( dialect: "SQLDialectBase", - edges_sql: Union[str, "bases.BaseExpression"], - start_vid: Union[int, "bases.BaseExpression"], - end_vid: Union[int, "bases.BaseExpression"], - directed: Union[bool, "bases.BaseExpression"] = True, + edges_sql: str, + start_vid: int, + end_vid: int, + directed: bool = True, ) -> core.FunctionCall: """Calculate the shortest path using A* algorithm. @@ -153,10 +127,10 @@ def pgr_astar( """ return core.FunctionCall( dialect, "pgr_astar", - _convert_to_expression(dialect, edges_sql), - _convert_to_expression(dialect, start_vid), - _convert_to_expression(dialect, end_vid), - _convert_to_expression(dialect, directed), + edges_sql if isinstance(edges_sql, bases.BaseExpression) else core.Literal(dialect, edges_sql), + start_vid if isinstance(start_vid, bases.BaseExpression) else core.Literal(dialect, start_vid), + end_vid if isinstance(end_vid, bases.BaseExpression) else core.Literal(dialect, end_vid), + directed if isinstance(directed, bases.BaseExpression) else core.Literal(dialect, directed), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py index 30eb0cf8..f0cbc5bd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py @@ -26,6 +26,9 @@ from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression from rhosocial.activerecord.backend.impl.postgres.type_values.pgvector import PostgresVector +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresVectorType, +) if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase @@ -38,39 +41,6 @@ } -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[PostgresVector, List[float], str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports PostgresVector objects, List[float], strings, and - existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, PostgresVector): - literal = core.Literal(dialect, expr.to_postgres_string()) - if expr.dimensions is not None: - return literal.cast(f"vector({expr.dimensions})") - return literal.cast("vector") - elif isinstance(expr, list): - vec = PostgresVector(values=expr) - literal = core.Literal(dialect, vec.to_postgres_string()) - return literal.cast(f"vector({vec.dimensions})") - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # === Distance Operators === def vector_l2_distance( @@ -94,8 +64,34 @@ def vector_l2_distance( >>> d = PostgresDialect() >>> expr = vector_l2_distance(d, "embedding", [1.0, 2.0, 3.0]) """ - left_expr = _convert_to_expression(dialect, left) - right_expr = _convert_to_expression(dialect, right) + if isinstance(left, bases.BaseExpression): + left_expr = left + elif isinstance(left, PostgresVector): + literal = core.Literal(dialect, left.to_postgres_string()) + left_expr = literal.cast( + PostgresVectorType(dialect, left.dimensions) + if left.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(left, list): + vec = PostgresVector(values=left) + left_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + left_expr = core.Literal(dialect, left) + + if isinstance(right, bases.BaseExpression): + right_expr = right + elif isinstance(right, PostgresVector): + literal = core.Literal(dialect, right.to_postgres_string()) + right_expr = literal.cast( + PostgresVectorType(dialect, right.dimensions) + if right.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(right, list): + vec = PostgresVector(values=right) + right_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + right_expr = core.Literal(dialect, right) + return BinaryArithmeticExpression(dialect, "<->", left_expr, right_expr) @@ -115,8 +111,34 @@ def vector_cosine_distance( Returns: BinaryArithmeticExpression for the cosine distance """ - left_expr = _convert_to_expression(dialect, left) - right_expr = _convert_to_expression(dialect, right) + if isinstance(left, bases.BaseExpression): + left_expr = left + elif isinstance(left, PostgresVector): + literal = core.Literal(dialect, left.to_postgres_string()) + left_expr = literal.cast( + PostgresVectorType(dialect, left.dimensions) + if left.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(left, list): + vec = PostgresVector(values=left) + left_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + left_expr = core.Literal(dialect, left) + + if isinstance(right, bases.BaseExpression): + right_expr = right + elif isinstance(right, PostgresVector): + literal = core.Literal(dialect, right.to_postgres_string()) + right_expr = literal.cast( + PostgresVectorType(dialect, right.dimensions) + if right.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(right, list): + vec = PostgresVector(values=right) + right_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + right_expr = core.Literal(dialect, right) + return BinaryArithmeticExpression(dialect, "<=>", left_expr, right_expr) @@ -139,8 +161,34 @@ def vector_inner_product( Returns: BinaryArithmeticExpression for the inner product """ - left_expr = _convert_to_expression(dialect, left) - right_expr = _convert_to_expression(dialect, right) + if isinstance(left, bases.BaseExpression): + left_expr = left + elif isinstance(left, PostgresVector): + literal = core.Literal(dialect, left.to_postgres_string()) + left_expr = literal.cast( + PostgresVectorType(dialect, left.dimensions) + if left.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(left, list): + vec = PostgresVector(values=left) + left_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + left_expr = core.Literal(dialect, left) + + if isinstance(right, bases.BaseExpression): + right_expr = right + elif isinstance(right, PostgresVector): + literal = core.Literal(dialect, right.to_postgres_string()) + right_expr = literal.cast( + PostgresVectorType(dialect, right.dimensions) + if right.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(right, list): + vec = PostgresVector(values=right) + right_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + right_expr = core.Literal(dialect, right) + return BinaryArithmeticExpression(dialect, "<#>", left_expr, right_expr) @@ -173,9 +221,23 @@ def vector_distance( column_expr = ( core.Column(dialect, column) if isinstance(column, str) else column ) + if isinstance(query_vector, bases.BaseExpression): + query_expr = query_vector + elif isinstance(query_vector, PostgresVector): + literal = core.Literal(dialect, query_vector.to_postgres_string()) + query_expr = literal.cast( + PostgresVectorType(dialect, query_vector.dimensions) + if query_vector.dimensions is not None else PostgresVectorType(dialect)) + elif isinstance(query_vector, list): + vec = PostgresVector(values=query_vector) + query_expr = core.Literal(dialect, vec.to_postgres_string()).cast( + PostgresVectorType(dialect, vec.dimensions)) + else: + query_expr = core.Literal(dialect, query_vector) + return BinaryArithmeticExpression( dialect, _METRIC_OPERATORS[metric], - column_expr, _convert_to_expression(dialect, query_vector), + column_expr, query_expr, ) @@ -229,7 +291,10 @@ def vector_literal( >>> # Produces: '[1.0, 2.0, 3.0]'::vector(3) """ vec = PostgresVector(values=values, dimensions=dimensions) - return _convert_to_expression(dialect, vec) + literal = core.Literal(dialect, vec.to_postgres_string()) + if vec.dimensions is not None: + return literal.cast(PostgresVectorType(dialect, vec.dimensions)) + return literal.cast(PostgresVectorType(dialect)) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis.py index 510861ba..99bf536a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis.py @@ -20,48 +20,45 @@ if TYPE_CHECKING: from rhosocial.activerecord.backend.dialect import SQLDialectBase - -def _convert_to_expression( +def geometry_expr( dialect: "SQLDialectBase", - expr: Union[PostgresGeometry, str, "bases.BaseExpression"], + value: Union[PostgresGeometry, str], ) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports PostgresGeometry objects, strings, and existing - BaseExpression objects. + """Build the expression for a geometry given as domain data. - For PostgresGeometry, generates the appropriate ST_GeomFromText / - ST_GeogFromText FunctionCall expression automatically. + A :class:`PostgresGeometry` carries a geometry type, a WKT body and an SRID, + and which function constructs it from those depends on the first: geography + goes to ST_GeogFromText and everything else to ST_GeomFromText. That is + worth doing and worth naming. It was not worth doing inside a converter that + every function in the module called on the way past, where it also had to + guess whether a string was WKT or a column name holding some. Args: - dialect: The SQL dialect instance - expr: Value to convert + dialect: The SQL dialect instance. + value: A geometry object, or WKT text. Returns: - BaseExpression representing the value + A construction call for an object, or a literal for WKT. """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, PostgresGeometry): - # Generate ST_GeomFromText / ST_GeogFromText FunctionCall - if expr.geometry_type == "geography": - return st_geog_from_text(dialect, expr.wkt) - else: - return st_geom_from_text(dialect, expr.wkt, expr.srid) - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) + if isinstance(value, PostgresGeometry): + if value.geometry_type == "geography": + return st_geog_from_text(dialect, core.Literal(dialect, value.wkt)) + return st_geom_from_text( + dialect, + core.Literal(dialect, value.wkt), + core.Literal(dialect, value.srid) if value.srid is not None else None, + ) + return core.Literal(dialect, value) # === Geometry Construction === def st_make_point( dialect: "SQLDialectBase", - x: Union[int, float, "bases.BaseExpression"], - y: Union[int, float, "bases.BaseExpression"], - z: Optional[Union[int, float, "bases.BaseExpression"]] = None, - m: Optional[Union[int, float, "bases.BaseExpression"]] = None, + x: "bases.BaseExpression", + y: "bases.BaseExpression", + z: Optional["bases.BaseExpression"] = None, + m: Optional["bases.BaseExpression"] = None, ) -> core.FunctionCall: """ST_MakePoint(x, y [, z [, m]]) - Construct a point geometry. @@ -75,18 +72,18 @@ def st_make_point( Returns: FunctionCall for ST_MakePoint """ - args = [_convert_to_expression(dialect, x), _convert_to_expression(dialect, y)] + args = [x, y] if z is not None: - args.append(_convert_to_expression(dialect, z)) + args.append(z) if m is not None: - args.append(_convert_to_expression(dialect, m)) + args.append(m) return core.FunctionCall(dialect, "ST_MakePoint", *args) def st_geom_from_text( dialect: "SQLDialectBase", - wkt: Union[str, PostgresGeometry, "bases.BaseExpression"], - srid: Optional[Union[int, "bases.BaseExpression"]] = None, + wkt: "bases.BaseExpression", + srid: Optional["bases.BaseExpression"] = None, ) -> core.FunctionCall: """ST_GeomFromText(wkt [, srid]) - Construct geometry from WKT. @@ -98,15 +95,15 @@ def st_geom_from_text( Returns: FunctionCall for ST_GeomFromText """ - args = [_convert_to_expression(dialect, wkt)] + args = [wkt] if srid is not None: - args.append(_convert_to_expression(dialect, srid)) + args.append(srid) return core.FunctionCall(dialect, "ST_GeomFromText", *args) def st_geog_from_text( dialect: "SQLDialectBase", - wkt: Union[str, PostgresGeometry, "bases.BaseExpression"], + wkt: "bases.BaseExpression", ) -> core.FunctionCall: """ST_GeogFromText(wkt) - Construct geography from WKT. @@ -117,13 +114,13 @@ def st_geog_from_text( Returns: FunctionCall for ST_GeogFromText """ - return core.FunctionCall(dialect, "ST_GeogFromText", _convert_to_expression(dialect, wkt)) + return core.FunctionCall(dialect, "ST_GeogFromText", wkt) def st_set_srid( dialect: "SQLDialectBase", - geometry: Union[str, PostgresGeometry, "bases.BaseExpression"], - srid: Union[int, "bases.BaseExpression"], + geometry: "bases.BaseExpression", + srid: "bases.BaseExpression", ) -> core.FunctionCall: """ST_SetSRID(geometry, srid) - Set the SRID of a geometry. @@ -137,15 +134,15 @@ def st_set_srid( """ return core.FunctionCall( dialect, "ST_SetSRID", - _convert_to_expression(dialect, geometry), - _convert_to_expression(dialect, srid), + geometry, + srid, ) def st_transform( dialect: "SQLDialectBase", - geometry: Union[str, PostgresGeometry, "bases.BaseExpression"], - target_srid: Union[int, "bases.BaseExpression"], + geometry: "bases.BaseExpression", + target_srid: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Transform(geometry, target_srid) - Transform geometry to a different SRID. @@ -159,8 +156,8 @@ def st_transform( """ return core.FunctionCall( dialect, "ST_Transform", - _convert_to_expression(dialect, geometry), - _convert_to_expression(dialect, target_srid), + geometry, + target_srid, ) @@ -168,8 +165,8 @@ def st_transform( def st_contains( dialect: "SQLDialectBase", - outer: Union[str, PostgresGeometry, "bases.BaseExpression"], - inner: Union[str, PostgresGeometry, "bases.BaseExpression"], + outer: "bases.BaseExpression", + inner: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Contains(outer, inner) - Test if outer geometry contains inner. @@ -178,15 +175,15 @@ def st_contains( """ return core.FunctionCall( dialect, "ST_Contains", - _convert_to_expression(dialect, outer), - _convert_to_expression(dialect, inner), + outer, + inner, ) def st_intersects( dialect: "SQLDialectBase", - geom1: Union[str, PostgresGeometry, "bases.BaseExpression"], - geom2: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom1: "bases.BaseExpression", + geom2: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Intersects(geom1, geom2) - Test if geometries intersect. @@ -195,15 +192,15 @@ def st_intersects( """ return core.FunctionCall( dialect, "ST_Intersects", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1, + geom2, ) def st_within( dialect: "SQLDialectBase", - inner: Union[str, PostgresGeometry, "bases.BaseExpression"], - outer: Union[str, PostgresGeometry, "bases.BaseExpression"], + inner: "bases.BaseExpression", + outer: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Within(inner, outer) - Test if inner geometry is within outer. @@ -212,16 +209,16 @@ def st_within( """ return core.FunctionCall( dialect, "ST_Within", - _convert_to_expression(dialect, inner), - _convert_to_expression(dialect, outer), + inner, + outer, ) def st_dwithin( dialect: "SQLDialectBase", - geom1: Union[str, PostgresGeometry, "bases.BaseExpression"], - geom2: Union[str, PostgresGeometry, "bases.BaseExpression"], - distance: Union[int, float, "bases.BaseExpression"], + geom1: "bases.BaseExpression", + geom2: "bases.BaseExpression", + distance: "bases.BaseExpression", use_spheroid: bool = False, ) -> core.FunctionCall: """ST_DWithin(geom1, geom2, distance [, use_spheroid]) - Test proximity. @@ -237,9 +234,9 @@ def st_dwithin( FunctionCall for ST_DWithin """ args = [ - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), - _convert_to_expression(dialect, distance), + geom1, + geom2, + distance, ] if use_spheroid: args.append(core.Literal(dialect, True)) @@ -248,40 +245,40 @@ def st_dwithin( def st_crosses( dialect: "SQLDialectBase", - geom1: Union[str, PostgresGeometry, "bases.BaseExpression"], - geom2: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom1: "bases.BaseExpression", + geom2: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Crosses(geom1, geom2) - Test if geometries cross.""" return core.FunctionCall( dialect, "ST_Crosses", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1, + geom2, ) def st_touches( dialect: "SQLDialectBase", - geom1: Union[str, PostgresGeometry, "bases.BaseExpression"], - geom2: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom1: "bases.BaseExpression", + geom2: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Touches(geom1, geom2) - Test if geometries touch.""" return core.FunctionCall( dialect, "ST_Touches", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1, + geom2, ) def st_overlaps( dialect: "SQLDialectBase", - geom1: Union[str, PostgresGeometry, "bases.BaseExpression"], - geom2: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom1: "bases.BaseExpression", + geom2: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Overlaps(geom1, geom2) - Test if geometries overlap.""" return core.FunctionCall( dialect, "ST_Overlaps", - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1, + geom2, ) @@ -289,8 +286,8 @@ def st_overlaps( def st_distance( dialect: "SQLDialectBase", - geom1: Union[str, PostgresGeometry, "bases.BaseExpression"], - geom2: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom1: "bases.BaseExpression", + geom2: "bases.BaseExpression", use_spheroid: bool = False, ) -> core.FunctionCall: """ST_Distance(geom1, geom2 [, use_spheroid]) - Calculate distance. @@ -305,8 +302,8 @@ def st_distance( FunctionCall for ST_Distance """ args = [ - _convert_to_expression(dialect, geom1), - _convert_to_expression(dialect, geom2), + geom1, + geom2, ] if use_spheroid: args.append(core.Literal(dialect, True)) @@ -315,7 +312,7 @@ def st_distance( def st_area( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom: "bases.BaseExpression", use_spheroid: bool = False, ) -> core.FunctionCall: """ST_Area(geom [, use_spheroid]) - Calculate area. @@ -328,7 +325,7 @@ def st_area( Returns: FunctionCall for ST_Area """ - args = [_convert_to_expression(dialect, geom)] + args = [geom] if use_spheroid: args.append(core.Literal(dialect, True)) return core.FunctionCall(dialect, "ST_Area", *args) @@ -336,7 +333,7 @@ def st_area( def st_length( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom: "bases.BaseExpression", use_spheroid: bool = False, ) -> core.FunctionCall: """ST_Length(geom [, use_spheroid]) - Calculate length. @@ -344,7 +341,7 @@ def st_length( Returns: FunctionCall for ST_Length """ - args = [_convert_to_expression(dialect, geom)] + args = [geom] if use_spheroid: args.append(core.Literal(dialect, True)) return core.FunctionCall(dialect, "ST_Length", *args) @@ -354,34 +351,34 @@ def st_length( def st_as_geojson( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom: "bases.BaseExpression", ) -> core.FunctionCall: """ST_AsGeoJSON(geom) - Convert geometry to GeoJSON. Returns: FunctionCall for ST_AsGeoJSON """ - return core.FunctionCall(dialect, "ST_AsGeoJSON", _convert_to_expression(dialect, geom)) + return core.FunctionCall(dialect, "ST_AsGeoJSON", geom) def st_as_text( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom: "bases.BaseExpression", ) -> core.FunctionCall: """ST_AsText(geom) - Convert geometry to WKT. Returns: FunctionCall for ST_AsText """ - return core.FunctionCall(dialect, "ST_AsText", _convert_to_expression(dialect, geom)) + return core.FunctionCall(dialect, "ST_AsText", geom) # === Spatial Operations === def st_buffer( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], - radius: Union[int, float, "bases.BaseExpression"], + geom: "bases.BaseExpression", + radius: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Buffer(geom, radius) - Create buffer around geometry. @@ -390,33 +387,33 @@ def st_buffer( """ return core.FunctionCall( dialect, "ST_Buffer", - _convert_to_expression(dialect, geom), - _convert_to_expression(dialect, radius), + geom, + radius, ) def st_envelope( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Envelope(geom) - Compute bounding box. Returns: FunctionCall for ST_Envelope """ - return core.FunctionCall(dialect, "ST_Envelope", _convert_to_expression(dialect, geom)) + return core.FunctionCall(dialect, "ST_Envelope", geom) def st_centroid( dialect: "SQLDialectBase", - geom: Union[str, PostgresGeometry, "bases.BaseExpression"], + geom: "bases.BaseExpression", ) -> core.FunctionCall: """ST_Centroid(geom) - Compute geometric centroid. Returns: FunctionCall for ST_Centroid """ - return core.FunctionCall(dialect, "ST_Centroid", _convert_to_expression(dialect, geom)) + return core.FunctionCall(dialect, "ST_Centroid", geom) __all__ = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis_raster.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis_raster.py index f3587d69..45ae23fd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis_raster.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/postgis_raster.py @@ -33,7 +33,7 @@ - They do not concatenate SQL strings directly """ -from typing import Union, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -41,37 +41,11 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== PostGIS Raster Functions ============== def st_rast_from_hexwkb( dialect: "SQLDialectBase", - raster_data: Union[str, "bases.BaseExpression"], + raster_data: str, srid: int = 4326, ) -> core.FunctionCall: """Generate SQL expression for constructing a raster from HexWKB data with SRID. @@ -101,20 +75,20 @@ def st_rast_from_hexwkb( """ inner = core.FunctionCall( dialect, "ST_RastFromHexWKB", - _convert_to_expression(dialect, raster_data), + raster_data if isinstance(raster_data, bases.BaseExpression) else core.Literal(dialect, raster_data), ) return core.FunctionCall( dialect, "ST_SetSRID", inner, - _convert_to_expression(dialect, srid), + srid if isinstance(srid, bases.BaseExpression) else core.Literal(dialect, srid), ) def st_value( dialect: "SQLDialectBase", - raster_expr: Union[str, "bases.BaseExpression"], - x: Union[int, str, "bases.BaseExpression"], - y: Union[int, str, "bases.BaseExpression"], + raster_expr: bases.BaseExpression, + x: int, + y: int, ) -> core.FunctionCall: """Generate SQL expression for the ST_Value function. @@ -139,15 +113,15 @@ def st_value( """ return core.FunctionCall( dialect, "ST_Value", - _convert_to_expression(dialect, raster_expr), - _convert_to_expression(dialect, x), - _convert_to_expression(dialect, y), + raster_expr if isinstance(raster_expr, bases.BaseExpression) else core.Literal(dialect, raster_expr), + x if isinstance(x, bases.BaseExpression) else core.Literal(dialect, x), + y if isinstance(y, bases.BaseExpression) else core.Literal(dialect, y), ) def st_summary( dialect: "SQLDialectBase", - raster_expr: Union[str, "bases.BaseExpression"], + raster_expr: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for the ST_Summary function. @@ -170,7 +144,7 @@ def st_summary( """ return core.FunctionCall( dialect, "ST_Summary", - _convert_to_expression(dialect, raster_expr), + raster_expr if isinstance(raster_expr, bases.BaseExpression) else core.Literal(dialect, raster_expr), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py index e45ce036..f0afeaef 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py @@ -12,7 +12,7 @@ - They return Expression objects (FunctionCall, BinaryExpression, etc.) """ -from typing import Any, Union, TYPE_CHECKING +from typing import Any, List, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -22,41 +22,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[PostgresRange, str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports PostgresRange objects, strings, and existing - BaseExpression objects. - - For PostgresRange inputs, generates a literal expression from - the PostgreSQL range string representation. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, PostgresRange): - return core.Literal(dialect, expr.to_postgres_string()) - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Range Operators ============== def range_contains( dialect: "SQLDialectBase", - range_value: Any, - element: Any, + range_value: bases.BaseExpression, + element: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range contains element operator. @@ -75,15 +46,21 @@ def range_contains( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, range_value), - _convert_to_expression(dialect, element), + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value), + element if isinstance(element, bases.BaseExpression) + else core.Literal(dialect, element.to_postgres_string()) + if isinstance(element, PostgresRange) + else core.Literal(dialect, element), ) def range_contained_by( dialect: "SQLDialectBase", - element: Any, - range_value: Any, + element: bases.BaseExpression, + range_value: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for element contained by range operator. @@ -100,15 +77,21 @@ def range_contained_by( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, element), - _convert_to_expression(dialect, range_value), + element if isinstance(element, bases.BaseExpression) + else core.Literal(dialect, element.to_postgres_string()) + if isinstance(element, PostgresRange) + else core.Literal(dialect, element), + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value), ) def range_contains_range( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range contains range operator. @@ -125,15 +108,21 @@ def range_contains_range( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_overlaps( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range overlaps operator. @@ -147,15 +136,21 @@ def range_overlaps( """ return BinaryExpression( dialect, "&&", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_adjacent( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range adjacent operator. @@ -169,15 +164,21 @@ def range_adjacent( """ return BinaryExpression( dialect, "-|-", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_strictly_left_of( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range strictly left of operator. @@ -191,15 +192,21 @@ def range_strictly_left_of( """ return BinaryExpression( dialect, "<<", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_strictly_right_of( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range strictly right of operator. @@ -213,15 +220,21 @@ def range_strictly_right_of( """ return BinaryExpression( dialect, ">>", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_not_extend_right( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range does not extend to the right operator. @@ -235,15 +248,21 @@ def range_not_extend_right( """ return BinaryExpression( dialect, "&<", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_not_extend_left( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range does not extend to the left operator. @@ -257,15 +276,21 @@ def range_not_extend_left( """ return BinaryExpression( dialect, "&>", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_union( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range union operator. @@ -279,15 +304,21 @@ def range_union( """ return BinaryExpression( dialect, "+", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_intersection( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range intersection operator. @@ -301,15 +332,21 @@ def range_intersection( """ return BinaryExpression( dialect, "*", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) def range_difference( dialect: "SQLDialectBase", - range1: Any, - range2: Any, + range1: bases.BaseExpression, + range2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for range difference operator. @@ -323,8 +360,14 @@ def range_difference( """ return BinaryExpression( dialect, "-", - _convert_to_expression(dialect, range1), - _convert_to_expression(dialect, range2), + range1 if isinstance(range1, bases.BaseExpression) + else core.Literal(dialect, range1.to_postgres_string()) + if isinstance(range1, PostgresRange) + else core.Literal(dialect, range1), + range2 if isinstance(range2, bases.BaseExpression) + else core.Literal(dialect, range2.to_postgres_string()) + if isinstance(range2, PostgresRange) + else core.Literal(dialect, range2), ) @@ -332,7 +375,7 @@ def range_difference( def range_lower( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range lower bound function. @@ -343,12 +386,17 @@ def range_lower( Returns: FunctionCall for lower(range) """ - return core.FunctionCall(dialect, "lower", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "lower", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) def range_upper( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range upper bound function. @@ -359,12 +407,17 @@ def range_upper( Returns: FunctionCall for upper(range) """ - return core.FunctionCall(dialect, "upper", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "upper", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) def range_is_empty( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range isempty function. @@ -375,12 +428,17 @@ def range_is_empty( Returns: FunctionCall for isempty(range) """ - return core.FunctionCall(dialect, "isempty", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "isempty", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) def range_lower_inc( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range lower_inc function. @@ -391,12 +449,17 @@ def range_lower_inc( Returns: FunctionCall for lower_inc(range) """ - return core.FunctionCall(dialect, "lower_inc", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "lower_inc", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) def range_upper_inc( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range upper_inc function. @@ -407,12 +470,17 @@ def range_upper_inc( Returns: FunctionCall for upper_inc(range) """ - return core.FunctionCall(dialect, "upper_inc", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "upper_inc", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) def range_lower_inf( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range lower_inf function. @@ -423,12 +491,17 @@ def range_lower_inf( Returns: FunctionCall for lower_inf(range) """ - return core.FunctionCall(dialect, "lower_inf", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "lower_inf", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) def range_upper_inf( dialect: "SQLDialectBase", - range_value: Any, + range_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range upper_inf function. @@ -439,15 +512,20 @@ def range_upper_inf( Returns: FunctionCall for upper_inf(range) """ - return core.FunctionCall(dialect, "upper_inf", _convert_to_expression(dialect, range_value)) + return core.FunctionCall( + dialect, "upper_inf", + range_value if isinstance(range_value, bases.BaseExpression) + else core.Literal(dialect, range_value.to_postgres_string()) + if isinstance(range_value, PostgresRange) + else core.Literal(dialect, range_value)) # ============== Multirange Operators ============== def multirange_contains( dialect: "SQLDialectBase", - multirange_value: Any, - element: Any, + multirange_value: bases.BaseExpression, + element: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for multirange contains element operator. @@ -464,15 +542,21 @@ def multirange_contains( """ return BinaryExpression( dialect, "@>", - _convert_to_expression(dialect, multirange_value), - _convert_to_expression(dialect, element), + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value), + element if isinstance(element, bases.BaseExpression) + else core.Literal(dialect, element.to_postgres_string()) + if isinstance(element, PostgresRange) + else core.Literal(dialect, element), ) def multirange_is_contained_by( dialect: "SQLDialectBase", - multirange_value: Any, - element: Any, + multirange_value: bases.BaseExpression, + element: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for multirange is contained by operator. @@ -489,15 +573,21 @@ def multirange_is_contained_by( """ return BinaryExpression( dialect, "<@", - _convert_to_expression(dialect, multirange_value), - _convert_to_expression(dialect, element), + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value), + element if isinstance(element, bases.BaseExpression) + else core.Literal(dialect, element.to_postgres_string()) + if isinstance(element, PostgresRange) + else core.Literal(dialect, element), ) def multirange_overlaps( dialect: "SQLDialectBase", - multirange_value: Any, - other: Any, + multirange_value: bases.BaseExpression, + other: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for multirange overlaps operator. @@ -514,15 +604,21 @@ def multirange_overlaps( """ return BinaryExpression( dialect, "&&", - _convert_to_expression(dialect, multirange_value), - _convert_to_expression(dialect, other), + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value), + other if isinstance(other, bases.BaseExpression) + else core.Literal(dialect, other.to_postgres_string()) + if isinstance(other, PostgresRange) + else core.Literal(dialect, other), ) def multirange_union( dialect: "SQLDialectBase", - multirange_value: Any, - other: Any, + multirange_value: bases.BaseExpression, + other: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for multirange union operator. @@ -539,15 +635,21 @@ def multirange_union( """ return BinaryExpression( dialect, "+", - _convert_to_expression(dialect, multirange_value), - _convert_to_expression(dialect, other), + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value), + other if isinstance(other, bases.BaseExpression) + else core.Literal(dialect, other.to_postgres_string()) + if isinstance(other, PostgresRange) + else core.Literal(dialect, other), ) def multirange_intersection( dialect: "SQLDialectBase", - multirange_value: Any, - other: Any, + multirange_value: bases.BaseExpression, + other: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for multirange intersection operator. @@ -564,15 +666,21 @@ def multirange_intersection( """ return BinaryExpression( dialect, "*", - _convert_to_expression(dialect, multirange_value), - _convert_to_expression(dialect, other), + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value), + other if isinstance(other, bases.BaseExpression) + else core.Literal(dialect, other.to_postgres_string()) + if isinstance(other, PostgresRange) + else core.Literal(dialect, other), ) def multirange_difference( dialect: "SQLDialectBase", - multirange_value: Any, - other: Any, + multirange_value: bases.BaseExpression, + other: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for multirange difference operator. @@ -589,8 +697,14 @@ def multirange_difference( """ return BinaryExpression( dialect, "-", - _convert_to_expression(dialect, multirange_value), - _convert_to_expression(dialect, other), + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value), + other if isinstance(other, bases.BaseExpression) + else core.Literal(dialect, other.to_postgres_string()) + if isinstance(other, PostgresRange) + else core.Literal(dialect, other), ) @@ -598,7 +712,7 @@ def multirange_difference( def range_merge( dialect: "SQLDialectBase", - multirange_value: Any, + multirange_value: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for range_merge function. @@ -615,12 +729,17 @@ def range_merge( Example: >>> range_merge(dialect, 'my_multirange') """ - return core.FunctionCall(dialect, "range_merge", _convert_to_expression(dialect, multirange_value)) + return core.FunctionCall( + dialect, "range_merge", + multirange_value if isinstance(multirange_value, bases.BaseExpression) + else core.Literal(dialect, multirange_value.to_postgres_string()) + if isinstance(multirange_value, PostgresRange) + else core.Literal(dialect, multirange_value)) def multirange_literal( dialect: "SQLDialectBase", - ranges: list, + ranges: List[str], multirange_type: str, ) -> core.FunctionCall: """Construct a multirange literal expression. @@ -636,7 +755,13 @@ def multirange_literal( Example: >>> multirange_literal(dialect, ['[1,5)', '[10,20)'], 'int4multirange') """ - args = [_convert_to_expression(dialect, r) for r in ranges] + args = [ + r if isinstance(r, bases.BaseExpression) + else core.Literal(dialect, r.to_postgres_string()) + if isinstance(r, PostgresRange) + else core.Literal(dialect, r) + for r in ranges + ] return core.FunctionCall(dialect, multirange_type, *args) @@ -658,7 +783,13 @@ def multirange_constructor( Example: >>> multirange_constructor(dialect, 'int4multirange', '[1,5)', '[10,20)') """ - args = [_convert_to_expression(dialect, r) for r in range_values] + args = [ + r if isinstance(r, bases.BaseExpression) + else core.Literal(dialect, r.to_postgres_string()) + if isinstance(r, PostgresRange) + else core.Literal(dialect, r) + for r in range_values + ] return core.FunctionCall(dialect, multirange_type, *args) @@ -852,13 +983,19 @@ def _build_range_constructor_args( if lower is None: args.append(core.Literal(dialect, "NULL")) else: - args.append(_convert_to_expression(dialect, lower)) + args.append(lower if isinstance(lower, bases.BaseExpression) + else core.Literal(dialect, lower.to_postgres_string()) + if isinstance(lower, PostgresRange) + else core.Literal(dialect, lower)) # Upper bound: NULL if None, otherwise convert to expression if upper is None: args.append(core.Literal(dialect, "NULL")) else: - args.append(_convert_to_expression(dialect, upper)) + args.append(upper if isinstance(upper, bases.BaseExpression) + else core.Literal(dialect, upper.to_postgres_string()) + if isinstance(upper, PostgresRange) + else core.Literal(dialect, upper)) # Optional bounds argument if bounds is not _BOUNDS_UNSET: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/tablefunc.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/tablefunc.py index d701431c..cb2947a4 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/tablefunc.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/tablefunc.py @@ -26,7 +26,7 @@ - They do not concatenate SQL strings directly """ -from typing import Optional, Union, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core @@ -34,38 +34,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - For string inputs, generates a literal expression. For - BaseExpression inputs, returns them unchanged. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Crosstab Functions ============== def crosstab( dialect: "SQLDialectBase", - source_sql: Union[str, "bases.BaseExpression"], - categories_sql: Optional[Union[str, "bases.BaseExpression"]] = None, + source_sql: str, + categories_sql: Optional[str] = None, ) -> core.FunctionCall: """Produce a pivot table (crosstab) display from query results. @@ -105,12 +79,13 @@ def crosstab( if categories_sql is not None: return core.FunctionCall( dialect, "crosstab", - _convert_to_expression(dialect, source_sql), - _convert_to_expression(dialect, categories_sql), + source_sql if isinstance(source_sql, bases.BaseExpression) else core.Literal(dialect, source_sql), + categories_sql if isinstance(categories_sql, bases.BaseExpression) + else core.Literal(dialect, categories_sql), ) return core.FunctionCall( dialect, "crosstab", - _convert_to_expression(dialect, source_sql), + source_sql if isinstance(source_sql, bases.BaseExpression) else core.Literal(dialect, source_sql), ) @@ -118,12 +93,12 @@ def crosstab( def connectby( dialect: "SQLDialectBase", - table_name: Union[str, "bases.BaseExpression"], - key_column: Union[str, "bases.BaseExpression"], - parent_column: Union[str, "bases.BaseExpression"], - start_value: Union[str, "bases.BaseExpression"], - max_depth: Optional[Union[int, "bases.BaseExpression"]] = None, - branch_delim: Optional[Union[str, "bases.BaseExpression"]] = None, + table_name: str, + key_column: str, + parent_column: str, + start_value: str, + max_depth: Optional[int] = None, + branch_delim: Optional[str] = None, ) -> core.FunctionCall: """Produce a tree-structured display of hierarchical data. @@ -154,15 +129,17 @@ def connectby( >>> connectby(dialect, 'employees', 'emp_id', 'manager_id', '1', max_depth=3, branch_delim='~') """ args = [ - _convert_to_expression(dialect, table_name), - _convert_to_expression(dialect, key_column), - _convert_to_expression(dialect, parent_column), - _convert_to_expression(dialect, start_value), + table_name if isinstance(table_name, bases.BaseExpression) else core.Literal(dialect, table_name), + key_column if isinstance(key_column, bases.BaseExpression) else core.Literal(dialect, key_column), + parent_column if isinstance(parent_column, bases.BaseExpression) else core.Literal(dialect, parent_column), + start_value if isinstance(start_value, bases.BaseExpression) else core.Literal(dialect, start_value), ] if max_depth is not None: - args.append(_convert_to_expression(dialect, max_depth)) + args.append(max_depth if isinstance(max_depth, bases.BaseExpression) else core.Literal(dialect, max_depth)) if branch_delim is not None: - args.append(_convert_to_expression(dialect, branch_delim)) + args.append( + branch_delim if isinstance(branch_delim, bases.BaseExpression) + else core.Literal(dialect, branch_delim)) return core.FunctionCall(dialect, "connectby", *args) @@ -170,10 +147,10 @@ def connectby( def normal_rand( dialect: "SQLDialectBase", - num_values: Union[int, "bases.BaseExpression"], - mean: Union[float, "bases.BaseExpression"], - stddev: Union[float, "bases.BaseExpression"], - seed: Optional[Union[int, "bases.BaseExpression"]] = None, + num_values: int, + mean: float, + stddev: float, + seed: Optional[int] = None, ) -> core.FunctionCall: """Generate a set of normally distributed random values. @@ -196,12 +173,12 @@ def normal_rand( >>> normal_rand(dialect, 50, 0, 1, seed=42) """ args = [ - _convert_to_expression(dialect, num_values), - _convert_to_expression(dialect, mean), - _convert_to_expression(dialect, stddev), + num_values if isinstance(num_values, bases.BaseExpression) else core.Literal(dialect, num_values), + mean if isinstance(mean, bases.BaseExpression) else core.Literal(dialect, mean), + stddev if isinstance(stddev, bases.BaseExpression) else core.Literal(dialect, stddev), ] if seed is not None: - args.append(_convert_to_expression(dialect, seed)) + args.append(seed if isinstance(seed, bases.BaseExpression) else core.Literal(dialect, seed)) return core.FunctionCall(dialect, "normal_rand", *args) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py index 7ca002e5..cef8463f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/text_search.py @@ -45,7 +45,7 @@ >>> ts_headline(dialect, "content", "to_tsquery('hello')") """ -from typing import Any, List, Optional, Union, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression import bases, core from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -54,35 +54,12 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression. - - Supports strings and existing BaseExpression objects. - - Args: - dialect: The SQL dialect instance - expr: Value to convert - - Returns: - BaseExpression representing the value - """ - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - # ============== Full-Text Search Functions ============== def to_tsvector( dialect: "SQLDialectBase", - document: Any, + document: bases.BaseExpression, config: str = "english", ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL to_tsvector function. @@ -104,13 +81,13 @@ def to_tsvector( return core.FunctionCall( dialect, "to_tsvector", core.Literal(dialect, config), - _convert_to_expression(dialect, document), + document if isinstance(document, bases.BaseExpression) else core.Literal(dialect, document), ) def to_tsquery( dialect: "SQLDialectBase", - query: Any, + query: bases.BaseExpression, config: str = "english", ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL to_tsquery function. @@ -132,13 +109,13 @@ def to_tsquery( return core.FunctionCall( dialect, "to_tsquery", core.Literal(dialect, config), - _convert_to_expression(dialect, query), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def plainto_tsquery( dialect: "SQLDialectBase", - query: Any, + query: bases.BaseExpression, config: str = "english", ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL plainto_tsquery function. @@ -160,13 +137,13 @@ def plainto_tsquery( return core.FunctionCall( dialect, "plainto_tsquery", core.Literal(dialect, config), - _convert_to_expression(dialect, query), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def phraseto_tsquery( dialect: "SQLDialectBase", - query: Any, + query: bases.BaseExpression, config: str = "english", ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL phraseto_tsquery function. @@ -188,13 +165,13 @@ def phraseto_tsquery( return core.FunctionCall( dialect, "phraseto_tsquery", core.Literal(dialect, config), - _convert_to_expression(dialect, query), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def websearch_to_tsquery( dialect: "SQLDialectBase", - query: Any, + query: bases.BaseExpression, config: str = "english", ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL websearch_to_tsquery function. @@ -216,14 +193,14 @@ def websearch_to_tsquery( return core.FunctionCall( dialect, "websearch_to_tsquery", core.Literal(dialect, config), - _convert_to_expression(dialect, query), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def ts_rank( dialect: "SQLDialectBase", - vector: Any, - query: Any, + vector: bases.BaseExpression, + query: bases.BaseExpression, weights: Optional[List[float]] = None, ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL ts_rank function. @@ -247,15 +224,15 @@ def ts_rank( if weights is not None: weights_str = "ARRAY[" + ", ".join(str(w) for w in weights) + "]" args.append(core.Literal(dialect, weights_str)) - args.append(_convert_to_expression(dialect, vector)) - args.append(_convert_to_expression(dialect, query)) + args.append(vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector)) + args.append(query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query)) return core.FunctionCall(dialect, "ts_rank", *args) def ts_rank_cd( dialect: "SQLDialectBase", - vector: Any, - query: Any, + vector: bases.BaseExpression, + query: bases.BaseExpression, weights: Optional[List[float]] = None, ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL ts_rank_cd function. @@ -279,17 +256,17 @@ def ts_rank_cd( if weights is not None: weights_str = "ARRAY[" + ", ".join(str(w) for w in weights) + "]" args.append(core.Literal(dialect, weights_str)) - args.append(_convert_to_expression(dialect, vector)) - args.append(_convert_to_expression(dialect, query)) + args.append(vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector)) + args.append(query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query)) return core.FunctionCall(dialect, "ts_rank_cd", *args) def ts_headline( dialect: "SQLDialectBase", - document: Any, - query: Any, + document: bases.BaseExpression, + query: bases.BaseExpression, config: str = "english", - options: Optional[Any] = None, + options: Optional[str] = None, ) -> core.FunctionCall: """Generate SQL expression for PostgreSQL ts_headline function. @@ -314,17 +291,17 @@ def ts_headline( """ args: list = [ core.Literal(dialect, config), - _convert_to_expression(dialect, document), - _convert_to_expression(dialect, query), + document if isinstance(document, bases.BaseExpression) else core.Literal(dialect, document), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ] if options is not None: - args.append(_convert_to_expression(dialect, options)) + args.append(options if isinstance(options, bases.BaseExpression) else core.Literal(dialect, options)) return core.FunctionCall(dialect, "ts_headline", *args) def tsvector_strip( dialect: "SQLDialectBase", - vector: Any, + vector: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for strip function. @@ -344,14 +321,14 @@ def tsvector_strip( """ return core.FunctionCall( dialect, "strip", - _convert_to_expression(dialect, vector), + vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector), ) def tsvector_setweight( dialect: "SQLDialectBase", - vector: Any, - weight: Any, + vector: bases.BaseExpression, + weight: str, ) -> core.FunctionCall: """Generate SQL expression for setweight function. @@ -372,14 +349,14 @@ def tsvector_setweight( """ return core.FunctionCall( dialect, "setweight", - _convert_to_expression(dialect, vector), - _convert_to_expression(dialect, weight), + vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector), + weight if isinstance(weight, bases.BaseExpression) else core.Literal(dialect, weight), ) def tsvector_length( dialect: "SQLDialectBase", - vector: Any, + vector: bases.BaseExpression, ) -> core.FunctionCall: """Generate SQL expression for length function. @@ -399,7 +376,7 @@ def tsvector_length( """ return core.FunctionCall( dialect, "length", - _convert_to_expression(dialect, vector), + vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector), ) @@ -408,8 +385,8 @@ def tsvector_length( def ts_matches( dialect: "SQLDialectBase", - vector: Any, - query: Any, + vector: bases.BaseExpression, + query: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for tsvector @@ tsquery match operator. @@ -426,15 +403,15 @@ def ts_matches( """ return BinaryExpression( dialect, "@@", - _convert_to_expression(dialect, vector), - _convert_to_expression(dialect, query), + vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def ts_matches_expr( dialect: "SQLDialectBase", - vector: Any, - query: Any, + vector: bases.BaseExpression, + query: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for tsvector @@@ tsquery match operator. @@ -453,15 +430,15 @@ def ts_matches_expr( """ return BinaryExpression( dialect, "@@@", - _convert_to_expression(dialect, vector), - _convert_to_expression(dialect, query), + vector if isinstance(vector, bases.BaseExpression) else core.Literal(dialect, vector), + query if isinstance(query, bases.BaseExpression) else core.Literal(dialect, query), ) def tsvector_concat( dialect: "SQLDialectBase", - vec1: Any, - vec2: Any, + vec1: bases.BaseExpression, + vec2: bases.BaseExpression, ) -> BinaryExpression: """Generate SQL expression for tsvector concatenation. @@ -478,8 +455,8 @@ def tsvector_concat( """ return BinaryExpression( dialect, "||", - _convert_to_expression(dialect, vec1), - _convert_to_expression(dialect, vec2), + vec1 if isinstance(vec1, bases.BaseExpression) else core.Literal(dialect, vec1), + vec2 if isinstance(vec2, bases.BaseExpression) else core.Literal(dialect, vec2), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py index 1d9fcee8..7501a33d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py @@ -26,21 +26,6 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[_uuid.UUID, str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression.""" - if isinstance(expr, bases.BaseExpression): - return expr - elif isinstance(expr, _uuid.UUID): - return core.Literal(dialect, str(expr)) - elif isinstance(expr, str): - return core.Literal(dialect, expr) - else: - return core.Literal(dialect, expr) - - def uuid_generate_v1(dialect: "SQLDialectBase") -> core.FunctionCall: """ Generate a version 1 (time-based) UUID. @@ -78,7 +63,7 @@ def uuid_generate_v1mc(dialect: "SQLDialectBase") -> core.FunctionCall: def uuid_generate_v3( dialect: "SQLDialectBase", namespace: Union[_uuid.UUID, str, "bases.BaseExpression"], - name: Union[str, "bases.BaseExpression"], + name: str, ) -> core.FunctionCall: """ Generate a version 3 (MD5 hash-based) UUID in the given namespace. @@ -98,8 +83,16 @@ def uuid_generate_v3( >>> func.to_sql() ('uuid_generate_v3(%s, %s)', ('dns', 'example.com')) """ - ns_expr = _convert_to_expression(dialect, namespace) - name_expr = _convert_to_expression(dialect, name) + ns_expr = ( + namespace if isinstance(namespace, bases.BaseExpression) + else core.Literal(dialect, str(namespace)) if isinstance(namespace, _uuid.UUID) + else core.Literal(dialect, namespace) + ) + name_expr = ( + name if isinstance(name, bases.BaseExpression) + else core.Literal(dialect, str(name)) if isinstance(name, _uuid.UUID) + else core.Literal(dialect, name) + ) return core.FunctionCall(dialect, "uuid_generate_v3", ns_expr, name_expr) @@ -123,7 +116,7 @@ def uuid_generate_v4(dialect: "SQLDialectBase") -> core.FunctionCall: def uuid_generate_v5( dialect: "SQLDialectBase", namespace: Union[_uuid.UUID, str, "bases.BaseExpression"], - name: Union[str, "bases.BaseExpression"], + name: str, ) -> core.FunctionCall: """ Generate a version 5 (SHA-1 hash-based) UUID in the given namespace. @@ -143,8 +136,16 @@ def uuid_generate_v5( >>> func.to_sql() ('uuid_generate_v5(%s, %s)', ('dns', 'example.com')) """ - ns_expr = _convert_to_expression(dialect, namespace) - name_expr = _convert_to_expression(dialect, name) + ns_expr = ( + namespace if isinstance(namespace, bases.BaseExpression) + else core.Literal(dialect, str(namespace)) if isinstance(namespace, _uuid.UUID) + else core.Literal(dialect, namespace) + ) + name_expr = ( + name if isinstance(name, bases.BaseExpression) + else core.Literal(dialect, str(name)) if isinstance(name, _uuid.UUID) + else core.Literal(dialect, name) + ) return core.FunctionCall(dialect, "uuid_generate_v5", ns_expr, name_expr) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py index d129ed1b..b9ea1fb0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/xml.py @@ -24,35 +24,31 @@ from ..type_values.xml import PostgresXML -def _convert_to_expression( - dialect: "SQLDialectBase", - expr: Union[PostgresXML, str, "bases.BaseExpression"], -) -> "bases.BaseExpression": - """Convert an input value to an appropriate BaseExpression.""" - if isinstance(expr, bases.BaseExpression): - return expr - if isinstance(expr, PostgresXML): - return core.Literal(dialect, expr.content) - return core.Literal(dialect, expr) - - def _namespaces_to_expression( dialect: "SQLDialectBase", namespaces: Dict[str, str], -) -> "bases.BaseExpression": +) -> bases.BaseExpression: namespace_pairs = [[prefix, uri] for prefix, uri in namespaces.items()] return core.Literal(dialect, namespace_pairs) def xpath_query( dialect: "SQLDialectBase", - xpath: Union[str, "bases.BaseExpression"], + xpath: str, xml_value: Union[PostgresXML, str, "bases.BaseExpression"], namespaces: Optional[Dict[str, str]] = None, ) -> core.FunctionCall: """Generate PostgreSQL xpath expression.""" - xpath_expr = _convert_to_expression(dialect, xpath) - xml_expr = _convert_to_expression(dialect, xml_value) + xpath_expr = ( + xpath if isinstance(xpath, bases.BaseExpression) + else core.Literal(dialect, xpath.content) if isinstance(xpath, PostgresXML) + else core.Literal(dialect, xpath) + ) + xml_expr = ( + xml_value if isinstance(xml_value, bases.BaseExpression) + else core.Literal(dialect, xml_value.content) if isinstance(xml_value, PostgresXML) + else core.Literal(dialect, xml_value) + ) args = [xpath_expr, xml_expr] if namespaces: @@ -63,13 +59,21 @@ def xpath_query( def xpath_exists( dialect: "SQLDialectBase", - xpath: Union[str, "bases.BaseExpression"], + xpath: str, xml_value: Union[PostgresXML, str, "bases.BaseExpression"], namespaces: Optional[Dict[str, str]] = None, ) -> core.FunctionCall: """Generate PostgreSQL xpath_exists expression.""" - xpath_expr = _convert_to_expression(dialect, xpath) - xml_expr = _convert_to_expression(dialect, xml_value) + xpath_expr = ( + xpath if isinstance(xpath, bases.BaseExpression) + else core.Literal(dialect, xpath.content) if isinstance(xpath, PostgresXML) + else core.Literal(dialect, xpath) + ) + xml_expr = ( + xml_value if isinstance(xml_value, bases.BaseExpression) + else core.Literal(dialect, xml_value.content) if isinstance(xml_value, PostgresXML) + else core.Literal(dialect, xml_value) + ) args = [xpath_expr, xml_expr] if namespaces: @@ -83,7 +87,11 @@ def xml_is_well_formed( content: Union[PostgresXML, str, "bases.BaseExpression"], ) -> core.FunctionCall: """Generate PostgreSQL xml_is_well_formed expression.""" - content_expr = _convert_to_expression(dialect, content) + content_expr = ( + content if isinstance(content, bases.BaseExpression) + else core.Literal(dialect, content.content) if isinstance(content, PostgresXML) + else core.Literal(dialect, content) + ) return core.FunctionCall(dialect, "xml_is_well_formed", content_expr) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index c9dff656..a2e8649e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -78,6 +78,7 @@ from .extensions.fuzzystrmatch import PostgresFuzzystrmatchMixin from .extensions.cube import PostgresCubeMixin from .extensions.uuid_ossp import PostgresUuidOssMixin +from .uuid import PostgresUUIDMixin from .extensions.bloom import PostgresBloomMixin from .extensions.btree_gin import PostgresBtreeGinMixin from .extensions.btree_gist import PostgresBtreeGistMixin @@ -95,6 +96,7 @@ # Additional mixins from .query_optimization import PostgresQueryOptimizationMixin from .data_type import PostgresDataTypeMixin +from .column_suggestion import PostgresColumnSuggestionMixin from .logical_replication import PostgresLogicalReplicationMixin from .parallel_query import PostgresParallelQueryMixin from .property_graph_query import PostgresPropertyGraphQueryMixin @@ -195,6 +197,7 @@ "PostgresFuzzystrmatchMixin", "PostgresCubeMixin", "PostgresUuidOssMixin", + "PostgresUUIDMixin", "PostgresBloomMixin", "PostgresBtreeGinMixin", "PostgresBtreeGistMixin", @@ -211,6 +214,7 @@ # Additional mixins "PostgresQueryOptimizationMixin", "PostgresDataTypeMixin", + "PostgresColumnSuggestionMixin", "PostgresLogicalReplicationMixin", "PostgresParallelQueryMixin", "PostgresPropertyGraphQueryMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py new file mode 100644 index 00000000..f8dde84d --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py @@ -0,0 +1,232 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py +"""PostgreSQL's answer to "which column class does this Python type mean here". + +The column half of the suggestion protocol (`.claude/plan/2026-10-08/ +column-suggestion-protocol.md` §1–§2, §12's PostgreSQL row). A column class +says what a value can **do** on this server; how it is **stored** is the +``DataType`` layer's separate decision and never read from here. Keeping the two +apart is why this table can answer ``dict`` with a JSON column class while the +DDL side reaches for ``jsonb`` (§0.2) — the two are answers to different +questions, and neither infers the other. + +Every answer below is written out rather than inherited from core's neutral +table, because a backend that owns its answer should be readable without a +second file. The eighteen entries are the protocol's closed list, all of them +answered, and none of them refused: :data:`UNSUPPORTED` is for a backend that +*cannot* express an entry, and there is no such entry here — see +``UNSUPPORTED`` below. + +**What the answers are measured against.** The live probes in +``.claude/plan/2026-10-08/suggested-mappings.md`` §8 (≈90 server instances +across ten backends), the per-family detail in ``suggested-pairing-array.md``, +``-json``, ``-datetime``, ``-string-enum``, ``-uuid``, ``-numeric``, and this +backend's own ``.claude/plan/2026-10-08/secondary-gaps-investigation.md`` +(附录 C mapping, 附录 D gap inventory, 附录 F catalog round-trip over eleven +servers). 附录 F is the discipline this table follows rather than a source of +answers: its single finding F1 (``bit varying(n)`` parsed as ``PostgresBitType`` +instead of ``PostgresVarBitType``) was invisible to render/parse round-trips +and only appeared once the **server's own catalog words** were fed in — four of +thirty-six words were wrong, and the declared spelling was wrong the same way, +so both sides of the comparison agreed with each other and both were wrong. Fixed +in ``04ec5e5``. An answer written from documentation rather than from a server +is exactly the shape of that bug, which is why the array and JSON rows below +quote the probe tables instead of the manual. +""" + +import datetime +import decimal +import enum +import uuid +from typing import Any, Dict, Type + +from rhosocial.activerecord.backend.dialect.mixins import ColumnSuggestionMixin +from rhosocial.activerecord.backend.expression.column_types import ( + ArrayColumn, + BinaryColumn, + BooleanColumn, + ColumnBase, + DateTimeColumn, + DecimalColumn, + FloatColumn, + IntegerColumn, + JSONColumn, + NumericColumn, + StringColumn, + UUIDColumn, +) + + +class PostgresColumnSuggestionMixin(ColumnSuggestionMixin): + """The full common-Python-type → column-class table for PostgreSQL. + + Composed into :class:`~rhosocial.activerecord.backend.impl.postgres.dialect.PostgresDialect` + ahead of this backend's own mixins, and after core's + :class:`~rhosocial.activerecord.backend.dialect.mixins.column_suggestion.ColumnSuggestionMixin` + supplies the resolution path it is queried through. + + **Two entries differ from core's neutral table** — ``float`` and + ``decimal.Decimal`` — and the other sixteen are the baseline all ten backends + agree on. The sixteen are still written here rather than spread-copied, so + that "this backend agrees" is a stated fact a test can check instead of an + accident of what core happened to publish. + + **No entry is ``UNSUPPORTED``.** The protocol reserves the sentinel for a + backend with no column class for an entry, and the two backends that need it + need it for ``dict`` — MySQL below 5.7 and Firebird 5/6, which have no JSON + functions at all (protocol §12). PostgreSQL has had ``json`` since 9.2 and + ``jsonb`` since 9.4, plus the path operators on both, so the refusal would + be false. Every measured PG version behind these answers (9.6, 12, 13, 16, + 18, 19beta4 — 附录 F) answers all eighteen, and the table has no version + branch because nothing in it moves with the version: the one thing that does + (``jsonb`` becoming available in 9.4) changes the DDL spelling, not which + column class a ``dict`` is. + """ + + #: All eighteen protocol entries, in the protocol's reading order. Commented + #: per group because the reasons are the content: the answer is a claim about + #: this server, and a claim without its evidence is decoration. + COLUMN_TYPE_SUGGESTIONS: Dict[Any, Type[ColumnBase]] = { + # -- Scalars --------------------------------------------------------- + # Native `boolean`, and the three-valued logic that comes with it, so + # `is_true()` / `is_false()` render as `IS TRUE` / `IS FALSE` rather + # than `= 1`. That spelling is not a stylistic choice: PostgreSQL rejects + # `boolean = integer` outright (suggested-mappings §8.1, `bool` row), + # which is why `BooleanColumn` exists rather than a numeric column. + bool: BooleanColumn, + int: IntegerColumn, + # DEVIATION 1 of 2. Core's neutral table answers `NumericColumn` for + # `float`; §12's ten-backend baseline says `FloatColumn`, and this + # backend's 附录 C row agrees ("核心默认 NumericColumn 偏泛;本后端 + # real/double precision 真实分界"). The two classes offer the same + # operations — `FloatColumn`, `RealColumn` and `DoubleColumn` differ in + # name, not surface — so this is a readability gain, not a capability + # claim, and it is why no operation is narrowed for it below. + float: FloatColumn, + # DEVIATION 2 of 2, same shape: core answers `NumericColumn`, §12 says + # `DecimalColumn`, and 附录 C gives the reason for this backend + # specifically — `numeric` is the exact family here, so a `Decimal` is + # not "some number" but a number whose sum has to come out right. + decimal.Decimal: DecimalColumn, + str: StringColumn, + # `bytea`. Binary comparison, length, substring and hex are all + # available (suggested-mappings §8.1, `bytes` row, where PostgreSQL is + # in the unqualified set). + bytes: BinaryColumn, + bytearray: BinaryColumn, + # -- Temporal -------------------------------------------------------- + # `date` / `time` / `datetime` are three entries and one answer, and the + # answer is the baseline: core has no `DateColumn` / `TimeColumn` to give + # (secondary-gaps 附录 D ① records both as core gaps), so splitting + # `DateTimeColumn` by operation set is a core modelling change and not + # this backend's to make in this table. A tz-aware `datetime` lands on + # the same class and is where this backend is strongest: `AT TIME ZONE` + # is native and named zones work (only the *read-back* follows the + # session time zone — §8.1 `datetime tz` row). + datetime.date: DateTimeColumn, + datetime.time: DateTimeColumn, + datetime.datetime: DateTimeColumn, + # `timedelta` → `NumericColumn`, which is the §12 fallback and NOT what + # this server can do. PostgreSQL has a real `interval` type — §1 spells + # it `INTERVAL` where eight backends have no answer at all, and + # 附录 D ① lists `IntervalColumn` as a class this backend would use and + # core does not have. + # + # It is kept at `NumericColumn` because `interval` is not a number: the + # server answers `justify_hours`, `EXTRACT(... FROM interval)` and field + # qualifiers for it, and refuses `sqrt(interval '1 day')` outright. + # `NumericColumn` offers transcendentals, so naming it here over-claims + # exactly that much — a real, and deliberately *declared*, gap rather + # than a silent one: it is inherited from core's neutral table, not + # chosen here, and the fix is core growing an `IntervalColumn` (pinned by + # `test_timedelta_is_the_numeric_gap_until_core_grows_an_interval_column`). + datetime.timedelta: NumericColumn, + # -- Value families -------------------------------------------------- + uuid.UUID: UUIDColumn, + # `dict` → the JSON column class, on both `json` and `jsonb`. §12's + # PostgreSQL row adds `jsonb` because `json` has **no equality operator, + # no `?` and no `@>`** (protocol §0.2; §8.1 `dict` row) — but that is a + # DDL-layer choice made at the `DataType` layer (`JsonBType` / + # `format_data_type_jsonb` on this backend), not something a column class + # can express. The column class is the operation surface, and it is the + # same either way: `json_path` / `json_value`, has-key, array length, + # validity, and on `jsonb` the containment operators on top + # (`PostgresJSONBEnhancedMixin`). + dict: JSONColumn, + # Native arrays — the strongest `ArrayColumn` backend of the ten, and the + # one place where this table's answer is a real *difference* rather than + # a shared baseline. Seven backends route `list` through a JSON document + # (§12); `suggested-pairing-array.md` §A measures PostgreSQL 9.6 / 12 / + # 18 with every row of the guarantee set green: `INT[]` / `TEXT[]` DDL, + # a Python list bound directly and read back as a list, `array_length`, + # 1-based `x[1]`, containment `x @> ARRAY[v]`, concatenation `x || v`, + # the quantified form `v = ANY(x)`, whole-column equality, and `unnest` + # — against a seven-backend field where the same ops are variously + # absent, silently wrong, or need emulation. 附录 C calls this the + # contrast case: same Python type, different storage, different answer. + # + # `set` / `frozenset` / `tuple` answer `ArrayColumn` too, for the same + # reason the baseline puts them together: they are sequences to the + # server, and order is the one thing a set does not promise. + list: ArrayColumn, + tuple: ArrayColumn, + set: ArrayColumn, + frozenset: ArrayColumn, + # `enum.Enum` → `StringColumn`, which is the baseline and is also what + # the value semantics say: the labels compare as strings, so equality, + # `IN` and `ORDER BY` render as they do for text (§8.1 `enum` row). + # + # PostgreSQL does have enums, but as a *named* type created once by + # `CREATE TYPE ... AS ENUM` and referred to by name — which is a DDL + # fact, carried by `PostgresEnumColumnType` on the `DataType` side + # (secondary-gaps §2 item 5). Spelling the label set into the column + # class would put storage into the operation layer, which is the split + # this protocol exists to keep. + enum.Enum: StringColumn, + } + + def supports_column_operation(self, column_name: str, op: str) -> bool: + """No operation is narrowed on this backend. Every one is available. + + The declared verdict, written out rather than inherited silently, because + "nothing was narrowed" and "nobody checked" look identical at the call + site and this backend is the series' **control**: eight of the ten backends + have to narrow ``ilike`` away, and this one is what proves the mechanism + discriminates. + + What the probes measured against, per §8.1 / §8.4 and + ``secondary-gaps-investigation.md`` 附录 D ③ ("核心提供、本后端做不到: + 几乎无——本后端能力面最全"): + + ``ilike`` (``StringColumn``) + Native, and the reason eight backends narrow it. PostgreSQL is one of + the two that answer it (§8.1 `str` row, §8.4 "ILIKE 仅 PG/CH"). + ``json_path`` / ``json_value`` (``JSONColumn``) + `->>` / `->` on both `json` and `jsonb`, with `jsonb`'s `@>`, `?`, + `?|`, `?&`, `#>>` beyond what the path accessors offer. The JSON + guarantee set — binding round-trip, scalar and nested paths, has-key, + array length, validity — is measured green here (§8.1 `dict` row), + where MySQL 5.6 and Firebird have no default at all. + ``array_length`` / ``unnest`` (``ArrayColumn``) + Both native and both measured on 9.6 / 12 / 18, together with `x[1]`, + `@>`, `||`, `v = ANY(x)` and whole-column equality + (``suggested-pairing-array.md`` §A). + tz-aware ``datetime`` + `AT TIME ZONE` is native and the offset form works everywhere; the + one caveat §8.1 records is that a tz-aware value **reads back** in the + session time zone, which is a value-layer converter question, not a + missing operation. + ``LENGTH`` on ``StringColumn`` + Characters, matching what the string family means on this backend + (§8.4: MySQL/ClickHouse are bytes, SQL Server UTF-16 code units). + Same answer, so nothing to declare. + + Args: + column_name: The column class name (``"StringColumn"``). Not + consulted — see the contract test that cross-checks the operation + names against ``hasattr``. + op: The operation name, as the public method that provides it. + + Returns: + Always ``True``. + """ + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py index 7e1c388d..3218c2af 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/data_type.py @@ -30,10 +30,6 @@ def supports_composite_domains(self) -> bool: """Composite domains are native feature, PG 11+.""" return self.version >= (11, 0, 0) - def supports_jsonb_subscript(self) -> bool: - """JSONB subscript is native feature, PG 14+.""" - return self.version >= (14, 0, 0) - def supports_numeric_infinity(self) -> bool: """Numeric Infinity is native feature, PG 14+.""" return self.version >= (14, 0, 0) @@ -50,10 +46,3 @@ def supports_xid8_type(self) -> bool: # PostgreSQL 17+ Data Type Features # ========================================================================= - def supports_infinity_numeric_infinity_jsonb(self) -> bool: - """Numeric infinity in JSONB is supported since PostgreSQL 17. - - Returns: - True if supported - """ - return self.version >= (17, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py index 9c806485..3ad199f0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -195,10 +195,11 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: all_params: List[Any] = [] type_sql, _ = col_def.data_type.to_sql() - if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): + if not re.fullmatch(r"[A-Za-z0-9_\s(),\[\]]+", type_sql): raise ValueError( f"Invalid data type '{type_sql}': " - "must contain only alphanumeric characters, spaces, parentheses, commas, and brackets." + "must contain only alphanumeric characters, underscores, spaces, " + "parentheses, commas, and brackets." ) col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py index b9c8173c..641c02f1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py @@ -24,17 +24,21 @@ def format_cast_expression(self, expr) -> Tuple[str, tuple]: Tuple of (SQL string, parameters) Example: - >>> Column(dialect, 'price').cast('numeric').to_sql() + >>> Column(dialect, 'price').cast(DecimalType(dialect)).to_sql() # Returns: ('"price"::numeric', ()) Note: For chained type conversions, each ::type is appended: - >>> col.cast('money').cast('numeric').cast('float8') + >>> col.cast(PostgresMoneyType(self.dialect)).cast(DecimalType(self.dialect)).cast(FloatType(self.dialect)) # Generates: "col"::money::numeric::float8 """ expr_sql, params = expr.expression.to_sql() - sql = f"{expr_sql}::{expr.target_type}" + # The target is an expression, so it renders itself and this + # concatenates. Interpolating it would have produced + # `col::IntegerType()` instead of `col::INTEGER`. + type_sql, _ = expr.target_type.to_sql() + sql = f"{expr_sql}::{type_sql}" if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/uuid_ossp.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/uuid_ossp.py index 2dd1a2fd..4e0486d9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/uuid_ossp.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/uuid_ossp.py @@ -1,17 +1,18 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/uuid_ossp.py -""" -PostgreSQL uuid-ossp UUID generation functionality mixin. - -This module provides functionality to check uuid-ossp extension features. +"""PostgreSQL uuid-ossp extension mixin. -For SQL expression generation, use the function factories in -``functions/uuid.py`` instead of the removed format_* methods. +Exposes whether the extension is installed. It used to define +``supports_uuid_generation`` as well, which meant two probes shared one name +and two questions — "is uuid-ossp installed" here and "can this server +generate a UUID" in ``PostgresUUIDMixin`` — and the MRO decided which answer a +caller got. The merged answer now lives in ``PostgresUUIDMixin``; the narrower +question is answerable here, under its own name. """ class PostgresUuidOssMixin: """uuid-ossp UUID generation functionality implementation.""" - def supports_uuid_generation(self) -> bool: - """Check if UUID generation functions are supported.""" + def supports_uuid_ossp_extension(self) -> bool: + """Whether the uuid-ossp extension is installed on this database.""" return self.check_extension_feature("uuid_ossp", "generation") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py index b44b38c9..5b471e46 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py @@ -6,5 +6,25 @@ class PostgresJSONMixin: """PostgreSQL JSON feature support.""" def supports_json_arrow_operators(self) -> bool: - """PostgreSQL supports -> and ->> operators for JSON/JSONB access.""" - return True + """Whether the ``->`` and ``->>`` operators can be spelled. 9.3+. + + Both operators arrived in 9.3 for the ``json`` type (``jsonb`` came + with the type itself in 9.4, and the operators with it). What is being + asked here is whether the server can parse an arrow at all, so 9.3 is + the gate: on 9.2 a JSON value exists and can only be navigated with + the functions ``json_extract_path`` / ``json_extract_path_text``, and + an arrow the core formatter renders would be a syntax error. + + The probe previously returned True unconditionally, so on 9.2 and + older -- where the type exists and the operators do not -- it + advertised SQL the parser rejects. It is asked through + :meth:`format_json_expression` (ARROW mode) and by callers deciding + whether to request that mode, so the wrong answer was ready to reach + SQL. + + An unadapted dialect raises from ``self.version`` + (``DialectNotAdaptedException``), as every other JSON probe in this + backend does: the question is about the server version, and a dialect + that has not been told one has no answer to give. + """ + return self.version >= (9, 3, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py index 2d956717..e56a5bbb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type.py @@ -54,11 +54,18 @@ def supports_composite_domains(self) -> bool: return self.version >= (11, 0, 0) def supports_jsonb_subscript(self) -> bool: - """Whether JSONB subscript notation is supported. + """Always False: PostgreSQL has no ``jsonb['key']`` subscript. - Native feature, PostgreSQL 14+. + SQL subscripting covers arrays and composites; jsonb values are read + with ``->`` / ``->>`` or the SQL/JSON path language. This probe used to + claim 14.0+, dating a capability the server does not have. + + Declared in PostgresDataTypeSupport, so this mixin must define it even + though PostgresJSONBEnhancedMixin is where JSONB concerns live. Both + return False, so the MRO cannot produce two different answers. """ - return self.version >= (14, 0, 0) + return False + def supports_numeric_infinity(self) -> bool: """Whether NUMERIC type supports Infinity values. 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 index 8969dbd6..32b56930 100644 --- 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 @@ -22,7 +22,6 @@ DoubleType, FloatType, IntegerType, - IntType, IntervalType, JsonBType, JsonType, @@ -35,11 +34,9 @@ TimestampTzType, TinyIntType, VarCharType, + XmlType, ) -from ...expression.types import ( - PostgresArrayType, - PostgresCharacterVaryingType, -) +from ...expression.types import PostgresArrayType class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): """PostgreSQL DataType formatting and parsing. @@ -85,11 +82,6 @@ def format_data_type_postgres_uuid(self, data_type) -> Tuple[str, tuple]: def format_data_type_postgres_xml(self, data_type) -> Tuple[str, tuple]: return "XML", () - def format_data_type_postgres_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", () - def format_data_type_postgres_tsvector(self, data_type) -> Tuple[str, tuple]: return "TSVECTOR", () @@ -241,6 +233,12 @@ def format_data_type_postgres_cube(self, data_type) -> Tuple[str, tuple]: def format_data_type_postgres_ltree(self, data_type) -> Tuple[str, tuple]: return "LTREE", () + def format_data_type_postgres_lquery(self, data_type) -> Tuple[str, tuple]: + return "LQUERY", () + + def format_data_type_postgres_ltxtquery(self, data_type) -> Tuple[str, tuple]: + return "LTXTQUERY", () + def format_data_type_postgres_raster(self, data_type) -> Tuple[str, tuple]: return "RASTER", () @@ -250,22 +248,183 @@ def format_data_type_postgres_array(self, data_type: ArrayType) -> Tuple[str, tu # --- Core formatters (PostgreSQL specialized) --- + def _refuse_unsigned_integer( + self, + expr, + word: str, + ) -> None: + """Refuse ``unsigned=True``, because PostgreSQL has no unsigned integer. + + The core integer concepts carry signedness as a field rather than as a + class, so ``IntegerType(unsigned=True)`` is constructible and the flag + reaches the formatter. PostgreSQL's honest answer is a refusal: + + * The numeric-types chapter enumerates the integer types as ``smallint`` + / ``int2`` (-32768 to 32767), ``integer`` / ``int4`` + (-2147483648 to 2147483647) and ``bigint`` / ``int8``, each with one + symmetric range, and there is no unsigned entry anywhere in it. + https://www.postgresql.org/docs/current/datatype-numeric.html + * ``UNSIGNED`` is an attribute MySQL and MariaDB define. PostgreSQL's + ``CREATE TABLE`` grammar has no such attribute to place after a type + name, so there is not even a spelling that would parse. + + Writing a bare ``INTEGER`` for an unsigned request would create a column + that accepts the negatives the caller declared it would not, and report + success: the same silent data loss as accepting the flag and discarding + it, which is what this replaces. + + The signed range does cover the unsigned range, so nothing is lost by + dropping the *concept* here; what would be lost is the caller's + statement that this column holds no negative value, and that belongs in + a ``CHECK`` constraint. + """ + if not expr.unsigned: + return + raise UnsupportedFeatureError( + self.name, + f"an unsigned {word} column " + f"(PostgreSQL has no unsigned integer type; {word} has one " + f"symmetric signed range and no unsigned variant)", + suggestion=( + "Declare the column signed and enforce the range with a CHECK " + "constraint if negatives must be rejected." + ), + ) + + def _refuse_unsigned_numeric(self, expr, word: str) -> None: + """Refuse ``unsigned=True`` on ``DECIMAL``, ``FLOAT``, ``REAL`` or + ``DOUBLE``. + + The same field reaches these four concepts that it reaches the four + integer widths -- ``DecimalType``, ``FloatType``, ``RealType`` and + ``DoubleType`` each carry ``unsigned`` in ``PARAMETERS``, so two + declarations differing only in it are *different columns* as far as the + schema differ is concerned -- and PostgreSQL's answer is the same refusal + as :meth:`_refuse_unsigned_integer`, for its own reasons: + + * **The type list is closed and carries no signedness.** PostgreSQL's + Table 8.2 enumerates the numeric types as ``smallint``, ``integer``, + ``bigint``, ``decimal``, ``numeric``, ``real``, ``double precision`` + and the three ``serial`` conveniences. ``decimal``/``numeric`` are + "variable, user-specified precision, exact, up to 131072 digits before + the decimal point; up to 16383 digits after the decimal point"; + ``real`` is "4 bytes, variable-precision, inexact, 6 decimal digits + precision"; ``double precision`` is "8 bytes ... 15 decimal digits + precision". There is no unsigned row for any of them, and a closed + table is what makes the absence evidence rather than a gap. + https://www.postgresql.org/docs/current/datatype-numeric.html + * **The storages are signed by construction, not by declaration.** + ``real`` and ``double precision`` are "implementations of IEEE + Standard 754 for Binary Floating-Point Arithmetic (single and double + precision, respectively)", and IEEE 754 binary formats carry a signed + exponent in their field layout -- there is no unsigned binary format to + select. Their documented ranges are signed on both sides: ``real`` + "around 1E-37 to 1E+37", ``double precision`` "around 1E-307 to + 1E+308". ``numeric`` is signed just as concretely: its documented + special values include ``Infinity`` **and** ``-Infinity``, and a type + that admits ``-Infinity`` is not a type with an unsigned form. + * **There is not even a spelling that would parse.** PostgreSQL accepts + the standard's ``float`` / ``float(p)`` notations for an inexact type + and its own ``numeric(precision, scale)``, but ``CREATE TABLE``'s + grammar has no attribute slot after the type name in which + ``UNSIGNED`` could go. ``UNSIGNED`` is an attribute MySQL and MariaDB + define; PostgreSQL does not, and documents no such attribute. + + Writing a bare ``DECIMAL`` or ``DOUBLE PRECISION`` for an unsigned + request would create a column that accepts the negatives the caller + declared it would not, and report success: the same silent loss as + accepting the flag and discarding it, which is what this replaces. + + ``word`` is the word PostgreSQL writes, so the message can say both what + the caller asked for and what it would otherwise have written. + + ``UnsupportedFeatureError``, not ``ValueError``: this is a declaration + the grammar cannot express at all rather than a wrong value, and the two + exceptions do not share a base class. + """ + if not getattr(expr, "unsigned", False): + return + raise UnsupportedFeatureError( + self.name, + f"an unsigned {word} column " + f"(PostgreSQL has no unsigned floating-point or fixed-point type: " + f"Table 8.2 lists decimal/numeric, real and double precision with no " + f"unsigned variant, real and double precision are IEEE 754 binary " + f"formats whose documented ranges are signed on both sides, numeric's " + f"own special values include -Infinity, and CREATE TABLE has no " + f"attribute slot after the type name in which UNSIGNED could go)", + suggestion=( + "Declare the column signed and enforce the range with a CHECK " + "constraint if negatives must be rejected." + ), + ) + def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: + """PostgreSQL has neither ``TINYINT`` nor ``INT1`` — no 1-byte integer + exists — so the concept is *widened* to the next size that does, and + the rendered column is a ``SMALLINT``. Both spellings are accepted + because the caller asked for one concept, and refusing would leave + ``TinyIntType`` unusable on this backend without telling them anything + a widened column does not already say. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`, which is where the reason is written + down.""" + self._check_spelling(data_type, TinyIntType) + self._refuse_unsigned_integer(data_type, "SMALLINT") return "SMALLINT", () def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple]: - return "SMALLINT", () + """Both ``SMALLINT`` and ``INT2`` are PostgreSQL spellings of this + concept, so both render as written. - def format_data_type_int(self, data_type: IntType) -> Tuple[str, tuple]: - return "INTEGER", () + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`.""" + self._check_spelling(data_type, SmallIntType) + self._refuse_unsigned_integer(data_type, data_type.spelling.upper()) + return data_type.spelling.upper(), () def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: - return "INTEGER", () + """PostgreSQL writes this concept both ways, ``INT`` and ``INTEGER``, and + the two are distinct renderings of one 4-byte signed integer — so the + spelling is honoured rather than normalised. + + ``INT4`` is refused, and now **explicitly**. It used to be refused by + absence — it was simply not on the concept's list — but it has a + documentation page on MySQL and on MariaDB, so it belongs to the + union, and the union is what the class carries. The list is therefore + every dialect's vocabulary; picking the subset this one *writes* is the + dialect's job, and PostgreSQL's subset is ``("integer", "int")``: + ``INT4`` is an internal catalog name, not a word its ``CREATE TABLE`` + grammar accepts. Without this the formatter would have started emitting + ``INT4`` the moment the list grew. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`.""" + self._check_spelling(data_type, ("integer", "int")) + self._refuse_unsigned_integer(data_type, data_type.spelling.upper()) + return data_type.spelling.upper(), () def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: - return "BIGINT", () + """``BIGINT`` and ``INT8`` are both PostgreSQL spellings of this + concept, so both render as written. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_integer`.""" + self._check_spelling(data_type, BigIntType) + self._refuse_unsigned_integer(data_type, data_type.spelling.upper()) + return data_type.spelling.upper(), () def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: + """``FLOAT(p)`` for a declared precision, ``REAL`` for none. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`, which is where the reason is written + down. The refusal comes first so it cannot be masked by the precision + check below, and so an unsigned request is answered the same way whether + or not it also named a precision. + """ + self._refuse_unsigned_numeric(data_type, "FLOAT") if data_type.precision is not None: if not (1 <= data_type.precision <= 53): raise ValueError( @@ -276,12 +435,53 @@ def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: return "REAL", () def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: + """``REAL``, and ``unsigned`` refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`, which is where the reason is written + down. + + ``REAL`` is refused for the same reason as ``DOUBLE PRECISION``, and by + the same helper: PostgreSQL's numeric-types chapter gives ``real`` a + single documented row -- "4 bytes, variable-precision, inexact, 6 + decimal digits precision", an IEEE 754 binary32 whose range is signed on + both sides -- and a closed table with no unsigned row is what makes the + absence evidence rather than a gap. + https://www.postgresql.org/docs/current/datatype-numeric.html + + The refusal is checked before anything else could answer for it, and + there is nothing else here to check: ``RealType`` carries ``unsigned`` + as its only parameter, and ``REAL`` is one of the two words PostgreSQL + writes verbatim, so nothing about a *signed* declaration moves. + """ + self._refuse_unsigned_numeric(data_type, "REAL") return "REAL", () def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: + """``double`` and ``double precision`` are both PostgreSQL spellings, + and PostgreSQL writes the long one itself — so both render as + ``DOUBLE PRECISION`` rather than the bare ``DOUBLE``, which is valid + but not what the server reports back. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`.""" + self._check_spelling(data_type, DoubleType) + self._refuse_unsigned_numeric(data_type, "DOUBLE PRECISION") return "DOUBLE PRECISION", () def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: + """``DECIMAL``, ``NUMERIC`` and ``DEC`` are interchangeable in + PostgreSQL — the manual states they are synonyms, not variants — so all + three are accepted and the declared one is rendered verbatim. + + ``unsigned`` is refused rather than ignored; see + :meth:`_refuse_unsigned_numeric`, which is where the reason is written + down. It is checked **before** the precision and scale checks below, and + that ordering is deliberate rather than incidental: the flag is part of + this concept's identity, so an unsigned request is answered the same way + whether or not it also named numbers this grammar could not have taken. + The scale-without-precision refusal and the two ``ValueError`` range + checks below are untouched and still fire for a signed declaration.""" + self._check_spelling(data_type, DecimalType) + self._refuse_unsigned_numeric(data_type, "DECIMAL") if data_type.precision is not None: if not (1 <= data_type.precision <= 1000): raise ValueError( @@ -294,6 +494,34 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: f"DECIMAL scale must be between 0 and precision " f"({data_type.precision}), got {data_type.scale}" ) + if data_type.scale is not None and data_type.precision is None: + # PostgreSQL's grammar is `numeric(precision, scale)`: the scale is + # the second argument, so there is nowhere to put it without a + # precision. `numeric(3)` means scale 0, so the alternative reading + # -- "scale with the default precision" -- does not exist either. + # Dropping the scale silently would hand back a column with unlimited + # integer digits, so say what is missing instead. + # https://www.postgresql.org/docs/current/datatype-numeric.html + # + # ``UnsupportedFeatureError`` rather than ``ValueError``, and the + # distinction is not cosmetic -- the two do not share a base class, + # so a caller catching one cannot catch the other. The rule across + # the backends: a value that is *wrong* raises ``ValueError`` (the + # range checks below), while a declaration this grammar cannot + # express at all raises ``UnsupportedFeatureError``, which carries + # the dialect name and a route forward. Every other backend answers + # the identical "scale without precision" case the same way; this + # used to be the one that did not. + raise UnsupportedFeatureError( + self.name, + f"a DECIMAL with scale={data_type.scale} but no precision " + f"(PostgreSQL's grammar is numeric(precision, scale) -- there is " + f"nowhere to put a bare scale, and numeric(precision) alone means " + f"scale 0, not the scale requested)", + suggestion=( + "Declare the precision as well: numeric(precision, scale)." + ), + ) 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: @@ -301,22 +529,49 @@ def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: return "DECIMAL", () 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", () + """``CHARACTER`` is PostgreSQL's own long form and is rendered as + written, since PostgreSQL emits it back from the catalog.""" + self._check_spelling(data_type, CharType) + width = f"({data_type.length})" if data_type.length is not None else "" + return f"{data_type.spelling.upper()}{width}", () def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: - if data_type.length is not None: - return f"VARCHAR({data_type.length})", () - return "VARCHAR", () + """PostgreSQL writes this concept both ways — and emits + ``character varying`` from the catalog, which is why the long form is a + spelling of the core type rather than a class of its own.""" + self._check_spelling(data_type, VarCharType) + width = f"({data_type.length})" if data_type.length is not None else "" + if data_type.spelling == "character varying": + return f"CHARACTER VARYING{width}", () + return f"VARCHAR{width}", () def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: + """``TEXT`` only. + + ``CLOB`` is in the concept's spelling list but is not PostgreSQL's word + for unbounded text — PostgreSQL has no CLOB type at all — so asking for + that spelling here is an error rather than a quiet rewrite into + something else. The default spelling must always render, or the concept + would be unusable on this backend. + """ + self._check_spelling(data_type, ("text",)) return "TEXT", () def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: - return "BOOLEAN", () + """``BOOL`` is a PostgreSQL alias, so it is accepted and rendered.""" + self._check_spelling(data_type, BooleanType) + return data_type.spelling.upper(), () def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: + """Both spellings are accepted and both render ``BYTEA``. + + ``BYTEA`` is PostgreSQL's name for this concept and ``BLOB`` is not a + PostgreSQL type — but refusing ``BLOB`` would refuse the concept's own + default spelling, which is the spelling every plain ``BlobType(d)`` + carries. A backend that writes the concept under another name normalises + the spelling; it does not refuse the request. + """ + self._check_spelling(data_type, BlobType) return "BYTEA", () def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: @@ -343,6 +598,24 @@ def format_data_type_timetz(self, data_type: TimeTzType) -> Tuple[str, tuple]: return "TIME WITH TIME ZONE", () def format_data_type_datetime(self, data_type: DateTimeType) -> Tuple[str, tuple]: + """``TIMESTAMP`` without a fraction-seconds precision, which is what + PostgreSQL reports for every such column -- so the bare form is what a + default declaration renders. + + A declared precision is honoured rather than dropped: PostgreSQL's + datetime chapter documents ``timestamp(p)`` with 0 to 6 fractional + digits, and silently discarding the caller's ``precision=3`` would create + a column that keeps more digits than they asked for while reporting + success. + https://www.postgresql.org/docs/current/datatype-datetime.html + """ + if data_type.precision is not None: + if not (0 <= data_type.precision <= 6): + raise ValueError( + f"TIMESTAMP precision must be between 0 and 6 " + f"(PostgreSQL limit), got {data_type.precision}" + ) + return f"TIMESTAMP({data_type.precision})", () return "TIMESTAMP", () def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: @@ -401,9 +674,6 @@ def supports_data_type_postgres_uuid(self) -> bool: def supports_data_type_postgres_xml(self) -> bool: return True - def supports_data_type_postgres_character_varying(self) -> bool: - return True - def supports_data_type_postgres_tsvector(self) -> bool: return True @@ -411,7 +681,14 @@ def supports_data_type_postgres_tsquery(self) -> bool: return True def supports_data_type_postgres_jsonpath(self) -> bool: - return True + """jsonpath arrived in PostgreSQL 12. + + Measured against the scenario servers: 'CREATE TABLE t(p jsonpath)' is + rejected on 11 with 'type "jsonpath" does not exist' and accepted from + 12 on. Unconditional True meant a 9, 10 or 11 server was told it could + have the type, and found out by executing the DDL. + """ + return self.version >= (12, 0, 0) def supports_data_type_postgres_bit(self) -> bool: return True @@ -429,7 +706,13 @@ def supports_data_type_postgres_macaddr(self) -> bool: return True def supports_data_type_postgres_macaddr8(self) -> bool: - return True + """macaddr8 arrived in PostgreSQL 10. + + The fourth empty gate in this file and the last one: creating + (v MACADDR8) on 9.6 comes back 'type "macaddr8" does not exist'. + jsonpath is 12, xid8 is 13, the multiranges are 14, this is 10. + """ + return self.version >= (10, 0, 0) def supports_data_type_postgres_point(self) -> bool: return True @@ -504,7 +787,14 @@ def supports_data_type_postgres_xid(self) -> bool: return True def supports_data_type_postgres_xid8(self) -> bool: - return True + """xid8 arrived in PostgreSQL 13. + + Measured: rejected on 11 and 12, accepted on 13 and 14. Unconditional + True sent CREATE TABLE ... (v XID8) to a 9 or 10 server, where it came + back as 'type "xid8" does not exist' rather than at the point of asking + the dialect what it supports. + """ + return self.version >= (13, 0, 0) def supports_data_type_postgres_cid(self) -> bool: return True @@ -542,6 +832,12 @@ def supports_data_type_postgres_cube(self) -> bool: def supports_data_type_postgres_ltree(self) -> bool: return True + def supports_data_type_postgres_lquery(self) -> bool: + return True + + def supports_data_type_postgres_ltxtquery(self) -> bool: + return True + def supports_data_type_postgres_raster(self) -> bool: return True @@ -556,9 +852,6 @@ def supports_data_type_tinyint(self) -> bool: def supports_data_type_smallint(self) -> bool: return True - def supports_data_type_int(self) -> bool: - return True - def supports_data_type_integer(self) -> bool: return True @@ -654,23 +947,68 @@ def supports_data_types(self) -> dict: result[name] = cls return result + def format_data_type_postgres_enum(self, data_type) -> Tuple[str, tuple]: + """Render the reference to a created enum type. + + A column of an enum type states the type's name, not its labels — the + labels belong to the CREATE TYPE that made it. + """ + return self.format_enum_type_name(data_type.type_name, data_type.schema) + + def supports_data_type_postgres_enum(self) -> bool: + return True + def suggested_data_types(self) -> dict: """Cross-backend type-consistency suggestion map. - PostgreSQL has native BYTEA for binary data (mapped via ``blob``), - and all core types it supports are rendered through their own - ``format_data_type_`` formatters. There are no core types - that PostgreSQL cannot handle natively and honestly suggest an - alternative for. - - Returns an empty dict — PostgreSQL covers every core type it - renders; suggestions are not needed. + PostgreSQL renders every core type it supports through its own + ``format_data_type_`` formatters — including ``blob`` (as + ``BYTEA``) and ``xml`` (natively). What it cannot spell, it names here: + + ``binary`` / ``varbinary`` + PostgreSQL has **no** fixed-length or variable-length byte-string + type. ``BYTEA`` is the only byte storage it has, and it is + unbounded, so a ``BINARY(16)`` on this backend is a ``BYTEA`` with + the length enforced by a CHECK constraint — a fact about the + column, not a type. The substitute is therefore the byte-string + concept this backend does have. + + ``enum`` + PostgreSQL does have enums, but a *named* type created once by + ``CREATE TYPE ... AS ENUM`` and referred to by name, which is what + lets two columns share one set of labels. The generic ``EnumType`` + carries labels and no name, so there is nothing to render. The + suggestion is the DataType-shaped door into that machinery — it + needs a ``type_name``, and constructing one without it is an error + rather than a guess. """ - return {} + from ...expression.types import PostgresEnumColumnType + + return { + "binary": BlobType, + "varbinary": BlobType, + "enum": PostgresEnumColumnType, + } def format_data_type_uuid(self, data_type) -> Tuple[str, tuple]: return "UUID", () + def supports_data_type_xml(self) -> bool: + """PostgreSQL has a native ``xml`` type, so it renders the XML concept + rather than substituting text for it.""" + return True + + def format_data_type_xml(self, data_type: XmlType) -> Tuple[str, tuple]: + """``xml`` in PostgreSQL. + + The type may be validated against an XML Schema registered for it, but + the schema is a column/domain attribute rather than part of the type's + identity, so it does not appear here. ``PostgresXMLType`` renders the + same SQL under its own namespaced name — both are the XML concept, one + reached through the core type and one through the backend's. + """ + return "XML", () + def format_data_type_custom(self, data_type) -> Tuple[str, tuple]: return data_type.raw, () @@ -699,7 +1037,7 @@ def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: re.IGNORECASE, ) _PG_STRING_TYPES = re.compile( - r"^(?:CHARACTER|CHAR|VARCHAR|TEXT)\b", + r"^(?:CHARACTER|CHAR|VARCHAR|TEXT|CITEXT)\b", re.IGNORECASE, ) _PG_BINARY_TYPES = re.compile( @@ -748,7 +1086,8 @@ def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: re.IGNORECASE, ) _PG_MISC_TYPES = re.compile( - r"^(?:MONEY|XML|PG_LSN|HSTORE|GEOMETRY|GEOGRAPHY)\b", + r"^(?:MONEY|XML|PG_LSN|HSTORE|GEOMETRY|GEOGRAPHY|CUBE|LTREE|LQUERY|" + r"LTXTQUERY|RASTER)\b", re.IGNORECASE, ) _PG_ARRAY_SUFFIX = re.compile(r"(\[(\d*)\])+\s*$") @@ -830,10 +1169,24 @@ def parse_type(self, raw: str) -> DataType: 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"): + if upper.startswith("CHARACTER VARYING"): + return VarCharType(dialect=self, length=length, + spelling="character varying") + if upper.startswith("VARCHAR"): return VarCharType(dialect=self, length=length) - if upper.startswith("CHAR") or upper.startswith("CHARACTER"): + if upper.startswith("CHARACTER"): + return CharType(dialect=self, length=length, + spelling="character") + if upper.startswith("CHAR"): return CharType(dialect=self, length=length) + # citext: case-insensitive text, a contrib extension with its own + # type. A TextType subclass rather than a spelling, because the + # comparison semantics differ -- a citext column compares equal + # case-insensitively, which text does not, so the differ must see + # the difference. + if upper.startswith("CITEXT"): + from ...expression.types import PostgresCitextType + return PostgresCitextType(self) return TextType(dialect=self) # Binary @@ -886,11 +1239,17 @@ def parse_type(self, raw: str) -> DataType: 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=n, dialect=self) - from ...expression.types import PostgresVarBitType - return PostgresVarBitType(n=n, dialect=self) + # The variable-length family is tested first: live ``format_type`` + # emits ``bit varying(n)`` for a varbit column, and that string + # starts with ``BIT`` exactly as the fixed-length word does. A + # leading ``startswith("BIT")`` test sent the catalog spelling to + # ``PostgresBitType``; ``VARBIT`` and ``BIT VARYING`` are the two + # words for the one storage and must answer the same class. + if upper.startswith("VARBIT") or re.match(r"BIT\s+VARYING\b", upper): + from ...expression.types import PostgresVarBitType + return PostgresVarBitType(n=n, dialect=self) + from ...expression.types import PostgresBitType + return PostgresBitType(n=n, dialect=self) # Network address if self._PG_NET_TYPES.match(upper): @@ -1036,6 +1395,30 @@ def parse_type(self, raw: str) -> DataType: return PostgresGeographyType(self) if upper.startswith("GEOMETRY"): return PostgresGeometryType(self) + # Extension types with one word each: cube and the ltree family + # are contrib extensions, RASTER is PostGIS -- the same home as + # HSTORE and GEOMETRY already above them, and ungated for the + # same reason they are: the word can only have come from a + # server where the extension exists, because a column of the + # type requires it. + if upper.startswith("CUBE"): + from ...expression.types import PostgresCubeType + return PostgresCubeType(self) + # LTXTQUERY is checked before LQUERY although the words cannot + # collide (they differ at the second letter), because the pair + # is where a prefix mistake would go if one were ever added. + if upper.startswith("LTXTQUERY"): + from ...expression.types import PostgresLtxtqueryType + return PostgresLtxtqueryType(self) + if upper.startswith("LQUERY"): + from ...expression.types import PostgresLqueryType + return PostgresLqueryType(self) + if upper.startswith("LTREE"): + from ...expression.types import PostgresLtreeType + return PostgresLtreeType(self) + if upper.startswith("RASTER"): + from ...expression.types import PostgresRasterType + return PostgresRasterType(self) # Vector types (pgvector) vector_match = re.match(r"^(VECTOR|HALFVEC|SPARSEVEC)\b", upper) @@ -1063,4 +1446,4 @@ def parse_type(self, raw: str) -> DataType: # Fallback from rhosocial.activerecord.backend.expression.types import CustomType - return CustomType(dialect=self, raw=stripped) \ No newline at end of file + return CustomType(dialect=self, raw=stripped) 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 4074e3c7..eb27e43e 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,10 +5,266 @@ Implements the PostgresJSONBEnhancedSupport protocol. """ -from typing import Tuple, TYPE_CHECKING +import re +from typing import Tuple, Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression import JSONExpression + from rhosocial.activerecord.backend.expression import ( + JSONDocumentExpression, + JSONTextExpression, + ) + + #: What both formatters below accept. Core used to have one class for both + #: JSON access operators; it now has two, one per operator, and this dialect + #: renders either from the same code path -- so the parameter is a union. + #: + #: The union is spelled out even though ``JSONTextExpression`` subclasses + #: ``JSONDocumentExpression`` and a checker would collapse it. That + #: inheritance exists so ``->>`` keeps the accessors that let a path chain + #: continue; it is not a claim that ``->>`` yields a document. Annotating + #: only the document class would type a text node as a document, which is + #: the confusion the split was made to remove. + JSONPathNode = Union["JSONDocumentExpression", "JSONTextExpression"] + + +# --------------------------------------------------------------------------- +# A jsonpath, translated to the key array ``#>`` takes +# --------------------------------------------------------------------------- + +#: One accessor of a jsonpath, in the only two shapes a key array can hold: an +#: object key and an array subscript. Everything else in the language -- filters, +#: wildcards, arithmetic, ``last`` -- has no spelling here, and +#: :func:`_jsonpath_key_array` refuses rather than approximate it. +#: +#: The three alternatives are exactly PostgreSQL's lax-mode accessor syntax: +#: ``.key`` for an unquoted key, ``."key"`` for anything else (a space, a dash, +#: a leading digit), and ``[0]`` for a subscript. ``$.tags[0]`` therefore +#: translates to ``("tags", "0")``, which is what ``#> '{tags,0}'`` walks. +_JSONPATH_ACCESSOR = re.compile( + r""" + \.\s*(?P[A-Za-z_][A-Za-z0-9_]*) # .key + | \.\s*"(?P(?:[^"\\]|\\.)*)" # ."a key with spaces" + | \[\s*(?P[0-9]+)\s*\] # [0] + """, + re.VERBOSE, +) + +#: Constructs a jsonpath can contain that a key array cannot express, each with +#: the clause to use when refusing it. Looked for in this order because some of +#: them contain others, and because in lax mode ``$.a-b`` is a *subtraction*, +#: not a key named ``a-b`` -- so the arithmetic markers have to be recognised +#: before anything treats the text as a name. +_UNSUPPORTED_JSONPATH_CONSTRUCTS = ( + ("..", "uses a recursive descent (..)"), + ("*", "uses a wildcard accessor ([*] or .*)"), + ("?", "uses a filter expression (?(...))"), + ("$", "uses a jsonpath variable"), + ("last", "uses the [last] subscript"), + ("+", "uses the addition operator"), + ("-", "uses the subtraction operator"), + ("/", "uses the division operator"), + ("%", "uses the modulus operator"), + ("||", "uses the concatenation operator"), + ("<", "compares two values with <"), + (">", "compares two values with >"), + ("=", "compares two values with =="), + ("!", "compares two values"), +) + + +def _describe_untranslatable_path(fragment: str) -> str: + """Name the construct in ``fragment`` that a key array cannot hold. + + Returns a clause that reads after "because it ...", so the caller can put it + straight into an ``UnsupportedFeatureError`` without re-parsing the path. + """ + for needle, clause in _UNSUPPORTED_JSONPATH_CONSTRUCTS: + if needle in fragment: + return clause + return f"is not a path the -> and #> operators can express: {fragment.strip()!r}" + + +def _jsonpath_accessors(text: str) -> Tuple[Tuple[str, str], ...]: + """Walk an accessor chain into ``(kind, text)`` pairs. + + ``kind`` is ``"index"`` (``[0]``), ``"key"`` (``.a``) or ``"quoted"`` + (``."a b"``), one per alternative of :data:`_JSONPATH_ACCESSOR`; ``text`` + is the match as written, so a quoted key keeps its backslash escapes. + + This is the one walk both spellings of a path are built from. + :func:`_jsonpath_key_array` takes the values, and + :func:`_canonical_jsonpath` needs the kind as well, because ``[0]`` and + ``."0"`` collapse to the same key array element but are different + jsonpaths. + + Raises: + ValueError: If any accessor is something a key array cannot hold. The + message names the construct, ready for an + ``UnsupportedFeatureError`` without re-parsing. + """ + accessors = [] + position = 0 + while position < len(text): + accessor = _JSONPATH_ACCESSOR.match(text, position) + if accessor is None: + raise ValueError(_describe_untranslatable_path(text[position:])) + position = accessor.end() + if accessor.group("index") is not None: + accessors.append(("index", accessor.group("index"))) + elif accessor.group("key") is not None: + accessors.append(("key", accessor.group("key"))) + else: + accessors.append(("quoted", accessor.group("quoted"))) + return tuple(accessors) + + +def _jsonpath_key_array(path: str) -> Tuple[str, ...]: + """Translate a jsonpath accessor chain into the keys ``#>`` takes. + + PostgreSQL before 12.0 has no jsonpath. Its path operators take a *key + array* instead, so a path written in the language has to be taken apart + before it can be rendered -- and taking it apart is only sound for the + subset that walks keys and subscripts. Anything else is refused rather than + approximated, because every approximation here returns a different value + instead of an error. + + Two things about the accepted subset are worth stating outright, because + they are the two places where this spelling and ``jsonb_path_query_first`` + can disagree, both checked against a live 12.22 server: + + * A path with no ``$`` is read the way ``->`` reads a path -- as a key, or + as a subscript -- and only when it is exactly one accessor. The path + language reads a rootless form as something else: ``'a'`` is a variable + reference, ``'a.b'`` and ``'[0]'`` are rejected, and ``'"a b"'`` is the + *string literal* ``a b``. Reading a single rootless accessor as a key is + deliberate, because that is what :meth:`format_json_arrow_expression` + reads, and the jsonpath spelling roots the same accessor rather than + passing it through (see :func:`_canonical_jsonpath`), so the same + argument means the same thing in ARROW mode, in this spelling, and in + the path language. A longer rootless chain is refused rather than + invented: the two spellings do not agree on what it means. + * ``#>`` is strict where the path language is lax. A path whose shape does + not match the document -- a member of a scalar, a subscript into an + object -- returns NULL here and the container itself under + ``jsonb_path_query_first``, because lax mode returns the item it was + standing on rather than failing. For a path that matches the shape, which + is every path written on purpose, the two agree exactly. + + Args: + path: A jsonpath such as ``$.a``, ``$.tags[0]``, ``$."a key"`` or ``a``. + + Returns: + The keys, outermost first. Empty for the root ``$``, which + ``#> '{}'`` reads as the whole document. + + Raises: + ValueError: If ``path`` uses anything a key array cannot express. The + message is a clause naming the construct, so the caller can quote it + in an ``UnsupportedFeatureError`` without parsing the path again. + """ + text = path.strip() + if not text: + raise ValueError("is empty") + + rootless = not text.startswith("$") + if rootless: + # A leading dot is added so a rootless accessor matches the same pattern + # as one written after a root. + if text[0] not in '.[': + text = "." + text + else: + text = text[1:].lstrip() + if text and text[0] not in '.[': + # ``$name`` is a variable reference, not the member ``name``: lax + # mode always spells a member ``$.name``. Turning it into a key + # would look up a key called ``name`` and answer with whatever that + # key holds, which is not what the jsonpath asked for. + raise ValueError("uses a jsonpath variable") + + accessors = _jsonpath_accessors(text) + keys = tuple( + raw.replace('\\"', '"').replace("\\\\", "\\") + if kind == "quoted" + else raw + for kind, raw in accessors + ) + + if rootless and len(keys) != 1: + raise ValueError( + "omits the root $, which the path language allows only for a single " + "accessor" + ) + + return tuple(keys) + + +def _canonical_jsonpath(path: str) -> str: + """Root a single rootless accessor, for the jsonpath spelling. + + ``jsonb_path_query_first`` takes a jsonpath, and the path language reads a + rootless form as something other than a key or a subscript: ``'a'`` is a + variable reference, ``'[0]'`` and ``'a.b'`` are rejected, and ``'"a b"'`` + is the *string literal* ``a b``. The arrow spelling this backend renders + reads a single rootless accessor as a key or subscript, so an argument + written that way has to arrive at the server rooted: ``a`` becomes + ``$.a``, ``[0]`` becomes ``$[0]`` and ``"a b"`` becomes ``$."a b"``. + + Only one accessor is rooted. A longer rootless chain is refused rather + than completed, because the two spellings do not agree on what it means: + the key array reads it as keys, the path language rejects it. What counts + as a single accessor is :func:`_jsonpath_key_array`'s question -- it is + the authority on which rootless forms have a shared meaning -- and this + asks it through the same :func:`_jsonpath_accessors` walk. + + A rooted path is returned unchanged: it is already what the server + expects, filters and wildcards included. + + Args: + path: A jsonpath such as ``$.a``, ``$.tags[0]``, ``$."a key"`` or a + single rootless accessor such as ``a``, ``[0]`` or ``"a b"``. + + Returns: + The rooted jsonpath, or ``path`` unchanged when it already starts + with ``$``. + + Raises: + ValueError: If ``path`` is empty, or omits the root without being a + single accessor. The message is a clause naming the reason, ready + to quote in an ``UnsupportedFeatureError``. + """ + text = path.strip() + if not text: + raise ValueError("is empty") + + if text.startswith("$"): + return text + + # A leading dot is added so a rootless accessor matches the same pattern + # as one written after a root, exactly as _jsonpath_key_array does it. + walkable = text if text[0] in '.[' else "." + text + try: + accessors = _jsonpath_accessors(walkable) + except ValueError as exc: + raise ValueError( + f"omits the root $, which the arrow spelling and the path language " + f"read the same way only for a single accessor ({exc})" + ) from None + + if len(accessors) != 1: + raise ValueError( + "omits the root $, which the arrow spelling and the path language " + "read the same way only for a single accessor" + ) + + kind, literal = accessors[0] + if kind == "index": + return f"$[{literal}]" + if kind == "key": + return f"$.{literal}" + # Quoted, kept as written so its backslash escapes stay escapes. + return f'$."{literal}"' class PostgresJSONBEnhancedMixin: @@ -16,29 +272,282 @@ class PostgresJSONBEnhancedMixin: Provides version-aware capability detection for PostgreSQL JSON and JSONB features beyond the standard JSONSupport protocol. + + PostgreSQL can navigate a document two ways, and which one applies is + decided by the expression's :class:`JSONPathMode` rather than by + preference: + + - ``->`` / ``->>`` arrows, one key and no subscript, since 9.3 for ``json`` + and 9.4 for ``jsonb``. Cheap, and the idiomatic form. + - a *path*, in two spellings that arrived nine years apart. ``#>`` and + ``#>>`` take a key array and came with the arrows in 9.3 (9.4 for + ``jsonb``); ``jsonb_path_query_first`` and the rest of the SQL/JSON path + language take a jsonpath and came in 12.0. + + For a path that only walks keys and subscripts the two spellings return the + same value, so AUTO and FUNCTION render whichever one the server has -- + :meth:`supports_sql_json_path` says which -- and the mode still decides + between ARROW and the path form. The language is preferred where it exists, + because it is the only one that can evaluate a predicate. A path that + genuinely needs it -- a filter, a wildcard, arithmetic -- is refused on an + older server, and refusing is the point: the alternative is a translation + that returns the wrong answer instead of an error. """ - def format_json_expression(self, expr: "JSONExpression") -> Tuple[str, tuple]: + #: The JSON functions this backend declares, each with the version that + #: introduced it, so :meth:`supports_json_function` never claims a function + #: the server does not have. Before 12.0 the ``jsonb_path_query_*`` family + #: does not exist at all -- that is the entire reason for the second + #: spelling of a path below -- and the probe answered from a flat set with + #: no version in it, so it claimed all three on a 9.6 server. + #: + #: ``#>`` and ``#>>`` are deliberately absent: they are operators, and a + #: probe that takes a function name should not answer about one. + _JSON_FUNCTIONS_BY_VERSION = ( + ((9, 3, 0), ("json_extract_path",)), + ((9, 4, 0), ("jsonb_extract_path",)), + ((9, 5, 0), ("json_extract_path_text",)), + ( + (12, 0, 0), + ("jsonb_path_query", "jsonb_path_query_array", "jsonb_path_query_first"), + ), + ) + + def supports_json_function(self, function_name: str) -> bool: + """Whether a named JSON function exists on this server. + + Gated per function rather than as one set, because the names did not + arrive together: ``json_extract_path`` is 9.3, ``jsonb_extract_path`` + is 9.4, ``json_extract_path_text`` is 9.5 and the ``jsonb_path_query_*`` + family is 12.0. A server has some of these and not others. + """ + wanted = function_name.lower() + return any( + wanted in {name.lower() for name in names} + for introduced, names in self._JSON_FUNCTIONS_BY_VERSION + if self.version >= introduced + ) + + def format_json_expression(self, expr: "JSONPathNode") -> Tuple[str, tuple]: + """Render a JSON path access according to the expression's mode. + + Overriding the core dispatch entry point previously ignored + ``expr.mode`` entirely, so ``ARROW`` silently produced jsonpath SQL + and ``FUNCTION`` was indistinguishable from ``AUTO`` -- even though + ``supports_json_arrow_operators()`` advertised arrows that this method + never emitted. + + ``AUTO`` keeps resolving to the path form rather than to arrows, and + that is deliberate. The two spellings do not take the same path + argument: ``->`` and ``->>`` take a *key name*, while + ``jsonb_path_query_first`` takes a *jsonpath* such as + ``$.tags[0]``. ``expression.functions.json_extract_text`` passes a + jsonpath, so preferring arrows here would look up a key literally named + ``$.tags[0]``, find nothing, and yield NULL. Preferring arrows is only + safe when the path is a single key, which the caller cannot be assumed + to know. Rooting a single rootless accessor for the path form (see + :func:`_canonical_jsonpath`) does not change that: it makes ``a`` mean + the key ``a`` in both modes, but arrows read their argument literally, + so a path the caller wrote with a root -- the only spelling that can + chain -- still cannot be handed to them. + + Args: + expr: The JSON path node -- a + :class:`~rhosocial.activerecord.backend.expression.JSONDocumentExpression` + for ``->`` or a + :class:`~rhosocial.activerecord.backend.expression.JSONTextExpression` + for ``->>``. Either is accepted; see ``JSONPathNode``. + + Returns: + Tuple of (SQL string, parameters tuple). + + Raises: + UnsupportedFeatureError: If ARROW mode is asked of a server with no + arrows, or if the path needs the SQL/JSON path language on a + server older than 12.0. Both are refusals of something the + server cannot do -- never of something it can. + """ + from rhosocial.activerecord.backend.expression.advanced_functions import JSONPathMode + + mode: JSONPathMode = getattr(expr, "mode", JSONPathMode.AUTO) + + if mode is JSONPathMode.ARROW: + return self.format_json_arrow_expression(expr) + + # AUTO and FUNCTION both render the path form: the path argument is a + # jsonpath, which the arrow operators cannot consume. + return self._format_json_path_expression(expr) + + def _format_json_path_expression(self, expr: "JSONPathNode") -> Tuple[str, tuple]: + """Render the path form, in whichever spelling this server has. + + Both core JSON path classes are accepted, and both are told apart by + ``expr.operation`` rather than by type: ``->>`` unwraps because it + yields text, ``->`` does not because it yields a document. The + ``operation`` field is what renders here, so which class the caller + happened to build does not change the SQL. + + Which spelling is used is the server's answer, not the caller's: the + SQL/JSON path language where it exists, the ``#>`` / ``#>>`` operators + where it does not. Both are the *path* form, which is what AUTO and + FUNCTION mean here. + + The path is bound as a parameter in both spellings -- as the jsonpath + itself on 12.0+, and as one bound element per key before that -- so a + document-driven path cannot alter the statement's shape either way. + """ from rhosocial.activerecord.backend.expression import bases + if not self.supports_json_path(): + raise UnsupportedFeatureError( + self.name, + "JSON path expressions (-> and #> operators)", + "This PostgreSQL is older than 9.3, which added the JSON path " + "operators. There is no way to read a path out of a JSON value " + "on this server: store the value as text and parse it in " + "Python instead.", + ) + if isinstance(expr.column, bases.BaseExpression): col_sql, col_params = expr.column.to_sql() else: col_sql, col_params = self.format_identifier(str(expr.column)), () - placeholder = self.get_parameter_placeholder() - value_sql = f"jsonb_path_query_first({col_sql}, {placeholder}::jsonpath)" - if expr.operation == "->>": - sql = f"({value_sql} #>> '{{}}')" + if self.supports_sql_json_path(): + sql, params = self._format_sql_json_path_expression(col_sql, expr) else: - sql = value_sql - params = col_params + (expr.path,) + sql, params = self._format_key_path_expression(col_sql, expr) + params = col_params + params if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" return sql, params + def _format_sql_json_path_expression( + self, col_sql: str, expr: "JSONPathNode" + ) -> Tuple[str, tuple]: + """Render via ``jsonb_path_query_first``, the SQL/JSON path form. + + The path is bound as a ``jsonpath`` parameter rather than inlined, so + a document-driven path cannot alter the statement's shape. A rootless + single accessor is rooted first -- ``a`` becomes ``$.a``, ``[0]`` + becomes ``$[0]`` -- because the path language reads a rootless form + as a variable reference or a syntax error where the arrow spelling + reads a key; see :func:`_canonical_jsonpath`. + + The column expression is cast to ``jsonb`` before the call, because + the function exists only for ``jsonb``. Without the cast a ``json`` or + ``text`` column is an ``UndefinedFunction`` on every server that has + the function rather than a value. The cast is a no-op on a ``jsonb`` + column; for a ``json`` column it means the document is parsed and + normalised as ``jsonb`` first -- duplicate object keys resolve to the + last one and object key order is not preserved -- which is also how + the ``#>`` spelling reads the same column through its own cast, so a + caller cannot choose between the two trade-offs by choosing a + spelling. A ``text`` column is parsed as JSON, so its contents have to + be valid JSON, as they must already be for any path to be read out of + them. + + Raises: + UnsupportedFeatureError: If the path omits the root without being + a single accessor -- a longer rootless chain has no meaning + shared by the arrow spelling and the path language, so there + is no rooted jsonpath to bind. + """ + placeholder = self.get_parameter_placeholder() + + try: + path = _canonical_jsonpath(expr.path) + except ValueError as exc: + raise UnsupportedFeatureError( + self.name, + "this JSON path", + f"The SQL/JSON path language reads only a rooted path, and this " + f"path {exc}. A rootless chain has no meaning shared by the " + f"arrow spelling and the path language, so only a single " + f"rootless accessor is accepted; write the root explicitly, " + f"e.g. '$.a'.", + ) from None + + value_sql = ( + f"jsonb_path_query_first(({col_sql})::jsonb, {placeholder}::jsonpath)" + ) + if expr.operation == "->>": + return f"({value_sql} #>> '{{}}')", (path,) + return value_sql, (path,) + + def _format_key_path_expression( + self, col_sql: str, expr: "JSONPathNode" + ) -> Tuple[str, tuple]: + """Render via ``#>`` / ``#>>``, the path form PostgreSQL had before 12.0. + + ``#>`` takes a key array. The column is cast first -- to ``jsonb``, or + to ``json`` on 9.3, the one version this spelling reaches before + ``jsonb`` exists -- so the operator accepts whatever the column is + declared as: ``jsonb`` and ``json`` are JSON types and the cast is a + no-op for the first and a normalisation for the second, and ``text`` + -- which has no ``#>`` operator at all -- is accepted once cast. + Without it a ``text`` column raises ``operator does not exist: text + #>> text[]`` on every version, and this spelling is the only one + servers before 12.0 have. The cast is also what lets one formatter + serve every column type: the function spellings of the same walk, + ``json_extract_path`` and ``jsonb_extract_path``, would each need to + know the column's declared type to be chosen correctly, while ``#>`` + works on either JSON type without having to know which one it holds. + + The cost of the cast is confined to a ``json`` (or ``text``) column: + the document is parsed and normalised as ``jsonb``, so duplicate + object keys resolve to the last one and object key order is not + preserved. The 12.0+ jsonpath spelling casts the same way, so the two + spellings agree on the value. + + ``#>>`` yields text and ``#>`` a document, matching what ``->>`` and + ``->`` yield. + + Each key is bound as its own parameter and assembled into ``ARRAY[...]``, + so no key is ever interpolated into SQL. + + Raises: + UnsupportedFeatureError: If the path needs the SQL/JSON path + language -- a filter, a wildcard, arithmetic, a variable, or a + rootless chain -- since nothing before 12.0 can evaluate one. + The message names the construct so the caller can see what the + server is missing rather than that the server is old. Note the + two deliberate limits of this spelling in + :func:`_jsonpath_key_array`. + """ + try: + keys = _jsonpath_key_array(expr.path) + except ValueError as exc: + raise UnsupportedFeatureError( + self.name, + "this JSON path", + f"This PostgreSQL is older than 12.0, which introduced the " + f"SQL/JSON path language, and the path cannot be translated to " + f"the key array -> and #> take because it {exc}. Nothing before " + f"12.0 can evaluate it.", + ) from None + + if keys: + placeholder = self.get_parameter_placeholder() + array_sql = "ARRAY[{}]::text[]".format( + ", ".join(f"{placeholder}::text" for _ in keys) + ) + params = keys + else: + # The root. No key to bind, and '{}' is an empty text[] literal, so + # there is nothing here a caller could have influenced. + array_sql = "'{}'" + params = () + + # ``jsonb`` is the cast target wherever it exists, but it arrived in + # 9.4 and this spelling reaches back to 9.3, where ``json`` is the + # only JSON type and ``#`` works on it just the same. + cast_type = "jsonb" if self.supports_jsonb() else "json" + operator = "#>>" if expr.operation == "->>" else "#>" + return f"({col_sql})::{cast_type} {operator} {array_sql}", params + def supports_json_type(self) -> bool: """JSON is supported since PostgreSQL 9.2.""" return self.version >= (9, 2, 0) @@ -48,16 +557,75 @@ def supports_jsonb(self) -> bool: return self.version >= (9, 4, 0) def supports_json_path(self) -> bool: - """Whether JSON path expressions are supported (PostgreSQL 12+).""" + """Whether a JSON path can be read at all. PostgreSQL 9.3+. + + The core contract for this probe is "can this server read a path out of + a JSON value", and PostgreSQL could from 9.3, when ``->``, ``->>``, + ``#>`` and ``#>>`` arrived for ``json`` (``jsonb`` followed in 9.4 with + the type itself). + + The probe answered 12.0+, which is the version of the *SQL/JSON path + language* -- a different capability, now + :meth:`supports_sql_json_path`. With the two conflated, 9.6, 10 and 11 + refused to render a path the server answers, behind two probes that + both said PostgreSQL had JSON. + """ + return self.version >= (9, 3, 0) + + def supports_sql_json_path(self) -> bool: + """Whether the SQL/JSON path language is available. PostgreSQL 12.0+. + + This is the capability :meth:`supports_json_path` used to be given credit + for, and the reason a path is rendered differently on 9.6 than on 12.0: + the jsonpath functions and the ``@?`` / ``@@`` operators arrived in + 12.0, and only they can evaluate a filter or a wildcard. + + Split out because a server can have one without the other. PostgreSQL has + had a path read since 9.3 and the language since 12.0, and a caller that + asks the only versioned question gets a refusal it cannot act on. + """ return self.version >= (12, 0, 0) def supports_json_table(self) -> bool: - """JSON_TABLE function is supported since PostgreSQL 12.""" - return self.version >= (12, 0, 0) + """PostgreSQL has no ``JSON_TABLE`` function, so this is always False. + + The probe previously claimed 12.0+ and the core formatter then emitted + ``JSON_TABLE(col, 'path' COLUMNS(...))``, which the server rejects. + PostgreSQL's closest relative is ``jsonb_to_recordset()``, and it is + not a drop-in: it takes no path, no per-column path, and returns a + set of rows rather than a table expression. See + :meth:`format_json_table_expression` for the guided error. + """ + return False + + def format_json_table_expression(self, expr) -> Tuple[str, tuple]: + """Always refuse, naming the construct that actually works here. + + Args: + expr: The JSONTableExpression node. + + Raises: + UnsupportedFeatureError: Always. + """ + raise UnsupportedFeatureError( + self.name, + "JSON_TABLE function", + "PostgreSQL has no JSON_TABLE. Use jsonb_to_recordset() in a " + "FROM clause instead: it takes the document and a column " + "definition list, but it has no path argument and no per-column " + "PATH, so a nested JSON_TABLE cannot be translated one-for-one.", + ) def supports_jsonb_subscript(self) -> bool: - """Whether JSONB subscript is supported (PostgreSQL 11+).""" - return self.version >= (11, 0, 0) + """PostgreSQL has no ``jsonb['key']`` subscript, so this is always False. + + This probe was previously defined three times across the mixin + hierarchy with two different version gates (11.0 and 14.0), and the + MRO silently picked a winner. No formatter anywhere emitted a + subscript -- the arrow operators are the only spelling this backend + uses -- so a True here advertised syntax PostgreSQL cannot parse. + """ + return False def supports_infinity_numeric_infinity_jsonb(self) -> bool: """Whether numeric infinity values are allowed in JSONB (PostgreSQL 17+).""" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/uuid.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/uuid.py new file mode 100644 index 00000000..43100e6f --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/uuid.py @@ -0,0 +1,107 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/uuid.py +"""PostgreSQL UUID value expressions. + +PostgreSQL can generate a UUID two ways and which one is available depends on +the server version, not on the extension state: ``gen_random_uuid()`` is +built in from 13.0, while ``uuid_generate_v4()`` only exists once the +``uuid-ossp`` extension has been installed on the server. The version is the +honest gate, so this mixin overrides generation and leaves the rest to the +core ``UUIDMixin`` table. +""" + +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.uuid import UUIDGenerationExpression + + +class PostgresUUIDMixin: + """UUID value support for PostgreSQL. + + Casting and the nil/max constants are version-independent, so they live in + the inherited ``UUID_SQL`` table. Generation is overridden because its SQL + depends on the server version. + """ + + #: ``gen_random_uuid()`` is absent from this table on purpose: it is not + #: one fixed spelling across supported servers, so + #: :meth:`format_uuid_generation` resolves it per version instead. + UUID_SQL = { + "constant": { + "nil": "'00000000-0000-0000-0000-000000000000'::uuid", + "max": "'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid", + }, + "cast": "{inner}::uuid", + } + + UUID_SUGGESTIONS = { + "generation": ( + "Generate the value in Python instead — `field/uuid.py` " + "(`UUIDMixin`) already does this with `uuid.uuid4()` and needs no " + "database function." + ), + "constant": ( + "Compare against a literal instead — the nil UUID is " + "'00000000-0000-0000-0000-000000000000' and the max UUID is " + "'ffffffff-ffff-ffff-ffff-ffffffffffff'." + ), + "cast": ( + "Cast in Python instead — `uuid.UUID(value)` validates the text " + "and raises ValueError on malformed input." + ), + } + + def supports_uuid_generation(self) -> bool: + """Whether the server can generate a UUID. + + True when either route works: 13.0+, where ``gen_random_uuid()`` is a + core function, or a server with the ``uuid-ossp`` extension installed. + + Both routes are folded into this one answer on purpose. The name + ``supports_uuid_generation`` used to mean two different things — "can + the database produce a UUID" here and "is uuid-ossp installed" in + :class:`PostgresUuidOssMixin` — so the MRO decided which question got + answered. There is one question, and this is its answer; the narrower + "is the extension installed" question is + ``supports_uuid_ossp_extension``. + """ + if self.version >= (13, 0, 0): + return True + return self.supports_uuid_ossp_extension() + + def format_uuid_generation( + self, expr: "UUIDGenerationExpression" + ) -> Tuple[str, Tuple]: + """Render the version-appropriate UUID generator. + + Args: + expr: The UUIDGenerationExpression node. + + Returns: + Tuple of (SQL string, parameters tuple). + + Raises: + UnsupportedFeatureError: Before 13.0 without ``uuid-ossp``. + """ + if self.version >= (13, 0, 0): + sql = "gen_random_uuid()" + elif self.supports_uuid_ossp_extension(): + sql = "uuid_generate_v4()" + else: + raise UnsupportedFeatureError( + self.name, + "UUID generation", + "This PostgreSQL is older than 13.0, so gen_random_uuid() is " + "not available. Install the uuid-ossp extension to use " + "uuid_generate_v4(), or generate the value in Python with " + "`field/uuid.py` (`UUIDMixin`), which needs no extension.", + ) + + if getattr(expr, "alias", None): + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, () + + +__all__ = ["PostgresUUIDMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/uuid_ossp.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/uuid_ossp.py index 427a92fa..95822121 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/uuid_ossp.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/uuid_ossp.py @@ -27,8 +27,24 @@ class PostgresUuidOssSupport(Protocol): - Install command: CREATE EXTENSION uuid-ossp; - Minimum version: 1.0 - Documentation: https://www.postgresql.org/docs/current/uuid-ossp.html + + It used to declare ``supports_uuid_generation``, which collided with the + core :class:`UUIDSupport` on the same name, and the overlap check rejected + the pair. The overlap was a real ambiguity, not bookkeeping: this protocol + asked whether the extension is *installed*, while the core asked whether + the server can *generate* a UUID. Two questions, two names. + + ``supports_uuid_generation`` now answers both at once and lives only in the + core protocol; :class:`PostgresUUIDMixin` folds the 13.0 built-in and this + extension into that single answer. What remains here is the narrower + question, under a name that cannot be confused with it. """ - def supports_uuid_generation(self) -> bool: - """Whether UUID generation functions are supported.""" + def supports_uuid_ossp_extension(self) -> bool: + """Whether the uuid-ossp extension is installed on this database. + + Distinct from generating a UUID: 13.0+ servers generate one with no + extension at all, so a True here says nothing about whether + ``supports_uuid_generation()`` is True. + """ ... 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 fbd53533..e4056adb 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,28 +5,46 @@ enhancements beyond the core JSON support. """ -from typing import Protocol, runtime_checkable, TYPE_CHECKING +from typing import Protocol, runtime_checkable -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression import JSONExpression +from rhosocial.activerecord.backend.dialect.protocols import JSONSupport @runtime_checkable -class PostgresJSONBEnhancedSupport(Protocol): +class PostgresJSONBEnhancedSupport(JSONSupport, Protocol): """PostgreSQL JSONB enhanced support protocol. PostgreSQL provides the JSONB data type with indexing support, JSON path expressions, and various query functions that go beyond basic JSON operations. + This protocol declares what PostgreSQL *adds* to the core JSON contract, so + it derives from :class:`~rhosocial.activerecord.backend.dialect.protocols.JSONSupport` + and repeats none of its members. That is not a style preference. Both classes + end up in ``PostgresDialect``'s base list, so a member restated here was + declared twice in one MRO, and ``supports_json_type`` -- a probe, declared + here as an abstract ``...`` and answered by the mixin -- resolved from four + classes: this protocol, the core protocol, the core mixin default and the + implementation. One implementation is not a defect; a second *declaration* + of the same probe is what put the count over the line, and derivation is + what removes it without removing a contract. + JSONB enhanced features: - - JSON type (PG 9.2+) - JSONB type with indexing (PG 9.4+) - - JSON path functions (PG 12+): jsonb_path_query, jsonb_path_exists, etc. - - JSON_TABLE function (PG 12+) - - JSONB subscript notation (PG 14+) + - JSONB extraction functions jsonb_extract_path (PG 9.4+), + json_extract_path (PG 9.3+), json_extract_path_text (PG 9.5+) + - SQL/JSON path language: jsonb_path_query, jsonb_path_query_first, + jsonb_path_query_array, jsonb_path_exists, jsonb_path_match, and the @? + and @@ operators (PG 12.0+) - Numeric infinity in JSONB (PG 17+) + Two features PostgreSQL does *not* have, so the mixin answers False and + refuses: ``JSON_TABLE``, whose declaration is the core one and whose gate + this file used to put at 12+, and the ``jsonb['key']`` subscript, declared + below and used to be gated at 14+. Both dated capabilities the server does + not have; ``jsonb_to_recordset`` in a FROM clause is PostgreSQL's nearest + relative to JSON_TABLE and is not a drop-in. + Feature Source: Native support (no extension required) Official Documentation: @@ -34,22 +52,20 @@ class PostgresJSONBEnhancedSupport(Protocol): https://www.postgresql.org/docs/current/functions-json.html Version Requirements: - - JSON type: PostgreSQL 9.2+ + - JSON type: PostgreSQL 9.2+ (core JSONSupport) + - JSON path read (->, #> operators): PostgreSQL 9.3+ - JSONB type: PostgreSQL 9.4+ - - JSON path / JSON_TABLE: PostgreSQL 12+ - - JSONB subscript: PostgreSQL 14+ + - JSON path language: PostgreSQL 12.0+ - Numeric infinity in JSONB: PostgreSQL 17+ """ - def format_json_expression(self, expr: "JSONExpression"): - """Format a PostgreSQL JSON expression.""" - ... - - def supports_json_type(self) -> bool: - """Whether JSON data type is supported. + def supports_json_function(self, function_name: str) -> bool: + """Whether a named JSON path function is available on this server. - Native feature, PostgreSQL 9.2+. - The JSON type stores text-based JSON data. + Declared because a conformance check compares the rendered SQL against + the functions this dialect claims, and PostgreSQL spells them + jsonb_path_query_* rather than the core's MySQL-shaped JSON_EXTRACT. The + answer is gated per function, because they did not arrive together. """ ... @@ -63,27 +79,33 @@ def supports_jsonb(self) -> bool: ... def supports_json_path(self) -> bool: - """Whether JSON path expressions are supported. + """Whether a JSON path can be read at all. - Native feature, PostgreSQL 12+. - Includes jsonb_path_query, jsonb_path_query_first, - jsonb_path_exists, and jsonb_path_match functions. + Native feature, PostgreSQL 9.3+, which added ``->``, ``->>``, ``#>`` and + ``#>>`` for ``json``. The SQL/JSON path language is 12.0 and is a + separate capability, :meth:`supports_sql_json_path`; a server can have + one without the other, and PostgreSQL has had both since 12.0. """ ... - def supports_json_table(self) -> bool: - """Whether JSON_TABLE function is supported. + def supports_sql_json_path(self) -> bool: + """Whether the SQL/JSON path language is available. - Native feature, PostgreSQL 12+. - Converts JSON data into relational table format. + Native feature, PostgreSQL 12.0+. + Includes jsonb_path_query, jsonb_path_query_first, + jsonb_path_exists, and jsonb_path_match functions, plus the @? and @@ + operators. Only these can evaluate a filter or a wildcard, so a path + that needs one is refused on an older server rather than approximated. """ ... def supports_jsonb_subscript(self) -> bool: """Whether JSONB subscript notation is supported. - Native feature, PostgreSQL 14+. - Enables jsonb['key'] subscript syntax for accessing values. + Always False: PostgreSQL has no ``jsonb['key']`` subscript. SQL + subscripting covers arrays and composites, and jsonb values are read + with ``->`` / ``->>``, the ``#>`` / ``#>>`` path operators, or the + SQL/JSON path language. """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/py.typed b/src/rhosocial/activerecord/backend/impl/postgres/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py b/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py index cc5f46c7..acb14b09 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/schema/differ.py @@ -7,9 +7,14 @@ 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. + Column order has no semantic meaning in PostgreSQL, so the default + ``_columns_equivalent`` — which compares parsed data types by + identity — is sufficient and needs no override here. + + One consequence worth knowing: because PostgreSQL normalises every + array declaration to a single dimension on storage, an introspected + array column always reports ``dimensions=1``. A snapshot compared + against one built with ``dimensions=2`` therefore reports a type + difference that the database does not itself consider one. """ pass diff --git a/src/rhosocial/activerecord/backend/impl/postgres/type_values/constants.py b/src/rhosocial/activerecord/backend/impl/postgres/type_values/constants.py index 89b15f44..6c9716de 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/type_values/constants.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/type_values/constants.py @@ -8,8 +8,8 @@ Example: >>> from rhosocial.activerecord.backend.impl.postgres.type_values.constants import MONEY, NUMERIC - >>> col.cast(MONEY) # Equivalent to col.cast("money") - >>> col.cast("money") # Also valid + >>> col.cast(MONEY) # Equivalent to col.cast(PostgresMoneyType(self.dialect)) + >>> col.cast(PostgresMoneyType(self.dialect)) # Also valid Note: These constants are provided for convenience and code clarity. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py index 54f7fbd4..10cd7eea 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py @@ -115,13 +115,13 @@ def test_column_name_wrapped_as_column(self, postgres_dialect): sql, params = expr.to_sql() # Column name must render as identifier, not a string literal parameter. assert 'AS' not in sql - assert '"embedding" <=> %s::vector(3)' in sql + assert '"embedding" <=> %s::VECTOR(3)' in sql assert "embedding" not in params def test_l2_column(self, postgres_dialect): expr = vector_distance(postgres_dialect, "embedding", [1.0], metric="l2") sql, params = expr.to_sql() - assert '"embedding" <-> %s::vector(1)' in sql + assert '"embedding" <-> %s::VECTOR(1)' in sql def test_invalid_metric(self, postgres_dialect): with pytest.raises(ValueError, match="Unsupported vector metric"): 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 4226cd99..f74ffbe6 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 @@ -87,8 +87,8 @@ def cube_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "(1,2,3)").cast("cube")], - [Literal(dialect, "(4,5,6)").cast("cube")], + [Literal(dialect, "(1,2,3)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "(4,5,6)").cast(PostgresCubeType(dialect))], ], ), ) @@ -122,8 +122,8 @@ def cube_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "(1,1)").cast("cube")], - [Literal(dialect, "(4,5)").cast("cube")], + [Literal(dialect, "(1,1)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "(4,5)").cast(PostgresCubeType(dialect))], ], ), ) @@ -156,7 +156,7 @@ def cube_env(postgres_backend_single): columns=["c"], source=ValuesSource( dialect, - [[Literal(dialect, "(1,2,3)").cast("cube")]], + [[Literal(dialect, "(1,2,3)").cast(PostgresCubeType(dialect))]], ), ) sql, params = insert_idx.to_sql() @@ -217,8 +217,8 @@ def test_cube_distance(self, cube_env): dist_func = FunctionCall( dialect, "cube_distance", - Literal(dialect, "(1,1)").cast("cube"), - Literal(dialect, "(4,5)").cast("cube"), + Literal(dialect, "(1,1)").cast(PostgresCubeType(dialect)), + Literal(dialect, "(4,5)").cast(PostgresCubeType(dialect)), ).as_("distance") query = QueryExpression( dialect=dialect, @@ -240,8 +240,8 @@ def test_cube_contains(self, cube_env): dialect, ComparisonPredicate( dialect, "@>", - Literal(dialect, "(0,0),(10,10)").cast("cube"), - Literal(dialect, "(5,5)").cast("cube"), + Literal(dialect, "(0,0),(10,10)").cast(PostgresCubeType(dialect)), + Literal(dialect, "(5,5)").cast(PostgresCubeType(dialect)), ), ).as_("contains") query = QueryExpression( @@ -258,8 +258,8 @@ def test_cube_contains(self, cube_env): dialect, ComparisonPredicate( dialect, "@>", - Literal(dialect, "(0,0),(10,10)").cast("cube"), - Literal(dialect, "(15,15)").cast("cube"), + Literal(dialect, "(0,0),(10,10)").cast(PostgresCubeType(dialect)), + Literal(dialect, "(15,15)").cast(PostgresCubeType(dialect)), ), ).as_("contains") query2 = QueryExpression( @@ -281,8 +281,8 @@ def test_cube_overlap(self, cube_env): dialect, ComparisonPredicate( dialect, "&&", - Literal(dialect, "(0,0),(5,5)").cast("cube"), - Literal(dialect, "(3,3),(8,8)").cast("cube"), + Literal(dialect, "(0,0),(5,5)").cast(PostgresCubeType(dialect)), + Literal(dialect, "(3,3),(8,8)").cast(PostgresCubeType(dialect)), ), ).as_("overlaps") query = QueryExpression( @@ -299,8 +299,8 @@ def test_cube_overlap(self, cube_env): dialect, ComparisonPredicate( dialect, "&&", - Literal(dialect, "(0,0),(2,2)").cast("cube"), - Literal(dialect, "(5,5),(8,8)").cast("cube"), + Literal(dialect, "(0,0),(2,2)").cast(PostgresCubeType(dialect)), + Literal(dialect, "(5,5),(8,8)").cast(PostgresCubeType(dialect)), ), ).as_("overlaps") query2 = QueryExpression( @@ -373,8 +373,8 @@ async def async_cube_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "(1,2,3)").cast("cube")], - [Literal(dialect, "(4,5,6)").cast("cube")], + [Literal(dialect, "(1,2,3)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "(4,5,6)").cast(PostgresCubeType(dialect))], ], ), ) @@ -408,8 +408,8 @@ async def async_cube_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "(1,1)").cast("cube")], - [Literal(dialect, "(4,5)").cast("cube")], + [Literal(dialect, "(1,1)").cast(PostgresCubeType(dialect))], + [Literal(dialect, "(4,5)").cast(PostgresCubeType(dialect))], ], ), ) @@ -442,7 +442,7 @@ async def async_cube_env(async_postgres_backend_single): columns=["c"], source=ValuesSource( dialect, - [[Literal(dialect, "(1,2,3)").cast("cube")]], + [[Literal(dialect, "(1,2,3)").cast(PostgresCubeType(dialect))]], ), ) sql, params = insert_idx.to_sql() @@ -504,8 +504,8 @@ async def test_async_cube_distance(self, async_cube_env): dist_func = FunctionCall( dialect, "cube_distance", - Literal(dialect, "(1,1)").cast("cube"), - Literal(dialect, "(4,5)").cast("cube"), + Literal(dialect, "(1,1)").cast(PostgresCubeType(dialect)), + Literal(dialect, "(4,5)").cast(PostgresCubeType(dialect)), ).as_("distance") query = QueryExpression( 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 a7276855..9a3def58 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 @@ -78,7 +78,7 @@ def _setup_hstore_table(backend, dialect, table, hstore_value): columns=["data"], source=ValuesSource( dialect, - [[Literal(dialect, hstore_value).cast("hstore")]], + [[Literal(dialect, hstore_value).cast(PostgresHstoreType(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -113,7 +113,7 @@ async def _async_setup_hstore_table(backend, dialect, table, hstore_value): columns=["data"], source=ValuesSource( dialect, - [[Literal(dialect, hstore_value).cast("hstore")]], + [[Literal(dialect, hstore_value).cast(PostgresHstoreType(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -261,7 +261,7 @@ def test_hstore_operators_contains(self, hstore_env): contains_expr = Subquery(dialect, BinaryExpression( dialect, "@>", Column(dialect, "data"), - Literal(dialect, "color=>red").cast("hstore"), + Literal(dialect, "color=>red").cast(PostgresHstoreType(dialect)), )).as_("contains") query = QueryExpression( dialect=dialect, @@ -282,7 +282,7 @@ def test_hstore_operators_contains(self, hstore_env): contains_expr2 = Subquery(dialect, BinaryExpression( dialect, "@>", Column(dialect, "data"), - Literal(dialect, "color=>blue").cast("hstore"), + Literal(dialect, "color=>blue").cast(PostgresHstoreType(dialect)), )).as_("contains") query2 = QueryExpression( dialect=dialect, @@ -355,7 +355,7 @@ def test_hstore_operators_concat(self, hstore_env): merged_expr = Subquery(dialect, BinaryExpression( dialect, "||", Column(dialect, "data"), - Literal(dialect, "b=>2").cast("hstore"), + Literal(dialect, "b=>2").cast(PostgresHstoreType(dialect)), )).as_("merged") query = QueryExpression( dialect=dialect, @@ -459,7 +459,7 @@ def test_hstore_update_key_value(self, hstore_env): "data": BinaryExpression( dialect, "||", Column(dialect, "data"), - Literal(dialect, "status=>inactive").cast("hstore"), + Literal(dialect, "status=>inactive").cast(PostgresHstoreType(dialect)), ), }, where=ComparisonPredicate( @@ -659,7 +659,7 @@ async def test_async_hstore_operators_contains(self, async_hstore_env): contains_expr = Subquery(dialect, BinaryExpression( dialect, "@>", Column(dialect, "data"), - Literal(dialect, "color=>red").cast("hstore"), + Literal(dialect, "color=>red").cast(PostgresHstoreType(dialect)), )).as_("contains") query = QueryExpression( dialect=dialect, @@ -680,7 +680,7 @@ async def test_async_hstore_operators_contains(self, async_hstore_env): contains_expr2 = Subquery(dialect, BinaryExpression( dialect, "@>", Column(dialect, "data"), - Literal(dialect, "color=>blue").cast("hstore"), + Literal(dialect, "color=>blue").cast(PostgresHstoreType(dialect)), )).as_("contains") query2 = QueryExpression( dialect=dialect, @@ -755,7 +755,7 @@ async def test_async_hstore_operators_concat(self, async_hstore_env): merged_expr = Subquery(dialect, BinaryExpression( dialect, "||", Column(dialect, "data"), - Literal(dialect, "b=>2").cast("hstore"), + Literal(dialect, "b=>2").cast(PostgresHstoreType(dialect)), )).as_("merged") query = QueryExpression( dialect=dialect, @@ -862,7 +862,7 @@ async def test_async_hstore_update_key_value(self, async_hstore_env): "data": BinaryExpression( dialect, "||", Column(dialect, "data"), - Literal(dialect, "status=>inactive").cast("hstore"), + Literal(dialect, "status=>inactive").cast(PostgresHstoreType(dialect)), ), }, where=ComparisonPredicate( 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 789014c5..a776b5fb 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 @@ -48,6 +48,16 @@ ) +def _int_array(dialect): + """The int array type these tests store their literals in. + + Named because the expression runs past the line limit wherever it appears + inline, and because what the literal is being cast *to* is the thing these + tests are actually about. + """ + return PostgresArrayType(element_type=IntegerType(dialect), dialect=dialect) + + # --- Helper functions --- @@ -61,7 +71,8 @@ def _setup_intarray_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -79,9 +90,9 @@ def _setup_intarray_table(backend, dialect, table): source=ValuesSource( dialect, [ - [Literal(dialect, "{1,2,3,4,5}").cast("integer[]")], - [Literal(dialect, "{10,20,30}").cast("integer[]")], - [Literal(dialect, "{3,4}").cast("integer[]")], + [Literal(dialect, "{1,2,3,4,5}").cast(_int_array(dialect))], + [Literal(dialect, "{10,20,30}").cast(_int_array(dialect))], + [Literal(dialect, "{3,4}").cast(_int_array(dialect))], ], ), ) @@ -99,7 +110,8 @@ def _setup_intarray_overlap_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -117,9 +129,9 @@ def _setup_intarray_overlap_table(backend, dialect, table): source=ValuesSource( dialect, [ - [Literal(dialect, "{1,2,3}").cast("integer[]")], - [Literal(dialect, "{4,5,6}").cast("integer[]")], - [Literal(dialect, "{3,4}").cast("integer[]")], + [Literal(dialect, "{1,2,3}").cast(_int_array(dialect))], + [Literal(dialect, "{4,5,6}").cast(_int_array(dialect))], + [Literal(dialect, "{3,4}").cast(_int_array(dialect))], ], ), ) @@ -137,7 +149,8 @@ def _setup_intarray_contained_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -155,9 +168,9 @@ def _setup_intarray_contained_table(backend, dialect, table): source=ValuesSource( dialect, [ - [Literal(dialect, "{1,2}").cast("integer[]")], - [Literal(dialect, "{1,2,3,4,5}").cast("integer[]")], - [Literal(dialect, "{10,20}").cast("integer[]")], + [Literal(dialect, "{1,2}").cast(_int_array(dialect))], + [Literal(dialect, "{1,2,3,4,5}").cast(_int_array(dialect))], + [Literal(dialect, "{10,20}").cast(_int_array(dialect))], ], ), ) @@ -175,7 +188,8 @@ def _setup_intarray_idx_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -192,7 +206,7 @@ def _setup_intarray_idx_table(backend, dialect, table): columns=["tags"], source=ValuesSource( dialect, - [[Literal(dialect, "{10,20,30,40}").cast("integer[]")]], + [[Literal(dialect, "{10,20,30,40}").cast(_int_array(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -213,7 +227,8 @@ def _setup_intarray_gin_table(backend, dialect, table, index_name): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -237,7 +252,7 @@ def _setup_intarray_gin_table(backend, dialect, table, index_name): columns=["tags"], source=ValuesSource( dialect, - [[Literal(dialect, "{1,5,10}").cast("integer[]")]], + [[Literal(dialect, "{1,5,10}").cast(_int_array(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -265,7 +280,8 @@ async def _async_setup_intarray_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -283,9 +299,9 @@ async def _async_setup_intarray_table(backend, dialect, table): source=ValuesSource( dialect, [ - [Literal(dialect, "{1,2,3,4,5}").cast("integer[]")], - [Literal(dialect, "{10,20,30}").cast("integer[]")], - [Literal(dialect, "{3,4}").cast("integer[]")], + [Literal(dialect, "{1,2,3,4,5}").cast(_int_array(dialect))], + [Literal(dialect, "{10,20,30}").cast(_int_array(dialect))], + [Literal(dialect, "{3,4}").cast(_int_array(dialect))], ], ), ) @@ -303,7 +319,8 @@ async def _async_setup_intarray_overlap_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -321,9 +338,9 @@ async def _async_setup_intarray_overlap_table(backend, dialect, table): source=ValuesSource( dialect, [ - [Literal(dialect, "{1,2,3}").cast("integer[]")], - [Literal(dialect, "{4,5,6}").cast("integer[]")], - [Literal(dialect, "{3,4}").cast("integer[]")], + [Literal(dialect, "{1,2,3}").cast(_int_array(dialect))], + [Literal(dialect, "{4,5,6}").cast(_int_array(dialect))], + [Literal(dialect, "{3,4}").cast(_int_array(dialect))], ], ), ) @@ -341,7 +358,8 @@ async def _async_setup_intarray_contained_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -359,9 +377,9 @@ async def _async_setup_intarray_contained_table(backend, dialect, table): source=ValuesSource( dialect, [ - [Literal(dialect, "{1,2}").cast("integer[]")], - [Literal(dialect, "{1,2,3,4,5}").cast("integer[]")], - [Literal(dialect, "{10,20}").cast("integer[]")], + [Literal(dialect, "{1,2}").cast(_int_array(dialect))], + [Literal(dialect, "{1,2,3,4,5}").cast(_int_array(dialect))], + [Literal(dialect, "{10,20}").cast(_int_array(dialect))], ], ), ) @@ -379,7 +397,8 @@ async def _async_setup_intarray_idx_table(backend, dialect, table): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -396,7 +415,7 @@ async def _async_setup_intarray_idx_table(backend, dialect, table): columns=["tags"], source=ValuesSource( dialect, - [[Literal(dialect, "{10,20,30,40}").cast("integer[]")]], + [[Literal(dialect, "{10,20,30,40}").cast(_int_array(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -417,7 +436,8 @@ async def _async_setup_intarray_gin_table(backend, dialect, table, index_name): ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition( + dialect, name="tags", data_type=_int_array(dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -440,7 +460,7 @@ async def _async_setup_intarray_gin_table(backend, dialect, table, index_name): columns=["tags"], source=ValuesSource( dialect, - [[Literal(dialect, "{1,5,10}").cast("integer[]")]], + [[Literal(dialect, "{1,5,10}").cast(_int_array(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -517,7 +537,7 @@ def test_intarray_contains_operator(self, intarray_env): ComparisonPredicate( dialect, "@>", Column(dialect, "tags"), - Literal(dialect, "{3,4}").cast("integer[]"), + Literal(dialect, "{3,4}").cast(_int_array(dialect)), ), ).as_("contains") query = QueryExpression( @@ -546,7 +566,7 @@ def test_intarray_overlap_operator(self, intarray_env): ComparisonPredicate( dialect, "&&", Column(dialect, "tags"), - Literal(dialect, "{3,4}").cast("integer[]"), + Literal(dialect, "{3,4}").cast(_int_array(dialect)), ), ).as_("overlaps") query = QueryExpression( @@ -576,7 +596,7 @@ def test_intarray_contained_by_operator(self, intarray_env): ComparisonPredicate( dialect, "<@", Column(dialect, "tags"), - Literal(dialect, "{1,2,3,4,5}").cast("integer[]"), + Literal(dialect, "{1,2,3,4,5}").cast(_int_array(dialect)), ), ).as_("contained") query = QueryExpression( @@ -667,7 +687,7 @@ def test_intarray_gin_index(self, intarray_env): ComparisonPredicate( dialect, "@>", Column(dialect, "tags"), - Literal(dialect, "{5}").cast("integer[]"), + Literal(dialect, "{5}").cast(_int_array(dialect)), ), ).as_("contains") query2 = QueryExpression( @@ -726,7 +746,7 @@ async def test_async_intarray_contains_operator(self, async_intarray_env): ComparisonPredicate( dialect, "@>", Column(dialect, "tags"), - Literal(dialect, "{3,4}").cast("integer[]"), + Literal(dialect, "{3,4}").cast(_int_array(dialect)), ), ).as_("contains") query = QueryExpression( @@ -755,7 +775,7 @@ async def test_async_intarray_overlap_operator(self, async_intarray_env): ComparisonPredicate( dialect, "&&", Column(dialect, "tags"), - Literal(dialect, "{3,4}").cast("integer[]"), + Literal(dialect, "{3,4}").cast(_int_array(dialect)), ), ).as_("overlaps") query = QueryExpression( @@ -784,7 +804,7 @@ async def test_async_intarray_contained_by_operator(self, async_intarray_env): ComparisonPredicate( dialect, "<@", Column(dialect, "tags"), - Literal(dialect, "{1,2,3,4,5}").cast("integer[]"), + Literal(dialect, "{1,2,3,4,5}").cast(_int_array(dialect)), ), ).as_("contained") query = QueryExpression( @@ -876,7 +896,7 @@ async def test_async_intarray_gin_index(self, async_intarray_env): ComparisonPredicate( dialect, "@>", Column(dialect, "tags"), - Literal(dialect, "{5}").cast("integer[]"), + Literal(dialect, "{5}").cast(_int_array(dialect)), ), ).as_("contains") query2 = QueryExpression( 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 accf1f3a..9b0865aa 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 @@ -90,11 +90,11 @@ def ltree_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "Top").cast("ltree")], - [Literal(dialect, "Top.Science").cast("ltree")], - [Literal(dialect, "Top.Science.Astronomy").cast("ltree")], - [Literal(dialect, "Top.Science.Physics").cast("ltree")], - [Literal(dialect, "Top.History").cast("ltree")], + [Literal(dialect, "Top").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science.Physics").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.History").cast(PostgresLtreeType(dialect))], ], ), ) @@ -129,11 +129,11 @@ def ltree_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "root").cast("ltree"), Literal(dialect, "Root")], - [Literal(dialect, "root.level1").cast("ltree"), Literal(dialect, "Level 1")], - [Literal(dialect, "root.level1.level2a").cast("ltree"), Literal(dialect, "Level 2a")], - [Literal(dialect, "root.level1.level2b").cast("ltree"), Literal(dialect, "Level 2b")], - [Literal(dialect, "root.other").cast("ltree"), Literal(dialect, "Other branch")], + [Literal(dialect, "root").cast(PostgresLtreeType(dialect)), Literal(dialect, "Root")], + [Literal(dialect, "root.level1").cast(PostgresLtreeType(dialect)), Literal(dialect, "Level 1")], + [Literal(dialect, "root.level1.level2a").cast(PostgresLtreeType(dialect)), Literal(dialect, "Level 2a")], + [Literal(dialect, "root.level1.level2b").cast(PostgresLtreeType(dialect)), Literal(dialect, "Level 2b")], + [Literal(dialect, "root.other").cast(PostgresLtreeType(dialect)), Literal(dialect, "Other branch")], ], ), ) @@ -166,7 +166,7 @@ def ltree_env(postgres_backend_single): columns=["path"], source=ValuesSource( dialect, - [[Literal(dialect, "A.B.C.D").cast("ltree")]], + [[Literal(dialect, "A.B.C.D").cast(PostgresLtreeType(dialect))]], ), ) sql, params = insert_func.to_sql() @@ -199,9 +199,9 @@ def ltree_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "a.b.c").cast("ltree")], - [Literal(dialect, "a.b.d").cast("ltree")], - [Literal(dialect, "x.y.z").cast("ltree")], + [Literal(dialect, "a.b.c").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "a.b.d").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "x.y.z").cast(PostgresLtreeType(dialect))], ], ), ) @@ -247,9 +247,9 @@ def ltree_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "Top.Science.Astronomy").cast("ltree")], - [Literal(dialect, "Top.Science.Physics").cast("ltree")], - [Literal(dialect, "Top.History").cast("ltree")], + [Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science.Physics").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.History").cast(PostgresLtreeType(dialect))], ], ), ) @@ -295,7 +295,7 @@ def test_ltree_path_operations(self, ltree_env): where=ComparisonPredicate( dialect, "=", Column(dialect, "path"), - Literal(dialect, "Top.Science.Astronomy").cast("ltree"), + Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect)), ), ) sql, params = query.to_sql() @@ -315,7 +315,7 @@ def test_ltree_path_operations(self, ltree_env): where=ComparisonPredicate( dialect, "=", Column(dialect, "path"), - Literal(dialect, "Top.Science.Astronomy").cast("ltree"), + Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect)), ), ) sql2, params2 = query2.to_sql() @@ -335,7 +335,7 @@ def test_ltree_path_operations(self, ltree_env): where=ComparisonPredicate( dialect, "=", Column(dialect, "path"), - Literal(dialect, "Top.Science.Astronomy").cast("ltree"), + Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect)), ), ) sql3, params3 = query3.to_sql() @@ -596,11 +596,11 @@ async def async_ltree_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "Top").cast("ltree")], - [Literal(dialect, "Top.Science").cast("ltree")], - [Literal(dialect, "Top.Science.Astronomy").cast("ltree")], - [Literal(dialect, "Top.Science.Physics").cast("ltree")], - [Literal(dialect, "Top.History").cast("ltree")], + [Literal(dialect, "Top").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science.Physics").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.History").cast(PostgresLtreeType(dialect))], ], ), ) @@ -635,11 +635,11 @@ async def async_ltree_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "root").cast("ltree"), Literal(dialect, "Root")], - [Literal(dialect, "root.level1").cast("ltree"), Literal(dialect, "Level 1")], - [Literal(dialect, "root.level1.level2a").cast("ltree"), Literal(dialect, "Level 2a")], - [Literal(dialect, "root.level1.level2b").cast("ltree"), Literal(dialect, "Level 2b")], - [Literal(dialect, "root.other").cast("ltree"), Literal(dialect, "Other branch")], + [Literal(dialect, "root").cast(PostgresLtreeType(dialect)), Literal(dialect, "Root")], + [Literal(dialect, "root.level1").cast(PostgresLtreeType(dialect)), Literal(dialect, "Level 1")], + [Literal(dialect, "root.level1.level2a").cast(PostgresLtreeType(dialect)), Literal(dialect, "Level 2a")], + [Literal(dialect, "root.level1.level2b").cast(PostgresLtreeType(dialect)), Literal(dialect, "Level 2b")], + [Literal(dialect, "root.other").cast(PostgresLtreeType(dialect)), Literal(dialect, "Other branch")], ], ), ) @@ -672,7 +672,7 @@ async def async_ltree_env(async_postgres_backend_single): columns=["path"], source=ValuesSource( dialect, - [[Literal(dialect, "A.B.C.D").cast("ltree")]], + [[Literal(dialect, "A.B.C.D").cast(PostgresLtreeType(dialect))]], ), ) sql, params = insert_func.to_sql() @@ -705,9 +705,9 @@ async def async_ltree_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "a.b.c").cast("ltree")], - [Literal(dialect, "a.b.d").cast("ltree")], - [Literal(dialect, "x.y.z").cast("ltree")], + [Literal(dialect, "a.b.c").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "a.b.d").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "x.y.z").cast(PostgresLtreeType(dialect))], ], ), ) @@ -753,9 +753,9 @@ async def async_ltree_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "Top.Science.Astronomy").cast("ltree")], - [Literal(dialect, "Top.Science.Physics").cast("ltree")], - [Literal(dialect, "Top.History").cast("ltree")], + [Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.Science.Physics").cast(PostgresLtreeType(dialect))], + [Literal(dialect, "Top.History").cast(PostgresLtreeType(dialect))], ], ), ) @@ -802,7 +802,7 @@ async def test_async_ltree_path_operations(self, async_ltree_env): where=ComparisonPredicate( dialect, "=", Column(dialect, "path"), - Literal(dialect, "Top.Science.Astronomy").cast("ltree"), + Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect)), ), ) sql, params = query.to_sql() @@ -822,7 +822,7 @@ async def test_async_ltree_path_operations(self, async_ltree_env): where=ComparisonPredicate( dialect, "=", Column(dialect, "path"), - Literal(dialect, "Top.Science.Astronomy").cast("ltree"), + Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect)), ), ) sql2, params2 = query2.to_sql() @@ -842,7 +842,7 @@ async def test_async_ltree_path_operations(self, async_ltree_env): where=ComparisonPredicate( dialect, "=", Column(dialect, "path"), - Literal(dialect, "Top.Science.Astronomy").cast("ltree"), + Literal(dialect, "Top.Science.Astronomy").cast(PostgresLtreeType(dialect)), ), ) sql3, params3 = query3.to_sql() 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 6c59ab7e..6fd0db72 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 @@ -230,7 +230,7 @@ def test_nvl(self, orafce_backend): func = nvl( dialect, Column(dialect, "discount"), - Literal(dialect, 0.0).cast("NUMERIC"), + Literal(dialect, 0.0).cast(DecimalType(dialect)), ).as_("safe_discount") query = QueryExpression( @@ -495,7 +495,7 @@ async def test_async_nvl(self, async_orafce_backend): func = nvl( dialect, Column(dialect, "discount"), - Literal(dialect, 0.0).cast("NUMERIC"), + Literal(dialect, 0.0).cast(DecimalType(dialect)), ).as_("safe_discount") query = QueryExpression( 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 435529bb..9f475830 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 @@ -3,7 +3,7 @@ These tests require a PostgreSQL database with the pgvector extension installed. Tests will be automatically skipped if the extension is not available. -Vector literal values use Literal.cast("vector") for inline embedding because +Vector literal values use Literal.cast(PostgresVectorType(self.dialect)) for inline embedding because psycopg does not natively support the vector type for bound parameters. pgvector distance operators (<->, <=>, <#>) still require RawSQLExpression because ComparisonPredicate does not yet support .as_() alias. @@ -133,8 +133,8 @@ def vector_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 2.0, 3.0]").cast("vector")], - [Literal(dialect, "[4.0, 5.0, 6.0]").cast("vector")], + [Literal(dialect, "[1.0, 2.0, 3.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[4.0, 5.0, 6.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -157,9 +157,9 @@ def vector_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 0.0, 0.0]").cast("vector")], - [Literal(dialect, "[0.0, 1.0, 0.0]").cast("vector")], - [Literal(dialect, "[0.0, 0.0, 1.0]").cast("vector")], + [Literal(dialect, "[1.0, 0.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[0.0, 1.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[0.0, 0.0, 1.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -182,9 +182,9 @@ def vector_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 0.0, 0.0]").cast("vector")], - [Literal(dialect, "[1.0, 1.0, 0.0]").cast("vector")], - [Literal(dialect, "[0.0, 0.0, 1.0]").cast("vector")], + [Literal(dialect, "[1.0, 0.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[1.0, 1.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[0.0, 0.0, 1.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -207,8 +207,8 @@ def vector_env(postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 2.0, 3.0]").cast("vector")], - [Literal(dialect, "[-1.0, 0.0, 0.0]").cast("vector")], + [Literal(dialect, "[1.0, 2.0, 3.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[-1.0, 0.0, 0.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -400,7 +400,7 @@ def test_vector_update(self, vector_env): columns=["embedding"], source=ValuesSource( dialect, - [[Literal(dialect, "[1.0, 2.0, 3.0]").cast("vector")]], + [[Literal(dialect, "[1.0, 2.0, 3.0]").cast(PostgresVectorType(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -409,8 +409,8 @@ def test_vector_update(self, vector_env): # Update the vector update_expr = UpdateExpression( dialect=dialect, - table=Table(dialect, 'test_vector_update'), - assignments={"embedding": Literal(dialect, "[4.0, 5.0, 6.0]").cast("vector")}, + table=Table(dialect, "test_vector_update"), + assignments={"embedding": Literal(dialect, "[4.0, 5.0, 6.0]").cast(PostgresVectorType(dialect))}, where=Column(dialect, "id") == Literal(dialect, 1), ) sql, params = update_expr.to_sql() @@ -453,7 +453,7 @@ def test_ivfflat_index(self, vector_env): try: # Insert 10 rows of vector data rows = [ - [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast("vector")] + [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast(PostgresVectorType(dialect))] for i in range(10) ] insert_expr = InsertExpression( @@ -514,7 +514,7 @@ def test_hnsw_index(self, vector_env): try: # Insert 10 rows of vector data rows = [ - [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast("vector")] + [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast(PostgresVectorType(dialect))] for i in range(10) ] insert_expr = InsertExpression( @@ -576,7 +576,7 @@ def test_vector_dimension_constraint(self, vector_env): columns=["embedding"], source=ValuesSource( dialect, - [[Literal(dialect, "[1.0, 2.0]").cast("vector")]], + [[Literal(dialect, "[1.0, 2.0]").cast(PostgresVectorType(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -635,8 +635,8 @@ async def async_vector_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 2.0, 3.0]").cast("vector")], - [Literal(dialect, "[4.0, 5.0, 6.0]").cast("vector")], + [Literal(dialect, "[1.0, 2.0, 3.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[4.0, 5.0, 6.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -659,9 +659,9 @@ async def async_vector_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 0.0, 0.0]").cast("vector")], - [Literal(dialect, "[0.0, 1.0, 0.0]").cast("vector")], - [Literal(dialect, "[0.0, 0.0, 1.0]").cast("vector")], + [Literal(dialect, "[1.0, 0.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[0.0, 1.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[0.0, 0.0, 1.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -684,9 +684,9 @@ async def async_vector_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 0.0, 0.0]").cast("vector")], - [Literal(dialect, "[1.0, 1.0, 0.0]").cast("vector")], - [Literal(dialect, "[0.0, 0.0, 1.0]").cast("vector")], + [Literal(dialect, "[1.0, 0.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[1.0, 1.0, 0.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[0.0, 0.0, 1.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -709,8 +709,8 @@ async def async_vector_env(async_postgres_backend_single): source=ValuesSource( dialect, [ - [Literal(dialect, "[1.0, 2.0, 3.0]").cast("vector")], - [Literal(dialect, "[-1.0, 0.0, 0.0]").cast("vector")], + [Literal(dialect, "[1.0, 2.0, 3.0]").cast(PostgresVectorType(dialect))], + [Literal(dialect, "[-1.0, 0.0, 0.0]").cast(PostgresVectorType(dialect))], ], ), ) @@ -903,7 +903,7 @@ async def test_async_vector_update(self, async_vector_env): columns=["embedding"], source=ValuesSource( dialect, - [[Literal(dialect, "[1.0, 2.0, 3.0]").cast("vector")]], + [[Literal(dialect, "[1.0, 2.0, 3.0]").cast(PostgresVectorType(dialect))]], ), ) sql, params = insert_expr.to_sql() @@ -911,8 +911,8 @@ async def test_async_vector_update(self, async_vector_env): update_expr = UpdateExpression( dialect=dialect, - table=Table(dialect, 'test_vector_update_async'), - assignments={"embedding": Literal(dialect, "[4.0, 5.0, 6.0]").cast("vector")}, + table=Table(dialect, "test_vector_update_async"), + assignments={"embedding": Literal(dialect, "[4.0, 5.0, 6.0]").cast(PostgresVectorType(dialect))}, where=Column(dialect, "id") == Literal(dialect, 1), ) sql, params = update_expr.to_sql() @@ -953,7 +953,7 @@ async def test_async_ivfflat_index(self, async_vector_env): try: rows = [ - [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast("vector")] + [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast(PostgresVectorType(dialect))] for i in range(10) ] insert_expr = InsertExpression( @@ -1011,7 +1011,7 @@ async def test_async_hnsw_index(self, async_vector_env): try: rows = [ - [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast("vector")] + [Literal(dialect, f"[{i}.0, {i+1}.0, {i+2}.0]").cast(PostgresVectorType(dialect))] for i in range(10) ] insert_expr = InsertExpression( @@ -1071,7 +1071,7 @@ async def test_async_vector_dimension_constraint(self, async_vector_env): columns=["embedding"], source=ValuesSource( dialect, - [[Literal(dialect, "[1.0, 2.0]").cast("vector")]], + [[Literal(dialect, "[1.0, 2.0]").cast(PostgresVectorType(dialect))]], ), ) sql, params = insert_expr.to_sql() 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 ea2eebb1..30555e14 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 @@ -790,7 +790,7 @@ def test_st_buffer(self, postgis_env): geog_point = FunctionCall( dialect, "ST_GeomFromText", Literal(dialect, "POINT(0 0)"), Literal(dialect, 4326), - ).cast("geography") + ).cast(PostgresGeographyType(self.dialect)) query = QueryExpression( dialect=dialect, select=[ @@ -1128,7 +1128,7 @@ async def test_async_st_buffer(self, async_postgis_env): geog_point = FunctionCall( dialect, "ST_GeomFromText", Literal(dialect, "POINT(0 0)"), Literal(dialect, 4326), - ).cast("geography") + ).cast(PostgresGeographyType(self.dialect)) query = QueryExpression( dialect=dialect, select=[ 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 f98e917c..7ecd7ab4 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 @@ -41,6 +41,7 @@ ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.expression.types import UUIDType UUID_V4_PATTERN = re.compile( @@ -142,7 +143,7 @@ def uuid_ossp_env(postgres_backend_single): columns=["id", "label"], source=ValuesSource( dialect, - [[Literal(dialect, str(uuid_val)).cast("uuid"), Literal(dialect, "explicit")]], + [[Literal(dialect, str(uuid_val)).cast(UUIDType(dialect)), Literal(dialect, "explicit")]], ), ) sql, params = insert_explicit.to_sql() @@ -347,7 +348,7 @@ async def async_uuid_ossp_env(async_postgres_backend_single): columns=["id", "label"], source=ValuesSource( dialect, - [[Literal(dialect, str(uuid_val)).cast("uuid"), Literal(dialect, "explicit")]], + [[Literal(dialect, str(uuid_val)).cast(UUIDType(dialect)), Literal(dialect, "explicit")]], ), ) sql, params = insert_explicit.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_cast_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_cast_integration.py new file mode 100644 index 00000000..0bdce829 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_cast_integration.py @@ -0,0 +1,580 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_cast_integration.py + +"""Type conversion against a live PostgreSQL, one case per type. + +These execute. A cast renders into a grammar position that cannot take a bound +parameter, so nothing about it can be settled by looking at the SQL string -- +``col::IntegerType()`` is obviously wrong to a reader, but the only way to know +that ``col::INTEGER`` is *accepted*, that ``'42'::int4range`` parses, or that a +chained ``::text::int`` round-trips is to run it and read the value back. So +each case builds a real table, inserts through a cast, and asserts on what came +back out. + +Three layers, because they fail differently: + +1. **Declare** -- every modelled type must be accepted by ``CREATE TABLE``. + This is the layer that covers the types you cannot insert into (serial + columns, and types whose value has no text form), and it is the one that + catches a type whose rendered spelling the server does not recognise. +2. **Insert through a cast** -- for every type with a text form, the value goes + in as ``Literal(text).cast(Type)``. This is the cast doing real work. +3. **Read back through a cast** -- the stored value is cast to text and + compared, so the assertion is on the value PostgreSQL actually holds rather + than on a driver-specific Python object. + +Where a type genuinely has nothing to cast, the case declares the column +instead of inserting into it, and says so with ``declared_only``. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + Column, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + InsertExpression, + Literal, + QueryExpression, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.statements import ValuesSource +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCidrType, + PostgresCircleType, + PostgresCitextType, + PostgresCubeType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHalfvecType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresLqueryType, + PostgresLtreeType, + PostgresLtxtqueryType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, +) +from rhosocial.activerecord.backend.errors import DatabaseError +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +DQL = ExecutionOptions(stmt_type=StatementType.DQL) + + +class Case: + """One type, and what PostgreSQL does with it. + + Args: + label: Used in the test id, so a failure names the type. + factory: Builds the type for a dialect; the vector types need an + argument the dialect cannot supply on their own. + literal: The text form inserted and cast to the type. + rendered: What ``value::text`` gives back, compared as a string. + extension: An extension this type needs, if any. + declared_only: Set when the column cannot be inserted into, so the + case asserts the declaration is accepted and stops there. + """ + + def __init__(self, label, factory=None, literal=None, rendered=None, + extension=None, declared_only=False, tolerant=False, + make=None, type_name=None): + self.label = label + self.factory = factory or make + self.literal = literal + self.rendered = rendered + self.extension = extension + self.declared_only = declared_only + #: Set when PostgreSQL's text rendering depends on server settings -- + #: the session timezone, the lc_monetary style -- so the text is not a + #: fixed expectation. The value still has to survive the round trip. + self.tolerant = tolerant + #: The dialect's own name for this type, used to ask whether the server + #: running the test has it. Multiranges and jsonpath arrived in + #: PostgreSQL 14; the dialect already refuses to render them below that, + #: and the tests have to decline rather than report the refusal as a + #: failure of the type. + self.type_name = type_name + + +def _pg_types_module(): + import rhosocial.activerecord.backend.impl.postgres.expression.types as types + + return types + + +def _core(name): + """Resolve a core type by class name, so the case table stays short.""" + import rhosocial.activerecord.backend.expression.types as core + + return getattr(core, name) + + +#: Every type worth a round trip. Ordered core-first, then PostgreSQL's own. +CASES = [ + # ---- core types PostgreSQL shares ---- + Case("smallint", lambda d: _core("SmallIntType")(d), "42", "42"), + Case("integer", lambda d: _core("IntegerType")(d), "42", "42"), + Case("int4", lambda d: _core("IntegerType")(d), "42", "42"), + Case("bigint", lambda d: _core("BigIntType")(d), "42", "42"), + Case("tinyint", lambda d: _core("TinyIntType")(d), "42", "42"), + Case("real", lambda d: _core("RealType")(d), "1.5", "1.5"), + Case("double", lambda d: _core("DoubleType")(d), "1.5", "1.5"), + Case("float", lambda d: _core("FloatType")(d), "1.5", "1.5"), + Case("numeric", lambda d: _core("DecimalType")(d), "10.25", "10.25"), + Case("decimal", lambda d: _core("DecimalType")(d, precision=10, scale=2), + "10.25", "10.25"), + Case("boolean", lambda d: _core("BooleanType")(d), "true", "true"), + Case("text", lambda d: _core("TextType")(d), "hello", "hello"), + Case("varchar", lambda d: _core("VarCharType")(d, length=32), "hello", "hello"), + Case("char", lambda d: _core("CharType")(d, length=8), "abc", "abc"), + Case("date", lambda d: _core("DateType")(d), "2026-01-15", "2026-01-15"), + Case("time", lambda d: _core("TimeType")(d), "13:45:30", "13:45:30"), + Case("timestamp", lambda d: _core("TimestampType")(d), "2026-01-15 13:45:30", + "2026-01-15 13:45:30"), + # Rendered in the server's own timezone, so the exact text is not ours to + # assert; ``tolerant`` checks the value survives the round trip instead. + Case("timestamptz", lambda d: _core("TimestampTzType")(d), + "2026-01-15 13:45:30+00", tolerant=True), + Case("interval", lambda d: _core("IntervalType")(d), "1 day", + "1 day"), + Case("json", lambda d: _core("JsonType")(d), '{"a": 1}', '{"a": 1}'), + Case("jsonb", lambda d: _core("JsonBType")(d), '{"a": 1}', '{"a": 1}'), + Case("uuid", lambda d: _core("UUIDType")(d), + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000001"), + Case("blob", lambda d: _core("BlobType")(d), "abc", r"\x616263"), + + # ---- PostgreSQL built-ins ---- + Case("character_varying", + lambda d: _core("VarCharType")(d, length=32, spelling="character varying"), + "hello", "hello"), + Case("bytea", lambda d: PostgresByteaType(d), "abc", r"\x616263"), + Case("bit", lambda d: PostgresBitType(n=3, dialect=d), "101", "101"), + Case("varbit", lambda d: PostgresVarBitType(n=3, dialect=d), "101", "101"), + Case("cidr", lambda d: PostgresCidrType(d), "192.168.0.0/24", "192.168.0.0/24"), + Case("inet", lambda d: PostgresInetType(d), "192.168.1.1", "192.168.1.1/32"), + Case("macaddr", lambda d: PostgresMacAddrType(d), "08:00:2b:01:02:03", + "08:00:2b:01:02:03"), + Case("macaddr8", lambda d: PostgresMacAddr8Type(d), + "08:00:2b:01:02:03:04:05", "08:00:2b:01:02:03:04:05", + type_name="postgres_macaddr8"), + Case("money", lambda d: PostgresMoneyType(d), "12.34", "$12.34"), + Case("oid", lambda d: PostgresOIDType(d), "1234", "1234"), + Case("xid", lambda d: PostgresXIDType(d), "1", "1"), + Case("xid8", lambda d: PostgresXID8Type(d), "1", "1", type_name="postgres_xid8"), + Case("tid", lambda d: PostgresTIDType(d), "(0,1)", "(0,1)"), + # CID is a network line number, not a dotted quad: it goes in as an + # integer and casts back to the same integer. + Case("cid", lambda d: PostgresCIDType(d), "16909060", "16909060"), + # tolerant, because PostgreSQL 19 zero-pads the high half of a pg_lsn and + # 16 through 18 do not: the same value reads back as '0/16B3748' on 16.15, + # 17.11 and 18.6, and as '0/016B3748' on 19beta4. Both spellings are + # accepted on input on every version, so the value round trips either way + # and the text is the server's to choose. + Case("pg_lsn", lambda d: PostgresPgLSNType(d), "0/16B3748", + "0/16B3748", tolerant=True), + Case("jsonpath", lambda d: PostgresJsonPathType(d), '"$.a"', '"$.a"', + type_name="postgres_jsonpath"), + Case("regtype", lambda d: PostgresRegTypeType(d), "integer", "integer"), + Case("regclass", lambda d: PostgresRegClassType(d), "pg_class", "pg_class"), + Case("xml", lambda d: PostgresXMLType(d), "", ""), + Case("tsvector", lambda d: PostgresTSVectorType(d), "cat", "'cat'"), + Case("tsquery", lambda d: PostgresTSQueryType(d), "cat", "'cat'"), + + # ---- geometry: built into the server, no extension ---- + Case("point", lambda d: PostgresPointType(d), "(1,2)", "(1,2)"), + Case("line", lambda d: PostgresLineType(d), "{1,2,3}", "{1,2,3}"), + Case("lseg", lambda d: PostgresLineSegmentType(d), "[(1,2),(3,4)]", + "[(1,2),(3,4)]"), + Case("box", lambda d: PostgresBoxType(d), "(1,2),(3,4)", "(3,4),(1,2)"), + Case("path", lambda d: PostgresPathType(d), "((1,2),(3,4))", "((1,2),(3,4))"), + Case("polygon", lambda d: PostgresPolygonType(d), "((1,2),(3,4),(5,6))", + "((1,2),(3,4),(5,6))"), + Case("circle", lambda d: PostgresCircleType(d), "<(1,2),3>", "<(1,2),3>"), + + # ---- ranges and multiranges ---- + Case("int4range", lambda d: PostgresInt4RangeType(d), "[1,5)", "[1,5)"), + Case("int8range", lambda d: PostgresInt8RangeType(d), "[1,5)", "[1,5)"), + Case("numrange", lambda d: PostgresNumRangeType(d), "[1.0,5.0)", "[1.0,5.0)"), + Case("tsrange", lambda d: PostgresTsRangeType(d), + '["2026-01-01 00:00:00","2026-02-01 00:00:00")', + '["2026-01-01 00:00:00","2026-02-01 00:00:00")'), + Case("tstzrange", lambda d: PostgresTsTzRangeType(d), + '["2026-01-01 00:00:00+00","2026-02-01 00:00:00+00")', + tolerant=True), + Case("daterange", lambda d: PostgresDateRangeType(d), + "[2026-01-01,2026-02-01)", "[2026-01-01,2026-02-01)"), + Case("int4multirange", lambda d: PostgresInt4MultirangeType(d), + "{[1,5)}", "{[1,5)}", type_name="postgres_int4multirange"), + Case("int8multirange", lambda d: PostgresInt8MultirangeType(d), + "{[1,5)}", "{[1,5)}", type_name="postgres_int8multirange"), + Case("nummultirange", lambda d: PostgresNumMultirangeType(d), + "{[1.0,5.0)}", "{[1.0,5.0)}", type_name="postgres_nummultirange"), + Case("tsmultirange", lambda d: PostgresTsMultirangeType(d), + '{["2026-01-01 00:00:00","2026-02-01 00:00:00")}', + "{[\"2026-01-01 00:00:00\",\"2026-02-01 00:00:00\")}", + type_name="postgres_tsmultirange"), + Case("tstzmultirange", lambda d: PostgresTsTzMultirangeType(d), + '{["2026-01-01 00:00:00+00","2026-02-01 00:00:00+00")}', + tolerant=True, type_name="postgres_tstzmultirange"), + Case("datemultirange", lambda d: PostgresDateMultirangeType(d), + "{[2026-01-01,2026-02-01)}", "{[2026-01-01,2026-02-01)}", + type_name="postgres_datemultirange"), + + # ---- extension types ---- + Case("citext", lambda d: PostgresCitextType(d), "Hello", "Hello", + extension="citext"), + Case("hstore", lambda d: PostgresHstoreType(d), '"a"=>"1"', '"a"=>"1"', + extension="hstore"), + Case("ltree", lambda d: PostgresLtreeType(d), "root.level1", "root.level1", + extension="ltree"), + Case("lquery", lambda d: PostgresLqueryType(d), "root.*", "root.*", + extension="ltree"), + Case("ltxtquery", lambda d: PostgresLtxtqueryType(d), "root", "root", + extension="ltree"), + Case("cube", lambda d: PostgresCubeType(d), "(1,2,3)", "(1, 2, 3)", + extension="cube"), + Case("vector", lambda d: PostgresVectorType(d, dim=3), "[1,2,3]", "[1,2,3]", + extension="vector"), + Case("halfvec", lambda d: PostgresHalfvecType(d, dim=3), "[1,2,3]", + "[1,2,3]", extension="vector"), + Case("geometry", lambda d: PostgresGeometryType(d), "POINT(1 2)", + "0101000000000000000000F03F0000000000000040", extension="postgis"), + Case("geography", lambda d: PostgresGeographyType(d), "POINT(1 2)", + "0101000020E6100000000000000000F03F0000000000000040", + extension="postgis"), + + # ---- declared only: the server fills these in ---- + Case("serial", lambda d: PostgresSerialType(d), declared_only=True), + Case("smallserial", lambda d: PostgresSmallSerialType(d), declared_only=True), + Case("bigserial", lambda d: _pg_types_module().PostgresBigSerialType(d), + declared_only=True), +] + +INSERTABLE = [c for c in CASES if not c.declared_only] +DECLARABLE = list(CASES) + + +def _ids(cases): + return [c.label for c in cases] + + +@pytest.fixture +def pg(postgres_backend_single): + """A live PostgreSQL.""" + backend = postgres_backend_single + return backend, backend.dialect + + +def _need_extension(backend, case): + """Install the extension *case* needs, skipping only that case if absent. + + Installing every extension up front would be simpler and wrong: the helper + skips when an extension is unavailable, so one missing extension -- pgvector + is not on every server -- would skip all 165 cases instead of the three + that need it. + """ + if case.type_name: + # The dialect knows which types the running server has -- multiranges + # and jsonpath did not exist before 13/14 -- and says so by refusing to + # render them. Asking it is cheaper than treating that refusal as a + # failure, and it is the same answer either way. + supported = getattr( + backend.dialect, f"supports_data_type_{case.type_name}", None) + if supported is not None and not supported(): + pytest.skip( + f"{case.type_name} needs a newer server than this one provides") + if not case.extension: + return + from rhosocial.activerecord_postgres_test.feature.backend.utils import ( + ensure_extension_installed, + ) + + ensure_extension_installed(backend, case.extension) + + +def _table(case): + return f"cast_t_{case.label}" + + +def _drop(backend, dialect, table): + sql, params = DropTableExpression( + dialect=dialect, table=Table(dialect, table), if_exists=True + ).to_sql() + backend.execute(sql, params) + + +def _select_as_text(backend, dialect, table): + """Read the stored value as text, so the assertion is on PostgreSQL's value.""" + query = QueryExpression( + dialect=dialect, + select=[Column(dialect, "v").cast(_core("TextType")(dialect))], + from_=NamedRelationRef(dialect, Table(dialect, table)), + ) + sql, params = query.to_sql() + result = backend.execute(sql, params, options=DQL) + return result + + +class TestEveryTypeCanBeDeclared: + """CREATE TABLE has to accept what the type renders. + + This is the layer that covers the types nothing can be inserted into, and + it is where a spelling the server does not recognise is caught. + """ + + @pytest.mark.parametrize("case", DECLARABLE, ids=_ids(DECLARABLE)) + def test_declared(self, pg, case): + backend, dialect = pg + _need_extension(backend, case) + table = _table(case) + _drop(backend, dialect, table) + try: + create = CreateTableExpression( + dialect=dialect, + table=Table(dialect, table), + columns=[ + ColumnDefinition( + dialect, name="id", + data_type=PostgresSerialType(dialect), + constraints=[ + ColumnConstraint( + dialect, ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + dialect, name="v", data_type=case.factory(dialect)), + ], + ) + sql, params = create.to_sql() + backend.execute(sql, params) + finally: + _drop(backend, dialect, table) + + +class TestInsertThroughACast: + """The value goes in as text, cast to the type on the way.""" + + @pytest.mark.parametrize("case", INSERTABLE, ids=_ids(INSERTABLE)) + def test_round_trip(self, pg, case): + backend, dialect = pg + _need_extension(backend, case) + table = _table(case) + _drop(backend, dialect, table) + try: + create = CreateTableExpression( + dialect=dialect, + table=Table(dialect, table), + columns=[ + ColumnDefinition( + dialect, name="id", + data_type=PostgresSerialType(dialect), + constraints=[ + ColumnConstraint( + dialect, ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + dialect, name="v", data_type=case.factory(dialect)), + ], + ) + sql, params = create.to_sql() + backend.execute(sql, params) + + insert = InsertExpression( + dialect=dialect, + into=Table(dialect, table), + columns=["v"], + source=ValuesSource( + dialect, + [[Literal(dialect, case.literal).cast( + case.factory(dialect))]], + ), + ) + sql, params = insert.to_sql() + backend.execute(sql, params) + + result = _select_as_text(backend, dialect, table) + assert result.data is not None and len(result.data) == 1 + if case.tolerant: + # The text form is the server's to choose, but the value still + # has to parse back into the type it came from. + assert result.data[0]["v"] + check = QueryExpression( + dialect=dialect, + select=[Column(dialect, "v")], + from_=NamedRelationRef(dialect, Table(dialect, table)), + ) + sql, params = check.to_sql() + assert backend.execute(sql, params, options=DQL).data is not None + else: + assert result.data[0]["v"] == case.rendered + finally: + _drop(backend, dialect, table) + + +class TestChainedCasts: + """A chained cast has to nest the way the expressions do.""" + + @pytest.mark.parametrize( + "label,chain,expected", + [ + ("text_to_int_to_text", ["text", "integer", "text"], "42"), + ("text_to_numeric_to_int", ["text", "decimal", "integer"], "42"), + ("text_to_int_to_bool", ["text", "integer", "boolean"], "true"), + ("text_to_date_to_text", ["text", "date", "text"], + "2026-01-15"), + ("text_to_jsonb_to_text", ["text", "jsonb", "text"], '{"a": 1}'), + ("text_to_uuid_to_text", ["text", "uuid", "text"], + "00000000-0000-0000-0000-000000000001"), + ("text_to_cidr_to_text", ["text", "cidr", "text"], + "192.168.0.0/24"), + ("text_to_int4range_to_text", ["text", "int4range", "text"], + "[1,5)"), + ], + ) + def test_chain(self, pg, label, chain, expected): + backend, dialect = pg + table = "cast_t_chain" + _drop(backend, dialect, table) + try: + create = CreateTableExpression( + dialect=dialect, + table=Table(dialect, table), + columns=[ + ColumnDefinition( + dialect, name="id", + data_type=PostgresSerialType(dialect), + constraints=[ + ColumnConstraint( + dialect, ColumnConstraintType.PRIMARY_KEY), + ], + ), + ColumnDefinition( + dialect, name="v", + data_type=_core("TextType")(dialect)), + ], + ) + sql, params = create.to_sql() + backend.execute(sql, params) + + # The chain starts at text and ends back at text, so the input is + # whatever the *intermediate* type parses -- chain[1], not + # chain[0], which is text for every chain and would feed "42" to a + # date or a uuid. + expression = Literal(dialect, _CHAIN_INPUT[chain[1]]) + for name in chain: + expression = expression.cast(_type_by_label(dialect, name)) + sql, params = InsertExpression( + dialect=dialect, into=Table(dialect, table), columns=["v"], + source=ValuesSource(dialect, [[expression]]), + ).to_sql() + backend.execute(sql, params) + + result = _select_as_text(backend, dialect, table) + assert result.data[0]["v"] == expected + finally: + _drop(backend, dialect, table) + + +_CHAIN_INPUT = { + "text": "42", + "integer": "42", + "decimal": "42", + "boolean": "42", + "date": "2026-01-15", + "jsonb": '{"a": 1}', + "uuid": "00000000-0000-0000-0000-000000000001", + "cidr": "192.168.0.0/24", + "int4range": "[1,5)", +} + +_BY_LABEL = {c.label: c.factory for c in CASES} + + +def _type_by_label(dialect, label): + return _BY_LABEL[label](dialect) + + +class TestRejectedConversions: + """A conversion the server refuses has to raise, not render.""" + + @pytest.mark.parametrize( + "label,literal", + [ + ("integer", "not a number"), + ("integer", ""), + ("boolean", "maybe"), + ("date", "2026-13-45"), + ("uuid", "not-a-uuid"), + ("cidr", "999.999.999.999/24"), + ("int4range", "int4range('x','y')"), + ("json", "{not json"), + ], + ) + def test_server_rejects_the_value(self, pg, label, literal): + backend, dialect = pg + case = next(c for c in CASES if c.label == label) + _need_extension(backend, case) + table = _table(case) + _drop(backend, dialect, table) + try: + sql, params = CreateTableExpression( + dialect=dialect, table=Table(dialect, table), + columns=[ + ColumnDefinition(dialect, name="v", + data_type=case.factory(dialect)), + ], + ).to_sql() + backend.execute(sql, params) + + sql, params = InsertExpression( + dialect=dialect, into=Table(dialect, table), columns=["v"], + source=ValuesSource(dialect, [[ + Literal(dialect, literal).cast(case.factory(dialect))]]), + ).to_sql() + with pytest.raises(DatabaseError): + backend.execute(sql, params) + finally: + _drop(backend, dialect, table) 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 index 63ce1194..8ff5b2f1 100644 --- 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 @@ -41,9 +41,16 @@ def test_datetime_to_timestamp(self, dialect): from rhosocial.activerecord.backend.expression.types import DateTimeType assert DateTimeType(dialect=dialect).to_sql() == ("TIMESTAMP", ()) - def test_int_to_integer(self, dialect): - from rhosocial.activerecord.backend.expression.types import IntType - assert IntType(dialect=dialect).to_sql() == ("INTEGER", ()) + def test_int_spelling_to_integer(self, dialect): + """``INT`` and ``INTEGER`` are two spellings of one concept, and + PostgreSQL writes both — so the spelling is honoured rather than + normalised away, and a spelling this backend does not write is refused + rather than quietly replaced.""" + from rhosocial.activerecord.backend.expression.types import IntegerType + assert IntegerType(dialect=dialect).to_sql() == ("INTEGER", ()) + assert IntegerType(dialect=dialect, spelling="int").to_sql() == ("INT", ()) + with pytest.raises(TypeError, match="does not spell this type"): + IntegerType(dialect=dialect, spelling="int4").to_sql() # Standard integer family def test_smallint(self, dialect): @@ -140,9 +147,11 @@ def test_array_equality(self, dialect): assert a1 == a2 assert a1 != a3 assert hash(a1) == hash(a2) - # PostgresArrayType.is_equivalent ignores dimensions + # is_element_type_equivalent answers "same kind of thing" and ignores + # dimensions; == answers "same declaration" and does not. a4 = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=1, dialect=dialect) - assert a1.is_equivalent(a4) + assert a1.is_element_type_equivalent(a4) + assert a1 != a4 def test_parse_array_bracket_suffix(self, dialect): from rhosocial.activerecord.backend.impl.postgres.expression.types import ( 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 index fdc61252..e183136b 100644 --- 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 @@ -196,7 +196,12 @@ def test_typed_create_with_schema_and_all_clauses(self, dialect): ) assert params == () - def test_create_domain_params_emit_one_constraint_key(self, dialect): + def test_create_domain_params_name_the_supplied_constraint_spelling(self, dialect): + # The generic get_params() emits every __init__ parameter; the spelling + # the caller did *not* use carries None ("not supplied"), which is what + # keeps `constraints` and `checks` mutually exclusive across a round + # trip without a get_params() override. See + # test_domain_expression_roundtrip.py for the full round-trip proof. value = DomainValueExpression(dialect) condition = value > Literal(dialect, 0, inline_literals=True) legacy = PostgresCreateDomainExpression( @@ -213,10 +218,10 @@ def test_create_domain_params_emit_one_constraint_key(self, dialect): ) legacy_params = legacy.get_params() typed_params = typed.get_params() - assert "constraints" in legacy_params - assert "checks" not in legacy_params - assert "checks" in typed_params - assert "constraints" not in typed_params + assert legacy_params["constraints"] == ["CHECK (VALUE > 0)"] + assert legacy_params["checks"] is None + assert [type(c) for c in typed_params["checks"]] == [DomainCheckConstraint] + assert typed_params["constraints"] is None def test_new_core_expression_rejects_raw_data_type(self, dialect): with pytest.raises(TypeError, match="DataType"): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_domain_expression_roundtrip.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_domain_expression_roundtrip.py new file mode 100644 index 00000000..cceb4db9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_domain_expression_roundtrip.py @@ -0,0 +1,192 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_domain_expression_roundtrip.py +"""Round-trip contract for ``PostgresCreateDomainExpression``. + +``CREATE DOMAIN`` has two mutually exclusive spellings for the same list of +CHECK clauses: ``constraints`` (historical, also accepts the deprecated raw +SQL string form) and ``checks`` (typed). This class used to override +``get_params()`` to re-key that list onto whichever spelling the caller used; +core's contract test +``test_expression_contract.py::TestInitParamAttributeContract::test_no_get_params_override`` +forbids that, because the generic introspection path is the single +serialization path. + +The repair is to fold the state into ``__init__``: it stores the merged list +under the slot matching the parameter the caller used and leaves the unused +spelling ``None``. These tests prove the generic path then reconstructs the +same expression — from *both* spellings — with identical SQL, and that it +never emits a key the constructor would reject. +""" + +import inspect +import warnings + +import pytest + +from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.serialization import ( + deserialize, + deserialize_json, + serialize, + serialize_json, +) +from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DomainCheckConstraint, + DomainNullability, + DomainValueExpression, +) +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.domain import ( + PostgresCreateDomainExpression, +) +from rhosocial.activerecord.testsuite.utils.expression import assert_params_equal + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(14, 0, 0)) + + +def _condition(dialect): + return DomainValueExpression(dialect) > Literal(dialect, 0, inline_literals=True) + + +def _check(dialect, name=None): + return DomainCheckConstraint(dialect, _condition(dialect), name=name) + + +def _round_trip(expr, dialect): + """get_params() -> reconstruct from those params alone -> prove fidelity. + + Returns the emitted params so the caller can inspect their shape. + """ + params = expr.get_params() + + accepted = set(inspect.signature(type(expr).__init__).parameters) + rejected = sorted((set(params) | set(serialize(expr)["params"])) - accepted) + assert not rejected, ( + f"get_params() emitted keys the constructor would reject: {rejected}" + ) + + for restored in ( + deserialize(serialize(expr), dialect), + deserialize_json(serialize_json(expr), dialect), + ): + rebuilt = type(expr)(dialect, **restored.get_params()) + assert_params_equal(rebuilt.get_params(), expr.get_params()) + assert rebuilt.to_sql() == expr.to_sql() + + return params + + +class TestPostgresCreateDomainRoundTrip: + """Both spellings survive the generic get_params() round trip.""" + + def test_constraints_spelling_round_trips(self, dialect): + check = _check(dialect, name="positive") + expr = PostgresCreateDomainExpression( + dialect, + "amount", + IntegerType(dialect), + schema="app", + collation="public.catalog", + default=0, + constraints=[check], + nullability=DomainNullability.NOT_NULL, + ) + + params = _round_trip(expr, dialect) + + # The used spelling carries the list; the unused one says "not supplied". + assert params["constraints"] == [check] + assert params["checks"] is None + assert expr.constraints == [check] + assert expr.checks is None + assert expr.check_constraints == [check] + assert expr.to_sql()[0] == ( + 'CREATE DOMAIN "app"."amount" AS INTEGER COLLATE "public"."catalog" ' + 'DEFAULT 0 CONSTRAINT "positive" CHECK (VALUE > 0) NOT NULL' + ) + + def test_checks_spelling_round_trips(self, dialect): + condition = _condition(dialect) + expr = PostgresCreateDomainExpression( + dialect, + "amount", + IntegerType(dialect), + schema="app", + collation="public.catalog", + default=0, + checks=[condition], + nullability=DomainNullability.NOT_NULL, + ) + + params = _round_trip(expr, dialect) + + # A bare SQLPredicate is normalised into a DomainCheckConstraint by the + # base class; that normalised list is what the `checks` slot keeps. + assert [type(c) for c in params["checks"]] == [DomainCheckConstraint] + assert params["checks"] == expr.check_constraints + assert params["constraints"] is None + assert expr.constraints is None + assert expr.to_sql()[0] == ( + 'CREATE DOMAIN "app"."amount" AS INTEGER COLLATE "public"."catalog" ' + 'DEFAULT 0 CHECK (VALUE > 0) NOT NULL' + ) + + def test_checks_spelling_with_named_check_round_trips(self, dialect): + check = _check(dialect, name="positive") + expr = PostgresCreateDomainExpression( + dialect, "amount", IntegerType(dialect), checks=[check] + ) + + params = _round_trip(expr, dialect) + + assert params["checks"] == [check] + assert params["constraints"] is None + assert expr.to_sql()[0] == ( + 'CREATE DOMAIN "amount" AS INTEGER CONSTRAINT "positive" CHECK (VALUE > 0)' + ) + + def test_neither_spelling_round_trips(self, dialect): + expr = PostgresCreateDomainExpression(dialect, "bare", IntegerType(dialect)) + + params = _round_trip(expr, dialect) + + assert params["constraints"] is None + assert params["checks"] is None + assert expr.to_sql()[0] == 'CREATE DOMAIN "bare" AS INTEGER' + + def test_legacy_raw_constraint_string_round_trips(self, dialect): + expr = PostgresCreateDomainExpression( + dialect, + "legacy", + IntegerType(dialect), + default=0, + constraints=["CHECK (VALUE > 0)"], + ) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + params = _round_trip(expr, dialect) + + assert params["constraints"] == ["CHECK (VALUE > 0)"] + assert params["checks"] is None + assert expr.to_sql()[0] == ( + 'CREATE DOMAIN "legacy" AS INTEGER DEFAULT 0 CHECK (VALUE > 0)' + ) + + def test_both_spellings_at_once_still_rejected(self, dialect): + with pytest.raises(ValueError, match="mutually exclusive"): + PostgresCreateDomainExpression( + dialect, + "both", + IntegerType(dialect), + constraints=[_check(dialect)], + checks=[_check(dialect)], + ) + + def test_class_does_not_override_get_params(self): + from rhosocial.activerecord.backend.expression.bases import BaseExpression + + assert PostgresCreateDomainExpression.get_params is BaseExpression.get_params \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_execution.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_execution.py new file mode 100644 index 00000000..8dce31aa --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_execution.py @@ -0,0 +1,465 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_execution.py + +"""The extension type operations, executed. + +Rendering is not execution. ``NETWORK(...)`` is a string PostgreSQL would +happily reject, and every one of these operations is one where the SQL has to +be *right* rather than merely well-formed -- the ltree operators run in both +directions, a multirange contains differently from a range, and hstore's +``exist`` counts a NULL value as present while ``defined`` does not. + +So each case builds the table, inserts through a cast, and asserts on the value +the database returns. A wrong operator renders fine and answers the wrong +question, which is exactly what a rendering test cannot see. + +The helper is deliberately narrow: :func:`read` takes the expression and the +table and does the wrapping, so a test says what it is asking rather than how +to spell a SELECT. That keeps the assertions readable, which matters when +there are this many near-identical cases. + +Arrays are parameterised through this too. An operation on a one-dimensional +array and the same operation reached through two dimensions are different +statements, and only one of them would ever be checked otherwise. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + Literal, + Column, + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, + CreateTableExpression, + DropTableExpression, + InsertExpression, + Literal, + QueryExpression, +) +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef +from rhosocial.activerecord.backend.expression.statements import ValuesSource +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + PostgresCidrType, + PostgresHstoreType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInetType, + PostgresLtreeType, + PostgresSerialType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.values import ( + operations_class_for, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +DQL = ExecutionOptions(stmt_type=StatementType.DQL) + + +@pytest.fixture +def pg(postgres_backend_single): + """The live PostgreSQL these tests run against.""" + return postgres_backend_single + + +class _Aliased: + """Names an expression for a result set without mutating it. + + The predicates come back from the operator factories as a bare + ``BinaryExpression``, which has no ``as_`` -- aliasing belongs to the + value-expression classes, not to the comparison tree. + """ + + def __init__(self, dialect, expression): + self._dialect = dialect + self._expression = expression + + def to_sql(self): + sql, params = self._expression.to_sql() + return f'{sql} AS "value"', params + + +def read(backend, expression, table): + """Return the single value *expression* produces for the row in *table*.""" + dialect = backend.dialect + query = QueryExpression( + dialect=dialect, + select=[_Aliased(dialect, expression)], + from_=NamedRelationRef(dialect, Table(dialect, table)), + ) + sql, params = query.to_sql() + result = backend.execute(sql, params, options=DQL) + return list(result.data[0].values())[0] + + +def make_table(backend, table, columns): + """Create *table*; each column is ``(name, DataType)``.""" + dialect = backend.dialect + drop(backend, table) + backend.execute(*CreateTableExpression( + dialect=dialect, + table=Table(dialect, table), + columns=[ + ColumnDefinition( + dialect, name="id", data_type=PostgresSerialType(dialect), + constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)], + ), + ] + [ + ColumnDefinition(dialect, name=name, data_type=data_type) + for name, data_type in columns + ], + ).to_sql()) + + +def insert(backend, table, name, value, data_type): + """Insert *value* into *name* as *data_type*, through a cast.""" + dialect = backend.dialect + backend.execute(*InsertExpression( + dialect=dialect, + into=Table(dialect, table), + columns=[name], + source=ValuesSource( + dialect, [[Literal(dialect, value).cast(data_type)]]), + ).to_sql()) + + +def drop(backend, table): + backend.execute(*DropTableExpression( + dialect=backend.dialect, table=Table(backend.dialect, table), if_exists=True).to_sql()) + + +class TestNetworkOperationsExecute: + TABLE = "ext_op_cidr" + + @pytest.fixture(autouse=True) + def table(self, pg): + cidr = PostgresCidrType(pg.dialect) + make_table(pg, self.TABLE, [("v", cidr)]) + insert(pg, self.TABLE, "v", "192.168.1.0/24", cidr) + yield pg + drop(pg, self.TABLE) + + def col(self, backend): + cls = operations_class_for(PostgresCidrType(backend.dialect)) + return cls(backend.dialect, Column(backend.dialect, "v", table=self.TABLE)) + + def test_network_part(self, pg): + assert str(read(pg, self.col(pg).network(), self.TABLE)) == "192.168.1.0/24" + + def test_masklen(self, pg): + assert read(pg, self.col(pg).masklen(), self.TABLE) == 24 + + def test_netmask(self, pg): + assert str(read(pg, self.col(pg).netmask(), self.TABLE)) == "255.255.255.0" + + def test_set_mask_widens(self, pg): + result = self.col(pg).set_mask(Literal(pg.dialect, 16)) + assert str(read(pg, result, self.TABLE)) == "192.168.0.0/16" + + def test_abbrev(self, pg): + assert str(read(pg, self.col(pg).abbrev(), self.TABLE)) == "192.168.1.0/24" + + def test_invert(self, pg): + """~cidr complements the mask, giving the network on the far side. + + PostgreSQL keeps the result a cidr rather than producing a host mask: + ~192.168.1.0/24 is 63.87.254.255/24. The point is that the operator + runs and stays in the address family; the exact network is the + server's arithmetic, not the wrapper's. + """ + result = read(pg, self.col(pg).invert(), self.TABLE) + assert str(result) == "63.87.254.255/24" + + def test_merge_finds_the_common_network(self, pg): + other = Literal(pg.dialect, "192.168.1.200").cast( + PostgresInetType(pg.dialect)) + assert str(read(pg, self.col(pg).merge(other), self.TABLE)) == "192.168.1.0/24" + + def test_a_chained_address_still_answers(self, pg): + """network() returns an address, so masklen() applies to it.""" + assert read(pg, self.col(pg).network().masklen(), self.TABLE) == 24 + + @pytest.mark.parametrize("value,expected", [ + ("192.168.1.1", True), ("10.0.0.1", False), + ]) + def test_and_mask_with_a_disjoint_address(self, pg, value, expected): + dialect = pg.dialect + other = Literal(dialect, value).cast(PostgresInetType(dialect)) + # A cidr & b only makes sense; both operands must be the same family, + # so this checks the operator renders and runs at all. + assert read(pg, self.col(pg).and_mask(other), self.TABLE) is not None \ + or expected + + +class TestRangeOperationsExecute: + TABLE = "ext_op_range" + + @pytest.fixture(autouse=True) + def table(self, pg): + rng = PostgresInt4RangeType(pg.dialect) + make_table(pg, self.TABLE, [("v", rng)]) + insert(pg, self.TABLE, "v", "[1,5)", rng) + yield pg + drop(pg, self.TABLE) + + def col(self, backend): + cls = operations_class_for(PostgresInt4RangeType(backend.dialect)) + return cls( + backend.dialect, + Column(backend.dialect, "v", table=self.TABLE), + element_type=IntegerType(backend.dialect), + ) + + def other(self, backend, text): + dialect = backend.dialect + return Literal(dialect, text).cast(PostgresInt4RangeType(dialect)) + + @pytest.mark.parametrize( + "value,contains", [(1, True), (3, True), (4, True), (5, False), (9, False)] + ) + def test_containment(self, pg, value, contains): + result = self.col(pg).contains(Literal(pg.dialect, value)) + assert bool(read(pg, result, self.TABLE)) is contains + + @pytest.mark.parametrize( + "other,overlaps", [("[3,9)", True), ("[5,9)", False), ("[9,10)", False)] + ) + def test_overlaps(self, pg, other, overlaps): + result = self.col(pg).overlaps(self.other(pg, other)) + assert bool(read(pg, result, self.TABLE)) is overlaps + + @pytest.mark.parametrize( + "other,adjacent", [("[5,9)", True), ("[6,9)", False), ("[2,4)", False)] + ) + def test_adjacent(self, pg, other, adjacent): + """Touching without overlapping: [1,5) and [5,9) are adjacent.""" + result = self.col(pg).adjacent(self.other(pg, other)) + assert bool(read(pg, result, self.TABLE)) is adjacent + + @pytest.mark.parametrize( + "other,left", [("[9,10)", True), ("[5,9)", True), ("[0,1)", False)] + ) + def test_strictly_left_of(self, pg, other, left): + result = self.col(pg).strictly_left_of(self.other(pg, other)) + assert bool(read(pg, result, self.TABLE)) is left + + @pytest.mark.parametrize( + "other,right", [("[9,10)", False), ("[0,1)", True), ("[1,5)", False)] + ) + def test_strictly_right_of(self, pg, other, right): + result = self.col(pg).strictly_right_of(self.other(pg, other)) + assert bool(read(pg, result, self.TABLE)) is right + + def test_contains_a_whole_range(self, pg): + result = self.col(pg).contains_range(self.other(pg, "[2,3)")) + assert bool(read(pg, result, self.TABLE)) is True + + def test_union_of_overlapping_ranges(self, pg): + """range_union joins two ranges; PostgreSQL refuses a gap. + + Unioning [1,5) with [9,10) raises + "result of range union would not be contiguous" rather than + silently producing a range covering the gap, so the case uses ranges + that touch and asserts the joined result. + """ + result = self.col(pg).union(self.other(pg, "[3,9)")) + assert str(read(pg, result, self.TABLE)) == "[1, 9)" + + def test_union_of_disjoint_ranges_is_refused(self, pg): + """Not every union is legal, and the server says so.""" + from rhosocial.activerecord.backend.errors import DatabaseError + + result = self.col(pg).union(self.other(pg, "[9,10)")) + with pytest.raises(DatabaseError): + read(pg, result, self.TABLE) + + +class TestMultirangeOperationsExecute: + """A multirange contains differently, and a rendering test cannot tell.""" + + TABLE = "ext_op_mrange" + + @pytest.fixture(autouse=True) + def table(self, pg): + # int4multirange arrived in PostgreSQL 14. The dialect knows, and + # asking it here keeps four tests from failing on every older server + # with the CREATE TABLE, which says nothing about containment. + if not pg.dialect.supports_data_type_postgres_int4multirange(): + pytest.skip("int4multirange needs PostgreSQL 14+") + mr = PostgresInt4MultirangeType(pg.dialect) + make_table(pg, self.TABLE, [("v", mr)]) + insert(pg, self.TABLE, "v", "{[1,5)}", mr) + yield pg + drop(pg, self.TABLE) + + def col(self, backend): + cls = operations_class_for(PostgresInt4MultirangeType(backend.dialect)) + return cls( + backend.dialect, + Column(backend.dialect, "v", table=self.TABLE), + element_type=IntegerType(backend.dialect), + ) + + @pytest.mark.parametrize( + "value,contains", [(1, True), (4, True), (7, False)] + ) + def test_multirange_contains(self, pg, value, contains): + """A Python int binds as smallint, which int4multirange rejects. + + The element is therefore typed against the range's own element type; + without that the query is right and the server still says + `operator does not exist: int4multirange @> smallint`. + """ + result = self.col(pg).multirange_contains(value) + assert bool(read(pg, result, self.TABLE)) is contains + + def test_multirange_overlaps(self, pg): + dialect = pg.dialect + other = Literal(dialect, "{[4,9)}").cast( + PostgresInt4MultirangeType(dialect)) + result = self.col(pg).multirange_overlaps(other) + assert bool(read(pg, result, self.TABLE)) is True + + +class TestHstoreOperationsExecute: + TABLE = "ext_op_hstore" + + @pytest.fixture(autouse=True) + def table(self, pg): + from rhosocial.activerecord_postgres_test.feature.backend.utils import ( + ensure_extension_installed, + ) + + ensure_extension_installed(pg, "hstore") + hstore = PostgresHstoreType(pg.dialect) + make_table(pg, self.TABLE, [("v", hstore)]) + insert(pg, self.TABLE, "v", '"a"=>"1", "b"=>NULL', hstore) + yield pg + drop(pg, self.TABLE) + + def col(self, backend): + cls = operations_class_for(PostgresHstoreType(backend.dialect)) + return cls(backend.dialect, Column(backend.dialect, "v", table=self.TABLE)) + + def test_get(self, pg): + assert read(pg, self.col(pg).get(Literal(pg.dialect, "a")), self.TABLE) == "1" + + def test_get_of_a_missing_key(self, pg): + assert read(pg, self.col(pg).get(Literal(pg.dialect, "zz")), self.TABLE) is None + + def test_a_present_but_null_value(self, pg): + """b exists with a NULL value; get() gives NULL, not a missing key.""" + assert read(pg, self.col(pg).get(Literal(pg.dialect, "b")), self.TABLE) is None + + @pytest.mark.parametrize("key,exists", [("a", True), ("b", True), ("zz", False)]) + def test_exist(self, pg, key, exists): + """exist() counts a NULL value as existing, which is the whole point.""" + assert bool(read(pg, self.col(pg).has_key(Literal(pg.dialect, key)), self.TABLE)) is exists + + @pytest.mark.parametrize("key,defined", [("a", True), ("b", False), ("zz", False)]) + def test_defined(self, pg, key, defined): + """defined() is the stricter one: present *and* not NULL.""" + assert bool(read(pg, self.col(pg).is_defined(Literal(pg.dialect, key)), self.TABLE)) is defined + + def test_keys(self, pg): + keys = read(pg, self.col(pg).keys(), self.TABLE) + assert sorted(keys) == ["a", "b"] + + def test_values(self, pg): + values = read(pg, self.col(pg).values(), self.TABLE) + assert len(values) == 2 + + +class TestLtreeOperationsExecute: + TABLE = "ext_op_ltree" + + @pytest.fixture(autouse=True) + def table(self, pg): + from rhosocial.activerecord_postgres_test.feature.backend.utils import ( + ensure_extension_installed, + ) + + ensure_extension_installed(pg, "ltree") + ltree = PostgresLtreeType(pg.dialect) + make_table(pg, self.TABLE, [("v", ltree)]) + insert(pg, self.TABLE, "v", "Top.Science.Astronomy", ltree) + yield pg + drop(pg, self.TABLE) + + def col(self, backend): + cls = operations_class_for(PostgresLtreeType(backend.dialect)) + return cls(backend.dialect, Column(backend.dialect, "v", table=self.TABLE)) + + def path(self, backend, text): + dialect = backend.dialect + return Literal(dialect, text).cast(PostgresLtreeType(dialect)) + + def test_nlevel(self, pg): + assert read(pg, self.col(pg).nlevel(), self.TABLE) == 3 + + def test_subpath(self, pg): + assert read(pg, self.col(pg).subpath(0, 1), self.TABLE) == "Top" + + def test_concat(self, pg): + result = self.col(pg).concat(self.path(pg, "Physics")) + assert read(pg, result, self.TABLE) == "Top.Science.Astronomy.Physics" + + def test_is_descendant_of(self, pg): + result = self.col(pg).is_descendant_of(self.path(pg, "Top.Science")) + assert bool(read(pg, result, self.TABLE)) is True + + def test_is_ancestor_of(self, pg): + """This path is deeper, so it is not an ancestor of Top.Science.""" + result = self.col(pg).is_ancestor_of(self.path(pg, "Top.Science")) + assert bool(read(pg, result, self.TABLE)) is False + + +class TestArrayDimensionsExecute: + """Rank 1 and rank 2 are different statements; both have to run.""" + + @pytest.mark.parametrize("dimensions", [1, 2]) + def test_an_array_column_round_trips(self, pg, dimensions): + element = PostgresCidrType(pg.dialect) + array = PostgresArrayType( + element_type=element, dimensions=dimensions, dialect=pg.dialect) + table = f"ext_op_cidr{dimensions}d" + make_table(pg, table, [("v", array)]) + literal = "{" + ",".join(["192.168.1.1"] * (2 ** dimensions)) + "}" + insert(pg, table, "v", literal, array) + + cls = operations_class_for(element) + expr = cls(pg.dialect, Column(pg.dialect, "v", table=table)) + assert expr.network().to_sql()[0] + drop(pg, table) + + @pytest.mark.parametrize("dimensions", [1, 2]) + def test_an_array_of_ranges_compares(self, pg, dimensions): + """The element's operations, reached through however many dimensions.""" + element = PostgresInt4RangeType(pg.dialect) + array = PostgresArrayType( + element_type=element, dimensions=dimensions, dialect=pg.dialect) + table = f"ext_op_rng{dimensions}d" + make_table(pg, table, [("v", array)]) + # An array element is quoted, so a range element is a quoted range: + # '{"[1,5)","[1,5)"}'. Unquoted it is malformed, which is the whole + # reason this is worth a test rather than a fixture. A rank-2 array + # additionally nests its rows. + cell = '"[1,5)"' + if dimensions == 1: + literal = "{" + ",".join([cell] * 2) + "}" + else: + row = "{" + ",".join([cell] * 2) + "}" + literal = "{" + ",".join([row] * 2) + "}" + insert(pg, table, "v", literal, array) + + cls = operations_class_for(element) + expr = cls( + pg.dialect, Column(pg.dialect, "v", table=table), + element_type=IntegerType(pg.dialect), + ) + assert "@>" in expr.contains(3).to_sql()[0] + drop(pg, table) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_operations.py new file mode 100644 index 00000000..c5178f37 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_operations.py @@ -0,0 +1,372 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extension_type_operations.py + +"""PostgreSQL's own types, and what you can do with them. + +The functions were all written -- ``network()``, ``range_contains()``, +``hstore_get_value()``, ``ltree_matches()`` -- as free functions in +``impl.postgres.functions``, and none of them was reachable from anything that +held the value. A ``cidr`` had no ``.network()``, an ``int4range`` had no +``.contains()``; the only way in was to import the module and pass the dialect +by hand. + +These tests are parameterised over *type x operation*, and over array +dimensions, because the two multiply. An array of cidr is a different column +from a cidr and offers array operations; a two-dimensional array of int4range +is a third thing again, and nothing about it is implied by either of the +others. A test that covered the scalar case alone would have passed while the +array case stayed broken. + +The family each operation returns is checked where it is the whole point. +``NETWORK()`` is an address and carries address operations; ``MASKLEN()`` is an +integer and must not, because ``MASKLEN(x).network()`` is a type error in +every backend and only PostgreSQL would have let it through. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + PostgresCIDType, + PostgresCidrType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4RangeType, + PostgresLqueryType, + PostgresLtreeType, + PostgresLtxtqueryType, + PostgresMacAddrType, + PostgresTsTzRangeType, +) +from rhosocial.activerecord.backend.impl.postgres.expression.values import ( + NetworkValueExpression, + operations_class_for, + value_expression_for, +) + + +@pytest.fixture +def dialect(): + d = PostgresDialect() + d._version = (16, 2, 0) + return d + + +def typed(data_type, dialect): + """Wrap a column the way a column of *data_type* would be wrapped.""" + # value_expression_for already returns a wrapper around this dialect's + # empty node; replace its node with the column rather than wrapping twice. + expression = value_expression_for(data_type, dialect) + expression.call = Column(dialect, "v") + return expression + + +# --------------------------------------------------------------------------- +# Which types have an operation surface at all +# --------------------------------------------------------------------------- + +WITH_OPERATIONS = [ + ("cidr", PostgresCidrType), + ("inet", PostgresInetType), + ("macaddr", PostgresMacAddrType), + ("hstore", PostgresHstoreType), + ("ltree", PostgresLtreeType), + ("lquery", PostgresLqueryType), + ("ltxtquery", PostgresLtxtqueryType), + ("int4range", PostgresInt4RangeType), + ("tstzrange", PostgresTsTzRangeType), +] + +WITHOUT_OPERATIONS = [ + ("cid", PostgresCIDType), + ("serial", None), +] + + +class TestOperationSurfaceSelection: + @pytest.mark.parametrize( + "label,cls", WITH_OPERATIONS, ids=[c[0] for c in WITH_OPERATIONS] + ) + def test_a_type_with_operations_has_them(self, dialect, label, cls): + assert value_expression_for(cls(dialect), dialect) is not None + + @pytest.mark.parametrize("label,cls", WITHOUT_OPERATIONS) + def test_a_type_without_operations_says_so(self, dialect, label, cls): + if cls is None: + pytest.skip("serial has no per-column operation surface by design") + assert value_expression_for(cls(dialect), dialect) is None + + def test_a_name_is_not_a_type(self, dialect): + """Choosing by name is how a wrong guess gets made.""" + with pytest.raises(ValueError, match="expected a DataType"): + value_expression_for("cidr", dialect) + + def test_an_array_borrows_its_element_s_operations(self, dialect): + """``cidr[]`` is an array, but what you can do to it is cidr's.""" + element = PostgresCidrType(dialect) + array = PostgresArrayType(element_type=element, dialect=dialect) + # The array's own dispatch key is postgres_array, so it selects + # nothing -- which is why the element has to be consulted. + assert operations_class_for(array) is None + assert operations_class_for(element) is not None + + +# --------------------------------------------------------------------------- +# inet / cidr +# --------------------------------------------------------------------------- + +NETWORK_OPERATIONS = [ + ("network", (), "NETWORK("), + ("netmask", (), "NETMASK("), + ("masklen", (), "MASKLEN("), + ("set_mask", (24,), "SET_MASKLEN("), + ("abbrev", (), "TEXT("), + ("merge", (Column, ), "MERGE("), + ("invert", (), "~"), + ("and_mask", (Column, ), "&"), + ("or_mask", (Column, ), "|"), +] + + +class TestNetworkOperations: + @pytest.mark.parametrize( + "method,args,expected", + [(m, a, e) for m, a, e in NETWORK_OPERATIONS], + ids=[m for m, _, _ in NETWORK_OPERATIONS], + ) + def test_operation(self, dialect, method, args, expected): + expr = typed(PostgresCidrType(dialect), dialect) + resolved = tuple( + Column(dialect, "w") if a is Column else a for a in args + ) + sql, _ = getattr(expr, method)(*resolved).to_sql() + assert expected in sql + + def test_the_column_is_the_operand(self, dialect): + expr = typed(PostgresCidrType(dialect), dialect) + sql, _ = expr.network().to_sql() + assert '"v"' in sql + + def test_a_mask_length_is_bound(self, dialect): + expr = typed(PostgresCidrType(dialect), dialect) + sql, params = expr.set_mask(24).to_sql() + assert "?" in sql or "%s" in sql + assert 24 in params + + def test_an_address_result_stays_an_address(self, dialect): + """NETWORK() returns a cidr, so the chain continues.""" + expr = typed(PostgresCidrType(dialect), dialect) + chained = expr.network().masklen() + sql, _ = chained.to_sql() + assert "MASKLEN(NETWORK(" in sql + + def test_a_scalar_result_does_not(self, dialect): + """MASKLEN() returns an integer; address operations must not follow.""" + expr = typed(PostgresCidrType(dialect), dialect) + assert not hasattr(expr.masklen(), "network") + + +# --------------------------------------------------------------------------- +# ranges +# --------------------------------------------------------------------------- + +RANGE_OPERATIONS = [ + ("contains", (3,), "@>"), + ("contains_range", (Column,), "@>"), + ("overlaps", (Column,), "&&"), + ("adjacent", (Column,), "-|-"), + ("strictly_left_of", (Column,), "<<"), + ("strictly_right_of", (Column,), ">>"), +] + + +class TestRangeOperations: + @pytest.mark.parametrize( + "method,args,operator", + [(m, a, o) for m, a, o in RANGE_OPERATIONS], + ids=[m for m, _, _ in RANGE_OPERATIONS], + ) + def test_operation(self, dialect, method, args, operator): + expr = typed(PostgresInt4RangeType(dialect), dialect) + resolved = tuple( + Column(dialect, "w") if a is Column else a for a in args + ) + sql, _ = getattr(expr, method)(*resolved).to_sql() + assert operator in sql + + def test_containment_of_an_element_binds_it(self, dialect): + expr = typed(PostgresInt4RangeType(dialect), dialect) + sql, params = expr.contains(3).to_sql() + assert 3 in params + + @pytest.mark.parametrize("label,cls", [ + ("int4range", PostgresInt4RangeType), + ("tstzrange", PostgresTsTzRangeType), + ]) + def test_every_range_type_offers_the_same_surface(self, dialect, label, cls): + """The operations belong to range-ness, not to one range type.""" + expr = typed(cls(dialect), dialect) + for method, args, _ in RANGE_OPERATIONS: + resolved = tuple( + Column(dialect, "w") if a is Column else a for a in args + ) + assert getattr(expr, method)(*resolved).to_sql()[0] + + def test_multirange_containment_is_a_different_operation(self, dialect): + """A multirange contains differently, so it is named differently.""" + from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresInt4MultirangeType, + ) + + expr = typed(PostgresInt4MultirangeType(dialect), dialect) + sql, params = expr.multirange_contains(3).to_sql() + assert 3 in params + + +# --------------------------------------------------------------------------- +# hstore +# --------------------------------------------------------------------------- + +class TestHstoreOperations: + def test_keys_and_values(self, dialect): + expr = typed(PostgresHstoreType(dialect), dialect) + assert "akeys(" in expr.keys().to_sql()[0].lower() + assert "avals(" in expr.values().to_sql()[0].lower() + + @pytest.mark.parametrize( + "method,expected", + [("get", "->"), ("get_as_text", "->"), ("subscript", "->")], + ) + def test_accessors(self, dialect, method, expected): + expr = typed(PostgresHstoreType(dialect), dialect) + sql, params = getattr(expr, method)(Literal(dialect, "k")).to_sql() + assert expected in sql + assert "k" in params + + @pytest.mark.parametrize( + "method", ["has_key", "is_defined", "key_exists"] + ) + def test_predicates(self, dialect, method): + expr = typed(PostgresHstoreType(dialect), dialect) + sql, params = getattr(expr, method)(Literal(dialect, "k")).to_sql() + assert "k" in params + assert sql + + def test_a_value_is_not_a_document(self, dialect): + """get() returns text; calling keys() on it would be a type error.""" + expr = typed(PostgresHstoreType(dialect), dialect) + assert not hasattr(expr.get(Literal(dialect, "k")), "keys") + + +# --------------------------------------------------------------------------- +# ltree and its pattern types +# --------------------------------------------------------------------------- + +class TestLtreeOperations: + def test_nlevel(self, dialect): + expr = typed(PostgresLtreeType(dialect), dialect) + assert "nlevel(" in expr.nlevel().to_sql()[0].lower() + + def test_subpath_takes_bound_arguments(self, dialect): + expr = typed(PostgresLtreeType(dialect), dialect) + sql, params = expr.subpath(0, 2).to_sql() + assert 0 in params and 2 in params + + def test_concat_is_an_operator(self, dialect): + expr = typed(PostgresLtreeType(dialect), dialect) + assert "||" in expr.concat(Column(dialect, "w")).to_sql()[0] + + def test_matches_a_pattern(self, dialect): + expr = typed(PostgresLtreeType(dialect), dialect) + sql, params = expr.matches("root.*").to_sql() + assert "root.*" in params + + def test_ancestry(self, dialect): + expr = typed(PostgresLtreeType(dialect), dialect) + assert "@>" in expr.is_ancestor_of(Column(dialect, "w")).to_sql()[0] + assert "<@" in expr.is_descendant_of(Column(dialect, "w")).to_sql()[0] + + def test_subpath_result_is_still_a_tree(self, dialect): + expr = typed(PostgresLtreeType(dialect), dialect) + assert hasattr(expr.subpath(0, 2), "concat") + + @pytest.mark.parametrize("label,cls", [ + ("ltree", PostgresLtreeType), + ("lquery", PostgresLqueryType), + ("ltxtquery", PostgresLtxtqueryType), + ]) + def test_the_pattern_types_share_the_surface(self, dialect, label, cls): + """lquery and ltxtquery match an ltree; they are not trees.""" + expr = typed(cls(dialect), dialect) + assert hasattr(expr, "matches") + assert hasattr(expr, "nlevel") + + +# --------------------------------------------------------------------------- +# Array dimensions, which multiply everything +# --------------------------------------------------------------------------- + +DIMENSIONS = [1, 2, 3] + + +class TestArrayDimensionsCompose: + """A cidr[] is not a cidr. Neither is a cidr[][].""" + + @pytest.mark.parametrize("dimensions", DIMENSIONS) + def test_the_array_renders_with_its_dimensions(self, dialect, dimensions): + array = PostgresArrayType( + element_type=PostgresCidrType(dialect), + dimensions=dimensions, + dialect=dialect, + ) + sql, _ = dialect.format_data_type(array) + assert sql == "CIDR" + "[]" * dimensions + + @pytest.mark.parametrize("dimensions", DIMENSIONS) + def test_operations_reach_through_the_dimensions(self, dialect, dimensions): + """The element's operations apply whatever the array's rank. + + This is the combination a scalar-only test misses: the type that + selects the operations is the element, and the element is reachable + through however many dimensions. + """ + array = PostgresArrayType( + element_type=PostgresCidrType(dialect), + dimensions=dimensions, + dialect=dialect, + ) + element = operations_class_for(array.element_type) + assert element is NetworkValueExpression + expr = element(dialect, Column(dialect, "v")) + assert "NETWORK(" in expr.network().to_sql()[0] + + @pytest.mark.parametrize("dimensions", DIMENSIONS) + def test_every_type_combines_with_every_rank(self, dialect, dimensions): + """Every extension type keeps its operations at every array rank.""" + for label, cls in WITH_OPERATIONS: + array = PostgresArrayType( + element_type=cls(dialect), dimensions=dimensions, dialect=dialect + ) + element = operations_class_for(array.element_type) + assert element is not None, f"{label}[] lost its operations" + + def test_a_two_dimensional_range_array_still_contains(self, dialect): + """The deepest combination: rank 3 of the type with predicates.""" + array = PostgresArrayType( + element_type=PostgresInt4RangeType(dialect), + dimensions=3, + dialect=dialect, + ) + sql, _ = dialect.format_data_type(array) + assert sql == "INT4RANGE[][][]" + element = operations_class_for(array.element_type) + expr = element(dialect, Column(dialect, "v")) + assert "@>" in expr.contains(3).to_sql()[0] + + def test_an_array_of_a_type_without_operations_stays_empty(self, dialect): + """Nothing is invented for a type that has no surface of its own.""" + array = PostgresArrayType( + element_type=PostgresCIDType(dialect), dialect=dialect + ) + assert operations_class_for(array.element_type) is None \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py index 56b21282..6b795ca7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extensions.py @@ -14,7 +14,7 @@ from unittest.mock import MagicMock # noqa: F401 from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.expression import core, operators +from rhosocial.activerecord.backend.expression import core, operators, Literal from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression from rhosocial.activerecord.backend.impl.postgres.functions.ltree import ( ltree_literal, @@ -261,7 +261,7 @@ class TestPostGISFunctions: def test_st_geom_from_text(self): """Test geometry from text.""" dialect = PostgresDialect((14, 0, 0)) - result = st_geom_from_text(dialect, 'POINT(0 0)', srid=4326) + result = st_geom_from_text(dialect, Literal(dialect, 'POINT(0 0)'), Literal(dialect, 4326)) assert isinstance(result, core.FunctionCall) sql, params = result.to_sql() assert "st_geomfromtext" in sql.lower() @@ -269,7 +269,7 @@ def test_st_geom_from_text(self): def test_st_geog_from_text(self): """Test geography from text.""" dialect = PostgresDialect((14, 0, 0)) - result = st_geog_from_text(dialect, 'POINT(0 0)') + result = st_geog_from_text(dialect, Literal(dialect, 'POINT(0 0)')) assert isinstance(result, core.FunctionCall) sql, params = result.to_sql() assert "st_geogfromtext" in sql.lower() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_json_path_modes.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_json_path_modes.py new file mode 100644 index 00000000..fa2b5b8a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_json_path_modes.py @@ -0,0 +1,613 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_json_path_modes.py +"""JSON path rendering modes on PostgreSQL. + +``format_json_expression`` overrode the core dispatch entry point and ignored +``expr.mode``: ARROW produced the jsonpath form and FUNCTION was +indistinguishable from AUTO, while ``supports_json_arrow_operators()`` +advertised arrows that the implementation never emitted. A probe and its +implementation have to agree, so the mode now decides. + +Core also split what used to be one ``JSONExpression`` class in two -- one per +operator -- so the tests below name the class their operator means: ``->>`` +yields text and is a ``JSONTextExpression``, ``->`` yields a document and is a +``JSONDocumentExpression``. PostgreSQL renders both from the same code path and +still branches on ``expr.operation``, so this is where the class is chosen, not +what changes the SQL. + +The path form then had to grow a second spelling. It was the SQL/JSON path +language, ``jsonb_path_query_first``, which is 12.0+, and every mode that means +"read a path" refused below that -- so ``json_extract_text`` could not be used at +all on 9.6, 10 or 11, on servers that have read a path since 9.3 through ``->``, +``#>`` and ``jsonb_extract_path``. Below 12.0 the path form is now the ``#>`` / +``#>>`` operators, which take a key array rather than a jsonpath, so a path has +to be taken apart to be rendered. 12.0 and later are untouched. + +Two things the renderer assumed about its column are asserted here because they +were wrong. ``jsonb_path_query_first`` exists only for ``jsonb``, so a ``json`` +or ``text`` column handed to it without a cast is an ``UndefinedFunction``; ``#>`` has +no ``text`` overload on any version. Both spellings now cast the column first. +And the path language reads a rootless argument as something other than a key +(``'a'`` is a variable reference, ``'a.b'`` a syntax error), so the jsonpath +spelling roots a single rootless accessor and refuses a longer chain, the way +the key-array spelling always has. + +Every assertion but the last is on rendered SQL, and those need no database. +The last one executes the rendered statements against the scenario the run +selected, because a cast and a canonicalised path are only proven by the +server's parser. +""" + +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_json_path_modes.py +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.json import JSONMixin +from rhosocial.activerecord.backend.dialect.protocols import JSONSupport +from rhosocial.activerecord.backend.expression import ( + Column, + JSONDocumentExpression, + JSONTextExpression, +) +from rhosocial.activerecord.backend.expression.advanced_functions import JSONPathMode +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.types.jsonb_enhanced import ( + PostgresJSONBEnhancedMixin, +) +from rhosocial.activerecord.backend.impl.postgres.protocols.types.jsonb_enhanced import ( + PostgresJSONBEnhancedSupport, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +from providers.scenarios import get_enabled_scenarios, get_scenario + +#: A SELECT, so the backend reads the result set back into ``.data``. +DQL = ExecutionOptions(stmt_type=StatementType.DQL) + +#: The scenarios the ``--scenarios`` option left enabled. Read here so the +#: execution test runs against the selected server, not every configured one: +#: the fixture layer next door predates the option's filtering and would walk +#: all of them. +LIVE_SCENARIOS = list(get_enabled_scenarios().keys()) + + +def _dialect(version=(16, 2, 1)): + dialect = PostgresDialect() + dialect._version = version + return dialect + + +def _text_expr(dialect, mode=None, path="$.a"): + """A ``->>`` access, which yields text and so is a JSONTextExpression.""" + return JSONTextExpression(dialect, Column(dialect, "data", table="t"), path, "->>", mode=mode) + + +def _document_expr(dialect, mode=None, path="$.a"): + """A ``->`` access, which yields a document and so is a JSONDocumentExpression.""" + return JSONDocumentExpression(dialect, Column(dialect, "data", table="t"), path, "->", mode=mode) + + +# --------------------------------------------------------------------------- +# The mode decides +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("mode", ["arrow", JSONPathMode.ARROW]) +def test_arrow_modes_use_the_operators(mode): + """Only an explicit ARROW renders arrows. + + The default (mode=None, which becomes AUTO) is deliberately absent. It + renders the path form, because the two spellings take different path + arguments: ``->`` takes a key name while ``jsonb_path_query_first`` takes a + jsonpath. ``json_extract_text`` passes jsonpaths, so AUTO cannot assume + the caller wants a key. + """ + sql, _ = _text_expr(_dialect(), mode).to_sql() + assert sql == """"t"."data"->>'$.a'""" + + +@pytest.mark.parametrize("mode", [None, "auto", "function"]) +def test_default_and_function_modes_use_the_path_language(mode): + sql, _ = _text_expr(_dialect(), mode).to_sql() + assert "jsonb_path_query_first" in sql + + +def test_function_mode_uses_the_path_language(): + sql, params = _text_expr(_dialect(), "function").to_sql() + assert "jsonb_path_query_first" in sql + assert params == ("$.a",) + + +def test_the_two_modes_really_differ(): + """Before the fix all four mode values produced the same SQL.""" + arrow, _ = _text_expr(_dialect(), "arrow").to_sql() + function, _ = _text_expr(_dialect(), "function").to_sql() + assert arrow != function + + +def test_json_operation_keeps_the_jsonb_result(): + """``->`` yields JSONB, so the path form is not unwrapped to text.""" + sql, _ = _document_expr(_dialect(), "function").to_sql() + assert " #>> " not in sql + assert "jsonb_path_query_first" in sql + + +def test_text_operation_unwraps_the_scalar(): + """``->>`` yields text, so the path form is unwrapped with ``#>> '{}'``.""" + sql, _ = _text_expr(_dialect(), "function").to_sql() + assert "#>> '{}'" in sql + + +def test_path_is_bound_not_inlined(): + """A document-driven path must not be able to change the statement shape.""" + sql, params = _text_expr( + _dialect(), "function", path="$.a'); DROP TABLE t; --" + ).to_sql() + assert "DROP TABLE" not in sql + assert params == ("$.a'); DROP TABLE t; --",) + + +def test_alias_is_appended(): + dialect = _dialect() + expr = JSONTextExpression( + dialect, Column(dialect, "data", table="t"), "$.a", "->>", alias="v", mode="arrow" + ) + assert expr.to_sql()[0] == """"t"."data"->>'$.a' AS "v\"""" + + +# --------------------------------------------------------------------------- +# Version gating +# --------------------------------------------------------------------------- + + +def test_path_can_be_read_from_nine_three(): + """The path operators arrived with 9.3, so the probe says so. + + It used to answer 12.0+, which is the version of the SQL/JSON *path + language*. Those are two capabilities: 9.6 through 11 refused to render a + path the server answers, behind a supports_json_type() that said PostgreSQL + had JSON. + """ + assert _dialect((9, 3, 0)).supports_json_path() is True + assert _dialect((9, 2, 0)).supports_json_path() is False + + +def test_the_path_language_is_its_own_probe(): + """12.0 is the language, not the path.""" + assert _dialect((12, 0, 0)).supports_sql_json_path() is True + assert _dialect((11, 16, 0)).supports_sql_json_path() is False + # ...and the older server still reads a path, just not with jsonpath. + assert _dialect((11, 16, 0)).supports_json_path() is True + + +def test_function_mode_uses_the_path_operators_before_twelve(): + """Below 12.0 the path form is ``#>``, which takes a key array. + + This test asserted the opposite -- that FUNCTION mode refuses below 12.0 -- + and that assertion was the defect: a formatter raising behind a probe that + said PostgreSQL had JSON, so ``json_extract_text`` could not be used at all + on three of the eleven supported servers. + """ + sql, params = _text_expr(_dialect((11, 5, 0)), "function").to_sql() + assert "#>>" in sql + assert "jsonb_path_query_first" not in sql + assert params == ("a",) + + +def test_function_mode_keeps_the_jsonpath_from_twelve(): + """12.0 and later are unchanged: the path language, not ``#>``.""" + sql, params = _text_expr(_dialect((12, 0, 0)), "function").to_sql() + assert "jsonb_path_query_first" in sql + assert "#>> ARRAY" not in sql + assert params == ("$.a",) + + +def test_a_rootless_accessor_is_rooted_for_the_path_language(): + """``a`` must arrive at the server as ``$.a``. + + The path language reads a rootless ``'a'`` as a variable reference and + rejects it; ARROW mode and the pre-12 key-array spelling read it as the + key ``a``. Binding ``$.a`` is how the same argument means the same thing + in the spelling that actually takes a jsonpath. + """ + sql, params = _text_expr(_dialect((12, 0, 0)), "function", path="a").to_sql() + assert "jsonb_path_query_first" in sql + assert params == ("$.a",) + + +def test_a_rootless_subscript_and_quoted_key_are_rooted_too(): + """The other two shapes a single rootless accessor can have. + + ``[0]`` is a subscript and ``"a b"`` a quoted key, and they have to stay + different jsonpaths: rooting the key array's string form would turn + ``[0]`` into ``$."0"``, a different member. + """ + dialect = _dialect((12, 0, 0)) + assert _text_expr(dialect, "function", path="[0]").to_sql()[1] == ("$[0]",) + assert _text_expr(dialect, "function", path='"a b"').to_sql()[1] == ('$."a b"',) + + +def test_a_rootless_chain_is_refused_with_the_path_language(): + """The two spellings do not agree on ``a.b``, so 12.0 refuses it too. + + Below 12.0 the refusal is that no key array can be built; here the + jsonpath cannot be rooted. Both come from the one single-accessor rule, + and both have to be errors: reading ``a.b`` as ``$.a.b`` would answer an + argument the key-array spelling refuses, and reading it as the variable + reference the path language sees would answer a different question. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + _text_expr(_dialect((12, 0, 0)), "function", path="a.b").to_sql() + message = str(excinfo.value) + assert "root $" in message + assert "single" in message + + +def test_the_key_array_is_built_from_the_path(): + """``$.tags[0]`` is the key array ``{tags,0}``, one bound element per key.""" + dialect = _dialect((11, 5, 0)) + sql, params = _text_expr(dialect, "function", path="$.tags[0]").to_sql() + assert params == ("tags", "0") + assert sql.count("%s::text") == 2, sql + + +def test_the_root_is_an_empty_key_array(): + sql, params = _text_expr(_dialect((11, 5, 0)), "function", path="$").to_sql() + assert params == () + assert "'{}'" in sql + + +def test_keys_are_bound_not_inlined(): + """A document-driven path must not be able to change the statement shape. + + Before 12.0 the path is taken apart into keys, so this is the stronger form + of the check: a key carrying SQL comes out as a parameter and never as text, + and it does not have to be escaped to get there. + """ + dialect = _dialect((11, 5, 0)) + sql, params = _text_expr( + dialect, "function", path="$.\"a'); DROP TABLE t; --\"" + ).to_sql() + assert "DROP TABLE" not in sql + assert params == ("a'); DROP TABLE t; --",) + + +def test_a_path_that_needs_the_language_still_refuses_before_twelve(): + """12.0 is where a filter becomes answerable, so 11 refuses that one. + + The refusal names the construct and the version, because "this server is + old" is not something a caller can act on. + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + _text_expr(_dialect((11, 5, 0)), "function", path="$.tags[*]").to_sql() + message = str(excinfo.value) + assert "12.0" in message + assert "wildcard" in message + + +@pytest.mark.parametrize( + "path, reason", + [ + ("$.tags[*]", "wildcard"), + ("$.a ? (@.b)", "filter"), + ("$..a", "recursive descent"), + ("$.tags[last]", "[last]"), + ("$.a-b", "subtraction"), + ("$name", "jsonpath variable"), + ("a.b", "root $"), + ], +) +def test_every_language_only_construct_is_refused_with_its_reason(path, reason): + with pytest.raises(UnsupportedFeatureError) as excinfo: + _text_expr(_dialect((11, 5, 0)), "function", path=path).to_sql() + assert reason in str(excinfo.value) + + +def test_a_single_rootless_accessor_is_read_as_a_key(): + """``a`` is what ``->`` reads, so the path form reads it the same way. + + The path language reads a rootless form as something else -- ``a`` is a + variable reference and ``a.b`` is a syntax error -- but ARROW mode on this + dialect has always read ``a`` as the key ``a``, and the two modes should not + disagree about the same argument on the same server. + """ + sql, params = _text_expr(_dialect((11, 5, 0)), "function", path="a").to_sql() + assert params == ("a",) + assert "#>> ARRAY" in sql + + +def test_a_rootless_chain_is_refused_rather_than_invented(): + with pytest.raises(UnsupportedFeatureError) as excinfo: + _text_expr(_dialect((11, 5, 0)), "function", path="a.b.c").to_sql() + assert "root $" in str(excinfo.value) + + +def test_no_path_at_all_is_refused_before_nine_three(): + """9.2 has a JSON type and no way to read a path out of it.""" + with pytest.raises(UnsupportedFeatureError) as excinfo: + _text_expr(_dialect((9, 2, 0)), "function").to_sql() + assert "9.3" in str(excinfo.value) + + +def test_arrow_mode_works_before_twelve(): + """Arrows are older than the path language, so PG 11 still has them.""" + sql, _ = _text_expr(_dialect((11, 5, 0)), "arrow").to_sql() + assert "->>" in sql + + +def test_arrow_probe_is_gated_on_nine_three(): + """``->`` and ``->>`` arrived in 9.3, and the probe now says so. + + It returned True unconditionally, so on 9.2 -- where JSON exists and the + operators do not -- it advertised a spelling the parser rejects. The class + docstring above has always said 9.3. + """ + assert _dialect((9, 3, 0)).supports_json_arrow_operators() is True + assert _dialect((9, 2, 4)).supports_json_arrow_operators() is False + + +def test_arrow_mode_is_refused_before_nine_three(): + """The arrow formatter consults the probe, so ARROW refuses on 9.2.""" + with pytest.raises(UnsupportedFeatureError): + _text_expr(_dialect((9, 2, 4)), "arrow").to_sql() + + +def test_arrow_probe_needs_a_version_to_answer(): + """An unadapted dialect raises from ``self.version``, like every probe. + + The version is what the question is about; a dialect that has not been + told one has no answer to give, and quietly answering True is how the + versionless probe above survived. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + DialectNotAdaptedException, + ) + + with pytest.raises(DialectNotAdaptedException): + PostgresDialect().supports_json_arrow_operators() + + +# --------------------------------------------------------------------------- +# The cast that makes the column's declared type stop mattering +# --------------------------------------------------------------------------- + + +def test_the_jsonpath_spelling_casts_the_column_to_jsonb(): + """``jsonb_path_query_first`` has overloads for ``jsonb`` only. + + A ``json`` or ``text`` column handed to it without a cast is an + ``UndefinedFunction`` on every server the function exists on, which is + why a live 12.22 refused the previous SQL. The cast is a no-op on a + ``jsonb`` column. + """ + sql, _ = _text_expr(_dialect((12, 0, 0)), "function", path="$.a").to_sql() + assert 'jsonb_path_query_first(("t"."data")::jsonb' in sql + + +def test_the_key_array_spelling_casts_the_column_to_jsonb(): + """``#>`` has no ``text`` overload on any version; the cast supplies one. + + The rendered shape is ``("t"."data")::jsonb #>> ARRAY[...]``, so the cast + belongs to the column and not to the array. + """ + sql, _ = _text_expr(_dialect((11, 5, 0)), "function", path="$.a").to_sql() + assert sql.startswith('("t"."data")::jsonb #>>') + assert "ARRAY" in sql + + +def test_the_key_array_spelling_casts_to_json_on_nine_three(): + """9.3 predates ``jsonb``, so the cast target is ``json`` there. + + The spelling reaches back to 9.3 and ``#`` works on ``json`` just as it + does on ``jsonb``; casting to a type the server does not have would turn + a working query into an error on the oldest server the probe claims. + """ + sql, _ = _text_expr(_dialect((9, 3, 0)), "function", path="$.a").to_sql() + assert sql.startswith('("t"."data")::json #>>') + assert "::jsonb" not in sql + + +# --------------------------------------------------------------------------- +# Probes that used to lie +# --------------------------------------------------------------------------- + + +def test_json_table_is_not_claimed(): + """PostgreSQL has no JSON_TABLE; the probe used to claim 12.0+.""" + assert _dialect((16, 2, 1)).supports_json_table() is False + + +def test_json_table_error_names_the_real_construct(): + dialect = _dialect() + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_json_table_expression(None) + assert "jsonb_to_recordset" in str(excinfo.value) + + +def test_jsonb_subscript_is_not_claimed(): + """There is no ``jsonb['key']`` in PostgreSQL.""" + assert _dialect((16, 2, 1)).supports_jsonb_subscript() is False + + +def test_probes_declared_twice_agree(): + """A probe may be declared in two mixins, but both must answer the same. + + ``supports_jsonb_subscript`` is declared by two protocols, so two mixins + define it and the MRO picks one arbitrarily. That is only safe because both + return the same thing; the previous version gates of 11.0 and 14.0 made the + winner an accident of class ordering. + """ + from rhosocial.activerecord.backend.impl.postgres.mixins.types.data_type import ( + PostgresDataTypeMixin, + ) + from rhosocial.activerecord.backend.impl.postgres.mixins.types.jsonb_enhanced import ( + PostgresJSONBEnhancedMixin, + ) + + dialect = _dialect() + answers = { + PostgresDataTypeMixin.supports_jsonb_subscript(dialect), + PostgresJSONBEnhancedMixin.supports_jsonb_subscript(dialect), + } + assert len(answers) == 1, f"probe answers disagree: {answers}" + + +def test_arrow_probe_matches_what_is_actually_emitted(): + """A dialect advertising arrows must emit them when ARROW is asked for. + + AUTO is deliberately not checked here: on this dialect it renders the path + form instead, which is why the check lives on the explicit mode. + """ + dialect = _dialect() + assert dialect.supports_json_arrow_operators() is True + assert "->>" in _text_expr(dialect, "arrow").to_sql()[0] + + +@pytest.mark.parametrize( + "function, introduced", + [ + ("json_extract_path", (9, 3, 0)), + ("jsonb_extract_path", (9, 4, 0)), + ("json_extract_path_text", (9, 5, 0)), + ("jsonb_path_query", (12, 0, 0)), + ("jsonb_path_query_first", (12, 0, 0)), + ("jsonb_path_query_array", (12, 0, 0)), + ], +) +def test_a_function_is_claimed_only_once_the_server_has_it(function, introduced): + """The probe answered from a flat name list, so it claimed 12.0+ functions + on 9.6. A caller asking whether ``jsonb_path_query_first`` exists got True + and then shipped SQL the server rejects.""" + assert _dialect(introduced).supports_json_function(function) is True + if introduced > (9, 3, 0): + just_below = (introduced[0], introduced[1] - 1, 0) + assert _dialect(just_below).supports_json_function(function) is False + + +def test_a_function_postgres_does_not_have_is_never_claimed(): + dialect = _dialect((19, 0, 0)) + for name in ("JSON_EXTRACT", "JSON_VALUE", "OPENJSON", "json_extract"): + assert dialect.supports_json_function(name) is False, name + + +def test_operators_are_not_answered_as_functions(): + """``#>`` is an operator. A probe that takes a function name saying yes to + one conflated the two, and it was the only reason ``#>>`` was in the list.""" + dialect = _dialect((19, 0, 0)) + assert dialect.supports_json_function("#>>") is False + + +@pytest.mark.parametrize( + "probe", + [ + "supports_json_type", + "supports_jsonb", + "supports_json_path", + "supports_sql_json_path", + "supports_json_arrow_operators", + "supports_json_table", + "format_json_expression", + ], +) +def test_a_json_probe_is_declared_once_per_layer(probe): + """One implementation, one core default, one Protocol declaration. + + ``supports_json_type`` resolved from four classes: this dialect's mixin, the + core ``JSONMixin`` default, the core ``JSONSupport`` protocol and *this + backend's* protocol, which restated a member the core protocol already + declared. The fourth was an abstract ``...``, not a competing answer -- but + it is a second declaration in one MRO, and PostgresJSONBEnhancedSupport now + derives from JSONSupport instead of repeating it. + """ + definitions = [ + klass.__name__ + for klass in PostgresDialect.__mro__ + if probe in vars(klass) + ] + assert len(definitions) <= 3, f"{probe} resolves from {len(definitions)}: {definitions}" + + +def test_the_enhanced_protocol_extends_the_core_one(): + """Derivation is what keeps the count at three, and it is also the shape + every other derived protocol here already had.""" + assert issubclass(PostgresJSONBEnhancedSupport, JSONSupport) + # One implementation of supports_json_type, and it is the one that wins. + assert ( + PostgresDialect.supports_json_type + is PostgresJSONBEnhancedMixin.supports_json_type + ) + assert PostgresDialect.__mro__.index(PostgresJSONBEnhancedMixin) < ( + PostgresDialect.__mro__.index(JSONMixin) + ) + + +# --------------------------------------------------------------------------- +# The cast, executed +# --------------------------------------------------------------------------- + + +@pytest.fixture(params=LIVE_SCENARIOS or [None]) +def live_json_scenario(request): + """One enabled scenario, or a skip when none is configured.""" + if request.param is None: + pytest.skip("no PostgreSQL scenario is enabled") + return request.param + + +def test_a_text_column_reads_through_both_spellings(live_json_scenario): + """The cast is not cosmetic: without it both spellings raise, live. + + 12.0 and later fail with ``function jsonb_path_query_first(text, + jsonpath) does not exist``; before 12.0 with ``operator does not exist: + text #>> text[]``. Rendering cannot catch either -- the SQL is + syntactically fine and the column is the argument that has no + overload -- so this runs the statement the test suite renders and reads + the value back. A ``text`` column is the strongest case: it is the one + no version had an operator for. + """ + backend_class, config = get_scenario(live_json_scenario) + backend = backend_class(connection_config=config) + backend.connect() + backend.introspect_and_adapt() + dialect = backend.dialect + if not dialect.supports_json_path(): + backend.disconnect() + pytest.skip(f"{live_json_scenario} has no JSON path operators") + + table = "json_path_probe" + backend.execute(f'DROP TABLE IF EXISTS "{table}"') + backend.execute(f'CREATE TABLE "{table}" (data TEXT)') + try: + backend.execute( + f'INSERT INTO "{table}" (data) VALUES (%s)', + ('{"language": "zh-CN", "tags": ["first", "second"]}',), + ) + # Rooted path, rootless single accessor, and a subscript: the second + # only has a meaning because the renderer roots it first. + for path, expected in ( + ("$.language", "zh-CN"), + ("language", "zh-CN"), + ("$.tags[0]", "first"), + ): + expr = JSONTextExpression( + dialect, + Column(dialect, "data", table=table), + path, + "->>", + alias="v", + ) + sql, params = expr.to_sql() + result = backend.execute( + f"SELECT {sql} FROM {dialect.format_identifier(table)}", + params, + options=DQL, + ) + assert result.data is not None and len(result.data) == 1 + assert result.data[0]["v"] == expected, ( + f"{live_json_scenario}: {path} rendered {sql!r}" + ) + finally: + try: + backend.execute(f'DROP TABLE IF EXISTS "{table}"') + finally: + backend.disconnect() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py index 8acd4887..c2543346 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_datetime_interval_expressions.py @@ -15,6 +15,7 @@ interval, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.expression.types import IntegerType class TestPostgresDateTimeIntervalExpressions: @@ -184,7 +185,7 @@ def test_alias_and_cast(self, postgres_dialect: PostgresDialect): "day", Column(postgres_dialect, "started_at"), Column(postgres_dialect, "ended_at"), - ).cast("INTEGER").as_("elapsed_days") + ).cast(IntegerType(postgres_dialect)).as_("elapsed_days") sql, params = expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py index ff4be722..376c0476 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py @@ -22,6 +22,7 @@ from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.functions.hstore import ( + hstore_literal, hstore_from_record, hstore_from_key_value, hstore_akeys, @@ -61,7 +62,7 @@ hstore_subscript_set, ) from rhosocial.activerecord.backend.impl.postgres.type_values.hstore import PostgresHstore -from rhosocial.activerecord.backend.expression import bases +from rhosocial.activerecord.backend.expression import bases, Literal from rhosocial.activerecord.backend.expression.core import FunctionCall from rhosocial.activerecord.backend.expression.operators import BinaryExpression @@ -71,14 +72,14 @@ class TestHstoreConstructors: def test_hstore_from_record(self, postgres_dialect: PostgresDialect): """Test hstore_from_record() returns FunctionCall.""" - result = hstore_from_record(postgres_dialect, "my_table") + result = hstore_from_record(postgres_dialect, Literal(postgres_dialect, "my_table")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE" in sql def test_hstore_from_key_value(self, postgres_dialect: PostgresDialect): """Test hstore_from_key_value() returns FunctionCall.""" - result = hstore_from_key_value(postgres_dialect, "name", "value") + result = hstore_from_key_value(postgres_dialect, Literal(postgres_dialect, "name"), Literal(postgres_dialect, "value")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE" in sql @@ -89,35 +90,35 @@ class TestHstoreKeyValueExtraction: def test_hstore_akeys(self, postgres_dialect: PostgresDialect): """Test hstore_akeys() returns FunctionCall.""" - result = hstore_akeys(postgres_dialect, "data") + result = hstore_akeys(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "AKEYS" in sql def test_hstore_skeys(self, postgres_dialect: PostgresDialect): """Test hstore_skeys() returns FunctionCall.""" - result = hstore_skeys(postgres_dialect, "data") + result = hstore_skeys(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "SKEYS" in sql def test_hstore_avals(self, postgres_dialect: PostgresDialect): """Test hstore_avals() returns FunctionCall.""" - result = hstore_avals(postgres_dialect, "data") + result = hstore_avals(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "AVALS" in sql def test_hstore_svals(self, postgres_dialect: PostgresDialect): """Test hstore_svals() returns FunctionCall.""" - result = hstore_svals(postgres_dialect, "data") + result = hstore_svals(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "SVALS" in sql def test_hstore_each(self, postgres_dialect: PostgresDialect): """Test hstore_each() returns FunctionCall.""" - result = hstore_each(postgres_dialect, "data") + result = hstore_each(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "EACH" in sql @@ -128,42 +129,42 @@ class TestHstoreConversion: def test_hstore_to_array(self, postgres_dialect: PostgresDialect): """Test hstore_to_array() returns FunctionCall.""" - result = hstore_to_array(postgres_dialect, "data") + result = hstore_to_array(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE_TO_ARRAY" in sql def test_hstore_to_matrix(self, postgres_dialect: PostgresDialect): """Test hstore_to_matrix() returns FunctionCall.""" - result = hstore_to_matrix(postgres_dialect, "data") + result = hstore_to_matrix(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE_TO_MATRIX" in sql def test_hstore_to_json(self, postgres_dialect: PostgresDialect): """Test hstore_to_json() returns FunctionCall.""" - result = hstore_to_json(postgres_dialect, "data") + result = hstore_to_json(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE_TO_JSON" in sql def test_hstore_to_jsonb(self, postgres_dialect: PostgresDialect): """Test hstore_to_jsonb() returns FunctionCall.""" - result = hstore_to_jsonb(postgres_dialect, "data") + result = hstore_to_jsonb(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE_TO_JSONB" in sql def test_hstore_to_json_loose(self, postgres_dialect: PostgresDialect): """Test hstore_to_json_loose() returns FunctionCall.""" - result = hstore_to_json_loose(postgres_dialect, "data") + result = hstore_to_json_loose(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE_TO_JSON_LOOSE" in sql def test_hstore_to_jsonb_loose(self, postgres_dialect: PostgresDialect): """Test hstore_to_jsonb_loose() returns FunctionCall.""" - result = hstore_to_jsonb_loose(postgres_dialect, "data") + result = hstore_to_jsonb_loose(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "HSTORE_TO_JSONB_LOOSE" in sql @@ -174,7 +175,7 @@ class TestHstoreSubset: def test_hstore_slice(self, postgres_dialect: PostgresDialect): """Test hstore_slice() returns FunctionCall.""" - result = hstore_slice(postgres_dialect, "data", "ARRAY['a', 'b']") + result = hstore_slice(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "ARRAY['a', 'b']")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "SLICE" in sql @@ -185,14 +186,14 @@ class TestHstoreExistence: def test_hstore_exist(self, postgres_dialect: PostgresDialect): """Test hstore_exist() returns FunctionCall.""" - result = hstore_exist(postgres_dialect, "data", "name") + result = hstore_exist(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "EXIST" in sql def test_hstore_defined(self, postgres_dialect: PostgresDialect): """Test hstore_defined() returns FunctionCall.""" - result = hstore_defined(postgres_dialect, "data", "name") + result = hstore_defined(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "DEFINED" in sql @@ -203,21 +204,21 @@ class TestHstoreDelete: def test_hstore_delete(self, postgres_dialect: PostgresDialect): """Test hstore_delete() returns FunctionCall.""" - result = hstore_delete(postgres_dialect, "data", "name") + result = hstore_delete(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "DELETE" in sql def test_hstore_delete_keys(self, postgres_dialect: PostgresDialect): """Test hstore_delete_keys() returns FunctionCall.""" - result = hstore_delete_keys(postgres_dialect, "data", "ARRAY['a', 'b']") + result = hstore_delete_keys(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "ARRAY['a', 'b']")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "DELETE" in sql def test_hstore_delete_pairs(self, postgres_dialect: PostgresDialect): """Test hstore_delete_pairs() returns FunctionCall.""" - result = hstore_delete_pairs(postgres_dialect, "data", "other") + result = hstore_delete_pairs(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "other")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "DELETE" in sql @@ -228,7 +229,7 @@ class TestHstoreRecord: def test_hstore_populate_record(self, postgres_dialect: PostgresDialect): """Test hstore_populate_record() returns FunctionCall.""" - result = hstore_populate_record(postgres_dialect, "my_record", "data") + result = hstore_populate_record(postgres_dialect, Literal(postgres_dialect, "my_record"), Literal(postgres_dialect, "data")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "POPULATE_RECORD" in sql @@ -239,63 +240,63 @@ class TestHstoreOperators: def test_hstore_get_value(self, postgres_dialect: PostgresDialect): """Test hstore_get_value() returns BinaryExpression with -> operator.""" - result = hstore_get_value(postgres_dialect, "data", "name") + result = hstore_get_value(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "->" in sql def test_hstore_get_value_as_text(self, postgres_dialect: PostgresDialect): """Test hstore_get_value_as_text() returns BinaryExpression with ->> operator.""" - result = hstore_get_value_as_text(postgres_dialect, "data", "name") + result = hstore_get_value_as_text(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "->>" in sql def test_hstore_get_values(self, postgres_dialect: PostgresDialect): """Test hstore_get_values() returns BinaryExpression with -> operator.""" - result = hstore_get_values(postgres_dialect, "data", "ARRAY['a', 'b']") + result = hstore_get_values(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "ARRAY['a', 'b']")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "->" in sql def test_hstore_concat(self, postgres_dialect: PostgresDialect): """Test hstore_concat() returns BinaryExpression with || operator.""" - result = hstore_concat(postgres_dialect, "data", "other") + result = hstore_concat(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "other")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "||" in sql def test_hstore_key_exists(self, postgres_dialect: PostgresDialect): """Test hstore_key_exists() returns BinaryExpression with ? operator.""" - result = hstore_key_exists(postgres_dialect, "data", "name") + result = hstore_key_exists(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "?" in sql def test_hstore_all_keys_exist(self, postgres_dialect: PostgresDialect): """Test hstore_all_keys_exist() returns BinaryExpression with ?& operator.""" - result = hstore_all_keys_exist(postgres_dialect, "data", "ARRAY['a', 'b']") + result = hstore_all_keys_exist(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "ARRAY['a', 'b']")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "?&" in sql def test_hstore_any_key_exists(self, postgres_dialect: PostgresDialect): """Test hstore_any_key_exists() returns BinaryExpression with ?| operator.""" - result = hstore_any_key_exists(postgres_dialect, "data", "ARRAY['a', 'b']") + result = hstore_any_key_exists(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "ARRAY['a', 'b']")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "?|" in sql def test_hstore_contains(self, postgres_dialect: PostgresDialect): """Test hstore_contains() returns BinaryExpression with @> operator.""" - result = hstore_contains(postgres_dialect, "data", "other") + result = hstore_contains(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "other")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "@>" in sql def test_hstore_contained_by(self, postgres_dialect: PostgresDialect): """Test hstore_contained_by() returns BinaryExpression with <@ operator.""" - result = hstore_contained_by(postgres_dialect, "data", "other") + result = hstore_contained_by(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "other")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "<@" in sql @@ -306,21 +307,21 @@ class TestHstoreSubtraction: def test_hstore_subtract_key(self, postgres_dialect: PostgresDialect): """Test hstore_subtract_key() returns BinaryExpression with - operator.""" - result = hstore_subtract_key(postgres_dialect, "data", "name") + result = hstore_subtract_key(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "-" in sql def test_hstore_subtract_keys(self, postgres_dialect: PostgresDialect): """Test hstore_subtract_keys() returns BinaryExpression with - operator.""" - result = hstore_subtract_keys(postgres_dialect, "data", "ARRAY['a', 'b']") + result = hstore_subtract_keys(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "ARRAY['a', 'b']")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "-" in sql def test_hstore_subtract_pairs(self, postgres_dialect: PostgresDialect): """Test hstore_subtract_pairs() returns BinaryExpression with - operator.""" - result = hstore_subtract_pairs(postgres_dialect, "data", "other") + result = hstore_subtract_pairs(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "other")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "-" in sql @@ -331,17 +332,17 @@ class TestHstoreSpecialOperators: def test_hstore_to_array_operator(self, postgres_dialect: PostgresDialect): """Test hstore_to_array_operator() returns BinaryExpression.""" - result = hstore_to_array_operator(postgres_dialect, "data") + result = hstore_to_array_operator(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, BinaryExpression) def test_hstore_to_matrix_operator(self, postgres_dialect: PostgresDialect): """Test hstore_to_matrix_operator() returns BinaryExpression.""" - result = hstore_to_matrix_operator(postgres_dialect, "data") + result = hstore_to_matrix_operator(postgres_dialect, Literal(postgres_dialect, "data")) assert isinstance(result, BinaryExpression) def test_hstore_record_update(self, postgres_dialect: PostgresDialect): """Test hstore_record_update() returns BinaryExpression with #= operator.""" - result = hstore_record_update(postgres_dialect, "my_record", "data") + result = hstore_record_update(postgres_dialect, Literal(postgres_dialect, "my_record"), Literal(postgres_dialect, "data")) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "#=" in sql @@ -352,12 +353,12 @@ class TestHstoreSubscript: def test_hstore_subscript_get(self, postgres_dialect: PostgresDialect): """Test hstore_subscript_get() returns expression.""" - result = hstore_subscript_get(postgres_dialect, "data", "name") + result = hstore_subscript_get(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name")) assert isinstance(result, (BinaryExpression, FunctionCall)) def test_hstore_subscript_set(self, postgres_dialect: PostgresDialect): """Test hstore_subscript_set() returns expression.""" - result = hstore_subscript_set(postgres_dialect, "data", "name", "value") + result = hstore_subscript_set(postgres_dialect, Literal(postgres_dialect, "data"), Literal(postgres_dialect, "name"), Literal(postgres_dialect, "value")) assert isinstance(result, (BinaryExpression, FunctionCall)) @@ -367,14 +368,16 @@ class TestHstoreWithPostgresHstoreInput: def test_akeys_with_hstore(self, postgres_dialect: PostgresDialect): """Test hstore_akeys() with PostgresHstore input.""" h = PostgresHstore(data={"a": "1", "b": "2"}) - result = hstore_akeys(postgres_dialect, h) + result = hstore_akeys(postgres_dialect, hstore_literal(postgres_dialect, h)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "AKEYS" in sql def test_contains_with_dict(self, postgres_dialect: PostgresDialect): """Test hstore_contains() with dict input.""" - result = hstore_contains(postgres_dialect, "data", {"color": "red"}) + result = hstore_contains(postgres_dialect, + Literal(postgres_dialect, "data"), + hstore_literal(postgres_dialect, {"color": "red"})) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "@>" in sql @@ -383,7 +386,9 @@ def test_concat_with_hstore(self, postgres_dialect: PostgresDialect): """Test hstore_concat() with PostgresHstore inputs.""" left = PostgresHstore(data={"a": "1"}) right = PostgresHstore(data={"b": "2"}) - result = hstore_concat(postgres_dialect, left, right) + result = hstore_concat(postgres_dialect, + hstore_literal(postgres_dialect, left), + hstore_literal(postgres_dialect, right)) assert isinstance(result, BinaryExpression) sql, params = result.to_sql() assert "||" in sql @@ -394,40 +399,40 @@ class TestHstoreAllFunctionsReturnExpressionObjects: def test_constructors_return_expressions(self, postgres_dialect: PostgresDialect): """Test constructors return BaseExpression.""" - assert isinstance(hstore_from_record(postgres_dialect, "t"), bases.BaseExpression) - assert isinstance(hstore_from_key_value(postgres_dialect, "k", "v"), bases.BaseExpression) + assert isinstance(hstore_from_record(postgres_dialect, Literal(postgres_dialect, "t")), bases.BaseExpression) + assert isinstance(hstore_from_key_value(postgres_dialect, Literal(postgres_dialect, "k"), Literal(postgres_dialect, "v")), bases.BaseExpression) def test_extraction_functions_return_expressions(self, postgres_dialect: PostgresDialect): """Test extraction functions return BaseExpression.""" - assert isinstance(hstore_akeys(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_skeys(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_avals(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_svals(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_each(postgres_dialect, "d"), bases.BaseExpression) + assert isinstance(hstore_akeys(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_skeys(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_avals(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_svals(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_each(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) def test_conversion_functions_return_expressions(self, postgres_dialect: PostgresDialect): """Test conversion functions return BaseExpression.""" - assert isinstance(hstore_to_array(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_to_matrix(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_to_json(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_to_jsonb(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_to_json_loose(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_to_jsonb_loose(postgres_dialect, "d"), bases.BaseExpression) + assert isinstance(hstore_to_array(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_to_matrix(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_to_json(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_to_jsonb(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_to_json_loose(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_to_jsonb_loose(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) def test_operator_functions_return_expressions(self, postgres_dialect: PostgresDialect): """Test operator functions return BaseExpression.""" - assert isinstance(hstore_get_value(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_get_value_as_text(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_get_values(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_concat(postgres_dialect, "d", "o"), bases.BaseExpression) - assert isinstance(hstore_key_exists(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_all_keys_exist(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_any_key_exists(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_contains(postgres_dialect, "d", "o"), bases.BaseExpression) - assert isinstance(hstore_contained_by(postgres_dialect, "d", "o"), bases.BaseExpression) - assert isinstance(hstore_subtract_key(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_subtract_keys(postgres_dialect, "d", "k"), bases.BaseExpression) - assert isinstance(hstore_subtract_pairs(postgres_dialect, "d", "o"), bases.BaseExpression) - assert isinstance(hstore_to_array_operator(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_to_matrix_operator(postgres_dialect, "d"), bases.BaseExpression) - assert isinstance(hstore_record_update(postgres_dialect, "r", "d"), bases.BaseExpression) + assert isinstance(hstore_get_value(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_get_value_as_text(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_get_values(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_concat(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "o")), bases.BaseExpression) + assert isinstance(hstore_key_exists(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_all_keys_exist(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_any_key_exists(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_contains(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "o")), bases.BaseExpression) + assert isinstance(hstore_contained_by(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "o")), bases.BaseExpression) + assert isinstance(hstore_subtract_key(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_subtract_keys(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "k")), bases.BaseExpression) + assert isinstance(hstore_subtract_pairs(postgres_dialect, Literal(postgres_dialect, "d"), Literal(postgres_dialect, "o")), bases.BaseExpression) + assert isinstance(hstore_to_array_operator(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_to_matrix_operator(postgres_dialect, Literal(postgres_dialect, "d")), bases.BaseExpression) + assert isinstance(hstore_record_update(postgres_dialect, Literal(postgres_dialect, "r"), Literal(postgres_dialect, "d")), bases.BaseExpression) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py index 62df9656..2ac85680 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py @@ -4,7 +4,7 @@ These include additional mathematical functions beyond the basic math module. """ -from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.functions.math_enhanced import ( round_, @@ -33,19 +33,19 @@ def test_round__default(self, postgres_dialect: PostgresDialect): def test_round__with_precision(self, postgres_dialect: PostgresDialect): """Test round_() with precision.""" - result = round_(postgres_dialect, Column(postgres_dialect, "price"), 2) + result = round_(postgres_dialect, Column(postgres_dialect, "price"), Literal(postgres_dialect, 2)) sql, _ = result.to_sql() assert "ROUND(" in sql def test_round__with_literal(self, postgres_dialect: PostgresDialect): """Test round_() with literal value.""" - result = round_(postgres_dialect, 3.14159, 2) + result = round_(postgres_dialect, Literal(postgres_dialect, 3.14159), Literal(postgres_dialect, 2)) sql, _ = result.to_sql() assert "ROUND(" in sql def test_pow(self, postgres_dialect: PostgresDialect): """Test pow() function.""" - result = pow(postgres_dialect, Column(postgres_dialect, "base"), 2) + result = pow(postgres_dialect, Column(postgres_dialect, "base"), Literal(postgres_dialect, 2)) sql, _ = result.to_sql() assert "POW(" in sql @@ -61,7 +61,7 @@ def test_pow_both_columns(self, postgres_dialect: PostgresDialect): def test_power(self, postgres_dialect: PostgresDialect): """Test power() function (alias for POW).""" - result = power(postgres_dialect, 2, 3) + result = power(postgres_dialect, Literal(postgres_dialect, 2), Literal(postgres_dialect, 3)) sql, _ = result.to_sql() assert "POWER(" in sql @@ -74,13 +74,13 @@ def test_sqrt(self, postgres_dialect: PostgresDialect): def test_sqrt_with_literal(self, postgres_dialect: PostgresDialect): """Test sqrt() with literal value.""" - result = sqrt(postgres_dialect, 16) + result = sqrt(postgres_dialect, Literal(postgres_dialect, 16)) sql, _ = result.to_sql() assert "SQRT(" in sql def test_mod(self, postgres_dialect: PostgresDialect): """Test mod() function.""" - result = mod(postgres_dialect, Column(postgres_dialect, "total"), 10) + result = mod(postgres_dialect, Column(postgres_dialect, "total"), Literal(postgres_dialect, 10)) sql, _ = result.to_sql() assert "MOD(" in sql @@ -103,7 +103,7 @@ def test_ceil(self, postgres_dialect: PostgresDialect): def test_ceil_with_literal(self, postgres_dialect: PostgresDialect): """Test ceil() with literal value.""" - result = ceil(postgres_dialect, 3.14) + result = ceil(postgres_dialect, Literal(postgres_dialect, 3.14)) sql, _ = result.to_sql() assert "CEIL(" in sql @@ -116,7 +116,7 @@ def test_floor(self, postgres_dialect: PostgresDialect): def test_floor_with_literal(self, postgres_dialect: PostgresDialect): """Test floor() with literal value.""" - result = floor(postgres_dialect, 3.14) + result = floor(postgres_dialect, Literal(postgres_dialect, 3.14)) sql, _ = result.to_sql() assert "FLOOR(" in sql @@ -129,13 +129,13 @@ def test_trunc(self, postgres_dialect: PostgresDialect): def test_trunc_with_literal(self, postgres_dialect: PostgresDialect): """Test trunc() with literal value.""" - result = trunc(postgres_dialect, 3.14) + result = trunc(postgres_dialect, Literal(postgres_dialect, 3.14)) sql, _ = result.to_sql() assert "TRUNC(" in sql def test_trunc_with_precision(self, postgres_dialect: PostgresDialect): """Test trunc() with precision.""" - result = trunc(postgres_dialect, 3.14159, 2) + result = trunc(postgres_dialect, Literal(postgres_dialect, 3.14159), Literal(postgres_dialect, 2)) sql, _ = result.to_sql() assert "TRUNC(" in sql @@ -158,7 +158,7 @@ def test_max__multiple_args(self, postgres_dialect: PostgresDialect): def test_max__with_literals(self, postgres_dialect: PostgresDialect): """Test max_() with literal values (uses GREATEST).""" - result = max_(postgres_dialect, 1, 2, 3) + result = max_(postgres_dialect, Literal(postgres_dialect, 1), Literal(postgres_dialect, 2), Literal(postgres_dialect, 3)) sql, _ = result.to_sql() assert "GREATEST(" in sql @@ -181,7 +181,7 @@ def test_min__multiple_args(self, postgres_dialect: PostgresDialect): def test_min__with_literals(self, postgres_dialect: PostgresDialect): """Test min_() with literal values (uses LEAST).""" - result = min_(postgres_dialect, 1, 2, 3) + result = min_(postgres_dialect, Literal(postgres_dialect, 1), Literal(postgres_dialect, 2), Literal(postgres_dialect, 3)) sql, _ = result.to_sql() assert "LEAST(" in sql @@ -194,65 +194,78 @@ def test_avg(self, postgres_dialect: PostgresDialect): def test_avg_with_literal(self, postgres_dialect: PostgresDialect): """Test avg() with literal value.""" - result = avg(postgres_dialect, 100) + result = avg(postgres_dialect, Literal(postgres_dialect, 100)) sql, _ = result.to_sql() assert "AVG(" in sql def test_round__with_string_integer(self, postgres_dialect: PostgresDialect): """Test round_() with string integer value.""" - result = round_(postgres_dialect, "123", 2) + result = round_(postgres_dialect, Literal(postgres_dialect, "123"), Literal(postgres_dialect, 2)) sql, _ = result.to_sql() assert "ROUND(" in sql def test_round__with_string_float(self, postgres_dialect: PostgresDialect): """Test round_() with string float value.""" - result = round_(postgres_dialect, "3.14159", 2) + result = round_(postgres_dialect, Literal(postgres_dialect, "3.14159"), Literal(postgres_dialect, 2)) sql, _ = result.to_sql() assert "ROUND(" in sql - def test_round__with_string_column_name(self, postgres_dialect: PostgresDialect): - """Test round_() with non-numeric string treated as column.""" - result = round_(postgres_dialect, "column_name", 2) + def test_round__of_a_column(self, postgres_dialect: PostgresDialect): + """A column is named by the caller, not inferred from a string.""" + result = round_(postgres_dialect, Column(postgres_dialect, "column_name"), 2) sql, _ = result.to_sql() assert "ROUND(" in sql assert '"column_name"' in sql + def test_round__of_string_text_is_data(self, postgres_dialect: PostgresDialect): + """A string is a value. It used to be parsed as a number when it looked + like one and read as a column name when it did not, so the same call + meant two different things depending on the argument's spelling.""" + result = round_(postgres_dialect, Literal(postgres_dialect, "3.5"), 2) + sql, params = result.to_sql() + assert "ROUND(" in sql + assert params == ("3.5", 2) + def test_pow_with_string_integer(self, postgres_dialect: PostgresDialect): """Test pow() with string integer exponent.""" - result = pow(postgres_dialect, Column(postgres_dialect, "base"), "2") + result = pow(postgres_dialect, Column(postgres_dialect, "base"), Literal(postgres_dialect, "2")) sql, _ = result.to_sql() assert "POW(" in sql def test_sqrt_with_string_integer(self, postgres_dialect: PostgresDialect): """Test sqrt() with string integer value.""" - result = sqrt(postgres_dialect, "16") + result = sqrt(postgres_dialect, Literal(postgres_dialect, "16")) sql, _ = result.to_sql() assert "SQRT(" in sql def test_mod_with_string_divisor(self, postgres_dialect: PostgresDialect): """Test mod() with string divisor.""" - result = mod(postgres_dialect, Column(postgres_dialect, "total"), "10") + result = mod(postgres_dialect, Column(postgres_dialect, "total"), Literal(postgres_dialect, "10")) sql, _ = result.to_sql() assert "MOD(" in sql - def test_max__with_string_literals(self, postgres_dialect: PostgresDialect): - """Test max_() with non-numeric string values (treated as columns in GREATEST).""" - result = max_(postgres_dialect, "a", "b", "c") - sql, _ = result.to_sql() + def test_max__with_string_values(self, postgres_dialect: PostgresDialect): + """String arguments are values, so they are bound rather than quoted + as identifiers.""" + result = max_(postgres_dialect, + Literal(postgres_dialect, "a"), + Literal(postgres_dialect, "b")) + sql, params = result.to_sql() assert "GREATEST(" in sql - # Non-numeric strings should be treated as column names and quoted - assert '"a"' in sql - - def test_min__with_string_literals(self, postgres_dialect: PostgresDialect): - """Test min_() with non-numeric string values (treated as columns in LEAST).""" - result = min_(postgres_dialect, "a", "b", "c") - sql, _ = result.to_sql() + assert params == ("a", "b") + + def test_min__with_string_values(self, postgres_dialect: PostgresDialect): + """String arguments are values, so they are bound rather than quoted + as identifiers.""" + result = min_(postgres_dialect, + Literal(postgres_dialect, "a"), + Literal(postgres_dialect, "b")) + sql, params = result.to_sql() assert "LEAST(" in sql - # Non-numeric strings should be treated as column names and quoted - assert '"a"' in sql + assert params == ("a", "b") def test_avg_with_string_literal(self, postgres_dialect: PostgresDialect): """Test avg() with string numeric value.""" - result = avg(postgres_dialect, "100") + result = avg(postgres_dialect, Literal(postgres_dialect, "100")) sql, _ = result.to_sql() assert "AVG(" in sql \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py index d45cc312..b3d8cd0b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_pgvector_functions.py @@ -52,7 +52,7 @@ def test_vector_l2_distance_with_postgresvector(self, postgres_dialect: Postgres assert isinstance(result, BinaryArithmeticExpression) sql, params = result.to_sql() assert "<->" in sql - assert "vector(3)" in sql + assert "VECTOR(3)" in sql def test_vector_cosine_distance_with_list(self, postgres_dialect: PostgresDialect): """Test cosine distance with List[float] input.""" @@ -60,7 +60,7 @@ def test_vector_cosine_distance_with_list(self, postgres_dialect: PostgresDialec assert isinstance(result, BinaryArithmeticExpression) sql, params = result.to_sql() assert "<=>" in sql - assert "vector(3)" in sql + assert "VECTOR(3)" in sql def test_vector_inner_product_with_list(self, postgres_dialect: PostgresDialect): """Test inner product with List[float] input.""" @@ -68,7 +68,7 @@ def test_vector_inner_product_with_list(self, postgres_dialect: PostgresDialect) assert isinstance(result, BinaryArithmeticExpression) sql, params = result.to_sql() assert "<#>" in sql - assert "vector(3)" in sql + assert "VECTOR(3)" in sql def test_vector_cosine_similarity(self, postgres_dialect: PostgresDialect): """Test cosine similarity expression (1 - cosine_distance).""" @@ -87,13 +87,13 @@ def test_vector_literal_with_values(self, postgres_dialect: PostgresDialect): result = vector_literal(postgres_dialect, [1.0, 2.0, 3.0]) assert isinstance(result, bases.BaseExpression) sql, params = result.to_sql() - assert "vector(3)" in sql + assert "VECTOR(3)" in sql def test_vector_literal_with_dimensions(self, postgres_dialect: PostgresDialect): """Test constructing vector literal with explicit dimensions.""" result = vector_literal(postgres_dialect, [1.0, 2.0, 3.0], dimensions=3) sql, params = result.to_sql() - assert "vector(3)" in sql + assert "VECTOR(3)" in sql def test_vector_literal_returns_expression(self, postgres_dialect: PostgresDialect): """Test that vector_literal returns a BaseExpression.""" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py index 6d42347d..252210ed 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_postgis_functions.py @@ -13,6 +13,7 @@ from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.functions.postgis import ( + geometry_expr, st_make_point, st_geom_from_text, st_geog_from_text, @@ -35,7 +36,7 @@ st_centroid, ) from rhosocial.activerecord.backend.impl.postgres.type_values.postgis import PostgresGeometry -from rhosocial.activerecord.backend.expression import bases +from rhosocial.activerecord.backend.expression import bases, Literal from rhosocial.activerecord.backend.expression.core import FunctionCall @@ -44,7 +45,7 @@ class TestPostgisConstructionFunctions: def test_st_make_point_2d(self, postgres_dialect: PostgresDialect): """Test ST_MakePoint with 2D coordinates.""" - result = st_make_point(postgres_dialect, 1.0, 2.0) + result = st_make_point(postgres_dialect, Literal(postgres_dialect, 1.0), Literal(postgres_dialect, 2.0)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_MAKEPOINT" in sql @@ -52,7 +53,7 @@ def test_st_make_point_2d(self, postgres_dialect: PostgresDialect): def test_st_make_point_3d(self, postgres_dialect: PostgresDialect): """Test ST_MakePoint with 3D coordinates.""" - result = st_make_point(postgres_dialect, 1.0, 2.0, 3.0) + result = st_make_point(postgres_dialect, Literal(postgres_dialect, 1.0), Literal(postgres_dialect, 2.0), Literal(postgres_dialect, 3.0)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_MAKEPOINT" in sql @@ -60,14 +61,14 @@ def test_st_make_point_3d(self, postgres_dialect: PostgresDialect): def test_st_geom_from_text_no_srid(self, postgres_dialect: PostgresDialect): """Test ST_GeomFromText without SRID.""" - result = st_geom_from_text(postgres_dialect, "POINT(1 2)") + result = st_geom_from_text(postgres_dialect, Literal(postgres_dialect, "POINT(1 2)")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_GEOMFROMTEXT" in sql def test_st_geom_from_text_with_srid(self, postgres_dialect: PostgresDialect): """Test ST_GeomFromText with SRID.""" - result = st_geom_from_text(postgres_dialect, "POINT(1 2)", 4326) + result = st_geom_from_text(postgres_dialect, Literal(postgres_dialect, "POINT(1 2)"), Literal(postgres_dialect, 4326)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_GEOMFROMTEXT" in sql @@ -75,15 +76,15 @@ def test_st_geom_from_text_with_srid(self, postgres_dialect: PostgresDialect): def test_st_geog_from_text(self, postgres_dialect: PostgresDialect): """Test ST_GeogFromText.""" - result = st_geog_from_text(postgres_dialect, "POINT(1 2)") + result = st_geog_from_text(postgres_dialect, Literal(postgres_dialect, "POINT(1 2)")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_GEOGFROMTEXT" in sql def test_st_set_srid(self, postgres_dialect: PostgresDialect): """Test ST_SetSRID with nested ST_MakePoint.""" - point = st_make_point(postgres_dialect, 1.0, 2.0) - result = st_set_srid(postgres_dialect, point, 4326) + point = st_make_point(postgres_dialect, Literal(postgres_dialect, 1.0), Literal(postgres_dialect, 2.0)) + result = st_set_srid(postgres_dialect, point, Literal(postgres_dialect, 4326)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_SETSRID" in sql @@ -92,7 +93,7 @@ def test_st_set_srid(self, postgres_dialect: PostgresDialect): def test_st_transform(self, postgres_dialect: PostgresDialect): """Test ST_Transform.""" - result = st_transform(postgres_dialect, "geom", 3857) + result = st_transform(postgres_dialect, Literal(postgres_dialect, "geom"), Literal(postgres_dialect, 3857)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_TRANSFORM" in sql @@ -101,7 +102,9 @@ def test_st_transform(self, postgres_dialect: PostgresDialect): def test_st_make_point_with_postgresgeometry(self, postgres_dialect: PostgresDialect): """Test using PostgresGeometry object as input to predicate.""" g = PostgresGeometry.point(1.0, 2.0, srid=4326) - result = st_contains(postgres_dialect, "boundary", g) + result = st_contains(postgres_dialect, + Literal(postgres_dialect, "boundary"), + geometry_expr(postgres_dialect, g)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_CONTAINS" in sql @@ -113,54 +116,54 @@ class TestPostgisPredicateFunctions: def test_st_contains(self, postgres_dialect: PostgresDialect): """Test ST_Contains.""" - result = st_contains(postgres_dialect, "boundary", "point") + result = st_contains(postgres_dialect, Literal(postgres_dialect, "boundary"), Literal(postgres_dialect, "point")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_CONTAINS" in sql def test_st_intersects(self, postgres_dialect: PostgresDialect): """Test ST_Intersects.""" - result = st_intersects(postgres_dialect, "geom1", "geom2") + result = st_intersects(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_INTERSECTS" in sql def test_st_within(self, postgres_dialect: PostgresDialect): """Test ST_Within.""" - result = st_within(postgres_dialect, "inner", "outer") + result = st_within(postgres_dialect, Literal(postgres_dialect, "inner"), Literal(postgres_dialect, "outer")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_WITHIN" in sql def test_st_dwithin(self, postgres_dialect: PostgresDialect): """Test ST_DWithin without spheroid.""" - result = st_dwithin(postgres_dialect, "geom1", "geom2", 1000) + result = st_dwithin(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2"), Literal(postgres_dialect, 1000)) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_DWITHIN" in sql def test_st_dwithin_spheroid(self, postgres_dialect: PostgresDialect): """Test ST_DWithin with spheroid.""" - result = st_dwithin(postgres_dialect, "geom1", "geom2", 1000, use_spheroid=True) + result = st_dwithin(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2"), Literal(postgres_dialect, 1000), use_spheroid=True) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_DWITHIN" in sql def test_st_crosses(self, postgres_dialect: PostgresDialect): """Test ST_Crosses.""" - result = st_crosses(postgres_dialect, "geom1", "geom2") + result = st_crosses(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2")) assert isinstance(result, FunctionCall) assert "ST_CROSSES" in result.to_sql()[0] def test_st_touches(self, postgres_dialect: PostgresDialect): """Test ST_Touches.""" - result = st_touches(postgres_dialect, "geom1", "geom2") + result = st_touches(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2")) assert isinstance(result, FunctionCall) assert "ST_TOUCHES" in result.to_sql()[0] def test_st_overlaps(self, postgres_dialect: PostgresDialect): """Test ST_Overlaps.""" - result = st_overlaps(postgres_dialect, "geom1", "geom2") + result = st_overlaps(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2")) assert isinstance(result, FunctionCall) assert "ST_OVERLAPS" in result.to_sql()[0] @@ -170,25 +173,25 @@ class TestPostgisMeasurementFunctions: def test_st_distance(self, postgres_dialect: PostgresDialect): """Test ST_Distance.""" - result = st_distance(postgres_dialect, "geom1", "geom2") + result = st_distance(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2")) assert isinstance(result, FunctionCall) sql, params = result.to_sql() assert "ST_DISTANCE" in sql def test_st_distance_spheroid(self, postgres_dialect: PostgresDialect): """Test ST_Distance with spheroid.""" - result = st_distance(postgres_dialect, "geom1", "geom2", use_spheroid=True) + result = st_distance(postgres_dialect, Literal(postgres_dialect, "geom1"), Literal(postgres_dialect, "geom2"), use_spheroid=True) assert isinstance(result, FunctionCall) def test_st_area(self, postgres_dialect: PostgresDialect): """Test ST_Area.""" - result = st_area(postgres_dialect, "geom") + result = st_area(postgres_dialect, Literal(postgres_dialect, "geom")) assert isinstance(result, FunctionCall) assert "ST_AREA" in result.to_sql()[0] def test_st_length(self, postgres_dialect: PostgresDialect): """Test ST_Length.""" - result = st_length(postgres_dialect, "geom") + result = st_length(postgres_dialect, Literal(postgres_dialect, "geom")) assert isinstance(result, FunctionCall) assert "ST_LENGTH" in result.to_sql()[0] @@ -198,13 +201,13 @@ class TestPostgisOutputFunctions: def test_st_as_geojson(self, postgres_dialect: PostgresDialect): """Test ST_AsGeoJSON.""" - result = st_as_geojson(postgres_dialect, "geom") + result = st_as_geojson(postgres_dialect, Literal(postgres_dialect, "geom")) assert isinstance(result, FunctionCall) assert "ST_ASGEOJSON" in result.to_sql()[0] def test_st_as_text(self, postgres_dialect: PostgresDialect): """Test ST_AsText.""" - result = st_as_text(postgres_dialect, "geom") + result = st_as_text(postgres_dialect, Literal(postgres_dialect, "geom")) assert isinstance(result, FunctionCall) assert "ST_ASTEXT" in result.to_sql()[0] @@ -214,19 +217,19 @@ class TestPostgisOperationFunctions: def test_st_buffer(self, postgres_dialect: PostgresDialect): """Test ST_Buffer.""" - result = st_buffer(postgres_dialect, "geom", 100) + result = st_buffer(postgres_dialect, Literal(postgres_dialect, "geom"), Literal(postgres_dialect, 100)) assert isinstance(result, FunctionCall) assert "ST_BUFFER" in result.to_sql()[0] def test_st_envelope(self, postgres_dialect: PostgresDialect): """Test ST_Envelope.""" - result = st_envelope(postgres_dialect, "geom") + result = st_envelope(postgres_dialect, Literal(postgres_dialect, "geom")) assert isinstance(result, FunctionCall) assert "ST_ENVELOPE" in result.to_sql()[0] def test_st_centroid(self, postgres_dialect: PostgresDialect): """Test ST_Centroid.""" - result = st_centroid(postgres_dialect, "geom") + result = st_centroid(postgres_dialect, Literal(postgres_dialect, "geom")) assert isinstance(result, FunctionCall) assert "ST_CENTROID" in result.to_sql()[0] @@ -236,28 +239,28 @@ class TestPostgisNestedExpressions: def test_nested_st_set_srid_st_make_point(self, postgres_dialect: PostgresDialect): """Test ST_SetSRID(ST_MakePoint(x, y), srid) pattern.""" - point = st_make_point(postgres_dialect, 116.4, 39.9) - result = st_set_srid(postgres_dialect, point, 4326) + point = st_make_point(postgres_dialect, Literal(postgres_dialect, 116.4), Literal(postgres_dialect, 39.9)) + result = st_set_srid(postgres_dialect, point, Literal(postgres_dialect, 4326)) sql, params = result.to_sql() assert "ST_SETSRID" in sql assert "ST_MAKEPOINT" in sql def test_all_functions_return_expression_objects(self, postgres_dialect: PostgresDialect): """Test that all PostGIS functions return Expression objects (not strings).""" - assert isinstance(st_make_point(postgres_dialect, 1, 2), bases.BaseExpression) - assert isinstance(st_geom_from_text(postgres_dialect, "POINT(1 2)"), bases.BaseExpression) - assert isinstance(st_geog_from_text(postgres_dialect, "POINT(1 2)"), bases.BaseExpression) - assert isinstance(st_set_srid(postgres_dialect, "geom", 4326), bases.BaseExpression) - assert isinstance(st_transform(postgres_dialect, "geom", 3857), bases.BaseExpression) - assert isinstance(st_contains(postgres_dialect, "a", "b"), bases.BaseExpression) - assert isinstance(st_intersects(postgres_dialect, "a", "b"), bases.BaseExpression) - assert isinstance(st_within(postgres_dialect, "a", "b"), bases.BaseExpression) - assert isinstance(st_dwithin(postgres_dialect, "a", "b", 100), bases.BaseExpression) - assert isinstance(st_distance(postgres_dialect, "a", "b"), bases.BaseExpression) - assert isinstance(st_area(postgres_dialect, "geom"), bases.BaseExpression) - assert isinstance(st_length(postgres_dialect, "geom"), bases.BaseExpression) - assert isinstance(st_as_geojson(postgres_dialect, "geom"), bases.BaseExpression) - assert isinstance(st_as_text(postgres_dialect, "geom"), bases.BaseExpression) - assert isinstance(st_buffer(postgres_dialect, "geom", 100), bases.BaseExpression) - assert isinstance(st_envelope(postgres_dialect, "geom"), bases.BaseExpression) - assert isinstance(st_centroid(postgres_dialect, "geom"), bases.BaseExpression) + assert isinstance(st_make_point(postgres_dialect, Literal(postgres_dialect, 1), Literal(postgres_dialect, 2)), bases.BaseExpression) + assert isinstance(st_geom_from_text(postgres_dialect, Literal(postgres_dialect, "POINT(1 2)")), bases.BaseExpression) + assert isinstance(st_geog_from_text(postgres_dialect, Literal(postgres_dialect, "POINT(1 2)")), bases.BaseExpression) + assert isinstance(st_set_srid(postgres_dialect, Literal(postgres_dialect, "geom"), Literal(postgres_dialect, 4326)), bases.BaseExpression) + assert isinstance(st_transform(postgres_dialect, Literal(postgres_dialect, "geom"), Literal(postgres_dialect, 3857)), bases.BaseExpression) + assert isinstance(st_contains(postgres_dialect, Literal(postgres_dialect, "a"), Literal(postgres_dialect, "b")), bases.BaseExpression) + assert isinstance(st_intersects(postgres_dialect, Literal(postgres_dialect, "a"), Literal(postgres_dialect, "b")), bases.BaseExpression) + assert isinstance(st_within(postgres_dialect, Literal(postgres_dialect, "a"), Literal(postgres_dialect, "b")), bases.BaseExpression) + assert isinstance(st_dwithin(postgres_dialect, Literal(postgres_dialect, "a"), Literal(postgres_dialect, "b"), Literal(postgres_dialect, 100)), bases.BaseExpression) + assert isinstance(st_distance(postgres_dialect, Literal(postgres_dialect, "a"), Literal(postgres_dialect, "b")), bases.BaseExpression) + assert isinstance(st_area(postgres_dialect, Literal(postgres_dialect, "geom")), bases.BaseExpression) + assert isinstance(st_length(postgres_dialect, Literal(postgres_dialect, "geom")), bases.BaseExpression) + assert isinstance(st_as_geojson(postgres_dialect, Literal(postgres_dialect, "geom")), bases.BaseExpression) + assert isinstance(st_as_text(postgres_dialect, Literal(postgres_dialect, "geom")), bases.BaseExpression) + assert isinstance(st_buffer(postgres_dialect, Literal(postgres_dialect, "geom"), Literal(postgres_dialect, 100)), bases.BaseExpression) + assert isinstance(st_envelope(postgres_dialect, Literal(postgres_dialect, "geom")), bases.BaseExpression) + assert isinstance(st_centroid(postgres_dialect, Literal(postgres_dialect, "geom")), bases.BaseExpression) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py index f232a2c7..cbb37b59 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py @@ -11,6 +11,7 @@ import warnings from rhosocial.activerecord.backend.expression.core import Column, Literal, CastExpression +from rhosocial.activerecord.backend.expression.types import CustomType from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.type_values.constants import ( MONEY, NUMERIC, FLOAT8, INTEGER, VARCHAR, TEXT, @@ -23,6 +24,23 @@ WARNED_CASTS, # noqa: F401 DIRECT_COMPATIBLE_CASTS, # noqa: F401 ) +from rhosocial.activerecord.backend.expression.types import ( + FloatType, + VarCharType, +) + + + +def _t(dialect, spelling): + """The type these tests name as a bare string, as a DataType. + + cast() takes a DataType. Naming a type as text puts it in the one position + in a statement that cannot be a parameter -- the type goes into the SQL + itself -- which is why a plain string is refused there and why a test that + asserts the rendered SQL has to hand over an object that knows its own name. + CustomType keeps the spelling, so the expected SQL is unchanged. + """ + return CustomType(dialect, raw=spelling) class TestTypeCastingMixin: @@ -34,7 +52,7 @@ def setup_method(self): def test_column_cast_basic(self): """Test basic type cast on Column.""" col = Column(self.dialect, "price") - expr = col.cast(INTEGER) + expr = col.cast(_t(self.dialect, INTEGER)) sql, params = expr.to_sql() # PostgreSQL dialect adds quotes to identifiers assert sql == '"price"::integer' @@ -43,7 +61,7 @@ def test_column_cast_basic(self): def test_column_cast_with_varchar_length(self): """Test type cast with VARCHAR length modifier.""" col = Column(self.dialect, "name") - expr = col.cast("VARCHAR(100)") + expr = col.cast(VarCharType(self.dialect, length=100)) sql, params = expr.to_sql() assert sql == '"name"::VARCHAR(100)' assert params == () @@ -51,7 +69,7 @@ def test_column_cast_with_varchar_length(self): def test_literal_cast(self): """Test type cast on Literal.""" lit = Literal(self.dialect, "123") - expr = lit.cast(INTEGER) + expr = lit.cast(_t(self.dialect, INTEGER)) sql, params = expr.to_sql() assert sql == "%s::integer" assert params == ("123",) @@ -59,7 +77,7 @@ def test_literal_cast(self): def test_chained_type_cast(self): """Test chained type conversions.""" col = Column(self.dialect, "amount") - expr = col.cast(MONEY).cast(NUMERIC).cast(FLOAT8) + expr = col.cast(_t(self.dialect, MONEY)).cast(_t(self.dialect, NUMERIC)).cast(_t(self.dialect, FLOAT8)) sql, params = expr.to_sql() # PostgreSQL uses float8 alias for double precision assert sql == '"amount"::money::numeric::float8' @@ -68,7 +86,7 @@ def test_chained_type_cast(self): def test_cast_with_alias(self): """Test type cast with alias.""" col = Column(self.dialect, "value") - expr = col.cast(INTEGER).as_("int_value") + expr = col.cast(_t(self.dialect, INTEGER)).as_("int_value") sql, params = expr.to_sql() assert sql == '"value"::integer AS "int_value"' assert params == () @@ -83,7 +101,7 @@ def setup_method(self): def test_cast_expression_creation(self): """Test cast via Column.cast() method.""" col = Column(self.dialect, "price") - expr = col.cast(NUMERIC) + expr = col.cast(_t(self.dialect, NUMERIC)) sql, params = expr.to_sql() assert sql == '"price"::numeric' assert params == () @@ -91,7 +109,7 @@ def test_cast_expression_creation(self): def test_cast_expression_chained(self): """Test chained type conversions.""" col = Column(self.dialect, "value") - expr = col.cast(MONEY).cast(NUMERIC) + expr = col.cast(_t(self.dialect, MONEY)).cast(_t(self.dialect, NUMERIC)) sql, params = expr.to_sql() assert sql == '"value"::money::numeric' assert params == () @@ -99,7 +117,7 @@ def test_cast_expression_chained(self): def test_cast_expression_supports_comparison(self): """Test cast expression supports comparison operators.""" col = Column(self.dialect, "amount") - expr = col.cast(INTEGER) + expr = col.cast(_t(self.dialect, INTEGER)) predicate = expr > 0 sql, params = predicate.to_sql() assert sql == '"amount"::integer > %s' @@ -108,7 +126,7 @@ def test_cast_expression_supports_comparison(self): def test_cast_expression_supports_arithmetic(self): """Test cast expression supports arithmetic operators.""" col = Column(self.dialect, "price") - expr = col.cast(NUMERIC) + expr = col.cast(_t(self.dialect, NUMERIC)) result = expr * 1.1 sql, params = result.to_sql() assert sql == '"price"::numeric * %s' @@ -123,14 +141,16 @@ def setup_method(self): def test_format_cast_basic(self): """Test basic type cast formatting.""" - expr = CastExpression(self.dialect, Column(self.dialect, "col"), INTEGER) + expr = CastExpression(self.dialect, Column(self.dialect, "col"), + _t(self.dialect, INTEGER)) sql, params = expr.to_sql() assert sql == '"col"::integer' assert params == () def test_format_cast_with_type_modifier(self): """Test type cast with type modifiers.""" - expr = CastExpression(self.dialect, Column(self.dialect, "name"), "VARCHAR(100)") + expr = CastExpression(self.dialect, Column(self.dialect, "name"), + _t(self.dialect, "VARCHAR(100)")) sql, params = expr.to_sql() assert sql == '"name"::VARCHAR(100)' assert params == () @@ -138,7 +158,8 @@ def test_format_cast_with_type_modifier(self): def test_format_cast_with_alias(self): """Test type cast with alias.""" expr = CastExpression( - self.dialect, Column(self.dialect, "value"), INTEGER, alias="int_val" + self.dialect, Column(self.dialect, "value"), + _t(self.dialect, INTEGER), alias="int_val" ) sql, params = expr.to_sql() assert sql == '"value"::integer AS "int_val"' @@ -147,7 +168,7 @@ def test_format_cast_with_alias(self): def test_format_cast_preserves_params(self): """Test that parameters are preserved.""" params_in = ("test_value",) - expr = Literal(self.dialect, "test_value").cast(TEXT) + expr = Literal(self.dialect, "test_value").cast(_t(self.dialect, TEXT)) sql, params = expr.to_sql() assert sql == "%s::text" assert params == params_in @@ -255,7 +276,7 @@ def test_money_to_float_via_numeric(self): # This should trigger a warning with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - expr = col.cast("float8") # noqa: F841 + expr = col.cast(FloatType(self.dialect)) # noqa: F841 # The warning should suggest using numeric as intermediate if len(w) > 0: assert "numeric" in str(w[0].message) @@ -263,7 +284,7 @@ def test_money_to_float_via_numeric(self): def test_cast_in_where_clause(self): """Test type cast in WHERE clause predicate.""" col = Column(self.dialect, "amount") - predicate = col.cast(NUMERIC) > 100 + predicate = col.cast(_t(self.dialect, NUMERIC)) > 100 sql, params = predicate.to_sql() assert sql == '"amount"::numeric > %s' assert params == (100,) @@ -272,7 +293,7 @@ def test_multiple_casts_in_expression(self): """Test multiple different cast chains in one expression.""" col1 = Column(self.dialect, "price1") col2 = Column(self.dialect, "price2") - expr = col1.cast(NUMERIC) + col2.cast(NUMERIC) + expr = col1.cast(_t(self.dialect, NUMERIC)) + col2.cast(_t(self.dialect, NUMERIC)) sql, params = expr.to_sql() assert sql == '"price1"::numeric + "price2"::numeric' assert params == () @@ -280,7 +301,7 @@ def test_multiple_casts_in_expression(self): def test_cast_with_table_prefix(self): """Test type cast on column with table prefix.""" col = Column(self.dialect, "amount", table="orders") - expr = col.cast(INTEGER) + expr = col.cast(_t(self.dialect, INTEGER)) sql, params = expr.to_sql() assert sql == '"orders"."amount"::integer' assert params == () @@ -288,7 +309,7 @@ def test_cast_with_table_prefix(self): def test_nested_cast_with_alias(self): """Test nested cast expressions with aliases.""" col = Column(self.dialect, "value") - expr = col.cast(MONEY).as_("m").cast(NUMERIC).as_("n") + expr = col.cast(_t(self.dialect, MONEY)).as_("m").cast(_t(self.dialect, NUMERIC)).as_("n") sql, params = expr.to_sql() # Note: Each .as_() creates an alias for that expression # The final expression is: (value::money AS "m")::numeric AS "n" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_uuid_values.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_uuid_values.py new file mode 100644 index 00000000..e0f86299 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_uuid_values.py @@ -0,0 +1,156 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_uuid_values.py +"""UUID value expressions on PostgreSQL. + +PostgreSQL has two routes to a generated UUID — the built-in +``gen_random_uuid()`` on 13.0+ and ``uuid_generate_v4()`` from uuid-ossp — +so version handling is the substance here. A probe that answers True for SQL +the server will reject is worse than one that says no. + +No database is needed: every assertion is on the SQL a dialect renders. +""" + +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_uuid_values.py +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression import ( + Literal, + UUIDCastExpression, + UUIDConstantExpression, + UUIDGenerationExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +def _dialect(version=(16, 2, 1)): + dialect = PostgresDialect() + dialect._version = version + return dialect + + +# --------------------------------------------------------------------------- +# Version-aware generation +# --------------------------------------------------------------------------- + + +def test_modern_server_uses_the_builtin_function(): + assert UUIDGenerationExpression(_dialect((16, 2, 1))).to_sql() == ("gen_random_uuid()", ()) + + +def test_generation_carries_its_alias(): + assert UUIDGenerationExpression(_dialect(), alias="u").to_sql() == ( + 'gen_random_uuid() AS "u"', + (), + ) + + +def test_builtin_arrives_in_thirteen(): + assert _dialect((13, 0, 0)).supports_uuid_generation() is True + assert UUIDGenerationExpression(_dialect((13, 0, 0))).to_sql() == ("gen_random_uuid()", ()) + + +def test_twelve_without_the_extension_reports_no_generation(): + """``uuid_generate_v4()`` needs uuid-ossp; without it the answer is no.""" + dialect = _dialect((12, 4, 0)) + assert dialect.supports_uuid_generation() is False + + +def test_twelve_without_the_extension_refuses_with_a_route_forward(): + dialect = _dialect((12, 4, 0)) + with pytest.raises(UnsupportedFeatureError) as excinfo: + UUIDGenerationExpression(dialect).to_sql() + message = str(excinfo.value) + assert "uuid-ossp" in message + assert "UUIDMixin" in message + + +def test_old_server_with_the_extension_falls_back_to_ossp(monkeypatch): + dialect = _dialect((12, 4, 0)) + monkeypatch.setattr( + type(dialect), "check_extension_feature", lambda *a, **k: True, raising=True + ) + assert dialect.supports_uuid_generation() is True + assert UUIDGenerationExpression(dialect).to_sql() == ("uuid_generate_v4()", ()) + + +def test_generation_consults_the_extension_probe(monkeypatch): + """The merged probe must go through the extension's own narrower probe. + + The two used to share the name ``supports_uuid_generation`` while asking + different questions, so the MRO picked an answer by class order. The + extension question now has its own name, and this asserts the merge still + consults it — otherwise a 12.4 server with uuid-ossp would report no + generation. + """ + from rhosocial.activerecord.backend.impl.postgres.mixins import ( + PostgresUUIDMixin, + PostgresUuidOssMixin, + ) + + dialect = _dialect((12, 4, 0)) + # Patch where the method is defined, not on the dialect's own class: the + # previous version patched PostgresDialect, so the direct mixin call below + # still reached the real implementation and read the extension state. + monkeypatch.setattr( + PostgresUuidOssMixin, + "supports_uuid_ossp_extension", + lambda self: True, + raising=True, + ) + assert PostgresUUIDMixin.supports_uuid_generation(dialect) is True + assert dialect.supports_uuid_ossp_extension() is True + + +def test_the_dialect_itself_resolves_one_answer(monkeypatch): + dialect = _dialect((12, 4, 0)) + monkeypatch.setattr( + type(dialect), "check_extension_feature", lambda *a, **k: True, raising=True + ) + assert dialect.supports_uuid_generation() is True + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + + +def test_capability_probes_reflect_the_table(): + dialect = _dialect() + assert dialect.supports_uuid_constant() is True + assert dialect.supports_uuid_cast() is True + + +@pytest.mark.parametrize( + "which, expected", + [ + ("nil", "'00000000-0000-0000-0000-000000000000'::uuid"), + ("max", "'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid"), + ], +) +def test_constants_are_casted_literals(which, expected): + assert UUIDConstantExpression(_dialect(), which).to_sql() == (expected, ()) + + +def test_constant_alias_lands_after_the_cast(): + assert UUIDConstantExpression(_dialect(), "nil", alias="n").to_sql() == ( + "'00000000-0000-0000-0000-000000000000'::uuid AS \"n\"", + (), + ) + + +# --------------------------------------------------------------------------- +# Cast +# --------------------------------------------------------------------------- + + +def test_cast_uses_postgres_shorthand(): + """PostgreSQL has ``::uuid``, so it needs no CAST() wrapper.""" + dialect = _dialect() + expr = UUIDCastExpression(dialect, Literal(dialect, "not-a-uuid")) + assert expr.to_sql() == ("%s::uuid", ("not-a-uuid",)) + + +def test_cast_preserves_nested_parameters(): + dialect = _dialect() + inner = UUIDCastExpression(dialect, UUIDConstantExpression(dialect, "nil")) + assert inner.to_sql() == ("'00000000-0000-0000-0000-000000000000'::uuid::uuid", ()) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py index d9eeaef4..d0f6b418 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py @@ -242,6 +242,18 @@ from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresArrayType, ) +from rhosocial.activerecord.backend.impl.postgres.expression.values import ( + HstoreValueExpression, + LtreeValueExpression, + NetworkValueExpression, + RangeValueExpression, +) +from rhosocial.activerecord.backend.impl.postgres.functions import ( + hstore as pg_hstore, + ltree as pg_ltree, + network as pg_network, + range as pg_range, +) from rhosocial.activerecord.testsuite.utils.expression import ( collect_expression_classes, make_instance, @@ -872,6 +884,42 @@ def path_pattern(d): "types.enum_.EnumType", lambda d: EnumType(d, values=["sad", "ok"]) ) + # -- the four value wrappers -------------------------------------------- + # Each wraps a FunctionCall and renders it verbatim, so the introspective + # guess must hand it a real call: a string left in ``call`` reaches + # ``to_sql`` as a string and raises AttributeError there instead of being + # refused at the door. The factories below are the ones the classes own. + register_special_constructor( + "values.NetworkValueExpression", + lambda d: NetworkValueExpression(d, pg_network.inet_client_addr(d)), + ) + register_special_constructor( + "values.RangeValueExpression", + lambda d: RangeValueExpression( + d, + pg_range.range_contains(d, Column(d, "v"), Literal(d, 3)), + element_type=None, + ), + ) + register_special_constructor( + "values.HstoreValueExpression", + lambda d: HstoreValueExpression( + d, + pg_hstore.hstore_akeys( + d, pg_hstore.hstore_literal(d, Literal(d, "a=>1")) + ), + ), + ) + register_special_constructor( + "values.LtreeValueExpression", + lambda d: LtreeValueExpression( + d, + pg_ltree.ltree_ancestor( + d, Column(d, "v"), pg_ltree.ltree_literal(d, "a.b") + ), + ), + ) + register_specials() @@ -901,6 +949,19 @@ def path_pattern(d): # There are none left. That is not a claim about the classes -- it is what # ``test_unconstructible_list_is_exact`` observes right now, and it is pinned # in both directions so the first class that needs an exemption fails CI. + # + # Three are exempt, each for the same underlying reason: a required value + # sits behind a defaulted parameter, and the filler skips defaulted ones. + # CUSTOM type. `raw` names the SQL type string and defaults to the empty + # string, so the filler builds a type with no spelling at all. + "rhosocial.activerecord.backend.expression.types.custom.CustomType", + # UUID constant. `which` is required, but the filler's "x" is refused at + # construction because __init__ accepts only 'nil' and 'max'. + "rhosocial.activerecord.backend.expression.uuid.UUIDConstantExpression", + # POSTGRES ENUM COLUMN. type_name, schema and values all default to None, so + # the filler supplies none of them and the type declares neither its name nor + # its members. + "rhosocial.activerecord.backend.impl.postgres.expression.types.PostgresEnumColumnType", ) #: Classes that construct but cannot render, for a reason belonging to their own @@ -1340,4 +1401,4 @@ def test_coverage_report(self, postgres_dialect): f"{len(REGISTERED)} registered" ) for fqn in UNCONSTRUCTIBLE: - print(f" not constructible: {fqn}") \ No newline at end of file + print(f" not constructible: {fqn}") 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 ae1096ae..915d5b85 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 @@ -94,8 +94,11 @@ def test_postgres_format_storage_options_string_escaping(dialect): def test_postgres_format_cast_expression_valid(dialect): - """Test that CAST expression renders the target type (:: syntax).""" - expr = CastExpression(dialect, RawSQLExpression(dialect, "column"), "INTEGER") + """The target type is a DataType, and the :: syntax renders what it says.""" + from rhosocial.activerecord.backend.expression.types import CustomType + + expr = CastExpression(dialect, RawSQLExpression(dialect, "column"), + CustomType(dialect, raw="INTEGER")) sql, params = expr.to_sql() assert "INTEGER" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 488e86d3..502b271b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -90,6 +90,7 @@ def get_postgres_protocols(): dialect_protocols.FilterClauseSupport, dialect_protocols.WindowFunctionSupport, dialect_protocols.JSONSupport, + dialect_protocols.UUIDSupport, dialect_protocols.ReturningSupport, dialect_protocols.AdvancedGroupingSupport, dialect_protocols.ArraySupport, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py new file mode 100644 index 00000000..6d02d2a8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py @@ -0,0 +1,348 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py +"""PostgreSQL's column-type suggestion table, and its declared lack of narrowing. + +The protocol's obligation on a backend is three things (protocol §2, §12): + +1. **Answer every entry.** All eighteen of the closed list, none of them empty + and none of them silently absent. A hole is a lie told to the model layer. +2. **Say so where this backend differs.** Here: native arrays for the four + sequence entries, ``FloatColumn`` / ``DecimalColumn`` where core answers the + generic ``NumericColumn``, and no ``UNSUPPORTED`` anywhere. +3. **Narrow only with evidence.** PostgreSQL narrows nothing, which is asserted + rather than assumed -- it is the series' control backend, eight others have to + take ``ilike`` away, so "nothing was narrowed" and "nobody looked" must not + look the same at the call site. + +No database is needed for any of it: the question is which class an annotation +resolves to, and the dialect already knows the server version. +""" + +import datetime +import decimal +import enum +import uuid +from typing import Optional + +import pytest + +from rhosocial.activerecord.backend.dialect.mixins import ColumnSuggestionMixin +from rhosocial.activerecord.backend.expression.column_suggestions import ( + COLUMN_TYPE_ENTRIES, + UNSUPPORTED, + ColumnTypeResolutionError, +) +from rhosocial.activerecord.backend.expression.column_types import ( + ArrayColumn, + BinaryColumn, + BooleanColumn, + ColumnBase, + DateTimeColumn, + DecimalColumn, + FloatColumn, + IntegerColumn, + JSONColumn, + NumericColumn, + StringColumn, + UUIDColumn, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.mixins.column_suggestion import ( + PostgresColumnSuggestionMixin, +) + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(16, 0, 0)) + + +_ENTRY_IDS = [getattr(entry, "__name__", str(entry)) for entry in COLUMN_TYPE_ENTRIES] + + +# --------------------------------------------------------------------------- +# 1. The table answers the whole protocol, and the dialect really answers it +# --------------------------------------------------------------------------- + + +def test_the_dialect_carries_the_protocol_mixin(dialect): + """PostgreSQL has to *have* the mixin; a table on a class nothing reaches is + documentation. ``field_proxy`` calls ``dialect.column_class_for`` on every + column access, so this is also what makes ``Model.c.`` work here.""" + assert isinstance(dialect, ColumnSuggestionMixin) + assert isinstance(dialect, PostgresColumnSuggestionMixin) + + +@pytest.mark.parametrize("entry", COLUMN_TYPE_ENTRIES, ids=_ENTRY_IDS) +def test_every_entry_is_answered(dialect, entry): + """Eighteen present, every value a real column class. + + ``None`` is the one answer the protocol forbids and it is not detectable by + ``suggested_column_types().get(entry, None)``, hence the explicit check: a + forgotten entry and a ``None`` have to fail differently from a refusal. + """ + table = dialect.suggested_column_types() + assert entry in table, f"PostgreSQL does not answer {entry!r}" + + suggested = table[entry] + assert suggested is not None, f"{entry!r} answered with None, which is silence" + assert suggested is not UNSUPPORTED, f"{entry!r} is refused; PostgreSQL can express it" + assert isinstance(suggested, type) + assert issubclass(suggested, ColumnBase) + + +def test_the_table_has_no_entry_beyond_the_protocol(dialect): + """A key of the backend's own is allowed by §2, and is registered separately + so "the framework has an answer" stays distinguishable from "this backend has + one of its own". PostgreSQL has none, and a stray key would be indistinguishable + from the core half at the call site.""" + extra = set(dialect.suggested_column_types()) - set(COLUMN_TYPE_ENTRIES) + assert extra == set() + + +def test_the_table_is_a_copy_not_the_shared_class_attribute(dialect): + """Mutating what a caller received must not reach the next resolution.""" + table = dialect.suggested_column_types() + table[str] = BinaryColumn + assert dialect.column_class_for(str) is StringColumn + + +# --------------------------------------------------------------------------- +# 2. Every entry resolves to what the table says +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("entry", COLUMN_TYPE_ENTRIES, ids=_ENTRY_IDS) +def test_every_entry_resolves_to_its_own_table_answer(dialect, entry): + """Model and table must agree, or the table is a comment rather than the + answer. Table-relative on purpose: the class may legitimately differ between + backends, and what must not differ is what a model builds.""" + assert dialect.column_class_for(entry) is dialect.suggested_column_types()[entry] + + +def test_resolution_is_the_baseline_for_the_scalar_and_value_families(dialect): + """The ten-backend baseline, written out so a change to one of these is a + test failure rather than a silent redefinition of what a ``str`` means.""" + assert dialect.column_class_for(bool) is BooleanColumn + assert dialect.column_class_for(int) is IntegerColumn + assert dialect.column_class_for(str) is StringColumn + assert dialect.column_class_for(bytes) is BinaryColumn + assert dialect.column_class_for(bytearray) is BinaryColumn + assert dialect.column_class_for(uuid.UUID) is UUIDColumn + assert dialect.column_class_for(dict) is JSONColumn + assert dialect.column_class_for(datetime.date) is DateTimeColumn + assert dialect.column_class_for(datetime.time) is DateTimeColumn + assert dialect.column_class_for(datetime.datetime) is DateTimeColumn + + +def test_float_and_decimal_answer_their_own_classes_not_the_generic_numeric(dialect): + """Two deviations from core's neutral table, both §12's baseline. + + ``FloatColumn`` and ``DecimalColumn`` offer exactly the operations + ``NumericColumn`` does, so this is about the name a reader sees rather than + about capability -- which is also why nothing is narrowed for them. PostgreSQL + is the backend where the distinction is real storage, though: ``numeric`` is + its exact family and ``real`` / ``double precision`` are distinct widths. + """ + assert dialect.column_class_for(float) is FloatColumn + assert dialect.column_class_for(decimal.Decimal) is DecimalColumn + assert dialect.column_class_for(float) is not NumericColumn + assert dialect.column_class_for(decimal.Decimal) is not NumericColumn + + +def test_the_sequence_entries_answer_array_column_not_json(dialect): + """**This backend's one real difference from the seven-backend majority.** + + Seven backends carry ``list`` as a JSON document and route array operations + through JSON functions; PostgreSQL has a real array type, and + ``suggested-pairing-array.md`` §A measured 9.6 / 12 / 18 with every row of + the array guarantee set green (DDL, direct list binding, ``array_length``, + 1-based subscript, ``@>``, ``||``, ``v = ANY(x)``, whole-column equality, + ``unnest``). Same Python type, different storage, different answer. + + ``tuple`` / ``set`` / ``frozenset`` come along for the same reason the + baseline groups them: to the server they are sequences, and order is the one + thing a set does not promise. + """ + for entry in (list, tuple, set, frozenset): + assert dialect.column_class_for(entry) is ArrayColumn, entry + + # And the class really is the array one -- the deviation has to be a class + # that can answer, not a rename. + for op in ("array_length", "unnest"): + assert hasattr(ArrayColumn, op), f"ArrayColumn lost {op}" + + +def test_dict_answers_the_json_column_while_the_ddl_layer_chooses_jsonb(dialect): + """The two halves stay apart, and the column half is the operations. + + §0.2 requires ``jsonb`` for a ``dict`` here because ``json`` has no equality + operator, no ``?`` and no ``@>``. That is a *spelling* decision belonging to + the ``DataType`` layer, and it is not the column class's to carry: the + operation surface -- ``json_path``, ``json_value``, has-key, array length, + validity -- is the same on ``json`` and ``jsonb``, and ``jsonb`` simply adds + the containment operators on top. + """ + assert dialect.column_class_for(dict) is JSONColumn + assert hasattr(JSONColumn, "json_path") + assert hasattr(JSONColumn, "json_value") + + from rhosocial.activerecord.backend.impl.postgres.mixins.types.data_type_formatting import ( + PostgresTypeFormatSupportMixin, + ) + + # The DDL side is real and independent of the answer above, which is what + # "neither layer infers the other" means in practice. + assert hasattr(PostgresTypeFormatSupportMixin, "format_data_type_jsonb") + + +def test_the_enum_entry_answers_string_column(dialect): + """PostgreSQL does have enums, but as a *named* type created once by + ``CREATE TYPE ... AS ENUM`` and referred to by name -- a DDL fact carried by + ``PostgresEnumColumnType``. The labels compare as strings, so the value + surface is the string one; putting the label set in the column class would + move storage into the operation layer. + """ + + class Mood(enum.Enum): + SAD = "sad" + OK = "ok" + + assert dialect.column_class_for(Mood) is StringColumn + + +def test_timedelta_is_the_numeric_gap_until_core_grows_an_interval_column(dialect): + """§12's ``IntervalColumn`` fallback, taken deliberately and pinned. + + PostgreSQL has a real ``interval`` type where eight backends have no answer at + all, so ``NumericColumn`` under-claims nothing and over-claims exactly one + thing: ``interval`` is not a number, and ``sqrt(interval '1 day')`` is an + error on the server while ``NumericColumn`` offers transcendentals. That gap + is inherited from core's neutral table, not chosen here, and it closes when + core grows an ``IntervalColumn`` -- so this test fails then, on purpose. + """ + assert dialect.column_class_for(datetime.timedelta) is NumericColumn + + from rhosocial.activerecord.backend.expression import column_types + + assert not hasattr(column_types, "IntervalColumn"), ( + "Core now has an IntervalColumn; PostgreSQL's native interval should " + "answer with it (protocol §12, PostgreSQL row) rather than the numeric " + "fallback. This test is the reminder to change the table." + ) + + +# --------------------------------------------------------------------------- +# 3. Normalisation: the protocol's rules, on this backend +# --------------------------------------------------------------------------- + + +def test_optional_is_transparent(dialect): + """``Optional[T]`` must resolve exactly as ``T`` does -- compared against + this dialect's own answer rather than a fixed class, since the answer may + legitimately vary by backend.""" + assert dialect.column_class_for(Optional[dict]) is dialect.column_class_for(dict) + assert dialect.column_class_for(Optional[str]) is dialect.column_class_for(str) + assert dialect.column_class_for(Optional[list]) is dialect.column_class_for(list) + + +def test_a_subclass_walks_to_its_entry(dialect): + """``class Code(str)`` answers the ``str`` entry. The walk runs over *this + backend's* key space, so a backend that extended the list would get the + behaviour for free.""" + + class Code(str): + pass + + assert dialect.column_class_for(Code) is StringColumn + + +def test_bool_is_its_own_entry_not_an_integer(dialect): + """``bool`` is an ``int`` subclass and the entry order is the only thing that + keeps them apart. Without it a truth-value field would offer integer + arithmetic -- which MySQL and SQLite would happily execute on 0/1.""" + assert dialect.column_class_for(bool) is not dialect.column_class_for(int) + assert COLUMN_TYPE_ENTRIES.index(bool) < COLUMN_TYPE_ENTRIES.index(int) + + +def test_an_unknown_annotation_fails_rather_than_becoming_a_universal_column(dialect): + """No permissive fallback: ``Any`` is a definition-time failure naming the way + out. The old universal column offered ``.like()`` to an integer and only + found out at the database.""" + with pytest.raises(ColumnTypeResolutionError) as excinfo: + dialect.column_class_for(object()) + assert "UseColumnType" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# 4. Narrowing: declared as none, and checked to be none +# --------------------------------------------------------------------------- + + +def test_nothing_is_narrowed_on_this_backend(dialect): + """The declared verdict. Eight of the ten backends have to take ``ilike`` + away; PostgreSQL is the control that proves the mechanism discriminates + rather than always answering True. + + The pairs are the ones §8.1/§8.4 measured here: ``ILIKE`` native (§8.4 + "ILIKE 仅 PG/CH"), JSON path access native on ``json`` and ``jsonb``, array + length and ``unnest`` native on 9.6/12/18, tz ``AT TIME ZONE`` native. + """ + assert dialect.supports_column_operation("StringColumn", "ilike") is True + assert dialect.supports_column_operation("StringColumn", "like") is True + assert dialect.supports_column_operation("JSONColumn", "json_path") is True + assert dialect.supports_column_operation("JSONColumn", "json_value") is True + assert dialect.supports_column_operation("ArrayColumn", "array_length") is True + assert dialect.supports_column_operation("ArrayColumn", "unnest") is True + assert dialect.supports_column_operation("DateTimeColumn", "date_trunc") is True + assert dialect.supports_column_operation("BooleanColumn", "is_true") is True + assert dialect.supports_column_operation("UUIDColumn", "eq") is True + assert dialect.supports_column_operation("BinaryColumn", "eq") is True + + +def test_no_narrowing_holds_for_any_operation_on_any_column_class(dialect): + """The whole surface, not a hand-picked sample of it. + + Driven off the table itself so a new entry cannot introduce a narrowing + without this noticing, and off ``dir()`` of each class so a new operation + method is covered too. + """ + narrowed = [] + for column_class in dialect.suggested_column_types().values(): + name = column_class.__name__ + for op in dir(column_class): + if op.startswith("_"): + continue + if not dialect.supports_column_operation(name, op): + narrowed.append(f"{name}.{op}") + assert narrowed == [] + + +def test_the_operation_names_the_contract_uses_are_real_attributes(dialect): + """Operation names are *method names on the column class* so the contract + tests can cross-check with ``hasattr`` (protocol §5). If a name here were a + label rather than an attribute, the contract would be checking a string. + + ``is_true`` / ``is_false`` come from ``BooleanLogicMixin``; the rest from the + mixins each class is composed from. + """ + expected = { + "StringColumn": ("like", "ilike"), + "JSONColumn": ("json_path", "json_value"), + "ArrayColumn": ("array_length", "unnest"), + "DateTimeColumn": ("date_trunc", "extract"), + "BooleanColumn": ("is_true", "is_false"), + "NumericColumn": ("__add__", "__mul__"), + } + # Resolved by *name* out of this backend's own table, so the cross-check runs + # against the class PostgreSQL actually suggests rather than against a + # hard-coded import that could stop being the answer. + by_name = {cls.__name__: cls for cls in dialect.suggested_column_types().values()} + + for column_name, ops in expected.items(): + assert column_name in by_name, f"{column_name} is not in the table" + for op in ops: + assert dialect.supports_column_operation(column_name, op) is True + assert hasattr(by_name[column_name], op), ( + f"{column_name} has no attribute {op!r}, so declaring it " + f"available would be checking a string" + ) 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 index 82bbec8a..f0265d95 100644 --- 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 @@ -21,7 +21,6 @@ DoubleType, FloatType, IntegerType, - IntType, IntervalType, JsonBType, JsonType, @@ -43,7 +42,6 @@ PostgresBoxType, PostgresByteaType, PostgresCIDType, - PostgresCharacterVaryingType, PostgresCidrType, PostgresCircleType, PostgresCitextType, @@ -160,8 +158,9 @@ def dialect(): (PostgresCubeType(), "CUBE"), (PostgresLtreeType(), "LTREE"), (PostgresRasterType(), "RASTER"), - (PostgresCharacterVaryingType(), "CHARACTER VARYING"), - (PostgresCharacterVaryingType(length=100), "CHARACTER VARYING(100)"), + (VarCharType(spelling="character varying"), "CHARACTER VARYING"), + (VarCharType(length=100, spelling="character varying"), + "CHARACTER VARYING(100)"), ] @@ -179,7 +178,8 @@ def test_format_postgres_specific_types(dialect, data_type, expected): CORE_FORMAT_CASES = [ (TinyIntType(), "SMALLINT"), (SmallIntType(), "SMALLINT"), - (IntType(), "INTEGER"), + (IntegerType(), "INTEGER"), + (IntegerType(spelling="int"), "INT"), (IntegerType(), "INTEGER"), (BigIntType(), "BIGINT"), (FloatType(), "REAL"), @@ -310,6 +310,11 @@ def test_format_custom_type_passthrough(dialect): ("BIT(8)", PostgresBitType, {"n": 8}), ("VARBIT", PostgresVarBitType, {}), ("VARBIT(16)", PostgresVarBitType, {"n": 16}), + # ``bit varying`` is the spelling live ``pg_catalog.format_type`` returns + # for a varbit column (report appendix F1); it must answer the same class + # as the renderer's ``VARBIT`` word, not the fixed-length ``BIT`` one. + ("BIT VARYING", PostgresVarBitType, {}), + ("BIT VARYING(16)", PostgresVarBitType, {"n": 16}), ("INET", PostgresInetType, {}), ("CIDR", PostgresCidrType, {}), ("MACADDR", PostgresMacAddrType, {}), @@ -385,9 +390,18 @@ def test_parse_type_array_with_dimension_bracket(dialect): 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_malformed_bracket_suffix_is_rejected(dialect): + """A malformed array marker is refused, not quietly dropped. + + Falling back to CustomType would put "FOO[abc]" into the statement as-is, + and the type position cannot take a bound parameter -- so a name that does + not parse is a name that has to be reported rather than passed through.""" + from rhosocial.activerecord.backend.expression.type_name import ( + InvalidTypeNameError, + ) + + with pytest.raises(InvalidTypeNameError): + dialect.parse_type("FOO[abc]") def test_parse_type_array_keyword(dialect): @@ -449,23 +463,23 @@ def test_parse_vector_allowed_when_extension_installed(dialect): def test_vector_types_equality_and_hash(): - assert PostgresVectorType(3) == PostgresVectorType(3) - assert PostgresVectorType(3) != PostgresVectorType(4) - assert PostgresVectorType(3) != PostgresHalfvecType(3) - assert PostgresVectorType(3) != object() - assert hash(PostgresVectorType(3)) == hash(PostgresVectorType(3)) + assert PostgresVectorType(dim=3) == PostgresVectorType(dim=3) + assert PostgresVectorType(dim=3) != PostgresVectorType(dim=4) + assert PostgresVectorType(dim=3) != PostgresHalfvecType(dim=3) + assert PostgresVectorType(dim=3) != object() + assert hash(PostgresVectorType(dim=3)) == hash(PostgresVectorType(dim=3)) - assert PostgresHalfvecType(3) == PostgresHalfvecType(3) - assert PostgresHalfvecType(3) != PostgresHalfvecType(4) - assert PostgresHalfvecType(3) != PostgresSparsevecType(3) - assert PostgresHalfvecType(3) != object() - assert hash(PostgresHalfvecType(3)) == hash(PostgresHalfvecType(3)) + assert PostgresHalfvecType(dim=3) == PostgresHalfvecType(dim=3) + assert PostgresHalfvecType(dim=3) != PostgresHalfvecType(dim=4) + assert PostgresHalfvecType(dim=3) != PostgresSparsevecType(dim=3) + assert PostgresHalfvecType(dim=3) != object() + assert hash(PostgresHalfvecType(dim=3)) == hash(PostgresHalfvecType(dim=3)) - assert PostgresSparsevecType(5) == PostgresSparsevecType(5) - assert PostgresSparsevecType(5) != PostgresSparsevecType(6) - assert PostgresSparsevecType(5) != PostgresVectorType(5) - assert PostgresSparsevecType(5) != object() - assert hash(PostgresSparsevecType(5)) == hash(PostgresSparsevecType(5)) + assert PostgresSparsevecType(dim=5) == PostgresSparsevecType(dim=5) + assert PostgresSparsevecType(dim=5) != PostgresSparsevecType(dim=6) + assert PostgresSparsevecType(dim=5) != PostgresVectorType(dim=5) + assert PostgresSparsevecType(dim=5) != object() + assert hash(PostgresSparsevecType(dim=5)) == hash(PostgresSparsevecType(dim=5)) # --------------------------------------------------------------------------- @@ -489,3 +503,144 @@ def test_supports_data_types_registers_all_postgres_types(dialect): IntegerType, ): assert expected in classes + + +class TestVersionGatesAreNotEmpty: + """A gate with no lower bound is not a gate. + + ``supports_data_type_x`` returning True unconditionally tells the dialect it + can render a type on any server, and the server disagrees at execution. That + is the expensive way to find out: the DDL is built, sent, and rejected. + + These boundaries were measured against the scenario servers rather than + read off the release notes, because the release notes and the servers + disagreed about jsonpath -- the notes say 13, and 12 has it. + """ + + @pytest.mark.parametrize("version", [(9, 0, 0), (10, 0, 0), (11, 0, 0)]) + def test_jsonpath_is_refused_below_12(self, version): + from rhosocial.activerecord.backend.impl.postgres.dialect import ( + PostgresDialect, + ) + + dialect = PostgresDialect(version=version) + assert dialect.supports_data_type_postgres_jsonpath() is False + + @pytest.mark.parametrize("version", [(12, 0, 0), (13, 0, 0), (14, 0, 0)]) + def test_jsonpath_is_available_from_12(self, version): + from rhosocial.activerecord.backend.impl.postgres.dialect import ( + PostgresDialect, + ) + + dialect = PostgresDialect(version=version) + assert dialect.supports_data_type_postgres_jsonpath() is True + + @pytest.mark.parametrize( + "name", ["postgres_int4multirange", "postgres_int8multirange", + "postgres_nummultirange", "postgres_datemultirange", + "postgres_tsmultirange", "postgres_tstzmultirange"], + ) + def test_multiranges_need_14(self, name): + from rhosocial.activerecord.backend.impl.postgres.dialect import ( + PostgresDialect, + ) + + for version in [(11, 0, 0), (12, 0, 0), (13, 0, 0)]: + dialect = PostgresDialect(version=version) + assert getattr(dialect, f"supports_data_type_{name}")() is False, ( + name, version) + dialect = PostgresDialect(version=(14, 0, 0)) + assert getattr(dialect, f"supports_data_type_{name}")() is True + + @pytest.mark.parametrize( + "name", ["text", "varchar", "boolean", "integer", "bigint", + "postgres_int4range", "postgres_int8range"], + ) + def test_base_types_are_unconditionally_available(self, name): + """The distinction that matters: most of these gates are True on + purpose, because the type has existed since before the oldest server + this backend supports. They are not a to-do list.""" + from rhosocial.activerecord.backend.impl.postgres.dialect import ( + PostgresDialect, + ) + + for version in [(9, 0, 0), (13, 0, 0)]: + dialect = PostgresDialect(version=version) + assert getattr(dialect, f"supports_data_type_{name}")() is True, ( + name, version) + + +#: Every version boundary this backend gates on, and the server it was read +#: off. Recorded so the next empty gate is a deliberate omission rather than an +#: oversight -- four of these were found by CI, one at a time: jsonpath (12), +#: xid8 (13), the multiranges (14) and macaddr8 (10). +KNOWN_VERSIONED_TYPES = { + "postgres_macaddr8": (10, 0, 0), + "postgres_jsonpath": (12, 0, 0), + "postgres_xid8": (13, 0, 0), + "postgres_int4multirange": (14, 0, 0), + "postgres_int8multirange": (14, 0, 0), + "postgres_nummultirange": (14, 0, 0), + "postgres_datemultirange": (14, 0, 0), + "postgres_tsmultirange": (14, 0, 0), + "postgres_tstzmultirange": (14, 0, 0), +} + + +class TestEveryVersionedTypeIsGated: + """A gate that says yes to a server that says no costs a round trip. + + Each of these was an unconditional True. The dialect rendered the type, the + DDL went out, and the server answered "type X does not exist" -- so the + failure arrived at execution, naming a table rather than the version gap + that caused it. + """ + + @pytest.mark.parametrize("name,floor", sorted(KNOWN_VERSIONED_TYPES.items())) + def test_refused_below_the_floor(self, name, floor): + from rhosocial.activerecord.backend.impl.postgres.dialect import ( + PostgresDialect, + ) + + below = floor[0] - 1 + dialect = PostgresDialect(version=(below, 0, 0)) + assert getattr(dialect, f"supports_data_type_{name}")() is False, name + + @pytest.mark.parametrize("name,floor", sorted(KNOWN_VERSIONED_TYPES.items())) + def test_available_from_the_floor(self, name, floor): + from rhosocial.activerecord.backend.impl.postgres.dialect import ( + PostgresDialect, + ) + + dialect = PostgresDialect(version=floor) + assert getattr(dialect, f"supports_data_type_{name}")() is True, name + + def test_no_unconditional_gate_left_on_a_versioned_type(self): + """A versioned type must not answer yes unconditionally. + + The reverse is fine and common: text, integer and varchar have been in + PostgreSQL since before the oldest server this backend supports, so + their gates say yes on purpose. What must not happen is the opposite -- + a type that arrived later carrying a gate that cannot say no. That is + the whole of what went wrong here four times. + """ + import inspect + + from rhosocial.activerecord.backend.impl.postgres.mixins.types import ( + PostgresTypeFormatSupportMixin, + ) + + unconditional = set() + for attr in dir(PostgresTypeFormatSupportMixin): + if not attr.startswith("supports_data_type_"): + continue + fn = getattr(PostgresTypeFormatSupportMixin, attr) + try: + body = inspect.getsource(fn) + except (OSError, TypeError): # pragma: no cover + continue + if "return True" in body and "version" not in body: + unconditional.add(attr[len("supports_data_type_"):]) + # The versioned ones must not be in that set. + assert not (unconditional & set(KNOWN_VERSIONED_TYPES)), ( + unconditional & set(KNOWN_VERSIONED_TYPES)) 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 index 16dd3e39..6dda498e 100644 --- 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 @@ -1,104 +1,199 @@ # 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. +Covers the base classes each PostgreSQL type is anchored to, equality and +hashing, and the array-type comparison that introspection and schema +comparison rely on. """ +import pytest + from rhosocial.activerecord.backend.expression.types import ( + ArrayType, + BigIntType, + BlobType, + DecimalType, IntegerType, + SmallIntType, + TextType, + UUIDType, VarCharType, + XmlType, ) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresArrayType, PostgresBigSerialType, PostgresBitType, PostgresByteaType, - PostgresCharacterVaryingType, + PostgresCitextType, + PostgresHalfvecType, + PostgresMoneyType, PostgresSerialType, PostgresSmallSerialType, + PostgresSparsevecType, + PostgresUUIDType, PostgresVarBitType, PostgresVectorType, + PostgresXMLType, ) -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 TestPostgresTypesAreAnchoredToCoreConcepts: + """A backend type that says "I am this concept" must be that concept. + + Without this, ``isinstance(col.data_type, IntegerType)`` is false for a + SERIAL column and every caller has to know the backend's private type list. + """ + + @pytest.mark.parametrize("cls,base", [ + (PostgresSmallSerialType, SmallIntType), + (PostgresSerialType, IntegerType), + (PostgresBigSerialType, BigIntType), + (PostgresByteaType, BlobType), + (PostgresUUIDType, UUIDType), + (PostgresXMLType, XmlType), + (PostgresCitextType, TextType), + (PostgresArrayType, ArrayType), + ]) + def test_derives_from(self, cls, base): + assert issubclass(cls, base) + + def test_money_is_not_a_decimal(self): + """``money`` changes the *result type* of division, stores a scale the + schema does not record, and prints locale-dependently. Any one of those + makes it a different concept from ``NUMERIC``.""" + assert not issubclass(PostgresMoneyType, DecimalType) + + +class TestPostgresSerialWidths: + """SERIAL is an integer of a given width plus a sequence default.""" + + @pytest.mark.parametrize("cls,base", [ + (PostgresSmallSerialType, SmallIntType), + (PostgresSerialType, IntegerType), + (PostgresBigSerialType, BigIntType), + ]) + def test_is_a_width_of_integer(self, cls, base): + assert isinstance(cls(), base) + + def test_widths_still_distinguish_them(self): + assert PostgresSmallSerialType() != PostgresSerialType() + assert PostgresSerialType() != PostgresBigSerialType() + + +class TestDialectFirstConstructor: + """``dialect`` is the first positional parameter of every DataType. + + A signature that puts ``n``/``dim`` first does not fail loudly when a + caller follows the documented convention — the dialect object is silently + stored as the bit count or dimension count, and the error only appears when + the column is rendered. + """ + + def test_bit_type_takes_dialect_first(self): + t = PostgresBitType(PostgresDialect(), 8) + assert t.n == 8 + assert t.dialect is not None + + def test_varbit_type_takes_dialect_first(self): + t = PostgresVarBitType(PostgresDialect(), 16) + assert t.n == 16 + assert t.dialect is not None + + @pytest.mark.parametrize("cls", [ + PostgresVectorType, PostgresHalfvecType, PostgresSparsevecType, + ]) + def test_vector_types_take_dialect_first(self, cls): + t = cls(PostgresDialect(), 384) + assert t.dim == 384 + assert t.dialect is not None + + @pytest.mark.parametrize("cls", [ + PostgresVectorType, PostgresHalfvecType, PostgresSparsevecType, + PostgresBitType, PostgresVarBitType, + ]) + def test_dialect_first_renders(self, cls): + """The end of the silent-misplacement chain: what it renders must + contain the number, not the dialect's repr.""" + sql, _ = cls(PostgresDialect(), 8).to_sql() + assert "8" in sql class TestPostgresBitTypeEquality: def test_equal(self): - assert PostgresBitType(8) == PostgresBitType(8) + assert PostgresBitType(None, 8) == PostgresBitType(None, 8) def test_not_equal_values(self): - assert PostgresBitType(8) != PostgresBitType(16) + assert PostgresBitType(None, 8) != PostgresBitType(None, 16) def test_not_equal_types(self): - assert PostgresBitType(8) != PostgresVarBitType(8) - assert PostgresBitType(8) != "not a type" + assert PostgresBitType(None, 8) != PostgresVarBitType(None, 8) + assert PostgresBitType(None, 8) != "not a type" def test_none_vs_value(self): - assert PostgresBitType(None) != PostgresBitType(8) + assert PostgresBitType(None) != PostgresBitType(None, 8) def test_hash(self): - assert hash(PostgresBitType(8)) == hash((type(PostgresBitType(8)), PostgresBitType(8)._type_params())) + assert hash(PostgresBitType(None, 8)) == hash(PostgresBitType(None, 8)) class TestPostgresVarBitTypeEquality: def test_equal(self): - assert PostgresVarBitType(16) == PostgresVarBitType(16) + assert PostgresVarBitType(None, 16) == PostgresVarBitType(None, 16) def test_not_equal_values(self): - assert PostgresVarBitType(16) != PostgresVarBitType(32) + assert PostgresVarBitType(None, 16) != PostgresVarBitType(None, 32) def test_not_equal_types(self): - assert PostgresVarBitType(16) != PostgresBitType(16) - assert PostgresVarBitType(16) != object() + assert PostgresVarBitType(None, 16) != PostgresBitType(None, 16) + assert PostgresVarBitType(None, 16) != object() def test_hash(self): - assert hash(PostgresVarBitType(16)) == hash((type(PostgresVarBitType(16)), PostgresVarBitType(16)._type_params())) + assert hash(PostgresVarBitType(None, 16)) == hash(PostgresVarBitType(None, 16)) class TestPostgresVectorTypeEquality: def test_equal(self): - assert PostgresVectorType(384) == PostgresVectorType(384) + assert PostgresVectorType(None, 384) == PostgresVectorType(None, 384) def test_not_equal_values(self): - assert PostgresVectorType(384) != PostgresVectorType(768) + assert PostgresVectorType(None, 384) != PostgresVectorType(None, 768) def test_not_equal_types(self): - assert PostgresVectorType(384) != PostgresBitType(8) - assert PostgresVectorType(384) != "vector" + assert PostgresVectorType(None, 384) != PostgresBitType(None, 8) + assert PostgresVectorType(None, 384) != "vector" def test_hash(self): - assert hash(PostgresVectorType(384)) == hash((type(PostgresVectorType(384)), PostgresVectorType(384)._type_params())) + assert hash(PostgresVectorType(None, 384)) == hash(PostgresVectorType(None, 384)) class TestPostgresArrayType: - def test_is_equivalent_matching_element(self): + """Dimensions describe the declaration; PostgreSQL stores one dimension.""" + + def test_element_type_comparison_ignores_dimensions(self): arr1 = PostgresArrayType(element_type=IntegerType(), dimensions=1) arr2 = PostgresArrayType(element_type=IntegerType(), dimensions=3) - assert arr1.is_equivalent(arr2) + assert arr1.is_element_type_equivalent(arr2) - def test_is_equivalent_non_array(self): + def test_element_type_comparison_accepts_a_bare_element_type(self): + """``*other*`` need not be an ArrayType — the question "does this array + store the same kind of thing" is answerable against a bare + ``IntegerType()``, which is what makes the call useful at a call site + that has just an element type in hand.""" arr = PostgresArrayType(element_type=IntegerType(), dimensions=1) - assert arr.is_equivalent(IntegerType()) is False - assert arr.is_equivalent(None) is False + assert arr.is_element_type_equivalent(IntegerType()) + assert not arr.is_element_type_equivalent(VarCharType()) + assert not arr.is_element_type_equivalent(None) - def test_is_equivalent_different_element(self): + def test_element_type_comparison_rejects_a_different_element(self): arr1 = PostgresArrayType(element_type=IntegerType(), dimensions=1) arr2 = PostgresArrayType(element_type=VarCharType(), dimensions=1) - assert arr1.is_equivalent(arr2) is False + assert not arr1.is_element_type_equivalent(arr2) + + def test_dimensions_are_still_part_of_identity(self): + """``==`` asks "same declaration"; the differ uses it, so a dimension + difference is reported rather than quietly accepted.""" + arr1 = PostgresArrayType(element_type=IntegerType(), dimensions=1) + arr3 = PostgresArrayType(element_type=IntegerType(), dimensions=3) + assert arr1 != arr3 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py index 3de4de2d..106166c3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py @@ -10,12 +10,16 @@ import pytest +from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, +) from rhosocial.activerecord.backend.expression.types import ( BigIntType, BlobType, BooleanType, DataType, DecimalType, + DoubleType, FloatType, IntegerType, JsonBType, @@ -38,6 +42,7 @@ PostgresSparsevecType, PostgresTsRangeType, PostgresUUIDType, + PostgresVarBitType, PostgresVectorType, ) @@ -249,11 +254,11 @@ def test_type_constructor_rejects_dialect_options(): # --------------------------------------------------------------------------- -# W1: _type_params() based equality replaces hand-written __eq__/__hash__ +# W1: PARAMETERS-based equality replaces hand-written __eq__/__hash__ # --------------------------------------------------------------------------- -def test_bit_type_type_params(): +def test_bit_type_identity(): t1 = PostgresBitType(n=8) t2 = PostgresBitType(n=8) t3 = PostgresBitType(n=16) @@ -266,7 +271,31 @@ def test_bit_type_type_params(): assert hash(t1) != hash(t3) -def test_vector_type_type_params(): +def test_catalog_bit_varying_spelling_parses_as_varbit(dialect): + """``bit varying(n)`` -- the live catalog word -- is the varbit class. + + Live evidence (report appendix F1, measured 2026-10-08 on PostgreSQL + 9.6/13/16/19beta4): ``pg_catalog.format_type(atttypid, atttypmod)`` for a + ``bit varying(16)`` column is exactly ``bit varying(16)``. Parsing used to + answer ``PostgresBitType`` because the fixed-length prefix test ran first, + so introspection turned every varbit column into a bit column of the same + width. The rendered-string sweep cannot see this: it only feeds back the + renderer's own ``VARBIT(16)`` spelling. + """ + varying = dialect.parse_type("bit varying(16)") + assert varying == PostgresVarBitType(dialect, n=16) + assert varying != PostgresBitType(dialect, n=16) + + bare = dialect.parse_type("bit varying") + assert bare == PostgresVarBitType(dialect) + assert bare != PostgresBitType(dialect) + + # Keep-pins for the words the renderer emits. + assert dialect.parse_type("BIT(8)") == PostgresBitType(dialect, n=8) + assert dialect.parse_type("VARBIT(16)") == PostgresVarBitType(dialect, n=16) + + +def test_vector_type_identity(): t1 = PostgresVectorType(dim=384) t2 = PostgresVectorType(dim=384) t3 = PostgresVectorType(dim=768) @@ -279,9 +308,9 @@ def test_vector_type_type_params(): def test_vector_types_cross_class_inequality(): """Different vector types with same dim are not equal.""" - assert PostgresVectorType(3) != PostgresHalfvecType(3) - assert PostgresVectorType(3) != PostgresSparsevecType(3) - assert PostgresHalfvecType(3) != PostgresSparsevecType(3) + assert PostgresVectorType(dim=3) != PostgresHalfvecType(dim=3) + assert PostgresVectorType(dim=3) != PostgresSparsevecType(dim=3) + assert PostgresHalfvecType(dim=3) != PostgresSparsevecType(dim=3) # --------------------------------------------------------------------------- @@ -345,6 +374,173 @@ def test_time_precision_validation(dialect): dialect.format_data_type(TimeType(precision=7)) +# --------------------------------------------------------------------------- +# Signedness on the floating-point and exact fixed-point concepts +# --------------------------------------------------------------------------- + +#: The three concepts that carry ``unsigned`` besides the four integer widths, +#: with the PostgreSQL type each renders when the flag is left alone and the +#: constructor arguments each needs to reach its precision-bearing form. +#: +#: ``DECIMAL`` and ``FLOAT`` are here with their precision/scale because those are +#: fields too, and the point of the table is that refusing ``unsigned`` does not +#: disturb them: every signed rendering below is byte-for-byte what it was. +NUMERIC_SIGNEDNESS = [ + (DecimalType, {}, "DECIMAL"), + (DecimalType, {"precision": 10, "scale": 2}, "DECIMAL(10,2)"), + (FloatType, {}, "REAL"), + (FloatType, {"precision": 24}, "FLOAT(24)"), + (DoubleType, {}, "DOUBLE PRECISION"), + (DoubleType, {"spelling": "double precision"}, "DOUBLE PRECISION"), +] + +NUMERIC_SIGNEDNESS_IDS = [ + f"{k.__name__}{''.join(sorted(kw)) or '-bare'}" for k, kw, _s + in NUMERIC_SIGNEDNESS +] + + +@pytest.mark.parametrize("klass,kwargs,signed_sql", NUMERIC_SIGNEDNESS, + ids=NUMERIC_SIGNEDNESS_IDS) +def test_the_signed_rendering_is_unchanged(dialect, klass, kwargs, signed_sql): + """The refusal is not paid for by moving a signed rendering.""" + assert dialect.format_data_type(klass(dialect, **kwargs)) == (signed_sql, ()) + assert dialect.format_data_type( + klass(dialect, unsigned=False, **kwargs)) == (signed_sql, ()) + + +@pytest.mark.parametrize("klass,kwargs,signed_sql", NUMERIC_SIGNEDNESS, + ids=NUMERIC_SIGNEDNESS_IDS) +def test_unsigned_is_refused_not_rendered_signed(dialect, klass, kwargs, + signed_sql): + """The defect this answers: flipping the field used to change nothing. + + ``DecimalType(unsigned=True)``, ``FloatType(unsigned=True)`` and + ``DoubleType(unsigned=True)`` each rendered byte-identical SQL to the signed + declaration, so a caller got a column that accepts the negatives it declared + it would not, and the call reported success. + + PostgreSQL's answer is a refusal. Its Table 8.2 lists ``decimal``/``numeric``, + ``real`` and ``double precision`` with no unsigned variant; ``real`` and + ``double precision`` are IEEE 754 binary formats whose documented ranges are + signed on both sides; ``numeric``'s own special values include + ``-Infinity``; and ``CREATE TABLE`` has no attribute slot after the type name + in which ``UNSIGNED`` could go. + https://www.postgresql.org/docs/current/datatype-numeric.html + """ + with pytest.raises(UnsupportedFeatureError) as excinfo: + dialect.format_data_type(klass(dialect, unsigned=True, **kwargs)) + message = str(excinfo.value) + assert "unsigned" in message, message + assert excinfo.value.suggestion, "the refusal must carry a route forward" + assert "CHECK" in excinfo.value.suggestion + + +@pytest.mark.parametrize("klass,kwargs,signed_sql", NUMERIC_SIGNEDNESS, + ids=NUMERIC_SIGNEDNESS_IDS) +def test_signedness_is_part_of_identity(dialect, klass, kwargs, signed_sql): + """Why refusing is right: the two are different columns to the differ. + + Refusing at render time must not flatten the model to make the refusal + easier -- the flag is in ``PARAMETERS`` and reaches ``identity()``. + """ + assert "unsigned" in klass.PARAMETERS, klass.__name__ + signed = klass(dialect, **kwargs) + unsigned = klass(dialect, unsigned=True, **kwargs) + assert signed != unsigned + assert hash(signed) != hash(unsigned) + assert klass(dialect, **kwargs) == signed + + +def test_unsigned_is_type_checked(dialect): + """It becomes a type modifier in the rendered DDL, so ``1`` is not a + truthy ``True`` and ``"yes"`` is not a spelling of one.""" + for klass, kwargs in ((DecimalType, {}), (FloatType, {}), (DoubleType, {})): + with pytest.raises(TypeError, match="unsigned must be a bool"): + klass(dialect, unsigned=1, **kwargs) + with pytest.raises(TypeError, match="unsigned must be a bool"): + klass(dialect, unsigned="yes", **kwargs) + + +def test_the_scale_and_precision_checks_are_untouched_by_the_new_refusal( + dialect): + """A new failure path must not have swallowed the old answers. + + ``DECIMAL`` still refuses a bare scale with ``UnsupportedFeatureError`` and + still raises ``ValueError`` for an out-of-range precision, and ``FLOAT`` + still raises ``ValueError`` for an out-of-range precision. The unsigned + refusal is checked first, so it is what a request that got *both* wrong is + told about -- but a signed request is unaffected. + """ + with pytest.raises(UnsupportedFeatureError) as bare_scale: + dialect.format_data_type(DecimalType(dialect, scale=2)) + assert "scale" in str(bare_scale.value) + + with pytest.raises(ValueError) as precision: + dialect.format_data_type(DecimalType(dialect, precision=1001)) + assert not isinstance(precision.value, UnsupportedFeatureError) + + with pytest.raises(ValueError) as float_precision: + dialect.format_data_type(FloatType(dialect, precision=54)) + assert not isinstance(float_precision.value, UnsupportedFeatureError) + + # Both faults at once: the flag is named, because it is checked first. + with pytest.raises(UnsupportedFeatureError) as both: + dialect.format_data_type( + DecimalType(dialect, scale=2, unsigned=True)) + assert "unsigned" in str(both.value) + + +def test_an_unsigned_request_is_answered_the_same_way_for_every_concept( + dialect): + """One exception type for one refusal, so portable code can catch it. + + ``UnsupportedFeatureError`` does not subclass ``ValueError``. The project's + rule is that a *wrong value* raises ``ValueError`` and a declaration the + grammar cannot express at all raises ``UnsupportedFeatureError``, and every + backend answers this identical refusal the same way -- which is what lets a + caller write one ``except`` clause for it. + """ + seen = 0 + for klass, kwargs, _sql in NUMERIC_SIGNEDNESS: + try: + dialect.format_data_type(klass(dialect, unsigned=True, **kwargs)) + except UnsupportedFeatureError: + seen += 1 + except ValueError as exc: # pragma: no cover + pytest.fail( + f"refused an unsigned {klass.__name__} with ValueError; every " + f"other backend raises UnsupportedFeatureError for the same " + f"refusal, and the two do not share a base class: {exc}" + ) + assert seen == len(NUMERIC_SIGNEDNESS) + + +def test_the_catalog_can_never_report_the_attribute_so_parse_type_needs_nothing( + dialect): + """Why ``parse_type`` is untouched here, unlike MySQL's and MariaDB's. + + On those two the catalog hands ``parse_type`` a ``COLUMN_TYPE`` that really + can contain ``unsigned``, so it has to be read or a changed column compares + equal to its own declaration. PostgreSQL's grammar has no such attribute, so + the word cannot appear in a ``format_type`` result and there is nothing to + read: inventing a branch for a string this backend never produces would be a + guess dressed as a fix. The rendering is still canonical, which is the half + of D8 that does apply. + """ + assert dialect.format_data_type(DecimalType(dialect, 10, 2)) == ( + "DECIMAL(10,2)", ()) + assert "UNSIGNED" not in dialect.format_data_type( + DecimalType(dialect, 10, 2))[0] + # ...and what it does render, it parses back to the same value. + for klass, kwargs, sql in NUMERIC_SIGNEDNESS: + if klass is FloatType and not kwargs: + # Bare FLOAT renders REAL on this backend, which is RealType's word: + # a pre-existing spelling asymmetry, not an unsigned one. + continue + assert dialect.parse_type(sql) == klass(dialect, **kwargs), sql + + # --------------------------------------------------------------------------- # PostgresEnumType not a DataType — protocol boundary # --------------------------------------------------------------------------- @@ -357,3 +553,77 @@ def test_postgres_enum_type_is_not_data_type(): assert not issubclass(PostgresEnumType, DataType) assert issubclass(PostgresEnumType, BaseExpression) + + +# --------------------------------------------------------------------------- +# parse_type: the shared round-trip sweep over the whole declared surface + + +class TestParseRoundTripSweep: + """Every rendered type parses back coherently, in one assertion each way. + + The render-parse-re-render sweep over this backend's own registry found + six words it wrote and could not read back -- CITEXT, CUBE, LTREE, + LQUERY, LTXTQUERY and RASTER, all extension types this dialect renders + under its own ``format_data_type_postgres_*`` formatters -- and each + answered ``CustomType``, so a column of one of them introspected as a + type the backend does not model. The branches now sit where HSTORE and + GEOMETRY already sat, ungated for the same reason: the word can only + have come from a server where the extension exists. + + The sweep asserts the two invariants everywhere: **string stability** -- + parsing a rendering and re-rendering the answer produces the identical + string -- and **class honesty** -- the answer is the declared + instance, or the documented widening answer recorded below with its + reason. + """ + + #: The documented widening answers. ``tinyint`` has no 1-byte storage + #: here, so the concept is widened to the 2-byte one the tinyint + #: formatter names; bare ``FLOAT`` renders ``REAL``, which is the + #: RealType concept; the ``datetime`` concept renders ``TIMESTAMP``, + #: which is the storage both it and the timestamp concept share; and + #: the bytea/uuid/xml/array renderings are the backend's own classes + #: for those storages. + WIDENING_ANSWERS = { + "ArrayType": "PostgresArrayType", + "BlobType": "PostgresByteaType", + "DateTimeType": "TimestampType", + "TinyIntType": "SmallIntType", + "FloatType": "RealType", + "UUIDType": "PostgresUUIDType", + "XmlType": "PostgresXMLType", + } + + @pytest.fixture(scope="class") + def registry(self): + """The round-trip module's registry, loaded from this directory's + parent. Executing it also registers its special constructors, which + the sweep's ``make_instance`` consults -- the same registrations the + full suite performs at collection time, repeated here so the sweep + also holds when this file runs alone. + """ + import importlib.util + from pathlib import Path + + path = Path(__file__).resolve().parent.parent / ( + "test_expression_roundtrip_all.py" + ) + spec = importlib.util.spec_from_file_location("postgres_rt_registry", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + registry = getattr(module, "ALL_CLASSES", None) or module.REGISTERED + assert registry, "the round-trip module exposes no registry" + return registry + + def test_every_rendered_type_parses_back_coherently(self, dialect, registry): + from rhosocial.activerecord.testsuite.utils.parse_contract import ( + parse_roundtrip_failures, + ) + + failures = parse_roundtrip_failures( + dialect, + registry, + widening=self.WIDENING_ANSWERS, + ) + assert failures == [], "\n".join(failures) From d01e5deb5bf54a1248e2ec7dd8bfb4c003e99177 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 08:12:24 +0800 Subject: [PATCH 02/15] feat: align the column-type table with the rebuilt core protocol --- .../backend/impl/postgres/dialect.py | 6 +- .../impl/postgres/expression/values.py | 7 +- .../backend/impl/postgres/mixins/__init__.py | 4 +- .../impl/postgres/mixins/column_suggestion.py | 232 ------------- .../impl/postgres/mixins/column_type.py | 197 +++++++++++ .../test_postgres_protocol_conformance.py | 7 + .../types/test_column_type_suggestions.py | 315 +++++++++--------- 7 files changed, 366 insertions(+), 402 deletions(-) delete mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 4cced4ed..e3bfdbda 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -160,7 +160,7 @@ PostgresDataTypeMixin, PostgresLogicalReplicationMixin, PostgresParallelQueryMixin, - PostgresColumnSuggestionMixin, + PostgresColumnTypeMixin, # Per-feature mixins PostgresCTEMixin, PostgresWindowMixin, @@ -485,12 +485,12 @@ class PostgresDialect( PostgresDataTypeMixin, PostgresLogicalReplicationMixin, PostgresParallelQueryMixin, - # Column-type suggestions. After the DataType mixin above because that is the + # Column types. After the DataType mixin above because that is the # sibling decision -- this one says which operations a value carries, the # other says how it is spelled. Nothing else in the list can answer for a # common Python type, so the MRO leaves the placement free; putting the two # side by side is what makes the separation legible to the next reader. - PostgresColumnSuggestionMixin, + PostgresColumnTypeMixin, # Extension feature mixins PostgresLtreeMixin, PostgresIntarrayMixin, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py index 5ce7e337..05dbeab8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/values.py @@ -172,9 +172,10 @@ class NetworkValueExpression( and can be masked or merged -- the chain does not stop at the first call. No family is declared, and that is deliberate rather than missing. - ``value_type_of`` reports a family the core lattice knows, and an inet - address is not one of them, so declaring "network" here would be a claim - nothing reports. The range, hstore and ltree expressions below take the same + The core's type casting re-types by class -- ``as_text``, ``as_number`` + and friends -- and an inet address is not among the classes it names, + so declaring "network" here would be a claim nothing reports. The range, + hstore and ltree expressions below take the same view. What identifies an address is its class and the operations on it, not a tag the core would drop. """ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index a2e8649e..2712e0b1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -96,7 +96,7 @@ # Additional mixins from .query_optimization import PostgresQueryOptimizationMixin from .data_type import PostgresDataTypeMixin -from .column_suggestion import PostgresColumnSuggestionMixin +from .column_type import PostgresColumnTypeMixin from .logical_replication import PostgresLogicalReplicationMixin from .parallel_query import PostgresParallelQueryMixin from .property_graph_query import PostgresPropertyGraphQueryMixin @@ -214,7 +214,7 @@ # Additional mixins "PostgresQueryOptimizationMixin", "PostgresDataTypeMixin", - "PostgresColumnSuggestionMixin", + "PostgresColumnTypeMixin", "PostgresLogicalReplicationMixin", "PostgresParallelQueryMixin", "PostgresPropertyGraphQueryMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py deleted file mode 100644 index f8dde84d..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py +++ /dev/null @@ -1,232 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/mixins/column_suggestion.py -"""PostgreSQL's answer to "which column class does this Python type mean here". - -The column half of the suggestion protocol (`.claude/plan/2026-10-08/ -column-suggestion-protocol.md` §1–§2, §12's PostgreSQL row). A column class -says what a value can **do** on this server; how it is **stored** is the -``DataType`` layer's separate decision and never read from here. Keeping the two -apart is why this table can answer ``dict`` with a JSON column class while the -DDL side reaches for ``jsonb`` (§0.2) — the two are answers to different -questions, and neither infers the other. - -Every answer below is written out rather than inherited from core's neutral -table, because a backend that owns its answer should be readable without a -second file. The eighteen entries are the protocol's closed list, all of them -answered, and none of them refused: :data:`UNSUPPORTED` is for a backend that -*cannot* express an entry, and there is no such entry here — see -``UNSUPPORTED`` below. - -**What the answers are measured against.** The live probes in -``.claude/plan/2026-10-08/suggested-mappings.md`` §8 (≈90 server instances -across ten backends), the per-family detail in ``suggested-pairing-array.md``, -``-json``, ``-datetime``, ``-string-enum``, ``-uuid``, ``-numeric``, and this -backend's own ``.claude/plan/2026-10-08/secondary-gaps-investigation.md`` -(附录 C mapping, 附录 D gap inventory, 附录 F catalog round-trip over eleven -servers). 附录 F is the discipline this table follows rather than a source of -answers: its single finding F1 (``bit varying(n)`` parsed as ``PostgresBitType`` -instead of ``PostgresVarBitType``) was invisible to render/parse round-trips -and only appeared once the **server's own catalog words** were fed in — four of -thirty-six words were wrong, and the declared spelling was wrong the same way, -so both sides of the comparison agreed with each other and both were wrong. Fixed -in ``04ec5e5``. An answer written from documentation rather than from a server -is exactly the shape of that bug, which is why the array and JSON rows below -quote the probe tables instead of the manual. -""" - -import datetime -import decimal -import enum -import uuid -from typing import Any, Dict, Type - -from rhosocial.activerecord.backend.dialect.mixins import ColumnSuggestionMixin -from rhosocial.activerecord.backend.expression.column_types import ( - ArrayColumn, - BinaryColumn, - BooleanColumn, - ColumnBase, - DateTimeColumn, - DecimalColumn, - FloatColumn, - IntegerColumn, - JSONColumn, - NumericColumn, - StringColumn, - UUIDColumn, -) - - -class PostgresColumnSuggestionMixin(ColumnSuggestionMixin): - """The full common-Python-type → column-class table for PostgreSQL. - - Composed into :class:`~rhosocial.activerecord.backend.impl.postgres.dialect.PostgresDialect` - ahead of this backend's own mixins, and after core's - :class:`~rhosocial.activerecord.backend.dialect.mixins.column_suggestion.ColumnSuggestionMixin` - supplies the resolution path it is queried through. - - **Two entries differ from core's neutral table** — ``float`` and - ``decimal.Decimal`` — and the other sixteen are the baseline all ten backends - agree on. The sixteen are still written here rather than spread-copied, so - that "this backend agrees" is a stated fact a test can check instead of an - accident of what core happened to publish. - - **No entry is ``UNSUPPORTED``.** The protocol reserves the sentinel for a - backend with no column class for an entry, and the two backends that need it - need it for ``dict`` — MySQL below 5.7 and Firebird 5/6, which have no JSON - functions at all (protocol §12). PostgreSQL has had ``json`` since 9.2 and - ``jsonb`` since 9.4, plus the path operators on both, so the refusal would - be false. Every measured PG version behind these answers (9.6, 12, 13, 16, - 18, 19beta4 — 附录 F) answers all eighteen, and the table has no version - branch because nothing in it moves with the version: the one thing that does - (``jsonb`` becoming available in 9.4) changes the DDL spelling, not which - column class a ``dict`` is. - """ - - #: All eighteen protocol entries, in the protocol's reading order. Commented - #: per group because the reasons are the content: the answer is a claim about - #: this server, and a claim without its evidence is decoration. - COLUMN_TYPE_SUGGESTIONS: Dict[Any, Type[ColumnBase]] = { - # -- Scalars --------------------------------------------------------- - # Native `boolean`, and the three-valued logic that comes with it, so - # `is_true()` / `is_false()` render as `IS TRUE` / `IS FALSE` rather - # than `= 1`. That spelling is not a stylistic choice: PostgreSQL rejects - # `boolean = integer` outright (suggested-mappings §8.1, `bool` row), - # which is why `BooleanColumn` exists rather than a numeric column. - bool: BooleanColumn, - int: IntegerColumn, - # DEVIATION 1 of 2. Core's neutral table answers `NumericColumn` for - # `float`; §12's ten-backend baseline says `FloatColumn`, and this - # backend's 附录 C row agrees ("核心默认 NumericColumn 偏泛;本后端 - # real/double precision 真实分界"). The two classes offer the same - # operations — `FloatColumn`, `RealColumn` and `DoubleColumn` differ in - # name, not surface — so this is a readability gain, not a capability - # claim, and it is why no operation is narrowed for it below. - float: FloatColumn, - # DEVIATION 2 of 2, same shape: core answers `NumericColumn`, §12 says - # `DecimalColumn`, and 附录 C gives the reason for this backend - # specifically — `numeric` is the exact family here, so a `Decimal` is - # not "some number" but a number whose sum has to come out right. - decimal.Decimal: DecimalColumn, - str: StringColumn, - # `bytea`. Binary comparison, length, substring and hex are all - # available (suggested-mappings §8.1, `bytes` row, where PostgreSQL is - # in the unqualified set). - bytes: BinaryColumn, - bytearray: BinaryColumn, - # -- Temporal -------------------------------------------------------- - # `date` / `time` / `datetime` are three entries and one answer, and the - # answer is the baseline: core has no `DateColumn` / `TimeColumn` to give - # (secondary-gaps 附录 D ① records both as core gaps), so splitting - # `DateTimeColumn` by operation set is a core modelling change and not - # this backend's to make in this table. A tz-aware `datetime` lands on - # the same class and is where this backend is strongest: `AT TIME ZONE` - # is native and named zones work (only the *read-back* follows the - # session time zone — §8.1 `datetime tz` row). - datetime.date: DateTimeColumn, - datetime.time: DateTimeColumn, - datetime.datetime: DateTimeColumn, - # `timedelta` → `NumericColumn`, which is the §12 fallback and NOT what - # this server can do. PostgreSQL has a real `interval` type — §1 spells - # it `INTERVAL` where eight backends have no answer at all, and - # 附录 D ① lists `IntervalColumn` as a class this backend would use and - # core does not have. - # - # It is kept at `NumericColumn` because `interval` is not a number: the - # server answers `justify_hours`, `EXTRACT(... FROM interval)` and field - # qualifiers for it, and refuses `sqrt(interval '1 day')` outright. - # `NumericColumn` offers transcendentals, so naming it here over-claims - # exactly that much — a real, and deliberately *declared*, gap rather - # than a silent one: it is inherited from core's neutral table, not - # chosen here, and the fix is core growing an `IntervalColumn` (pinned by - # `test_timedelta_is_the_numeric_gap_until_core_grows_an_interval_column`). - datetime.timedelta: NumericColumn, - # -- Value families -------------------------------------------------- - uuid.UUID: UUIDColumn, - # `dict` → the JSON column class, on both `json` and `jsonb`. §12's - # PostgreSQL row adds `jsonb` because `json` has **no equality operator, - # no `?` and no `@>`** (protocol §0.2; §8.1 `dict` row) — but that is a - # DDL-layer choice made at the `DataType` layer (`JsonBType` / - # `format_data_type_jsonb` on this backend), not something a column class - # can express. The column class is the operation surface, and it is the - # same either way: `json_path` / `json_value`, has-key, array length, - # validity, and on `jsonb` the containment operators on top - # (`PostgresJSONBEnhancedMixin`). - dict: JSONColumn, - # Native arrays — the strongest `ArrayColumn` backend of the ten, and the - # one place where this table's answer is a real *difference* rather than - # a shared baseline. Seven backends route `list` through a JSON document - # (§12); `suggested-pairing-array.md` §A measures PostgreSQL 9.6 / 12 / - # 18 with every row of the guarantee set green: `INT[]` / `TEXT[]` DDL, - # a Python list bound directly and read back as a list, `array_length`, - # 1-based `x[1]`, containment `x @> ARRAY[v]`, concatenation `x || v`, - # the quantified form `v = ANY(x)`, whole-column equality, and `unnest` - # — against a seven-backend field where the same ops are variously - # absent, silently wrong, or need emulation. 附录 C calls this the - # contrast case: same Python type, different storage, different answer. - # - # `set` / `frozenset` / `tuple` answer `ArrayColumn` too, for the same - # reason the baseline puts them together: they are sequences to the - # server, and order is the one thing a set does not promise. - list: ArrayColumn, - tuple: ArrayColumn, - set: ArrayColumn, - frozenset: ArrayColumn, - # `enum.Enum` → `StringColumn`, which is the baseline and is also what - # the value semantics say: the labels compare as strings, so equality, - # `IN` and `ORDER BY` render as they do for text (§8.1 `enum` row). - # - # PostgreSQL does have enums, but as a *named* type created once by - # `CREATE TYPE ... AS ENUM` and referred to by name — which is a DDL - # fact, carried by `PostgresEnumColumnType` on the `DataType` side - # (secondary-gaps §2 item 5). Spelling the label set into the column - # class would put storage into the operation layer, which is the split - # this protocol exists to keep. - enum.Enum: StringColumn, - } - - def supports_column_operation(self, column_name: str, op: str) -> bool: - """No operation is narrowed on this backend. Every one is available. - - The declared verdict, written out rather than inherited silently, because - "nothing was narrowed" and "nobody checked" look identical at the call - site and this backend is the series' **control**: eight of the ten backends - have to narrow ``ilike`` away, and this one is what proves the mechanism - discriminates. - - What the probes measured against, per §8.1 / §8.4 and - ``secondary-gaps-investigation.md`` 附录 D ③ ("核心提供、本后端做不到: - 几乎无——本后端能力面最全"): - - ``ilike`` (``StringColumn``) - Native, and the reason eight backends narrow it. PostgreSQL is one of - the two that answer it (§8.1 `str` row, §8.4 "ILIKE 仅 PG/CH"). - ``json_path`` / ``json_value`` (``JSONColumn``) - `->>` / `->` on both `json` and `jsonb`, with `jsonb`'s `@>`, `?`, - `?|`, `?&`, `#>>` beyond what the path accessors offer. The JSON - guarantee set — binding round-trip, scalar and nested paths, has-key, - array length, validity — is measured green here (§8.1 `dict` row), - where MySQL 5.6 and Firebird have no default at all. - ``array_length`` / ``unnest`` (``ArrayColumn``) - Both native and both measured on 9.6 / 12 / 18, together with `x[1]`, - `@>`, `||`, `v = ANY(x)` and whole-column equality - (``suggested-pairing-array.md`` §A). - tz-aware ``datetime`` - `AT TIME ZONE` is native and the offset form works everywhere; the - one caveat §8.1 records is that a tz-aware value **reads back** in the - session time zone, which is a value-layer converter question, not a - missing operation. - ``LENGTH`` on ``StringColumn`` - Characters, matching what the string family means on this backend - (§8.4: MySQL/ClickHouse are bytes, SQL Server UTF-16 code units). - Same answer, so nothing to declare. - - Args: - column_name: The column class name (``"StringColumn"``). Not - consulted — see the contract test that cross-checks the operation - names against ``hasattr``. - op: The operation name, as the public method that provides it. - - Returns: - Always ``True``. - """ - return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py new file mode 100644 index 00000000..7f3931ca --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py @@ -0,0 +1,197 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py +"""PostgreSQL's answer to "which column class does this Python type mean here". + +The column half of the column-type protocol. A column class says what a value +can **do** on this server; how it is **stored** is the ``DataType`` layer's +separate decision and never read from here. Keeping the two apart is why this +table answers ``dict`` with a JSON column class while the DDL side reaches for +``jsonb`` (``.claude/plan/2026-10-08/column-suggestion-protocol.md`` §0.2) -- +the two are answers to different questions, and neither infers the other. + +Every answer below is written out rather than inherited from a core-side +table, because a backend that owns its answer should be readable without a +second file. The eighteen entries are the protocol's closed list, all of them +answered, and exactly one of them is refused: ``datetime.timedelta``, which +this server can spell as ``interval`` but which no core column class can +answer for -- see the entry's own comment. + +**What the answers are measured against.** The live probes in +``.claude/plan/2026-10-08/suggested-mappings.md`` §8 (≈90 server instances +across ten backends), the per-family detail in ``suggested-pairing-array.md``, +``-json``, ``-datetime``, ``-string-enum``, ``-uuid``, ``-numeric``, and this +backend's own ``.claude/plan/2026-10-08/secondary-gaps-investigation.md`` +(附录 C mapping, 附录 D gap inventory, 附录 F catalog round-trip over eleven +servers). 附录 F is the discipline this table follows rather than a source of +answers: its single finding F1 (``bit varying(n)`` parsed as ``PostgresBitType`` +instead of ``PostgresVarBitType``) was invisible to render/parse round-trips +and only appeared once the **server's own catalog words** were fed in -- four of +thirty-six words were wrong, and the declared spelling was wrong the same way, +so both sides of the comparison agreed with each other and both were wrong. Fixed +in ``04ec5e5``. An answer written from documentation rather than from a server +is exactly the shape of that bug, which is why the array and JSON rows below +quote the probe tables instead of the manual. + +**The two numeric deviations are gone, and the reason is a protocol fact.** +This table used to answer ``float`` with ``FloatColumn`` and ``decimal.Decimal`` +with ``DecimalColumn`` where the framework answered the generic +``NumericColumn``. The rebuild removed those two classes -- the numeric family +is deliberately one class (``NumericColumn``), because the operations are the +same whatever the value was declared as, and the difference between +``numeric(18,4)`` and ``double precision`` belongs to the DDL layer's +``DataType``. So the deviation closed for both entries without an edit here: +they now answer ``NumericColumn``, which is what +``.claude/plan/2026-10-08/secondary-gaps-investigation.md`` 附录 C called the +"偏泛" answer, and what the family has to say now that there is only one class +to say it with. +""" + +import datetime +import decimal +import enum +import uuid +from typing import Any, Dict, Optional, Type + +from rhosocial.activerecord.backend.dialect.mixins import ColumnTypeMixin +from rhosocial.activerecord.backend.expression.column_types import ( + ArrayColumn, + BinaryColumn, + BooleanColumn, + ColumnBase, + DateTimeColumn, + IntegerColumn, + JSONColumn, + NumericColumn, + StringColumn, + UUIDColumn, +) + +#: All eighteen protocol entries, in the protocol's reading order. Commented +#: per group because the reasons are the content: the answer is a claim about +#: this server, and a claim without its evidence is decoration. +POSTGRES_COLUMN_TYPES: Dict[Any, Optional[Type[ColumnBase]]] = { + # -- Scalars --------------------------------------------------------- + # Native `boolean`, and the three-valued logic that comes with it, so + # `is_true()` / `is_false()` render as `IS TRUE` / `IS FALSE` rather + # than `= 1`. That spelling is not a stylistic choice: PostgreSQL rejects + # `boolean = integer` outright (suggested-mappings §8.1, `bool` row), + # which is why `BooleanColumn` exists rather than a numeric column. + bool: BooleanColumn, + int: IntegerColumn, + # `NumericColumn`, as for the entries above and below it. PostgreSQL really + # does have distinct `numeric` / `real` / `double precision` families + # (suggested-mappings §8.1, `float` row), but that is a *storage* fact the + # `DataType` layer spells; the operation surface is the numeric one, and it + # is one class since the rebuild removed `FloatColumn` / `RealColumn` / + # `DoubleColumn`. + float: NumericColumn, + decimal.Decimal: NumericColumn, + str: StringColumn, + # `bytea`. Binary comparison, length, substring and hex are all + # available (suggested-mappings §8.1, `bytes` row, where PostgreSQL is + # in the unqualified set). + bytes: BinaryColumn, + bytearray: BinaryColumn, + # -- Temporal -------------------------------------------------------- + # `date` / `time` / `datetime` are three entries and one answer, and the + # answer is the baseline all ten backends agree on. A tz-aware `datetime` + # lands on the same class and is where this backend is strongest: + # `AT TIME ZONE` is native and named zones work (only the *read-back* + # follows the session time zone -- §8.1 `datetime tz` row). + datetime.date: DateTimeColumn, + datetime.time: DateTimeColumn, + datetime.datetime: DateTimeColumn, + # THE ONE DELIBERATE `None`. PostgreSQL has a real `interval` type -- §1 + # spells it `INTERVAL` where eight backends have no answer at all -- and no + # core column class answers for it (`IntervalColumn` is a core gap, + # secondary-gaps 附录 D ①). `interval` is not a number: the server answers + # `justify_hours`, `EXTRACT(... FROM interval)` and field qualifiers for + # it, and refuses `sqrt(interval '1 day')` outright, so naming a number + # class here would hand the caller operations the engine cannot execute. + # `None` is the protocol's last resort and this is its last resort: the + # value is carried (psycopg binds and reads back a `timedelta`), and the + # storage is spelled by the `DataType` layer + # (`format_data_type_interval`), but the *operations* have no class to + # name. A field declared `timedelta` fails to resolve with a message + # naming the fix -- declare one explicitly with + # `UseColumnType(SomeColumn)`. When core grows an `IntervalColumn`, this + # entry is where it goes (pinned by + # `test_the_interval_entry_is_refused_until_core_grows_an_interval_column`). + datetime.timedelta: None, + # -- Value families -------------------------------------------------- + uuid.UUID: UUIDColumn, + # `dict` → the JSON column class, on both `json` and `jsonb`. §12's + # PostgreSQL row adds `jsonb` because `json` has **no equality operator, + # no `?` and no `@>`** (protocol §0.2; §8.1 `dict` row) -- but that is a + # DDL-layer choice made at the `DataType` layer (`JsonBType` / + # `format_data_type_jsonb` on this backend), not something a column class + # can express. The column class is the operation surface, and it is the + # same either way: `json_path` / `json_value`, has-key, array length, + # validity, and on `jsonb` the containment operators on top + # (`PostgresJSONBEnhancedMixin`). + dict: JSONColumn, + # Native arrays -- the strongest `ArrayColumn` backend of the ten, and the + # one place where this table's answer is a real *difference* rather than + # a shared baseline. Seven backends route `list` through a JSON document + # (§12); `suggested-pairing-array.md` §A measures PostgreSQL 9.6 / 12 / + # 18 with every row of the guarantee set green: `INT[]` / `TEXT[]` DDL, + # a Python list bound directly and read back as a list, `array_length`, + # 1-based `x[1]`, containment `x @> ARRAY[v]`, concatenation `x || v`, + # the quantified form `v = ANY(x)`, whole-column equality, and `unnest` + # -- against a seven-backend field where the same ops are variously + # absent, silently wrong, or need emulation. 附录 C calls this the + # contrast case: same Python type, different storage, different answer. + # + # `set` / `frozenset` / `tuple` answer `ArrayColumn` too, for the same + # reason the baseline puts them together: they are sequences to the + # server, and order is the one thing a set does not promise. + list: ArrayColumn, + tuple: ArrayColumn, + set: ArrayColumn, + frozenset: ArrayColumn, + # `enum.Enum` → `StringColumn`, which is the baseline and is also what + # the value semantics say: the labels compare as strings, so equality, + # `IN` and `ORDER BY` render as they do for text (§8.1 `enum` row). + # + # PostgreSQL does have enums, but as a *named* type created once by + # `CREATE TYPE ... AS ENUM` and referred to by name -- which is a DDL + # fact, carried by `PostgresEnumColumnType` on the `DataType` side + # (secondary-gaps §2 item 5). Spelling the label set into the column + # class would put storage into the operation layer, which is the split + # this protocol exists to keep. + enum.Enum: StringColumn, +} + + +class PostgresColumnTypeMixin(ColumnTypeMixin): + """The full common-Python-type → column-class table for PostgreSQL. + + Composed into :class:`~rhosocial.activerecord.backend.impl.postgres.dialect.PostgresDialect` + ahead of this backend's own mixins. Nothing else in the list can answer for + a common Python type, so the MRO leaves the placement free. + + **Every entry is measured, and two of them moved with the rebuild.** + ``float`` and ``decimal.Decimal`` now answer ``NumericColumn`` like every + other backend: core had a ``FloatColumn`` and a ``DecimalColumn`` when this + table was written, and the entry-by-entry measurements were right against + them; the numeric family is now deliberately one class, so the two answers + became the generic one. The change is in the class name, not in the + operations, and it is pinned by the backend's own test file rather than + left to drift. + + **One entry is refused.** ``datetime.timedelta`` answers ``None`` because + PostgreSQL's native ``interval`` has no core column class -- the gap this + backend used to hide by naming ``NumericColumn`` and accepting that + ``sqrt(interval '1 day')`` would be offered and would fail on the server. + A refusal that reaches the model layer as a resolution error is the honest + version of that gap, and the entry's comment says what closes it. + + The table has no version branch because nothing in it moves with the + version: the one thing that does (``jsonb`` becoming available in 9.4) + changes the DDL spelling, not which column class a ``dict`` is. + """ + + def suggested_column_types(self) -> Dict[Any, Optional[Type[ColumnBase]]]: + return dict(POSTGRES_COLUMN_TYPES) + + +__all__ = ["POSTGRES_COLUMN_TYPES", "PostgresColumnTypeMixin"] diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 502b271b..757a49a3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -160,6 +160,13 @@ def get_postgres_protocols(): dialect_protocols.AutoIncrementColumnSupport, dialect_protocols.IdentityColumnSupport, dialect_protocols.GeneratedColumnSupport, + # The column-type table: `PostgresColumnTypeMixin` answers + # `suggested_column_types()` for all eighteen common Python types and + # `suggested_extra_column_types()` with nothing of its own, and the + # dialect composes the mixin. The table is the model layer's source for + # which column class a field's annotation resolves to, so PostgreSQL + # declares it rather than leaving it for another dialect to answer. + dialect_protocols.ColumnTypeSupport, # Statement forms PostgreSQL implements through its mixins, so the # partition test below can classify them rather than leaving them # unclassified. diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py index 6d02d2a8..16c5bd9b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py @@ -1,17 +1,18 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py -"""PostgreSQL's column-type suggestion table, and its declared lack of narrowing. +"""PostgreSQL's column-type table, and the one entry it refuses. -The protocol's obligation on a backend is three things (protocol §2, §12): +The protocol's obligation on a backend is three things +(``backend/dialect/mixins/column_type.py``, the contract list in +``testsuite/feature/query/typed_column/column_helpers.COMMON_TYPES``): -1. **Answer every entry.** All eighteen of the closed list, none of them empty - and none of them silently absent. A hole is a lie told to the model layer. +1. **Answer every entry.** All eighteen of the closed list, none of them + silently absent. A hole is a lie told to the model layer. 2. **Say so where this backend differs.** Here: native arrays for the four - sequence entries, ``FloatColumn`` / ``DecimalColumn`` where core answers the - generic ``NumericColumn``, and no ``UNSUPPORTED`` anywhere. -3. **Narrow only with evidence.** PostgreSQL narrows nothing, which is asserted - rather than assumed -- it is the series' control backend, eight others have to - take ``ilike`` away, so "nothing was narrowed" and "nobody looked" must not - look the same at the call site. + sequence entries, where seven backends carry a JSON document. +3. **Refuse only what it truly cannot express** -- with ``None``, which reaches + the caller as a resolution error rather than as a silent pick. Here: the one + entry, ``datetime.timedelta``, whose native ``interval`` this server carries + but whose operations no core column class names. No database is needed for any of it: the question is which class an annotation resolves to, and the dialect already knows the server version. @@ -25,20 +26,13 @@ import pytest -from rhosocial.activerecord.backend.dialect.mixins import ColumnSuggestionMixin -from rhosocial.activerecord.backend.expression.column_suggestions import ( - COLUMN_TYPE_ENTRIES, - UNSUPPORTED, - ColumnTypeResolutionError, -) +from rhosocial.activerecord.backend.dialect.mixins import ColumnTypeMixin from rhosocial.activerecord.backend.expression.column_types import ( ArrayColumn, BinaryColumn, BooleanColumn, ColumnBase, DateTimeColumn, - DecimalColumn, - FloatColumn, IntegerColumn, JSONColumn, NumericColumn, @@ -46,8 +40,14 @@ UUIDColumn, ) from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.mixins.column_suggestion import ( - PostgresColumnSuggestionMixin, +from rhosocial.activerecord.backend.impl.postgres.mixins.column_type import ( + POSTGRES_COLUMN_TYPES, + PostgresColumnTypeMixin, +) +from rhosocial.activerecord.base.field_proxy import ColumnTypeResolutionError +from rhosocial.activerecord.testsuite.feature.query.typed_column.column_helpers import ( + COMMON_TYPES, + resolve_column_class, ) @@ -56,7 +56,12 @@ def dialect(): return PostgresDialect(version=(16, 0, 0)) -_ENTRY_IDS = [getattr(entry, "__name__", str(entry)) for entry in COLUMN_TYPE_ENTRIES] +def resolve(dialect, annotation): + """The class the field accessor's own selection picks on PostgreSQL.""" + return resolve_column_class(dialect, annotation) + + +_ENTRY_IDS = [getattr(entry, "__name__", str(entry)) for entry in COMMON_TYPES] # --------------------------------------------------------------------------- @@ -66,87 +71,96 @@ def dialect(): def test_the_dialect_carries_the_protocol_mixin(dialect): """PostgreSQL has to *have* the mixin; a table on a class nothing reaches is - documentation. ``field_proxy`` calls ``dialect.column_class_for`` on every - column access, so this is also what makes ``Model.c.`` work here.""" - assert isinstance(dialect, ColumnSuggestionMixin) - assert isinstance(dialect, PostgresColumnSuggestionMixin) + documentation. ``Model.c.`` asks the dialect's table on every column + access, so this is also what makes a model's fields work here.""" + assert isinstance(dialect, ColumnTypeMixin) + assert isinstance(dialect, PostgresColumnTypeMixin) -@pytest.mark.parametrize("entry", COLUMN_TYPE_ENTRIES, ids=_ENTRY_IDS) +@pytest.mark.parametrize("entry", COMMON_TYPES, ids=_ENTRY_IDS) def test_every_entry_is_answered(dialect, entry): - """Eighteen present, every value a real column class. + """Eighteen present, every value a column class or a deliberate refusal. - ``None`` is the one answer the protocol forbids and it is not detectable by - ``suggested_column_types().get(entry, None)``, hence the explicit check: a - forgotten entry and a ``None`` have to fail differently from a refusal. + A missing key and a ``None`` have to fail differently from each other, hence + two assertions rather than one ``.get()``: a forgotten entry is a hole in + the contract, while a ``None`` is this backend stating that it has no class + for a value family -- and the protocol wants that stated, not guessed. """ table = dialect.suggested_column_types() assert entry in table, f"PostgreSQL does not answer {entry!r}" suggested = table[entry] - assert suggested is not None, f"{entry!r} answered with None, which is silence" - assert suggested is not UNSUPPORTED, f"{entry!r} is refused; PostgreSQL can express it" - assert isinstance(suggested, type) - assert issubclass(suggested, ColumnBase) + assert suggested is None or ( + isinstance(suggested, type) and issubclass(suggested, ColumnBase) + ), f"{entry!r} answered with {suggested!r}, which is neither a column class nor None" def test_the_table_has_no_entry_beyond_the_protocol(dialect): - """A key of the backend's own is allowed by §2, and is registered separately - so "the framework has an answer" stays distinguishable from "this backend has - one of its own". PostgreSQL has none, and a stray key would be indistinguishable - from the core half at the call site.""" - extra = set(dialect.suggested_column_types()) - set(COLUMN_TYPE_ENTRIES) + """A key of the backend's own is allowed by the protocol, and is reported + separately through ``suggested_extra_column_types()`` so "the framework has + an answer" stays distinguishable from "this backend has one of its own". + PostgreSQL has neither, and a stray key in the common table would be + indistinguishable from a framework entry at the call site.""" + extra = set(dialect.suggested_column_types()) - set(COMMON_TYPES) assert extra == set() -def test_the_table_is_a_copy_not_the_shared_class_attribute(dialect): - """Mutating what a caller received must not reach the next resolution.""" - table = dialect.suggested_column_types() - table[str] = BinaryColumn - assert dialect.column_class_for(str) is StringColumn +def test_the_backend_declares_no_extra_python_types(dialect): + """The framework's eighteen are the whole of what PostgreSQL answers for. + Its *server* types beyond them (``interval``, ``xml``, the multirange + family) are reached through their ``DataType`` spelling, not through a + Python-annotation entry of their own: annotating a field with an + unregistered custom class is what ``UseColumnType`` is for. + """ + assert dialect.suggested_extra_column_types() == {} -# --------------------------------------------------------------------------- -# 2. Every entry resolves to what the table says -# --------------------------------------------------------------------------- +def test_the_table_agrees_with_the_module_level_constant(dialect): + """One table, one source: the mixin's answer is the documented table. + + Written as an equality rather than an import-and-call so that a future edit + which makes the mixin compute something else fails here, naming the drift. + """ + assert dialect.suggested_column_types() == POSTGRES_COLUMN_TYPES + assert dialect.suggested_column_types() is not POSTGRES_COLUMN_TYPES -@pytest.mark.parametrize("entry", COLUMN_TYPE_ENTRIES, ids=_ENTRY_IDS) -def test_every_entry_resolves_to_its_own_table_answer(dialect, entry): - """Model and table must agree, or the table is a comment rather than the - answer. Table-relative on purpose: the class may legitimately differ between - backends, and what must not differ is what a model builds.""" - assert dialect.column_class_for(entry) is dialect.suggested_column_types()[entry] + +# --------------------------------------------------------------------------- +# 2. Every answer is the measured one +# --------------------------------------------------------------------------- def test_resolution_is_the_baseline_for_the_scalar_and_value_families(dialect): """The ten-backend baseline, written out so a change to one of these is a test failure rather than a silent redefinition of what a ``str`` means.""" - assert dialect.column_class_for(bool) is BooleanColumn - assert dialect.column_class_for(int) is IntegerColumn - assert dialect.column_class_for(str) is StringColumn - assert dialect.column_class_for(bytes) is BinaryColumn - assert dialect.column_class_for(bytearray) is BinaryColumn - assert dialect.column_class_for(uuid.UUID) is UUIDColumn - assert dialect.column_class_for(dict) is JSONColumn - assert dialect.column_class_for(datetime.date) is DateTimeColumn - assert dialect.column_class_for(datetime.time) is DateTimeColumn - assert dialect.column_class_for(datetime.datetime) is DateTimeColumn - - -def test_float_and_decimal_answer_their_own_classes_not_the_generic_numeric(dialect): - """Two deviations from core's neutral table, both §12's baseline. - - ``FloatColumn`` and ``DecimalColumn`` offer exactly the operations - ``NumericColumn`` does, so this is about the name a reader sees rather than - about capability -- which is also why nothing is narrowed for them. PostgreSQL - is the backend where the distinction is real storage, though: ``numeric`` is - its exact family and ``real`` / ``double precision`` are distinct widths. + assert resolve(dialect, bool) is BooleanColumn + assert resolve(dialect, int) is IntegerColumn + assert resolve(dialect, str) is StringColumn + assert resolve(dialect, bytes) is BinaryColumn + assert resolve(dialect, bytearray) is BinaryColumn + assert resolve(dialect, uuid.UUID) is UUIDColumn + assert resolve(dialect, dict) is JSONColumn + assert resolve(dialect, datetime.date) is DateTimeColumn + assert resolve(dialect, datetime.time) is DateTimeColumn + assert resolve(dialect, datetime.datetime) is DateTimeColumn + assert resolve(dialect, enum.Enum) is StringColumn + + +def test_the_numeric_entries_answer_the_one_numeric_class(dialect): + """``float`` / ``decimal.Decimal`` answer ``NumericColumn``, and why. + + This table used to answer them with the dedicated ``FloatColumn`` / + ``DecimalColumn`` core then had (secondary-gaps 附录 C: "real/double + precision 真实分界"), on the measured strength of PostgreSQL spelling + ``real`` and ``double precision`` as distinct widths. The rebuild removed + those classes -- the numeric family is deliberately one class -- so the + distinction now lives where it was always measurable in storage anyway: the + ``DataType`` layer, not the operation surface. The answer changed name, not + capability, and this is the test that says so. """ - assert dialect.column_class_for(float) is FloatColumn - assert dialect.column_class_for(decimal.Decimal) is DecimalColumn - assert dialect.column_class_for(float) is not NumericColumn - assert dialect.column_class_for(decimal.Decimal) is not NumericColumn + assert resolve(dialect, float) is NumericColumn + assert resolve(dialect, decimal.Decimal) is NumericColumn def test_the_sequence_entries_answer_array_column_not_json(dialect): @@ -164,7 +178,7 @@ def test_the_sequence_entries_answer_array_column_not_json(dialect): thing a set does not promise. """ for entry in (list, tuple, set, frozenset): - assert dialect.column_class_for(entry) is ArrayColumn, entry + assert resolve(dialect, entry) is ArrayColumn, entry # And the class really is the array one -- the deviation has to be a class # that can answer, not a rename. @@ -175,14 +189,15 @@ def test_the_sequence_entries_answer_array_column_not_json(dialect): def test_dict_answers_the_json_column_while_the_ddl_layer_chooses_jsonb(dialect): """The two halves stay apart, and the column half is the operations. - §0.2 requires ``jsonb`` for a ``dict`` here because ``json`` has no equality - operator, no ``?`` and no ``@>``. That is a *spelling* decision belonging to - the ``DataType`` layer, and it is not the column class's to carry: the - operation surface -- ``json_path``, ``json_value``, has-key, array length, - validity -- is the same on ``json`` and ``jsonb``, and ``jsonb`` simply adds - the containment operators on top. + ``.claude/plan/2026-10-08/column-suggestion-protocol.md`` §0.2 requires + ``jsonb`` for a ``dict`` here because ``json`` has no equality operator, no + ``?`` and no ``@>``. That is a *spelling* decision belonging to the + ``DataType`` layer, and it is not the column class's to carry: the operation + surface -- ``json_path``, ``json_value``, has-key, array length, validity -- + is the same on ``json`` and ``jsonb``, and ``jsonb`` simply adds the + containment operators on top. """ - assert dialect.column_class_for(dict) is JSONColumn + assert resolve(dialect, dict) is JSONColumn assert hasattr(JSONColumn, "json_path") assert hasattr(JSONColumn, "json_value") @@ -207,28 +222,44 @@ class Mood(enum.Enum): SAD = "sad" OK = "ok" - assert dialect.column_class_for(Mood) is StringColumn + assert resolve(dialect, Mood) is StringColumn + + +def test_the_interval_entry_is_refused_until_core_grows_an_interval_column(dialect): + """**The one deliberate refusal**, and the reasons it is not ``None``'s + opposite either. + PostgreSQL has a real ``interval`` type where eight backends have no answer + at all (the protocol's own survey, §1), so there is no "this backend cannot + carry a duration" story here: psycopg binds a ``timedelta`` and reads one + back, and the ``DataType`` layer spells the storage + (``format_data_type_interval``). What no core column class offers is the + *operation* surface -- ``justify_hours``, ``EXTRACT(... FROM interval)``, + the field qualifiers -- and naming a number class would offer + transcendentals the server refuses: ``sqrt(interval '1 day')`` is an error. -def test_timedelta_is_the_numeric_gap_until_core_grows_an_interval_column(dialect): - """§12's ``IntervalColumn`` fallback, taken deliberately and pinned. + ``NumericColumn`` was the answer the last version of this table gave, and + it was a *declared* over-claim kept visible only because ``FloatColumn`` + and ``DecimalColumn`` existed to differ from. With the numeric family merged + into one class that visibility is gone, so the honest answer under the + rebuilt protocol is ``None``: the gap reaches the model layer as a + resolution error naming the way out, instead of as a number class that + cannot keep its promise. - PostgreSQL has a real ``interval`` type where eight backends have no answer at - all, so ``NumericColumn`` under-claims nothing and over-claims exactly one - thing: ``interval`` is not a number, and ``sqrt(interval '1 day')`` is an - error on the server while ``NumericColumn`` offers transcendentals. That gap - is inherited from core's neutral table, not chosen here, and it closes when - core grows an ``IntervalColumn`` -- so this test fails then, on purpose. + When core grows an ``IntervalColumn``, this entry is where it goes -- so + this test fails then, on purpose. """ - assert dialect.column_class_for(datetime.timedelta) is NumericColumn + assert dialect.suggested_column_types()[datetime.timedelta] is None - from rhosocial.activerecord.backend.expression import column_types + with pytest.raises(ColumnTypeResolutionError) as excinfo: + resolve(dialect, datetime.timedelta) + assert "UseColumnType" in str(excinfo.value) - assert not hasattr(column_types, "IntervalColumn"), ( - "Core now has an IntervalColumn; PostgreSQL's native interval should " - "answer with it (protocol §12, PostgreSQL row) rather than the numeric " - "fallback. This test is the reminder to change the table." - ) + # The refusal is this table's, not the framework's: the generic numeric + # answer is what the portable baseline would have said. + from rhosocial.activerecord.backend.impl.dummy.column_type import DUMMY_COLUMN_TYPES + + assert DUMMY_COLUMN_TYPES[datetime.timedelta] is NumericColumn # --------------------------------------------------------------------------- @@ -240,9 +271,9 @@ def test_optional_is_transparent(dialect): """``Optional[T]`` must resolve exactly as ``T`` does -- compared against this dialect's own answer rather than a fixed class, since the answer may legitimately vary by backend.""" - assert dialect.column_class_for(Optional[dict]) is dialect.column_class_for(dict) - assert dialect.column_class_for(Optional[str]) is dialect.column_class_for(str) - assert dialect.column_class_for(Optional[list]) is dialect.column_class_for(list) + assert resolve(dialect, Optional[dict]) is resolve(dialect, dict) + assert resolve(dialect, Optional[str]) is resolve(dialect, str) + assert resolve(dialect, Optional[list]) is resolve(dialect, list) def test_a_subclass_walks_to_its_entry(dialect): @@ -253,74 +284,32 @@ def test_a_subclass_walks_to_its_entry(dialect): class Code(str): pass - assert dialect.column_class_for(Code) is StringColumn + assert resolve(dialect, Code) is StringColumn def test_bool_is_its_own_entry_not_an_integer(dialect): """``bool`` is an ``int`` subclass and the entry order is the only thing that keeps them apart. Without it a truth-value field would offer integer arithmetic -- which MySQL and SQLite would happily execute on 0/1.""" - assert dialect.column_class_for(bool) is not dialect.column_class_for(int) - assert COLUMN_TYPE_ENTRIES.index(bool) < COLUMN_TYPE_ENTRIES.index(int) + assert resolve(dialect, bool) is not resolve(dialect, int) + assert list(dialect.suggested_column_types()).index(bool) < list( + dialect.suggested_column_types() + ).index(int) def test_an_unknown_annotation_fails_rather_than_becoming_a_universal_column(dialect): - """No permissive fallback: ``Any`` is a definition-time failure naming the way - out. The old universal column offered ``.like()`` to an integer and only - found out at the database.""" + """No permissive fallback: the error names the way out. The old universal + column offered ``.like()`` to an integer and only found out at the + database.""" with pytest.raises(ColumnTypeResolutionError) as excinfo: - dialect.column_class_for(object()) + resolve(dialect, object()) assert "UseColumnType" in str(excinfo.value) -# --------------------------------------------------------------------------- -# 4. Narrowing: declared as none, and checked to be none -# --------------------------------------------------------------------------- - - -def test_nothing_is_narrowed_on_this_backend(dialect): - """The declared verdict. Eight of the ten backends have to take ``ilike`` - away; PostgreSQL is the control that proves the mechanism discriminates - rather than always answering True. - - The pairs are the ones §8.1/§8.4 measured here: ``ILIKE`` native (§8.4 - "ILIKE 仅 PG/CH"), JSON path access native on ``json`` and ``jsonb``, array - length and ``unnest`` native on 9.6/12/18, tz ``AT TIME ZONE`` native. - """ - assert dialect.supports_column_operation("StringColumn", "ilike") is True - assert dialect.supports_column_operation("StringColumn", "like") is True - assert dialect.supports_column_operation("JSONColumn", "json_path") is True - assert dialect.supports_column_operation("JSONColumn", "json_value") is True - assert dialect.supports_column_operation("ArrayColumn", "array_length") is True - assert dialect.supports_column_operation("ArrayColumn", "unnest") is True - assert dialect.supports_column_operation("DateTimeColumn", "date_trunc") is True - assert dialect.supports_column_operation("BooleanColumn", "is_true") is True - assert dialect.supports_column_operation("UUIDColumn", "eq") is True - assert dialect.supports_column_operation("BinaryColumn", "eq") is True - - -def test_no_narrowing_holds_for_any_operation_on_any_column_class(dialect): - """The whole surface, not a hand-picked sample of it. - - Driven off the table itself so a new entry cannot introduce a narrowing - without this noticing, and off ``dir()`` of each class so a new operation - method is covered too. - """ - narrowed = [] - for column_class in dialect.suggested_column_types().values(): - name = column_class.__name__ - for op in dir(column_class): - if op.startswith("_"): - continue - if not dialect.supports_column_operation(name, op): - narrowed.append(f"{name}.{op}") - assert narrowed == [] - - def test_the_operation_names_the_contract_uses_are_real_attributes(dialect): """Operation names are *method names on the column class* so the contract - tests can cross-check with ``hasattr`` (protocol §5). If a name here were a - label rather than an attribute, the contract would be checking a string. + tests can cross-check with ``hasattr``. If a name here were a label rather + than an attribute, the contract would be checking a string. ``is_true`` / ``is_false`` come from ``BooleanLogicMixin``; the rest from the mixins each class is composed from. @@ -335,14 +324,16 @@ def test_the_operation_names_the_contract_uses_are_real_attributes(dialect): } # Resolved by *name* out of this backend's own table, so the cross-check runs # against the class PostgreSQL actually suggests rather than against a - # hard-coded import that could stop being the answer. - by_name = {cls.__name__: cls for cls in dialect.suggested_column_types().values()} + # hard-coded import that could stop being the answer. A refused entry has no + # class to name, which is the point of refusing it. + by_name = { + cls.__name__: cls for cls in dialect.suggested_column_types().values() if cls is not None + } for column_name, ops in expected.items(): - assert column_name in by_name, f"{column_name} is not in the table" + cls = by_name[column_name] for op in ops: - assert dialect.supports_column_operation(column_name, op) is True - assert hasattr(by_name[column_name], op), ( + assert hasattr(cls, op), ( f"{column_name} has no attribute {op!r}, so declaring it " f"available would be checking a string" ) From 120a1d7bffde83bf139547b8850f7cc707085093 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 12:17:52 +0800 Subject: [PATCH 03/15] fix: follow the core's DateTimeColumn -> TimestampColumn rename --- .../backend/impl/postgres/mixins/column_type.py | 8 ++++---- .../backend/types/test_column_type_suggestions.py | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py index 7f3931ca..2ec98ffc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/column_type.py @@ -57,7 +57,7 @@ BinaryColumn, BooleanColumn, ColumnBase, - DateTimeColumn, + TimestampColumn, IntegerColumn, JSONColumn, NumericColumn, @@ -97,9 +97,9 @@ # lands on the same class and is where this backend is strongest: # `AT TIME ZONE` is native and named zones work (only the *read-back* # follows the session time zone -- §8.1 `datetime tz` row). - datetime.date: DateTimeColumn, - datetime.time: DateTimeColumn, - datetime.datetime: DateTimeColumn, + datetime.date: TimestampColumn, + datetime.time: TimestampColumn, + datetime.datetime: TimestampColumn, # THE ONE DELIBERATE `None`. PostgreSQL has a real `interval` type -- §1 # spells it `INTERVAL` where eight backends have no answer at all -- and no # core column class answers for it (`IntervalColumn` is a core gap, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py index 16c5bd9b..30de4844 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_column_type_suggestions.py @@ -32,7 +32,7 @@ BinaryColumn, BooleanColumn, ColumnBase, - DateTimeColumn, + TimestampColumn, IntegerColumn, JSONColumn, NumericColumn, @@ -141,9 +141,9 @@ def test_resolution_is_the_baseline_for_the_scalar_and_value_families(dialect): assert resolve(dialect, bytearray) is BinaryColumn assert resolve(dialect, uuid.UUID) is UUIDColumn assert resolve(dialect, dict) is JSONColumn - assert resolve(dialect, datetime.date) is DateTimeColumn - assert resolve(dialect, datetime.time) is DateTimeColumn - assert resolve(dialect, datetime.datetime) is DateTimeColumn + assert resolve(dialect, datetime.date) is TimestampColumn + assert resolve(dialect, datetime.time) is TimestampColumn + assert resolve(dialect, datetime.datetime) is TimestampColumn assert resolve(dialect, enum.Enum) is StringColumn @@ -318,7 +318,7 @@ def test_the_operation_names_the_contract_uses_are_real_attributes(dialect): "StringColumn": ("like", "ilike"), "JSONColumn": ("json_path", "json_value"), "ArrayColumn": ("array_length", "unnest"), - "DateTimeColumn": ("date_trunc", "extract"), + "TimestampColumn": ("date_trunc", "extract"), "BooleanColumn": ("is_true", "is_false"), "NumericColumn": ("__add__", "__mul__"), } From a4a7c6edbde27bcc5712fa44ccd9b5f83b0c9fd6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 12:17:52 +0800 Subject: [PATCH 04/15] test: follow the rebuilt DDL-source accessor semantics (column_type / column_data_type) --- .../postgres/test_ddl_source_collection.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py index e52d06d8..4e8ee5b7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_source_collection.py @@ -144,7 +144,7 @@ ) BATCH_METHODS = ( "columns_name", - "columns_type", + "columns_data_type", "columns_constraints", "columns_attributes", "columns_indexes", @@ -263,12 +263,16 @@ def test_models_satisfy_ddl_source_and_preserve_field_order(): def test_field_markers_preserve_declared_objects_and_order(): - assert SyncDDLSource.column_type("tenant_id") is SQL_TYPE_MARKER - assert SyncDDLSource.column_type("tenant_id").data_types == ( + assert SyncDDLSource.column_data_type("tenant_id") is SQL_TYPE_MARKER + assert SyncDDLSource.column_data_type("tenant_id").data_types == ( SQL_TYPE, FALLBACK_SQL_TYPE, ) - assert SyncDDLSource.column_type("tenant_id").data_types[0] is SQL_TYPE + assert SyncDDLSource.column_data_type("tenant_id").data_types[0] is SQL_TYPE + # ``tenant_id`` declares storage only, and no field here declares a column + # class: the declaration accessor answers ``None`` on both counts. + assert SyncDDLSource.column_type("tenant_id") is None + assert SyncDDLSource.column_type("optional_note") is None constraints = SyncDDLSource.column_constraints("status") assert constraints[0] is DEFAULT_CONSTRAINT.constraint @@ -304,7 +308,7 @@ def test_field_markers_preserve_declared_objects_and_order(): def test_backend_types_and_column_options_are_collected_without_dialect_selection(): - sql_type = SyncDDLSource.column_type("tenant_id") + sql_type = SyncDDLSource.column_data_type("tenant_id") assert isinstance(sql_type, UseSqlType) assert isinstance(sql_type.data_type, PostgresUUIDType) assert sql_type.data_types[1] is FALLBACK_SQL_TYPE @@ -343,7 +347,7 @@ def test_primary_keys_use_physical_column_names_and_declaration_order(): ColumnConstraintType.NOT_NULL, ] assert SyncSingleKey.columns_name() == {"record_id": "record_pk"} - assert SyncSingleKey.column_type("record_id") is SINGLE_SQL_TYPE_MARKER + assert SyncSingleKey.column_data_type("record_id") is SINGLE_SQL_TYPE_MARKER assert SyncSingleKey.table_constraints() == [] @@ -404,7 +408,7 @@ def test_batch_interfaces_cover_all_fields_and_preserve_selected_order(): "tenant_id": "tenant_key", "optional_note": "optional_note", } - assert model.columns_type(selected)["tenant_id"] is SQL_TYPE_MARKER + assert model.columns_data_type(selected)["tenant_id"] is SQL_TYPE_MARKER assert model.columns_constraints(selected)["status"][0] is DEFAULT_CONSTRAINT.constraint assert model.columns_attributes(selected)["status"][0] is COLLATION assert model.columns_comment(selected)["status"] == COMMENT.comment @@ -413,7 +417,7 @@ def test_batch_interfaces_cover_all_fields_and_preserve_selected_order(): def test_sync_and_async_sources_collect_the_same_declarations(): - assert AsyncDDLSource.column_type("tenant_id") is SyncDDLSource.column_type("tenant_id") + assert AsyncDDLSource.column_data_type("tenant_id") is SyncDDLSource.column_data_type("tenant_id") assert AsyncDDLSource.column_comment("status") == SyncDDLSource.column_comment("status") assert AsyncDDLSource.generated_column("computed") is GENERATED assert AsyncDDLSource.column_options("option_one") is COLUMN_OPTION @@ -464,4 +468,4 @@ def test_sync_and_async_sources_collect_the_same_declarations(): ColumnConstraintType.NOT_NULL, ] assert model.column_name("record_id") == "record_pk" - assert model.column_type("record_id") is SINGLE_SQL_TYPE_MARKER + assert model.column_data_type("record_id") is SINGLE_SQL_TYPE_MARKER From e234cbacc8947354a749a36aa11607993618e25d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 18:44:11 +0800 Subject: [PATCH 05/15] test: build predicates explicitly, now that the bare Column offers no operators --- .../impl/postgres/examples/ddl/alter_table.py | 3 +- .../postgres/examples/ddl/create_index.py | 3 +- .../postgres/examples/extensions/citext.py | 6 +- .../postgres/examples/extensions/intarray.py | 3 +- .../named_expressions/order_expressions.py | 17 +++-- .../pgq/recursive_cte_graph_traversal.py | 22 +++--- .../dialect/test_alter_table_if_exists.py | 7 +- .../test_insert_on_conflict_clauses.py | 5 +- .../test_property_graph_query_format.py | 13 +++- .../extensions/test_bloom_integration.py | 17 +++-- .../extensions/test_btree_gin_integration.py | 13 +++- .../extensions/test_btree_gist_integration.py | 12 ++- .../extensions/test_cube_integration.py | 6 +- .../extensions/test_pgvector_integration.py | 25 ++++-- .../backend/postgres/test_ddl_alter_config.py | 24 ++++-- .../feature/backend/postgres/test_index_pg.py | 6 +- .../backend/postgres/test_pg19_copy_repack.py | 5 +- .../test_pg19_copy_repack_integration.py | 5 +- .../feature/backend/postgres/test_policy.py | 21 ++--- .../test_property_graph_query_scenarios.py | 32 ++++++-- .../test_recursive_cte_graph_traversal.py | 76 ++++++++++++++----- .../feature/backend/postgres/test_trigger.py | 3 +- .../test_partition_advanced_operations.py | 11 +-- .../feature/backend/test_partition_explain.py | 10 ++- .../backend/test_partition_operations.py | 40 ++++++++-- .../backend/test_postgres_dialect_security.py | 5 +- .../basic/fields/test_derived_field.py | 46 ++++++++--- .../basic/fields/test_derived_field_async.py | 46 ++++++++--- 28 files changed, 346 insertions(+), 136 deletions(-) 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 5a3b8846..7b60d29a 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 @@ -17,6 +17,7 @@ DropTableExpression, ) from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements import ( ColumnDefinition, ColumnConstraint, @@ -161,7 +162,7 @@ Column(dialect, 'is_nullable'), ], from_=Table(dialect, 'columns', schema_name='information_schema'), - where=Column(dialect, 'table_name') == Literal(dialect, 'users'), + where=ComparisonPredicate(dialect, "=", Column(dialect, 'table_name'), Literal(dialect, 'users')), order_by=OrderByClause(dialect, [Column(dialect, 'ordinal_position')]), ) options = ExecutionOptions(stmt_type=StatementType.DQL) 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 79e782c9..e3be47eb 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 @@ -86,12 +86,13 @@ from rhosocial.activerecord.backend.expression import QueryExpression from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate verify_expr = QueryExpression( dialect=dialect, select=[Column(dialect, 'indexname')], from_=Table(dialect, 'pg_indexes'), - where=Column(dialect, 'indexname') == Literal(dialect, 'idx_category_price'), + where=ComparisonPredicate(dialect, "=", Column(dialect, 'indexname'), Literal(dialect, 'idx_category_price')), ) options = ExecutionOptions(stmt_type=StatementType.DQL) verify_result = backend.execute(*verify_expr.to_sql(), options=options) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/citext.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/citext.py index 1bd4d9af..9c9964d6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/citext.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/citext.py @@ -49,6 +49,7 @@ ColumnConstraintType, ) from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.statements.dml import ( InsertExpression, ) @@ -145,8 +146,9 @@ Column(dialect, "email"), ], from_=Table(dialect, "users"), - where=Column(dialect, "email", table="users") - == Literal(dialect, "test@example.com"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "email", table="users"), Literal(dialect, "test@example.com") + ), ) sql, params = query.to_sql() print("\n--- SELECT (case-insensitive) ---") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py index 706b2fb5..3a52250e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/intarray.py @@ -56,6 +56,7 @@ ) from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.core import Literal, FunctionCall +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.operators import ( BinaryExpression, ) @@ -218,7 +219,7 @@ unique_sorted_func, ], from_=Table(dialect, "tags"), - where=Column(dialect, "id") == Literal(dialect, 4), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 4)), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py index d8859c4f..837c76f2 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py @@ -23,6 +23,7 @@ Literal, QueryExpression, ) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate # noqa: E402 from rhosocial.activerecord.backend.expression.objects import Table # noqa: E402 @@ -32,7 +33,7 @@ def get_order(dialect, order_id: int): dialect, select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], from_=Table(dialect, "orders"), - where=Column(dialect, "id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, order_id)), ) @@ -42,7 +43,7 @@ def check_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "available")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -52,7 +53,7 @@ def reserve_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "id"), Column(dialect, "available")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -62,7 +63,7 @@ def send_notification(dialect, user_id: int, type: str): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "notifications"), - where=Column(dialect, "user_id") == Literal(dialect, user_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, user_id)), ) @@ -72,7 +73,7 @@ def process_payment(dialect, order_id: int, amount: float): dialect, select=[Column(dialect, "status"), Column(dialect, "transaction_id")], from_=Table(dialect, "payments"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -82,7 +83,7 @@ def release_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -92,7 +93,7 @@ def create_order_record(dialect, order_id: int, user_id: int, amount: float): dialect, select=[Column(dialect, "id"), Column(dialect, "created_at")], from_=Table(dialect, "order_records"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) @@ -102,7 +103,7 @@ def confirm_inventory(dialect, order_id: int): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "inventory"), - where=Column(dialect, "order_id") == Literal(dialect, order_id), + where=ComparisonPredicate(dialect, "=", Column(dialect, "order_id"), Literal(dialect, order_id)), ) 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 index c8f0b33d..900ae9a9 100644 --- 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 @@ -50,7 +50,7 @@ from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.core import Literal, Column -from rhosocial.activerecord.backend.expression.predicates import BetweenPredicate +from rhosocial.activerecord.backend.expression.predicates import BetweenPredicate, ComparisonPredicate from rhosocial.activerecord.backend.expression.query_parts import ( WhereClause, OrderByClause, @@ -157,7 +157,7 @@ Literal(dialect, 0).as_('depth'), ], from_=Table(dialect, 'users'), - where=WhereClause(dialect, condition=Column(dialect, 'name') == Literal(dialect, 'Alice')), + where=WhereClause(dialect, condition=ComparisonPredicate(dialect, "=", Column(dialect, 'name'), Literal(dialect, 'Alice'))), ) # --- Recursive query: follow edges one hop, depth+1, bound depth<4 --- @@ -166,10 +166,10 @@ left_table=NamedRelationRef(dialect, Table(dialect, 'traversal'), alias='t'), right_table=NamedRelationRef(dialect, Table(dialect, 'follows'), alias='f'), join_type='INNER JOIN', - condition=Column(dialect, 'id', table='t') == Column(dialect, 'follower_id', table='f'), + condition=ComparisonPredicate(dialect, "=", Column(dialect, 'id', table='t'), Column(dialect, 'follower_id', table='f')), ).inner_join( right_table=NamedRelationRef(dialect, Table(dialect, 'users'), alias='u'), - condition=Column(dialect, 'followed_id', table='f') == Column(dialect, 'id', table='u'), + condition=ComparisonPredicate(dialect, "=", Column(dialect, 'followed_id', table='f'), Column(dialect, 'id', table='u')), ) recursive_query = QueryExpression( @@ -180,7 +180,7 @@ 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)), + where=WhereClause(dialect, condition=ComparisonPredicate(dialect, "<", Column(dialect, 'depth', table='t'), Literal(dialect, 4))), ) # --- Combine with UNION ALL --- @@ -245,7 +245,7 @@ left_table=NamedRelationRef(dialect, Table(dialect, 'transactions'), alias='tx'), right_table=NamedRelationRef(dialect, Table(dialect, 'accounts'), alias='a'), join_type='INNER JOIN', - condition=Column(dialect, 'source_account_id', table='tx') == Column(dialect, 'id', table='a'), + condition=ComparisonPredicate(dialect, "=", Column(dialect, 'source_account_id', table='tx'), Column(dialect, 'id', table='a')), ) aml_base = QueryExpression( @@ -258,7 +258,7 @@ Literal(dialect, 1).as_('depth'), ], from_=aml_base_join, - where=WhereClause(dialect, condition=Column(dialect, 'target_account_id', table='tx') == Literal(dialect, 1)), + where=WhereClause(dialect, condition=ComparisonPredicate(dialect, "=", Column(dialect, 'target_account_id', table='tx'), Literal(dialect, 1))), ) # --- Recursive query: trace upstream along the transaction chain --- @@ -267,10 +267,10 @@ left_table=NamedRelationRef(dialect, Table(dialect, 'fund_trace'), alias='tr'), right_table=NamedRelationRef(dialect, Table(dialect, 'transactions'), alias='tx'), join_type='INNER JOIN', - condition=Column(dialect, 'target_account_id', table='tx') == Column(dialect, 'id', table='tr'), + condition=ComparisonPredicate(dialect, "=", Column(dialect, 'target_account_id', table='tx'), Column(dialect, 'id', table='tr')), ).inner_join( right_table=NamedRelationRef(dialect, Table(dialect, 'accounts'), alias='a'), - condition=Column(dialect, 'source_account_id', table='tx') == Column(dialect, 'id', table='a'), + condition=ComparisonPredicate(dialect, "=", Column(dialect, 'source_account_id', table='tx'), Column(dialect, 'id', table='a')), ) aml_recursive = QueryExpression( @@ -283,7 +283,7 @@ 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)), + where=WhereClause(dialect, condition=ComparisonPredicate(dialect, "<", Column(dialect, 'depth', table='tr'), Literal(dialect, 5))), ) # --- Combine with UNION ALL --- @@ -313,7 +313,7 @@ Column(dialect, 'depth'), ], from_=Table(dialect, 'fund_trace'), - where=WhereClause(dialect, condition=Column(dialect, 'depth') >= Literal(dialect, 1)), + where=WhereClause(dialect, condition=ComparisonPredicate(dialect, ">=", Column(dialect, 'depth'), Literal(dialect, 1))), order_by=OrderByClause(dialect, [Column(dialect, 'depth'), Column(dialect, 'id')]), ) 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 index 71ab6e15..676a70b5 100644 --- 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 @@ -136,6 +136,9 @@ def test_set_data_type_plain(self, dialect): def test_set_data_type_with_using(self, dialect): from rhosocial.activerecord.backend.expression import Column, Literal + from rhosocial.activerecord.backend.expression.operators import ( + BinaryArithmeticExpression, + ) from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresAlterColumn, ) @@ -145,7 +148,9 @@ def test_set_data_type_with_using(self, dialect): "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", - using=Column(dialect, "price") + Literal(dialect, 1), + using=BinaryArithmeticExpression( + dialect, "+", Column(dialect, "price"), Literal(dialect, 1) + ), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql 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 index 6af35262..bb85a873 100644 --- 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 @@ -17,6 +17,7 @@ OnConflictClause, ValuesSource, ) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.expression.objects import Table @@ -97,7 +98,9 @@ def test_do_update_with_where(self, dialect): dialect, conflict_target=["id"], update_assignments={"qty": Column(dialect, "qty", "EXCLUDED")}, - update_where=Column(dialect, "qty", "users") > Column(dialect, "qty", "EXCLUDED"), + update_where=ComparisonPredicate( + dialect, ">", Column(dialect, "qty", "users"), Column(dialect, "qty", "EXCLUDED") + ), ) expr = InsertExpression( dialect, into=Table(dialect, 'users'), columns=["id", "qty"], source=source, on_conflict=clause 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 936197d6..fee3e3d3 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 @@ -28,6 +28,7 @@ VertexTable, ) from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.query_parts import WhereClause from rhosocial.activerecord.backend.expression.objects import ( EdgeTable as EdgeTableObject, @@ -147,7 +148,9 @@ def test_anonymous(self, pg19_dialect: PostgresDialect): def test_with_where(self, pg19_dialect: PostgresDialect): where = WhereClause( pg19_dialect, - condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18), + condition=ComparisonPredicate( + pg19_dialect, ">", Column(pg19_dialect, "age"), Literal(pg19_dialect, 18) + ), ) vertex = GraphVertex(pg19_dialect, "p", NodeTable(pg19_dialect, 'person'), where=where) sql, params = vertex.to_sql() @@ -257,8 +260,12 @@ def test_basic(self, pg19_dialect: PostgresDialect): assert "COLUMNS" in sql def test_with_where(self, pg19_dialect: PostgresDialect): - where = WhereClause(pg19_dialect, - condition=Column(pg19_dialect, "age") > Literal(pg19_dialect, 18)) + where = WhereClause( + pg19_dialect, + condition=ComparisonPredicate( + pg19_dialect, ">", Column(pg19_dialect, "age"), Literal(pg19_dialect, 18) + ), + ) v = GraphVertex(pg19_dialect, "p", NodeTable(pg19_dialect, 'person'), where=where) e = GraphEdge(pg19_dialect, "e", EdgeTableObject(pg19_dialect, 'knows'), GraphEdgeDirection.RIGHT) b = GraphVertex(pg19_dialect, "b", NodeTable(pg19_dialect, 'person')) 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 6ba020c0..27e49521 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 @@ -30,6 +30,7 @@ from rhosocial.activerecord.backend.expression.objects import Index, Table from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -132,7 +133,7 @@ def test_bloom_index_creation(self, bloom_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, TABLE_NAME), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, TABLE_NAME)), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -148,7 +149,7 @@ def test_bloom_index_query(self, bloom_env): dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, TABLE_NAME), - where=Column(dialect, "name") == Literal(dialect, "user25"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "user25")), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -206,7 +207,9 @@ def test_bloom_index_with_options(self, bloom_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_bloom_opts"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_bloom_opts") + ), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -309,7 +312,7 @@ async def test_async_bloom_index_creation(self, async_bloom_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, ASYNC_TABLE_NAME), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, ASYNC_TABLE_NAME)), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) @@ -326,7 +329,7 @@ async def test_async_bloom_index_query(self, async_bloom_env): dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Column(dialect, "email")], from_=Table(dialect, ASYNC_TABLE_NAME), - where=Column(dialect, "name") == Literal(dialect, "user25"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "user25")), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) @@ -381,7 +384,9 @@ async def test_async_bloom_index_with_options(self, async_bloom_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_bloom_opts_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_bloom_opts_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) 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 d6c23f96..4965e1c7 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 @@ -38,6 +38,7 @@ ) from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -133,7 +134,7 @@ def test_btree_gin_int4_ops_index(self, btree_gin_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gin"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gin")), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -164,7 +165,7 @@ def test_btree_gin_text_ops_index(self, btree_gin_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gin"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gin")), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -289,7 +290,9 @@ async def test_async_btree_gin_int4_ops_index(self, async_btree_gin_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gin_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gin_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) @@ -321,7 +324,9 @@ async def test_async_btree_gin_text_ops_index(self, async_btree_gin_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gin_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gin_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) 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 216991e4..1d313f3e 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 @@ -136,7 +136,7 @@ def test_btree_gist_timestamp_index(self, btree_gist_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gist"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gist")), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -167,7 +167,7 @@ def test_btree_gist_int4_index(self, btree_gist_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gist"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gist")), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -301,7 +301,9 @@ async def test_async_btree_gist_timestamp_index(self, async_btree_gist_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gist_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gist_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) @@ -333,7 +335,9 @@ async def test_async_btree_gist_int4_index(self, async_btree_gist_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_btree_gist_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_btree_gist_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) 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 f74ffbe6..5446a2b5 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 @@ -321,7 +321,7 @@ def test_cube_index(self, cube_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_cube_idx"), + where=ComparisonPredicate(dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_cube_idx")), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -594,7 +594,9 @@ async def test_async_cube_index(self, async_cube_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_cube_idx_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_cube_idx_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) 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 9f475830..bc59f0f9 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 @@ -34,6 +34,7 @@ from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.statements import ValuesSource from rhosocial.activerecord.backend.expression.core import Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.query_parts import OrderByClause from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -411,7 +412,7 @@ def test_vector_update(self, vector_env): dialect=dialect, table=Table(dialect, "test_vector_update"), assignments={"embedding": Literal(dialect, "[4.0, 5.0, 6.0]").cast(PostgresVectorType(dialect))}, - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) sql, params = update_expr.to_sql() backend.execute(sql, params) @@ -422,7 +423,7 @@ def test_vector_update(self, vector_env): dialect=dialect, select=[Column(dialect, "embedding")], from_=Table(dialect, "test_vector_update"), - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -478,7 +479,9 @@ def test_ivfflat_index(self, vector_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_vector_ivfflat"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_vector_ivfflat") + ), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -539,7 +542,9 @@ def test_hnsw_index(self, vector_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_vector_hnsw"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_vector_hnsw") + ), ) sql, params = query.to_sql() result = backend.execute(sql, params, options=opts) @@ -913,7 +918,7 @@ async def test_async_vector_update(self, async_vector_env): dialect=dialect, table=Table(dialect, "test_vector_update_async"), assignments={"embedding": Literal(dialect, "[4.0, 5.0, 6.0]").cast(PostgresVectorType(dialect))}, - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) sql, params = update_expr.to_sql() await backend.execute(sql, params) @@ -923,7 +928,7 @@ async def test_async_vector_update(self, async_vector_env): dialect=dialect, select=[Column(dialect, "embedding")], from_=Table(dialect, "test_vector_update_async"), - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) @@ -977,7 +982,9 @@ async def test_async_ivfflat_index(self, async_vector_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_vector_ivfflat_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_vector_ivfflat_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) @@ -1035,7 +1042,9 @@ async def test_async_hnsw_index(self, async_vector_env): dialect=dialect, select=[Column(dialect, "indexname")], from_=Table(dialect, "pg_indexes"), - where=Column(dialect, "tablename") == Literal(dialect, "test_vector_hnsw_async"), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "tablename"), Literal(dialect, "test_vector_hnsw_async") + ), ) sql, params = query.to_sql() result = await backend.execute(sql, params, options=opts) 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 index 002b07a3..852dc705 100644 --- 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 @@ -7,7 +7,12 @@ import pytest -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import ( + BinaryArithmeticExpression, + Column, + ComparisonPredicate, + Literal, +) from rhosocial.activerecord.backend.expression.statements import ( AddColumn, ColumnDefinition, @@ -205,7 +210,7 @@ def test_using_clause(self, dialect): "price", "SET DATA TYPE", new_value="NUMERIC(10,2)", - using=Column(dialect, "price") + Literal(dialect, 1), + using=BinaryArithmeticExpression(dialect, "+", Column(dialect, "price"), Literal(dialect, 1)), ) sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql @@ -385,6 +390,7 @@ def test_create_and_add_check_fk_enforcement(self): ColumnConstraint, ColumnConstraintType, ColumnDefinition, + ComparisonPredicate, ForeignKeyConstraint, Literal, TableConstraint, @@ -393,7 +399,7 @@ def test_create_and_add_check_fk_enforcement(self): from rhosocial.activerecord.backend.expression.types import IntegerType dialect = PostgresDialect((18, 0, 0)) - condition = Column(dialect, "age") > Literal(dialect, 0, inline_literals=True) + condition = ComparisonPredicate(dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline_literals=True)) check = TableConstraint( dialect, TableConstraintType.CHECK, @@ -448,7 +454,9 @@ def test_not_valid_is_limited_to_check_and_fk(self): string_constraint = TableConstraint( dialect, " check ", - check_condition=Column(dialect, "age") > Literal(dialect, 0, inline_literals=True), + check_condition=ComparisonPredicate( + dialect, ">", Column(dialect, "age"), Literal(dialect, 0, inline_literals=True) + ), validation=" not valid ", ) assert AddTableConstraint(dialect, string_constraint).to_sql()[0].endswith("NOT VALID") @@ -553,7 +561,7 @@ def test_exclude_create_and_add(self): not_valid_check = TableConstraint( dialect, TableConstraintType.CHECK, - check_condition=Column(dialect, "id") > 0, + check_condition=ComparisonPredicate(dialect, ">", Column(dialect, "id"), Literal(dialect, 0)), validation=ConstraintValidation.NOVALIDATE, ) invalid_create = CreateTableExpression( @@ -568,6 +576,7 @@ def test_exclude_create_and_add(self): def test_exclude_expression_parentheses_and_recursive_binding(self): from rhosocial.activerecord.backend.expression import ( Column, + ComparisonPredicate, FunctionCall, Literal, RawSQLExpression, @@ -585,8 +594,9 @@ def test_exclude_expression_parentheses_and_recursive_binding(self): "=", ) ], - where=Column(dialect, "active") - == Literal(dialect, True, inline_literals=True), + where=ComparisonPredicate( + dialect, "=", Column(dialect, "active"), Literal(dialect, True, inline_literals=True) + ), ) assert declared.to_sql() == ( 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 5a0b5dbd..742c016b 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 @@ -893,9 +893,10 @@ def test_where(self): d = PostgresDialect() from rhosocial.activerecord.backend.expression import Literal from rhosocial.activerecord.backend.expression import Column + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate expr = CreateIndexExpression( d, Index(d, 'idx_w'), Table(d, 't'), ["a"], - where=Column(d, "status") == Literal(d, 1), + where=ComparisonPredicate(d, "=", Column(d, "status"), Literal(d, 1)), ) sql, params = expr.to_sql() assert "WHERE" in sql @@ -928,11 +929,12 @@ class TestFormatCreateIndexPgStatementWhereExpression: def test_where_expression(self): d = PostgresDialect() from rhosocial.activerecord.backend.expression import Column, Literal + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate sql, params = d.format_create_index_pg_statement( index_name="idx_active", table_name="users", columns=["email"], - where_clause=Column(d, "status") == Literal(d, 1), + where_clause=ComparisonPredicate(d, "=", Column(d, "status"), Literal(d, 1)), ) assert "WHERE" in sql assert params == (1,) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py index fdfbc16f..e83b964b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack.py @@ -14,6 +14,7 @@ QueryExpression, RawSQLExpression, ) +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize from rhosocial.activerecord.backend.impl.postgres.backend.async_backend import AsyncPostgresBackend @@ -136,7 +137,7 @@ def select_query(dialect): dialect, select=[Column(dialect, "id")], from_=Table(dialect, "users"), - where=Column(dialect, "id") > Literal(dialect, 3), + where=ComparisonPredicate(dialect, ">", Column(dialect, "id"), Literal(dialect, 3)), ) @@ -483,7 +484,7 @@ def test_copy_from_where_returns_parameters(): expression = PostgresCopyFromExpression( dialect, "users", - where=Column(dialect, "id") > Literal(dialect, 7), + where=ComparisonPredicate(dialect, ">", Column(dialect, "id"), Literal(dialect, 7)), ) sql, params = expression.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py index f95688a7..68a4b99c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_pg19_copy_repack_integration.py @@ -4,6 +4,7 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal, QueryExpression +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.objects import Table from rhosocial.activerecord.backend.impl.postgres.expression import ( PostgresCopyFromExpression, @@ -41,7 +42,7 @@ def test_live_copy_round_trip_and_native_repack(postgres_backend): dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, TABLE_NAME), - where=Column(dialect, "id") > Literal(dialect, 1), + where=ComparisonPredicate(dialect, ">", Column(dialect, "id"), Literal(dialect, 1)), ) assert postgres_backend.copy_to(PostgresCopyToExpression(dialect, query=query)) == b"2\tBob\n" @@ -105,7 +106,7 @@ async def test_live_async_copy_round_trip_and_native_repack(async_postgres_backe dialect, select=[Column(dialect, "id"), Column(dialect, "name")], from_=Table(dialect, TABLE_NAME), - where=Column(dialect, "id") > Literal(dialect, 1), + where=ComparisonPredicate(dialect, ">", Column(dialect, "id"), Literal(dialect, 1)), ) copied_query = await async_postgres_backend.copy_to(PostgresCopyToExpression(dialect, query=query)) assert copied_query == b"2\tBob\n" 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 index 00439023..d35670f3 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_policy.py @@ -17,6 +17,7 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.expression.ddl.policy import ( @@ -232,7 +233,7 @@ def test_create_using_with_param_passing(self, dialect): dialect, name="p1", table_name="t1", command=PolicyCommand.SELECT, - using=Column(dialect, "user_id") == Literal(dialect, 1), + using=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, 1)), ) sql, params = expr.to_sql() assert "USING (" in sql @@ -245,7 +246,7 @@ def test_create_with_check(self, dialect): dialect, name="p1", table_name="t1", command=PolicyCommand.UPDATE, - with_check=Column(dialect, "status") == Literal(dialect, "active"), + with_check=ComparisonPredicate(dialect, "=", Column(dialect, "status"), Literal(dialect, "active")), ) sql, params = expr.to_sql() assert "WITH CHECK (" in sql @@ -257,7 +258,7 @@ def test_create_insert_with_using_rejected(self, dialect): dialect, name="p1", table_name="t1", command=PolicyCommand.INSERT, - using=Column(dialect, "user_id") == Literal(dialect, 1), + using=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, 1)), ) with pytest.raises(ValueError, match="INSERT.*USING"): expr.to_sql() @@ -268,7 +269,7 @@ def test_create_select_with_check_rejected(self, dialect): dialect, name="p1", table_name="t1", command=PolicyCommand.SELECT, - with_check=Column(dialect, "user_id") == Literal(dialect, 1), + with_check=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, 1)), ) with pytest.raises(ValueError, match="SELECT.*WITH CHECK"): expr.to_sql() @@ -279,7 +280,7 @@ def test_create_delete_with_check_rejected(self, dialect): dialect, name="p1", table_name="t1", command=PolicyCommand.DELETE, - with_check=Column(dialect, "user_id") == Literal(dialect, 1), + with_check=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, 1)), ) with pytest.raises(ValueError, match="DELETE.*WITH CHECK"): expr.to_sql() @@ -294,7 +295,7 @@ def test_create_full_combination(self, dialect): policy_type=PolicyType.RESTRICTIVE, command=PolicyCommand.UPDATE, roles=["app_user", "PUBLIC"], - using=Column(dialect, "owner") == Literal(dialect, "alice"), + using=ComparisonPredicate(dialect, "=", Column(dialect, "owner"), Literal(dialect, "alice")), with_check=Column(dialect, "owner").is_not_null(), ) sql, params = expr.to_sql() @@ -367,7 +368,7 @@ 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), + using=ComparisonPredicate(dialect, "=", Column(dialect, "user_id"), Literal(dialect, 42)), ) sql, params = expr.to_sql() assert "USING (" in sql @@ -377,7 +378,7 @@ 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"), + with_check=ComparisonPredicate(dialect, "=", Column(dialect, "status"), Literal(dialect, "active")), ) sql, params = expr.to_sql() assert "WITH CHECK (" in sql @@ -388,8 +389,8 @@ def test_alter_replace_full(self, dialect): 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"), + using=ComparisonPredicate(dialect, "=", Column(dialect, "a"), Literal(dialect, 1)), + with_check=ComparisonPredicate(dialect, "=", Column(dialect, "b"), Literal(dialect, "x")), ) sql, params = expr.to_sql() assert 'TO "app_user"' in sql 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 b18b9b95..0a94e1aa 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 @@ -32,6 +32,7 @@ WhereClause, OrderByClause, GroupByHavingClause, LimitOffsetClause, ) from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.types import ( IntegerType, TextType, @@ -206,7 +207,9 @@ def test_single_hop_followers(self, postgres_backend, social_data): NodeTable(dialect, 'person'), where=WhereClause( dialect, - condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "name", table="a"), Literal(dialect, "Alice") + ), ), ) f = GraphEdge(dialect, "f", EdgeTable(dialect, EdgeTableObject(dialect, 'follows')), GraphEdgeDirection.RIGHT) @@ -234,7 +237,9 @@ def test_two_hop_recommendation(self, postgres_backend, social_data): NodeTable(dialect, 'person'), where=WhereClause( dialect, - condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "name", table="a"), Literal(dialect, "Alice") + ), ), ) f1 = GraphEdge(dialect, "f1", EdgeTable(dialect, EdgeTableObject(dialect, 'follows')), GraphEdgeDirection.RIGHT) @@ -248,7 +253,12 @@ def test_two_hop_recommendation(self, postgres_backend, social_data): query = QueryExpression(dialect, select=[Column(dialect, "c_name")], from_=gt, - where=WhereClause(dialect, condition=Column(dialect, "c_name") != Literal(dialect, "Alice")), + where=WhereClause( + dialect, + condition=ComparisonPredicate( + dialect, "!=", Column(dialect, "c_name"), Literal(dialect, "Alice") + ), + ), order_by=OrderByClause(dialect, [Column(dialect, "c_name")])) sql, params = query.to_sql() rows = postgres_backend.fetch_all(sql, params) @@ -264,7 +274,9 @@ def test_likes_on_posts(self, postgres_backend, social_data): NodeTable(dialect, 'person'), where=WhereClause( dialect, - condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "name", table="a"), Literal(dialect, "Alice") + ), ), ) p = GraphEdge(dialect, "p", EdgeTable(dialect, EdgeTableObject(dialect, 'authored')), GraphEdgeDirection.RIGHT) @@ -333,7 +345,9 @@ def test_graph_table_combined_with_regular_sql(self, postgres_backend, social_da left_table=gt, right_table=NamedRelationRef(dialect, Table(dialect, "people"), alias="p"), join_type="INNER JOIN", - condition=Column(dialect, "follower", "g") == Column(dialect, "name", "p")) + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "follower", "g"), Column(dialect, "name", "p") + )) query = QueryExpression(dialect, select=[WildcardExpression(dialect)], @@ -354,7 +368,9 @@ def test_parameter_binding(self, postgres_backend, social_data): NodeTable(dialect, 'person'), where=WhereClause( dialect, - condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "name", table="a"), Literal(dialect, "Alice") + ), ), ) f = GraphEdge(dialect, "f", EdgeTable(dialect, EdgeTableObject(dialect, 'follows')), GraphEdgeDirection.RIGHT) @@ -441,7 +457,9 @@ async def test_async_single_hop(self, async_postgres_backend, async_social_data) NodeTable(dialect, 'person'), where=WhereClause( dialect, - condition=Column(dialect, "name", table="a") == Literal(dialect, "Alice"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "name", table="a"), Literal(dialect, "Alice") + ), ), ) f = GraphEdge(dialect, "f", EdgeTable(dialect, EdgeTableObject(dialect, 'follows')), GraphEdgeDirection.RIGHT) 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 index 03bde4e2..cecf3776 100644 --- 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 @@ -35,7 +35,8 @@ DecimalType, ) from rhosocial.activerecord.backend.expression.core import Literal, Column -from rhosocial.activerecord.backend.expression.predicates import BetweenPredicate +from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.predicates import BetweenPredicate, ComparisonPredicate from rhosocial.activerecord.backend.expression.query_parts import ( WhereClause, OrderByClause, @@ -150,7 +151,10 @@ def _build_traversal_cte(self, dialect): Literal(dialect, 0).as_("depth"), ], from_=Table(dialect, "users"), - where=WhereClause(dialect, condition=Column(dialect, "name") == Literal(dialect, "Alice")), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), + ), ) recursive_join = JoinClause( @@ -158,10 +162,14 @@ def _build_traversal_cte(self, dialect): left_table=NamedRelationRef(dialect, Table(dialect, "traversal"), alias="t"), right_table=NamedRelationRef(dialect, Table(dialect, "follows"), alias="f"), join_type="INNER JOIN", - condition=Column(dialect, "id", table="t") == Column(dialect, "follower_id", table="f"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "id", table="t"), Column(dialect, "follower_id", table="f") + ), ).inner_join( right_table=NamedRelationRef(dialect, Table(dialect, "users"), alias="u"), - condition=Column(dialect, "followed_id", table="f") == Column(dialect, "id", table="u"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "followed_id", table="f"), Column(dialect, "id", table="u") + ), ) recursive = QueryExpression( @@ -169,10 +177,15 @@ def _build_traversal_cte(self, dialect): select=[ Column(dialect, "id", table="u"), Column(dialect, "name", table="u"), - (Column(dialect, "depth", table="t") + Literal(dialect, 1)).as_("depth"), + BinaryArithmeticExpression( + 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)), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, "<", Column(dialect, "depth", table="t"), Literal(dialect, 4)), + ), ) union = SetOperationExpression( @@ -237,7 +250,10 @@ def test_friend_recommendation_depth_2(self, postgres_backend, social_network_da Column(dialect, "depth"), ], from_=Table(dialect, "traversal"), - where=WhereClause(dialect, condition=Column(dialect, "depth") == Literal(dialect, 2)), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, "=", Column(dialect, "depth"), Literal(dialect, 2)), + ), order_by=OrderByClause(dialect, [Column(dialect, "name")]), ) @@ -267,7 +283,10 @@ def test_exclude_start_node(self, postgres_backend, social_network_data): Column(dialect, "depth"), ], from_=Table(dialect, "traversal"), - where=WhereClause(dialect, condition=Column(dialect, "depth") >= Literal(dialect, 1)), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, ">=", Column(dialect, "depth"), Literal(dialect, 1)), + ), order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "name")]), ) @@ -295,7 +314,9 @@ def _build_aml_cte(self, dialect): left_table=NamedRelationRef(dialect, Table(dialect, "transactions"), alias="tx"), right_table=NamedRelationRef(dialect, Table(dialect, "accounts"), alias="a"), join_type="INNER JOIN", - condition=Column(dialect, "source_account_id", table="tx") == Column(dialect, "id", table="a"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "source_account_id", table="tx"), Column(dialect, "id", table="a") + ), ) base = QueryExpression( @@ -310,8 +331,9 @@ def _build_aml_cte(self, dialect): from_=base_join, where=WhereClause( dialect, - condition=Column(dialect, "target_account_id", table="tx") - == Literal(dialect, 1), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "target_account_id", table="tx"), Literal(dialect, 1) + ), ), ) @@ -320,10 +342,14 @@ def _build_aml_cte(self, dialect): left_table=NamedRelationRef(dialect, Table(dialect, "fund_trace"), alias="tr"), right_table=NamedRelationRef(dialect, Table(dialect, "transactions"), alias="tx"), join_type="INNER JOIN", - condition=Column(dialect, "target_account_id", table="tx") == Column(dialect, "id", table="tr"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "target_account_id", table="tx"), Column(dialect, "id", table="tr") + ), ).inner_join( right_table=NamedRelationRef(dialect, Table(dialect, "accounts"), alias="a"), - condition=Column(dialect, "source_account_id", table="tx") == Column(dialect, "id", table="a"), + condition=ComparisonPredicate( + dialect, "=", Column(dialect, "source_account_id", table="tx"), Column(dialect, "id", table="a") + ), ) recursive = QueryExpression( @@ -333,10 +359,15 @@ def _build_aml_cte(self, dialect): 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"), + BinaryArithmeticExpression( + dialect, "+", 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)), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, "<", Column(dialect, "depth", table="tr"), Literal(dialect, 5)), + ), ) union = SetOperationExpression( @@ -366,7 +397,10 @@ def test_fund_trace_chain(self, postgres_backend, aml_data): Column(dialect, "depth"), ], from_=Table(dialect, "fund_trace"), - where=WhereClause(dialect, condition=Column(dialect, "depth") >= Literal(dialect, 1)), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, ">=", Column(dialect, "depth"), Literal(dialect, 1)), + ), order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "id")]), ) @@ -400,7 +434,10 @@ def test_fund_trace_depth_filter(self, postgres_backend, aml_data): Column(dialect, "depth"), ], from_=Table(dialect, "fund_trace"), - where=WhereClause(dialect, condition=Column(dialect, "depth") >= Literal(dialect, 2)), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, ">=", Column(dialect, "depth"), Literal(dialect, 2)), + ), order_by=OrderByClause(dialect, [Column(dialect, "depth"), Column(dialect, "id")]), ) @@ -502,7 +539,10 @@ async def test_async_single_hop(self, async_postgres_backend, async_social_netwo dialect=dialect, select=[Column(dialect, "id"), Column(dialect, "name"), Literal(dialect, 0).as_("depth")], from_=Table(dialect, "users"), - where=WhereClause(dialect, condition=Column(dialect, "name") == Literal(dialect, "Alice")), + where=WhereClause( + dialect, + condition=ComparisonPredicate(dialect, "=", Column(dialect, "name"), Literal(dialect, "Alice")), + ), ) result = base.to_sql() sql, params = result diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py index a5c4abd0..2b424561 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py @@ -216,8 +216,9 @@ def test_create_trigger_no_level(self): def test_create_trigger_with_condition(self): """Test CREATE TRIGGER with WHEN condition expression.""" from rhosocial.activerecord.backend.expression import Column, Literal + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate dialect = PostgresDialect((14, 0, 0)) - condition = Column(dialect, "status") == Literal(dialect, "ACTIVE") + condition = ComparisonPredicate(dialect, "=", Column(dialect, "status"), Literal(dialect, "ACTIVE")) expr = CreateTriggerExpression( dialect, trigger=Trigger(dialect, 'check_status'), 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 7fa02c07..30dfd7e2 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 @@ -10,6 +10,7 @@ from rhosocial.activerecord.backend.expression import ( Column, ColumnDefinition, + ComparisonPredicate, CreateTableExpression, DropTableExpression, ExplainExpression, @@ -436,7 +437,7 @@ def test_partition_key_update_moves_row_between_partitions( dialect=dialect, table=Table(dialect, RANGE_PARENT), assignments={"created_at": Literal(dialect, "2026-02-15")}, - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) postgres_backend.execute(*update.to_sql()) row = postgres_backend.fetch_one( @@ -449,7 +450,7 @@ def test_partition_key_update_moves_row_between_partitions( dialect=dialect, table=Table(dialect, RANGE_PARENT), assignments={"created_at": Literal(dialect, "2026-03-15")}, - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) with pytest.raises(Exception): postgres_backend.execute(*invalid_update.to_sql()) @@ -476,8 +477,8 @@ def test_json_explain_pruning_reports_target_partition( where=LogicalPredicate( dialect, "AND", - Column(dialect, "created_at") >= Literal(dialect, "2026-02-01"), - Column(dialect, "created_at") < Literal(dialect, "2026-03-01"), + ComparisonPredicate(dialect, ">=", Column(dialect, "created_at"), Literal(dialect, "2026-02-01")), + ComparisonPredicate(dialect, "<", Column(dialect, "created_at"), Literal(dialect, "2026-03-01")), ), ) rows = postgres_backend.fetch_all(*_json_explain_sql(dialect, query)) @@ -561,7 +562,7 @@ async def test_partition_key_update_moves_row_between_partitions( dialect=dialect, table=Table(dialect, RANGE_PARENT), assignments={"created_at": Literal(dialect, "2026-02-15")}, - where=Column(dialect, "id") == Literal(dialect, 1), + where=ComparisonPredicate(dialect, "=", Column(dialect, "id"), Literal(dialect, 1)), ) await async_postgres_backend.execute(*update.to_sql()) row = await async_postgres_backend.fetch_one( 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 index 407f1633..271abb90 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py @@ -14,10 +14,12 @@ ColumnDefinition, ColumnConstraint, ColumnConstraintType, + ComparisonPredicate, CreateTableExpression, DropTableExpression, InsertExpression, Literal, + LogicalPredicate, QueryExpression, ValuesSource, WildcardExpression, @@ -123,8 +125,12 @@ def _range_query_expression(dialect, start, end): dialect, select=[WildcardExpression(dialect)], from_=Table(dialect, PARTITION_EXPLAIN_TABLE), - where=(Column(dialect, "created_at") >= Literal(dialect, start)) - & (Column(dialect, "created_at") < Literal(dialect, end)), + where=LogicalPredicate( + dialect, + "AND", + ComparisonPredicate(dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start)), + ComparisonPredicate(dialect, "<", Column(dialect, "created_at"), Literal(dialect, end)), + ), ) 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 d5a7113a..df44ca43 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 @@ -14,6 +14,7 @@ from rhosocial.activerecord.backend.errors import IntegrityError from rhosocial.activerecord.backend.expression import ( Column, + ComparisonPredicate, CreateIndexExpression, DeleteExpression, FunctionCall, @@ -136,8 +137,16 @@ def _delete_partition_events_for_range_expression(dialect, start, end): return DeleteExpression( dialect=dialect, tables=Table(dialect, 'ar_partition_events'), - where=(Column(dialect, "created_at") >= Literal(dialect, start)) - & (Column(dialect, "created_at") < Literal(dialect, end)), + where=LogicalPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start) + ), + ComparisonPredicate( + dialect, "<", Column(dialect, "created_at"), Literal(dialect, end) + ), + ), ) @@ -324,14 +333,19 @@ def _insert_production_events_expression(dialect, rows): def _select_production_payloads_expression(dialect, start, end, *, tenant_id: Optional[int] = None): - predicate = (Column(dialect, "created_at") >= Literal(dialect, start)) & ( - Column(dialect, "created_at") < Literal(dialect, end) + predicate = LogicalPredicate( + dialect, + "AND", + ComparisonPredicate(dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start)), + ComparisonPredicate(dialect, "<", Column(dialect, "created_at"), Literal(dialect, end)), ) if tenant_id is not None: predicate = LogicalPredicate( dialect, "AND", - Column(dialect, "tenant_id") == Literal(dialect, tenant_id), + ComparisonPredicate( + dialect, "=", Column(dialect, "tenant_id"), Literal(dialect, tenant_id) + ), predicate, ) return QueryExpression( @@ -351,9 +365,19 @@ def _production_range_query_expression(dialect, start, end, *, tenant_id: int): where=LogicalPredicate( dialect, "AND", - Column(dialect, "tenant_id") == Literal(dialect, tenant_id), - (Column(dialect, "created_at") >= Literal(dialect, start)) - & (Column(dialect, "created_at") < Literal(dialect, end)), + ComparisonPredicate( + dialect, "=", Column(dialect, "tenant_id"), Literal(dialect, tenant_id) + ), + LogicalPredicate( + dialect, + "AND", + ComparisonPredicate( + dialect, ">=", Column(dialect, "created_at"), Literal(dialect, start) + ), + ComparisonPredicate( + dialect, "<", Column(dialect, "created_at"), Literal(dialect, end) + ), + ), ), ) 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 915d5b85..44b00c64 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 @@ -205,8 +205,11 @@ def test_exclude_constraint_rejects_empty_elements(dialect): def test_exclude_constraint_formats_expression_and_where(dialect): from rhosocial.activerecord.backend.expression import Column, Literal + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate - where = Column(dialect, "active") == Literal(dialect, True, inline_literals=True) + where = ComparisonPredicate( + dialect, "=", Column(dialect, "active"), Literal(dialect, True, inline_literals=True) + ) constraint = PostgresExcludeConstraint( dialect, elements=[("range", "&&")], 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 index 1c68d47b..5ddb2e2d 100644 --- 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 @@ -5,7 +5,11 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import ( + BinaryArithmeticExpression, + Column, + Literal, +) class TestDerivedFieldDeclaration: @@ -20,7 +24,9 @@ def test_descriptor_class_access(self, product_class): 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) + static_expr = BinaryArithmeticExpression( + dialect, "*", Column(dialect, "price"), Literal(dialect, 2) + ) df = DerivedField(static_expr) resolved = df.resolve(dialect) assert resolved is static_expr @@ -137,7 +143,11 @@ def _insert(self, Model, name, price, quantity): 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(dialect, d, "price") * Literal(d, 0.8)} + derived={ + "my_discount": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 0.8) + ) + } ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -153,7 +163,9 @@ def test_derived_dict_with_derived_field_instance(self, product_class): 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) + expr = BinaryArithmeticExpression( + dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5) + ) results = product_class.find_all( derived={"half_price": expr} ) @@ -185,7 +197,11 @@ def _insert(self, Model, name, price, quantity): 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(dialect, d, "price") * Literal(d, 3)} + extra_derived={ + "triple_price": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 3) + ) + } ) assert len(results) == 1 assert results[0].__dict__["triple_price"] == pytest.approx(300.0) @@ -194,14 +210,22 @@ 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(dialect, d, "price") * Literal(d, 0.5)} + extra_derived={ + "discounted_price": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, 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(dialect, d, "quantity") * Literal(d, 2)} + extra_derived={ + "double_qty": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "quantity"), Literal(d, 2) + ) + } ) assert results[0].discounted_price == pytest.approx(36.0) assert results[0].total_value == pytest.approx(200.0) @@ -315,7 +339,9 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(dialect, d, "price") * Literal(d, 0.9), + lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 0.9) + ), ), UseColumn("disc")]] def test_use_column_no_conflict_different_names(self): @@ -325,7 +351,9 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(dialect, d, "price") * Literal(d, 0.9), + lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, 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 index 77485609..85c8526b 100644 --- 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 @@ -5,7 +5,11 @@ from rhosocial.activerecord.base import DerivedField, UseColumn from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import ( + BinaryArithmeticExpression, + Column, + Literal, +) class TestAsyncDerivedFieldDeclaration: @@ -20,7 +24,9 @@ async def test_descriptor_class_access(self, async_product_class): 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) + static_expr = BinaryArithmeticExpression( + dialect, "*", Column(dialect, "price"), Literal(dialect, 2) + ) df = DerivedField(static_expr) resolved = df.resolve(dialect) assert resolved is static_expr @@ -137,7 +143,11 @@ async def _insert(self, Model, name, price, quantity): 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(dialect, d, "price") * Literal(d, 0.8)} + derived={ + "my_discount": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 0.8) + ) + } ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -153,7 +163,9 @@ async def test_derived_dict_with_derived_field_instance(self, async_product_clas 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) + expr = BinaryArithmeticExpression( + dialect, "*", Column(dialect, "price"), Literal(dialect, 0.5) + ) results = await async_product_class.find_all( derived={"half_price": expr} ) @@ -185,7 +197,11 @@ async def _insert(self, Model, name, price, quantity): 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(dialect, d, "price") * Literal(d, 3)} + extra_derived={ + "triple_price": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 3) + ) + } ) assert len(results) == 1 assert results[0].__dict__["triple_price"] == pytest.approx(300.0) @@ -194,14 +210,22 @@ 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(dialect, d, "price") * Literal(d, 0.5)} + extra_derived={ + "discounted_price": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, 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(dialect, d, "quantity") * Literal(d, 2)} + extra_derived={ + "double_qty": lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "quantity"), Literal(d, 2) + ) + } ) assert results[0].discounted_price == pytest.approx(36.0) assert results[0].total_value == pytest.approx(200.0) @@ -315,7 +339,9 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(dialect, d, "price") * Literal(d, 0.9), + lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 0.9) + ), ), UseColumn("disc")]] async def test_use_column_no_conflict_different_names(self): @@ -325,7 +351,9 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(dialect, d, "price") * Literal(d, 0.9), + lambda d: BinaryArithmeticExpression( + dialect, "*", Column(dialect, d, "price"), Literal(d, 0.9) + ), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" From 5680cb385880163ae385dda1d11076050b7724c9 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 20:20:40 +0800 Subject: [PATCH 06/15] test: build predicates explicitly, now that the bare Column offers no operators --- .../feature/relation/fixtures/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py index 7a5199d8..5c4f8da4 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py @@ -159,7 +159,7 @@ class Post(ActiveRecord): # DerivedField: hotness score hotness: ClassVar[Annotated[int, DerivedField( - lambda d: Column(dialect, d, "view_count") + Literal(d, 1), + lambda d: BinaryArithmeticExpression(dialect, "+", Column(dialect, d, "view_count"), Literal(d, 1)), )]] # DerivedField (JSON): first tag from metadata @@ -254,7 +254,7 @@ class AsyncPost(AsyncActiveRecord): )]] hotness: ClassVar[Annotated[int, DerivedField( - lambda d: Column(dialect, d, "view_count") + Literal(d, 1), + lambda d: BinaryArithmeticExpression(dialect, "+", Column(dialect, d, "view_count"), Literal(d, 1)), )]] first_tag: ClassVar[Annotated[str, DerivedField( From 56ea312e04bb9024d15f8390cabc7317a7fb7bde Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 21:28:19 +0800 Subject: [PATCH 07/15] deps: pin pydantic 2.12.2 / pydantic-core 2.41.4 on Python 3.13 (incl. 3.13t) --- requirements-dev.txt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index a6698464..025bfa64 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -53,3 +53,17 @@ rich # Test timeout plugin for long-running database integration tests pytest-timeout + +# Python 3.13, including the free-threaded 3.13t build. The python_version +# marker distinguishes release lines, not GIL configuration, so these two lines +# pin both 3.13 builds. pydantic-core has no cp313t wheel after 2.41.4, and the +# newer sdists cannot be built there (PyO3 0.29 refuses the free-threaded build +# of CPython < 3.14); 2.12.2 is the last pydantic paired with 2.41.4. +# +# These mirror the same pins in rhosocial-activerecord's own dependencies, and +# they have to be restated here because this file is installed before the core +# package: resolving an unpinned `pydantic` here picks a pydantic-core whose only +# cp313t distribution is an sdist, and the build then fails on the free-threaded +# jobs before the core's pin is ever in scope. +pydantic[email]==2.12.2; python_version == '3.13' +pydantic-core==2.41.4; python_version == '3.13' From 57f1c293359b90d63e97ec6590613749d30fa653 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 10 Oct 2026 23:51:37 +0800 Subject: [PATCH 08/15] feat: render the new pad/repeat/trim nodes (PostgreSQL spells them natively) --- .../backend/impl/postgres/dialect.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index e3bfdbda..51045352 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -61,6 +61,15 @@ ILIKEMixin, ConstraintMixin, PartitionMixin, + # TRIM/LPAD/RPAD/REPEAT are nodes with default formatters, and PostgreSQL + # spells all four natively -- ``TRIM([BOTH|LEADING|TRAILING] [chars] FROM + # str)``, ``LPAD``/``RPAD(str, len, padstr)`` and ``REPEAT(str, n)`` -- so + # the shared defaults are the answer here and no override is needed. + LpadMixin, + RepeatMixin, + RpadMixin, + TrimMixin, + # New Mixins PredicateMixin, ExpressionMixin, @@ -420,6 +429,15 @@ class PostgresDialect( PostgresCollationMixin, PostgresOrderedSetAggMixin, PostgresFeaturesMixin, + # TRIM/LPAD/RPAD/REPEAT are nodes with default formatters, and PostgreSQL + # spells all four natively: ``TRIM([BOTH|LEADING|TRAILING] [chars] FROM + # str)``, ``LPAD``/``RPAD(str, len, padstr)`` and ``REPEAT(str, n)`` + # (functions-string.html; ``TRIM(BOTH 'xy' FROM 'xyxab')`` live-verified + # 2026-10-09 as the set form). No override is needed for any of them. + LpadMixin, + RepeatMixin, + RpadMixin, + TrimMixin, SQLXMLMixin, CollationMixin, SetOperationMixin, From ae7b0444352a51ee65301e7ca5b9ae09acfc905b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 05:54:32 +0800 Subject: [PATCH 09/15] test: enumerate every core node class so a missing formatter cannot stay silent --- .../backend/test_expression_roundtrip_all.py | 192 +++++++++++++++++- 1 file changed, 184 insertions(+), 8 deletions(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py index d0f6b418..2f96584f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py @@ -50,14 +50,42 @@ * **renders** -- all three encodings must restore byte-identical SQL *and* byte-identical bind parameters. -* ``UnsupportedFeatureError`` -- this dialect does not model the feature. - Asserted as exactly that type, so a different error cannot hide behind it. +* ``UnsupportedFeatureError`` from a probe inside a formatter -- this dialect + does not model the feature. Asserted as exactly that type, so a different + error cannot hide behind it. +* ``UnsupportedFeatureError`` from the dispatch -- the dialect declares no + ``format_*`` method for the class at all. The method it names must be a row + in :data:`UNMODELLED_FORMATTERS`, so the gap is a decision with a reason + rather than an omission; see the next section. * a member of :data:`LEGITIMATE_NON_RENDERS` -- a class that cannot render for a reason belonging to its own tree or to PostgreSQL's feature set. Each entry pins the exception type *and* a message fragment, so a class that started failing for a different reason fails here instead of staying quietly green. * **anything else** -- a failure naming the class and the exception. +Why one ``UnsupportedFeatureError`` became two answers +====================================================== + +Core reports a missing formatter and a declined feature through the same +exception type, and this matrix allowed both identically -- which let one of +them hide. A class whose formatter this dialect never mixed in read as +"PostgreSQL lacks the feature" and passed. Core turning ``trim``/``lpad``/ +``rpad``/``repeat`` into dedicated nodes made the hole load-bearing: a backend +that shipped no ``format_lpad_expression`` answered every ``.lpad(...)`` with +that refusal, and nothing here could tell it apart from a genuine "not on +this engine". + +The two are told apart by *who* is refusing, and the exception itself records +that: a probe inside a formatter names the feature it lacks, while the +dispatch names the formatting method it could not find, framed +``the '' statement`` -- see :func:`_dispatched_formatter`. Only the +second is a wiring fact about this backend, so only the second has to be +accounted for: the method it names must be in +:data:`UNMODELLED_FORMATTERS` with the reason the feature is absent, and +:meth:`TestMatrixIntegrity.test_unmodelled_formatter_list_is_exact` pins that +table in both directions. A method no class needs any more retires; a method +nobody listed fails the run. + Why some pins are themselves ``UnsupportedFeatureError`` ========================================================= @@ -1130,6 +1158,77 @@ def _no_such_formatter(formatter_name: str) -> str: } +# --------------------------------------------------------------------------- +# The dispatch gap: a missing formatter is a decision, not an omission +# --------------------------------------------------------------------------- +# +# ``to_sql()`` reports a dialect that names no formatter for a class through +# the same ``UnsupportedFeatureError`` a capability probe uses, so the type +# alone cannot say "this backend never wired the feature in". The frame the +# dispatch puts around the method name can, and it is the only place in the +# tree that opens it -- see :func:`_dispatched_formatter`. + +#: The two ends of the frame ``to_sql()`` puts around a formatting method name +#: in the ``feature_name`` of the ``UnsupportedFeatureError`` it raises for a +#: dialect that has no such formatter. +_DISPATCH_FEATURE_PREFIX = "the '" +_DISPATCH_FEATURE_SUFFIX = "' statement" + + +def _dispatched_formatter(exc): + """The formatting method *exc* says the dialect does not have, or ``None``. + + ``None`` means *exc* is not the dispatch reporting a missing formatter -- + it is a probe inside a formatter that refused a feature, which is the + ``"unsupported"`` branch and needs no row anywhere. + + Args: + exc: An :class:`UnsupportedFeatureError` raised out of ``to_sql()``. + + Returns: + The method name out of the ``feature_name`` frame, or ``None`` when + *exc* came from a probe rather than from the dispatch. + """ + feature = exc.feature_name + if not feature.startswith(_DISPATCH_FEATURE_PREFIX): + return None + if not feature.endswith(_DISPATCH_FEATURE_SUFFIX): + return None + method = feature[len(_DISPATCH_FEATURE_PREFIX) : len(feature) - len(_DISPATCH_FEATURE_SUFFIX)] + return method if method else None + + +#: Formatters PostgreSQL declares no implementation of, grouped by the feature +#: that is absent, each group naming every method the matrix's classes need. +#: +#: A class needing one of these fails with ``UnsupportedFeatureError`` naming +#: the method it wanted, and :func:`assert_sql_roundtrip_classified` asserts +#: that method is a row here. :meth:`TestMatrixIntegrity.\ +#: test_unmodelled_formatter_list_is_exact` pins the table in both directions: +#: a formatter that starts existing moves its classes out of the table and the +#: pin fails until the entry goes, and a class that starts needing a method +#: outside the table fails outright instead of passing as "unsupported". +#: +#: These are not defects: they are features PostgreSQL does not have, which +#: the shared renderer reports by naming the method it could not find. The +#: grouping is the point -- "N methods" says nothing, "no PIVOT keyword, and +#: the row-source root is never rendered" says what the dialect is. +UNMODELLED_FORMATTERS = { + "SQL:2003 PIVOT / UNPIVOT (PostgreSQL has no PIVOT keyword; pivoting is " + "spelled as CASE + GROUP BY, or crosstab from the tablefunc extension)": ( + "format_pivot_expression", + "format_unpivot_expression", + ), + "the TableSource root (every concrete row source overrides its formatter; " + "the root carries nothing but an alias and is never rendered)": ("format_table_source",), +} + + +def _unmodelled_methods(): + """The flat set of formatter names in :data:`UNMODELLED_FORMATTERS`.""" + return {method for methods in UNMODELLED_FORMATTERS.values() for method in methods} + + def _channels_that_lose_params(fqn, instance, dialect): """The encodings whose round-trip does not restore ``get_params()``. @@ -1169,6 +1268,10 @@ def assert_sql_roundtrip_classified(fqn, instance, dialect): before :data:`LEGITIMATE_NON_RENDERS`, so a class pinned with that same type is classified here and its fragment is enforced by ``test_pinned_non_render_really_does_not_render`` instead. + * ``UnsupportedFeatureError`` naming a formatting method -- the dispatch + found nothing, which is a wiring fact about PostgreSQL rather than a + capability answer. The method must be in + :data:`UNMODELLED_FORMATTERS`; the table entry is the reason. * a member of :data:`LEGITIMATE_NON_RENDERS` -- unrenderable by design, asserted as its exact type *and* message fragment. * **anything else** -- a failure naming the class and the exception. @@ -1183,6 +1286,17 @@ def assert_sql_roundtrip_classified(fqn, instance, dialect): expected_sql, expected_params = instance.to_sql() except UnsupportedFeatureError as exc: assert type(exc) is UnsupportedFeatureError, fqn + method = _dispatched_formatter(exc) + if method is not None: + assert method in _unmodelled_methods(), ( + f"{fqn}: to_sql() reported that {exc.dialect_name} declares no " + f"{method!r}, which is not in UNMODELLED_FORMATTERS.\n" + f" Either PostgreSQL now needs that formatter -- in which case " + f"the class should render and this entry should go -- or the " + f"feature is absent and the method belongs in that table with " + f"its reason." + ) + return "unmodelled" return "unsupported" except Exception as exc: if fqn not in LEGITIMATE_NON_RENDERS: @@ -1335,6 +1449,60 @@ def test_pinned_non_render_really_does_not_render(self, postgres_dialect): f"{exc_info.value}" ) + def test_unmodelled_formatter_list_is_exact(self, postgres_dialect): + """Pin the unmodelled-formatter table against what the dialect lacks. + + Observed rather than assumed: for each class the matrix covers, either + it renders or it fails, and every dispatch refusal is attributed to the + method it named. Then the table is compared with the set observed, in + both directions, so: + + * a method in the table that PostgreSQL now implements fails here, + because its classes render and are no longer attributed to it -- + which is the moment to delete the entry rather than leave a lie in + the table; + * a class that starts needing a method outside the table fails the + per-class assertion in :func:`assert_sql_roundtrip_classified` + instead of being absorbed into "unsupported". + + This is the regression test for the pad/trim incident. Core turned + ``trim``/``lpad``/``rpad``/``repeat`` into dedicated nodes, and a + backend that mixed in no formatter for one of them would have shipped + a silent ``UnsupportedFeatureError`` on every ``.lpad(...)`` while this + matrix stayed green, because the blanket ``unsupported`` branch + allowed the dispatch's refusal exactly as it allows a probe's. The + dispatch names the method it wanted; this test makes that name + load-bearing on both sides of the table. + """ + observed = set() + for fqn in sorted(REGISTERED): + instance, source = make_instance(REGISTERED[fqn], postgres_dialect) + if instance is None: + continue + try: + instance.to_sql() + except UnsupportedFeatureError as exc: + method = _dispatched_formatter(exc) + if method is not None: + observed.add(method) + continue + except Exception: + continue + + declared = _unmodelled_methods() + assert not (observed - declared), ( + "classes need formatters that are not in UNMODELLED_FORMATTERS: " + f"{sorted(observed - declared)}. Add each with the feature that is " + f"absent and why -- or mix the formatter in, in which case the " + f"classes render and the entry is not needed." + ) + assert not (declared - observed), ( + "UNMODELLED_FORMATTERS names formatters no class actually needs: " + f"{sorted(declared - observed)}. PostgreSQL may have gained one of " + f"these, in which case the classes needing it now render and the " + f"entry should go." + ) + def test_matrix_covers_both_packages_completely(self): """The matrix covers every concrete class both packages define. @@ -1389,16 +1557,24 @@ def test_every_covered_class_is_registered_for_deserialization(self): def test_coverage_report(self, postgres_dialect): """Surface what the matrix covers, so coverage stays transparent.""" ExpressionRegistry._auto_register_builtins() - constructible = [ - fqn - for fqn in REGISTERED - if make_instance(REGISTERED[fqn], postgres_dialect)[0] is not None - ] + census: Dict[str, int] = {} + constructible = [] + for fqn in sorted(REGISTERED): + instance, source = make_instance(REGISTERED[fqn], postgres_dialect) + if instance is None: + census["pinned-unconstructible"] = census.get("pinned-unconstructible", 0) + 1 + continue + constructible.append(fqn) + branch = assert_sql_roundtrip_classified(fqn, instance, postgres_dialect) + census[branch] = census.get(branch, 0) + 1 assert constructible + summary = ", ".join(f"{count} {name}" for name, count in sorted(census.items())) print( f"\nexpression matrix: {len(constructible)} constructible, " f"{len(UNCONSTRUCTIBLE)} pinned-unconstructible, " - f"{len(REGISTERED)} registered" + f"{len(REGISTERED)} registered; {summary}" ) for fqn in UNCONSTRUCTIBLE: print(f" not constructible: {fqn}") + for feature, methods in sorted(UNMODELLED_FORMATTERS.items()): + print(f" not modelled: {feature} ({len(methods)} formatters)") From c87bcf93c0ab2b9b2644dccf2ba1d499f0b6bf8e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 11:07:58 +0800 Subject: [PATCH 10/15] test: follow the core's rename of DateTimeSupport to TimestampSupport --- .../feature/backend/test_postgres_protocol_conformance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 757a49a3..14b993b2 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -174,7 +174,7 @@ def get_postgres_protocols(): dialect_protocols.CreateTableCloneSupport, dialect_protocols.CreateTableLikeSupport, dialect_protocols.CreateTableUsingTemplateSupport, - dialect_protocols.DateTimeSupport, + dialect_protocols.TimestampSupport, dialect_protocols.DqlOrderSupport, dialect_protocols.FulltextIndexSupport, dialect_protocols.SynonymObjectSupport, From cd44c7c0425fc8b670d82721d095217671b12fab Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 12:32:26 +0800 Subject: [PATCH 11/15] ci: Python 3.15 is a required leg, not an experimental one setup-python now ships Python 3.15.0, so the release-candidate pins are replaced with plain 3.15 (both the GIL and the free-threaded legs), and the exemptions that made the leg optional are removed: - the "(experimental)" tag in the job names, and the experimental job ids; - `continue-on-error: true`, which let the leg report failure without failing the run; - postgres' disable gate (`if:` set to false) and its comment. The remaining matrix entries are unchanged: every backend keeps a GIL and a free-threaded leg on 3.15, now on equal footing with the other interpreters. --- .github/workflows/test.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b6940a2..e95d0634 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -219,20 +219,17 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - test-experimental-python-315: - # Temporarily disabled until Python 3.15 toolchain compatibility stabilizes. - if: ${{ false }} - name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} (experimental) + test-python-315: + name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest permissions: contents: read - continue-on-error: true strategy: fail-fast: false matrix: include: - - { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: false } - - { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: true } + - { python-version: '3.15', postgres-version: '18', freethreaded: false } + - { python-version: '3.15', postgres-version: '18', freethreaded: true } services: postgres: From ee025055d4a74f98acd3ccb9ec19678e4f05fd11 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 12:48:44 +0800 Subject: [PATCH 12/15] ci(postgres): drop the free-threaded 3.15 leg, and name legs distinctly psycopg does not support free-threaded builds -- it is tracked upstream as psycopg/psycopg#1095 -- so a free-threaded leg on this backend can only ever fail at install or at connection. The 3.15 job keeps its GIL leg only, which is what every other interpreter here already runs. The job name now carries the free-threaded marker. It renders empty today, but the matrix had two 3.15 legs that both displayed as "Python 3.15 / postgres 18": the GIL leg passed and the free-threaded leg failed, which read as one job that had simultaneously passed and failed. With one leg the collision is invisible; keeping the marker stops it recurring if a second leg is ever added. --- .github/workflows/test.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e95d0634..79f9f655 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -220,7 +220,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" test-python-315: - name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} + name: Python ${{ matrix.python-version }}${{ matrix.freethreaded && 't' || '' }} / postgres ${{ matrix.postgres-version }} runs-on: ubuntu-latest permissions: contents: read @@ -228,8 +228,15 @@ jobs: fail-fast: false matrix: include: + # The free-threaded marker is in the job name even though there is + # currently no free-threaded leg, because two legs named alike is how + # a GIL pass and a free-threaded failure came to look like one job + # that both passed and failed. + # + # psycopg does not support free-threaded builds -- tracked as + # psycopg/psycopg#1095 -- so the GIL leg is the only one this backend + # has, on 3.15 as on every other interpreter. - { python-version: '3.15', postgres-version: '18', freethreaded: false } - - { python-version: '3.15', postgres-version: '18', freethreaded: true } services: postgres: From e295d0f3a0f109b9b1c94b45e1036e9ab04b6643 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 13:40:51 +0800 Subject: [PATCH 13/15] ci: benchmark the newest supported interpreter, and pair Pydantic per Python The benchmark job was pinned to 3.14 while the test matrix had already moved to 3.15, so the newest interpreter this package supports was never exercised under load. It now runs on 3.15 (core, postgres and mysql) -- job id, job name and the summary text included -- while the per-version test legs stay on their own interpreters. The README carried the Pydantic requirement as one coarse line. It now carries the actual pairing: 3.8 pins an exact pair (the last installable there), 3.13 and 3.13t pin the pair whose pydantic-core still has a wheel, and the rest stay open. The reason column records why the 3.13 row cannot simply track the newest release, and the free-threaded paragraphs now state per backend which legs exist and why. --- .github/workflows/test.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79f9f655..ec368494 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,8 +18,8 @@ permissions: contents: read jobs: - benchmark-python-314-postgres-18: - name: Run benchmarks with Python 3.14 / postgres 18 + benchmark-python-315-postgres-18: + name: Run benchmarks with Python 3.15 / postgres 18 runs-on: ubuntu-latest permissions: contents: read @@ -56,10 +56,10 @@ jobs: - name: Check out the repository uses: actions/checkout@v6 - - name: Set up Python 3.14 + - name: Set up Python 3.15 uses: actions/setup-python@v6 with: - python-version: "3.14" + python-version: "3.15" - name: Install dependencies run: | @@ -181,9 +181,9 @@ jobs: if: always() run: | { - echo "## Python 3.14 / postgres 18 Benchmark Conclusion" + echo "## Python 3.15 / postgres 18 Benchmark Conclusion" echo - echo "- Python: 3.14" + echo "- Python: 3.15" echo "- postgres: 18" echo "- Scope: testsuite generic benchmarks + ActiveRecord bulk benchmarks + PostgreSQL backend direct benchmarks" echo "- Size: small" @@ -202,9 +202,9 @@ jobs: if: always() run: | { - echo "## Python 3.14 / postgres 18 FastAPI Benchmark Conclusion" + echo "## Python 3.15 / postgres 18 FastAPI Benchmark Conclusion" echo - echo "- Python: 3.14" + echo "- Python: 3.15" echo "- postgres: 18" echo "- Scope: testsuite FastAPI database benchmarks" echo "- Size: small" From 5ea3e16d5dc3eaa2fabd5d2e8fe6e31811695784 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 15:33:50 +0800 Subject: [PATCH 14/15] ci: install the core from its release branch now that typed-columns has merged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While these branches were in development, CI installed the core from `@feature/typed-columns`, because the release branch did not carry the rebuilt column layer yet. That branch has now merged into `release/v1.0.0.dev30` (PR #126), so the feature reference is both stale and about to disappear with branch deletion. Point the core dependency at `release/v1.0.0.dev30` instead — the same branch these backends are merging into, which is what the release-side CI already used. The testsuite reference is deliberately left alone: that branch (`test/typed-column-contracts`) has not merged into its release yet, and pointing at a release that lacks the new contracts would break every backend at once. It moves when the testsuite merges. ClickHouse expresses the dependency as a `ref:` on `actions/checkout` rather than as a pip requirement; that one is switched the same way. --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec368494..ddad0a63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config @@ -283,7 +283,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config @@ -395,7 +395,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config @@ -493,7 +493,7 @@ jobs: - name: Install activerecord and testsuite run: | - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/typed-columns" + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@test/typed-column-contracts" - name: Create postgres scenario config From ad028cc903cebd09fdcc48d3d748e498e719c62b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sun, 11 Oct 2026 15:57:56 +0800 Subject: [PATCH 15/15] chore: add changelog fragment for PR #62 --- changelog.d/62.added.md | 1 + changelog.d/62.changed.md | 1 + changelog.d/62.fixed.md | 1 + changelog.d/62.internal.md | 1 + changelog.d/62.removed.md | 1 + 5 files changed, 5 insertions(+) create mode 100644 changelog.d/62.added.md create mode 100644 changelog.d/62.changed.md create mode 100644 changelog.d/62.fixed.md create mode 100644 changelog.d/62.internal.md create mode 100644 changelog.d/62.removed.md diff --git a/changelog.d/62.added.md b/changelog.d/62.added.md new file mode 100644 index 00000000..86360f9d --- /dev/null +++ b/changelog.d/62.added.md @@ -0,0 +1 @@ +Add the PostgreSQL answer to the core column-type protocol and the four new pad/repeat/trim nodes. `PostgresColumnTypeMixin` and `POSTGRES_COLUMN_TYPES` carry the protocol's closed list of eighteen common Python types with every entry measured: `list`/`tuple`/`set`/`frozenset` answer `ArrayColumn` (native arrays, measured on 9.6/12/18 with the whole guarantee set green — `INT[]` DDL, a Python list bound directly and read back, `array_length`, 1-based `x[1]`, containment `x @> ARRAY[v]`, concatenation, `v = ANY(x)`, whole-column equality and `unnest`), `dict` answers `JSONColumn` while the DDL layer chooses `jsonb` through the `DataType` layer, `enum.Enum` answers `StringColumn`, and `float`/`decimal.Decimal` answer `NumericColumn` now that core removed `FloatColumn`/`DecimalColumn`/`RealColumn`/`DoubleColumn` and the numeric family is deliberately one class. `datetime.timedelta` is refused on purpose: PostgreSQL spells it `interval`, but no core column class names an interval's operations, and the previous `NumericColumn` answer offered `sqrt(interval '1 day')` for the engine to refuse at execution — a field declared `timedelta` now fails to resolve with a message naming the fix (`UseColumnType`). `PostgresDialect` composes the generic `TrimMixin`/`LpadMixin`/`RpadMixin`/`RepeatMixin` with no override, because PostgreSQL spells all four natively (`TRIM([BOTH|LEADING|TRAILING] [chars] FROM str)`, `LPAD`/`RPAD(str, len, padstr)`, `REPEAT(str, n)`). `PostgresUUIDMixin` and `PostgresUUIDAdapter` state the version-13-built-in-or-uuid-ossp answer and win over the core adapter's single fixed spelling, and `supports_uuid_ossp_extension` names the narrower question of whether uuid-ossp is installed. diff --git a/changelog.d/62.changed.md b/changelog.d/62.changed.md new file mode 100644 index 00000000..7ba2fd10 --- /dev/null +++ b/changelog.d/62.changed.md @@ -0,0 +1 @@ +Change what the dialect answers and where the protocol bases sit. `format_cast_expression` renders its target through `expr.target_type.to_sql()` instead of interpolating it, so `col.cast(DecimalType(dialect))` produces `"col"::NUMERIC` rather than `"col"::DecimalType()`, and the `type_values` and expression examples that showed a string target now show a type object. `JSONSupport` is no longer listed in `PostgresDialect`'s protocol block alongside its own derived `PostgresJSONBEnhancedSupport` — a base listed ahead of its own subclass is not a consistent MRO, and the pair had put `supports_json_type` in the MRO four times (two abstract protocol declarations, the core mixin default and the one implementation); the protocol still reaches the MRO, so `isinstance(dialect, JSONSupport)` is unchanged. `PostgresTableMixin`'s data-type validation accepts underscores in a type name, which no valid PostgreSQL type spelling was previously allowed to contain. `PostgresSchemaDiffer` records the consequence of comparing snapshots against an introspected array column, which always reports `dimensions=1` because PostgreSQL normalises every array declaration to a single dimension on storage. diff --git a/changelog.d/62.fixed.md b/changelog.d/62.fixed.md new file mode 100644 index 00000000..470cafda --- /dev/null +++ b/changelog.d/62.fixed.md @@ -0,0 +1 @@ +Fix the probes that promised a server answer the server does not give, and two mixins listed in an order that made their own implementation dead code. `supports_data_type_postgres_jsonpath`, `postgres_xid8`, the six `postgres_*multirange` probes and `postgres_macaddr8` were unconditional `True` on types that arrived in PostgreSQL 12, 13, 14 and 10 respectively — a gate that cannot say no tells the dialect it can render the type on any server, and the disagreement arrives at execution, with the DDL built and sent, and the error naming a table rather than the version gap that caused it. `supports_jsonb_subscript` claimed 14.0+ for a capability PostgreSQL does not have at all (SQL subscripting covers arrays and composites; jsonb is read with `->`/`->>` or the SQL/JSON path language) and now answers `False` unconditionally, and `supports_json_arrow_operators` returned `True` unconditionally, advertising on 9.2 — where the operators do not parse — an arrow the core formatter would happily render; it is now gated at 9.3. The four version floors were measured against the scenario servers rather than read off the release notes, because the notes and the servers disagreed about jsonpath: the notes say 13, and 12 has it. `PostgresArrayEnhancedMixin` is now listed before the core `ArrayMixin` and `PostgresUUIDMixin` before the core `UUIDMixin`, where each answers `True` to a question the core answers `False` for; listed in the backend block, the core default was what every caller actually saw and the backend's implementation was unreachable. Each fix is pinned by a test in both directions, so a versioned type cannot again answer yes unconditionally while `text`/`integer`/`varchar` saying yes remains correct on purpose. diff --git a/changelog.d/62.internal.md b/changelog.d/62.internal.md new file mode 100644 index 00000000..78ec251a --- /dev/null +++ b/changelog.d/62.internal.md @@ -0,0 +1 @@ +Make Python 3.15 a required CI leg instead of an experimental one: `test-python-315` replaces the `if: ${{ false }}` / `continue-on-error` `test-experimental-python-315` job that ran a 3.15.0-rc.2 pair. The free-threaded 3.15 leg is dropped because psycopg does not support free-threaded builds (psycopg/psycopg#1095), but the free-threaded marker stays in the job *name*, since two legs named alike is how a GIL pass and a free-threaded failure came to look like one job that both passed and failed. The benchmark job moves to Python 3.15 / PostgreSQL 18. `requirements-dev.txt` pins pydantic 2.12.2 / pydantic-core 2.41.4 for Python 3.13 including the free-threaded 3.13t build, restated in this file because it is installed before the core package and an unpinned `pydantic` there resolves to a pydantic-core whose only cp313t distribution is an sdist that cannot build (PyO3 0.29 refuses the free-threaded build of CPython lower than 3.14). CI installs the core from its `release/v1.0.0.dev30` branch now that the column-layer rebuild has merged, and the testsuite from `test/typed-column-contracts`. The expression round-trip matrix now tells a missing formatter apart from a declined feature — core reports both through the same `UnsupportedFeatureError`, which let a class whose formatter this dialect never mixed in pass as "PostgreSQL lacks the feature" — so the method the dispatch names must be a row in the new `UNMODELLED_FORMATTERS` table with the reason (SQL:2003 PIVOT/UNPIVOT and the TableSource root, today), pinned in both directions. New coverage alongside it: column-type suggestions, cast integration, domain-expression and expression round-trips, extension type execution and operations, JSON path modes, UUID values, and the DDL-source accessor and DDL-object contracts. diff --git a/changelog.d/62.removed.md b/changelog.d/62.removed.md new file mode 100644 index 00000000..9317389e --- /dev/null +++ b/changelog.d/62.removed.md @@ -0,0 +1 @@ +**BREAKING**: Remove `PostgresCharacterVaryingType` from the `rhosocial.activerecord.backend.impl.postgres.expression` namespace and its `__all__`, remove `supports_uuid_generation` from `PostgresUuidOssSupport`/`PostgresUuidOssMixin` (renamed `supports_uuid_ossp_extension`, which asks the narrower question of whether the extension is installed; the merged answer now lives only in core's `UUIDSupport` and is folded by `PostgresUUIDMixin`, which answers True for a 13.0+ built-in with no extension at all), and remove `supports_infinity_numeric_infinity_jsonb` from `PostgresDataTypeMixin`, together with the two probes the core protocol's probe-family retirement asked this backend to stop declaring.