From c57970164f719249d87ca11ee8a21a05526ceec3 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 6 Oct 2026 17:30:14 +0800 Subject: [PATCH 1/3] fix(testsuite): construct expressions from the objects core now asks for Core's expression classes now take the object they act on rather than a parsed string, so the fixtures construct them from objects and several need explicit constructors where the introspective guess cannot satisfy the shape. The introspective constructor read a parameter's annotation and computed its text as the alias' own __name__. If that text mentioned a relation word -- Table, View, Sequence and the rest -- it handed the parameter a catalogue object. List[str] was safe because the container branch answered first, but Sequence[X] was not: get_origin yields collections.abc.Sequence, the test for list/tuple/set misses, and what is left is the literal string "Sequence". So a parameter asking for a list of columns received a Sequence object and the class could not be built. _empty_container_for now answers for any parameterised alias before the relation-name test, matching the collections.abc ABCs rather than the concrete types an annotation happens to name, so Sequence[str], FrozenSet[str] and Mapping[str, int] are recognised as what they are. A bare class annotation returns None, so the relation test still sees the parameters that really do name an object. Measured by walking every class's __init__ in the core expression package and re-deriving what the old code decided: 58 parameters reached the relation test, 51 of them annotated with a single class, and 7 wrongly annotated with a parameterised alias -- columns, attributes, actions, parts, items, keys and passing, all Sequence[...]. Against MariaDB the seven classes went from none constructible to four. The remaining three are a different defect, keyword-only parameters hidden behind defaulted positionals, and stay unbuilt. --- .../ddl/test_materialized_view_contract.py | 26 +- .../testsuite/feature/ddl/fixtures/models.py | 5 +- .../testsuite/feature/ddl/test_derivation.py | 2 +- .../feature/ddl/test_derivation_async.py | 2 +- .../query/cte/test_cte_query_set_operation.py | 4 +- .../cte/test_cte_query_set_operation_async.py | 4 +- .../feature/query/joins/test_join_using.py | 2 +- .../testsuite/utils/expression.py | 400 +++++++++++++++++- 8 files changed, 414 insertions(+), 31 deletions(-) diff --git a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py index e16570e..663c738 100644 --- a/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py +++ b/src/rhosocial/activerecord/testsuite/feature/basic/ddl/test_materialized_view_contract.py @@ -61,8 +61,13 @@ UnsupportedFeatureError, ) from rhosocial.activerecord.backend.dialect.mixins import ViewMixin -from rhosocial.activerecord.backend.dialect.protocols import ViewSupport -from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression +from rhosocial.activerecord.backend.dialect.protocols import ViewObjectSupport +from rhosocial.activerecord.backend.expression import Column, QueryExpression +from rhosocial.activerecord.backend.expression.objects import ( + MaterializedView, + Table, +) +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.backend.expression.statements.ddl_view import ( CreateMaterializedViewExpression, DropMaterializedViewExpression, @@ -126,7 +131,7 @@ def _source_query(dialect): return QueryExpression( dialect=dialect, select=[Column(dialect, "id")], - from_=TableExpression(dialect, "source_table"), + from_=NamedRelationRef(dialect, Table(dialect, "source_table")), ) @@ -195,7 +200,9 @@ def _render_generic_create(self, dialect): parameters. """ expression = CreateMaterializedViewExpression( - dialect=dialect, view_name="mv_contract", query=_source_query(dialect) + dialect=dialect, + view=MaterializedView(dialect, "mv_contract"), + query=_source_query(dialect), ) try: return _render(expression) @@ -224,7 +231,9 @@ def test_drop_renders(self, ddl_dialect): if not _mv_supported(ddl_dialect): pytest.skip("backend does not advertise materialized view support") expression = DropMaterializedViewExpression( - dialect=ddl_dialect, view_name="mv_contract", if_exists=True + dialect=ddl_dialect, + view=MaterializedView(ddl_dialect, "mv_contract"), + if_exists=True, ) sql, params = _render(expression) assert sql.upper().startswith("DROP "), f"DROP rendered as {sql!r}" @@ -245,7 +254,8 @@ def test_refresh_renders_statement(self, ddl_dialect): if not _mv_refresh_supported(ddl_dialect): pytest.skip("backend does not advertise materialized view refresh") expression = RefreshMaterializedViewExpression( - dialect=ddl_dialect, view_name="mv_contract" + dialect=ddl_dialect, + view=MaterializedView(ddl_dialect, "mv_contract"), ) sql, params = _render(expression) assert sql.strip(), "REFRESH rendered an empty statement" @@ -260,7 +270,7 @@ def test_refresh_renders_statement(self, ddl_dialect): class TestMaterializedViewProtocolDeclaration: """The capability must be reachable through the declared protocol.""" - def test_probe_is_declared_by_view_support(self, ddl_dialect): + def test_probe_is_declared_by_view_object_support(self, ddl_dialect): if not _mv_supported(ddl_dialect): pytest.skip("backend does not advertise materialized view support") - assert isinstance(ddl_dialect, ViewSupport) + assert isinstance(ddl_dialect, ViewObjectSupport) diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py b/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py index 568ccaa..6454082 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/fixtures/models.py @@ -31,6 +31,7 @@ from rhosocial.activerecord.base.field_proxy import FieldProxy from rhosocial.activerecord.field import UUIDMixin from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.backend.expression.objects import Table class BareItem(ActiveRecord): @@ -190,7 +191,7 @@ class SpecComment(ActiveRecord): ForeignKeyConstraint( None, columns=["order_id"], - foreign_key_table="ddl_spec_orders", + foreign_key_table=Table(None, "ddl_spec_orders"), foreign_key_columns=["id"], on_delete=ReferentialAction.CASCADE, name="fk_ddl_spec_comments_order", @@ -213,7 +214,7 @@ class AsyncSpecComment(AsyncActiveRecord): ForeignKeyConstraint( None, columns=["order_id"], - foreign_key_table="ddl_spec_orders", + foreign_key_table=Table(None, "ddl_spec_orders"), foreign_key_columns=["id"], on_delete=ReferentialAction.CASCADE, name="fk_ddl_spec_comments_order", diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py index 1ed2e68..fab431d 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation.py @@ -73,6 +73,6 @@ def test_foreign_key_declaration(self, spec_comment_class): constraint = spec_comment_class.table_constraints()[0] assert isinstance(constraint, ForeignKeyConstraint) assert constraint.columns == ["order_id"] - assert constraint.foreign_key_table == "ddl_spec_orders" + assert constraint.foreign_key_table.name == "ddl_spec_orders" assert constraint.foreign_key_columns == ["id"] assert constraint.on_delete.value == "CASCADE" diff --git a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py index dcf67c4..3630a9c 100644 --- a/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/ddl/test_derivation_async.py @@ -76,6 +76,6 @@ async def test_foreign_key_declaration(self, async_spec_comment_class): constraint = async_spec_comment_class.table_constraints()[0] assert isinstance(constraint, ForeignKeyConstraint) assert constraint.columns == ["order_id"] - assert constraint.foreign_key_table == "ddl_spec_orders" + assert constraint.foreign_key_table.name == "ddl_spec_orders" assert constraint.foreign_key_columns == ["id"] assert constraint.on_delete.value == "CASCADE" diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation.py b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation.py index 944ca7f..c61fd3d 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation.py @@ -16,6 +16,8 @@ from rhosocial.activerecord.query import CTEQuery, AsyncCTEQuery from rhosocial.activerecord.backend.expression import statements, core, query_parts +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.testsuite.utils import requires_cte from rhosocial.activerecord.backend.dialect.protocols import SetOperationSupport from rhosocial.activerecord.testsuite.utils import requires_protocol @@ -185,7 +187,7 @@ def test_cte_with_query_expression_as_subquery(self, order_fixtures): query_expr = statements.QueryExpression( dialect, select=[core.Column(dialect, "id"), core.Column(dialect, "status"), core.Column(dialect, "total_amount")], - from_=core.TableExpression(dialect, Order.table_name()), + from_=NamedRelationRef(dialect, Table(dialect, Order.table_name())), where=query_parts.WhereClause(dialect, condition=core.Column(dialect, "status") == core.Literal(dialect, 'active')) ) diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation_async.py b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation_async.py index a4637dc..c6074a6 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cte/test_cte_query_set_operation_async.py @@ -16,6 +16,8 @@ from rhosocial.activerecord.query import CTEQuery, AsyncCTEQuery from rhosocial.activerecord.backend.expression import statements, core, query_parts +from rhosocial.activerecord.backend.expression.objects import Table +from rhosocial.activerecord.backend.expression.sources import NamedRelationRef from rhosocial.activerecord.testsuite.utils import requires_cte from rhosocial.activerecord.backend.dialect.protocols import SetOperationSupport from rhosocial.activerecord.testsuite.utils import requires_protocol @@ -188,7 +190,7 @@ async def test_cte_with_query_expression_as_subquery(self, async_order_fixtures) query_expr = statements.QueryExpression( dialect, select=[core.Column(dialect, "id"), core.Column(dialect, "status"), core.Column(dialect, "total_amount")], - from_=core.TableExpression(dialect, AsyncOrder.table_name()), + from_=NamedRelationRef(dialect, Table(dialect, AsyncOrder.table_name())), where=query_parts.WhereClause(dialect, condition=core.Column(dialect, "status") == core.Literal(dialect, 'active')) ) diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py index da05dfd..436998c 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py """USING-clause join coverage. -``JoinExpression`` renders ``... JOIN ... USING ("col")`` when ``using`` +``JoinClause`` renders ``... JOIN ... USING ("col")`` when ``using`` columns are given instead of an ON predicate; the ActiveQuery convenience methods forward the ``using`` keyword. """ diff --git a/src/rhosocial/activerecord/testsuite/utils/expression.py b/src/rhosocial/activerecord/testsuite/utils/expression.py index d7fd988..17cc50b 100644 --- a/src/rhosocial/activerecord/testsuite/utils/expression.py +++ b/src/rhosocial/activerecord/testsuite/utils/expression.py @@ -17,14 +17,61 @@ rendering is identical """ +import collections.abc import dataclasses import inspect +import re import typing import warnings from typing import Any, Callable, Dict, List, Optional, Type from rhosocial.activerecord.backend.expression.bases import BaseExpression +# Annotation fragments that mean "this parameter names a relation object". +# Word boundaries matter: ``TableConstraint`` and ``AddTableConstraint`` are +# constraint actions, not relations, and a substring test would hand them a +# Table. ``SchemaObject`` is the base every catalogue object shares, so an +# annotation naming it wants an object too. +_MENTIONS_RELATION = re.compile( + r"\b(Table|View|MaterializedView|Index|Sequence|Type|Domain|Function|Trigger" + r"|Schema|Database|RelationObject|SchemaObject|NodeTable|EdgeTable)\b" +) + +# An INSERT's data source. It is an expression now, so it needs a real one -- +# the generic placeholder cannot invent the rows a VALUES list is made of. +_MENTIONS_INSERT_SOURCE = re.compile(r"\bInsertDataSource\b") + + +def _empty_container_for(annotation: Any, dialect: Any): + """Return an empty container for a parameterised alias, else None. + + A parameterised alias like ``List[str]`` or ``Dict[str, BaseExpression]`` + says what the *elements* are, never the container itself, so there is no + catalogue object to hand back -- only an empty container. Deciding this + before the relation-name test is what keeps ``typing.List[str]`` from being + read as a ``Table``: ``typing.get_origin`` on it gives ``list``, but the + relation test only ever saw the alias' own name, and ``List[str]`` matches + ``Sequence``/``Table``-family fragments well enough to be mistaken for one. + + Returns ``None`` when ``annotation`` is not a parameterised container, which + is the caller's signal to keep reading. + """ + origin = typing.get_origin(annotation) + if origin is None: + return None + if origin in (list, tuple, set, frozenset): + return [] + if origin is dict: + return {} + # ``Sequence[str]`` and friends resolve to the collections.abc ABC rather + # than to ``list``, so match on the abstract base instead of the concrete + # one the annotation happens to name. + if isinstance(origin, type) and issubclass(origin, (collections.abc.Sequence, collections.abc.Set)): + return [] + if isinstance(origin, type) and issubclass(origin, collections.abc.Mapping): + return {} + return None + def _placeholder_for(param: inspect.Parameter, dialect: Any = None): """Return a heuristic construction value for a required parameter.""" @@ -36,20 +83,23 @@ def _placeholder_for(param: inspect.Parameter, dialect: Any = None): text = annotation else: text = getattr(raw, "__name__", "") + container = _empty_container_for(annotation, dialect) + if container is not None: + return container if raw in (list, tuple, set): return [] if raw is dict: return {} - if "TableExpression" in text: - from rhosocial.activerecord.backend.expression.core import TableExpression - - return TableExpression(dialect, "t") + if _MENTIONS_RELATION.search(text): + return _relation(dialect, text) + if _MENTIONS_INSERT_SOURCE.search(text): + return _values_source(dialect) if raw is str or "str" in text: return "x" if "DataType" in text or "Type" in text.split(".")[-1:]: from rhosocial.activerecord.backend.expression.types import IntegerType - return IntegerType() + return IntegerType(dialect) if raw is int: return 1 if raw is float: @@ -133,8 +183,24 @@ def special_constructors(): """ return { "statements.ddl_table.ColumnDefinition": _column_definition, + "statements.dml.ValuesSource": _values_source, + "statements.ddl_sequence.CreateSequenceExpression": _create_sequence_expr, + "statements.ddl_sequence.DropSequenceExpression": _drop_sequence_expr, + "statements.ddl_sequence.AlterSequenceExpression": _alter_sequence_expr, + "statements.ddl_type.CreateTypeExpression": _create_type_expr, + "statements.ddl_type.AlterTypeExpression": _alter_type_expr, + "statements.ddl_type.DropTypeExpression": _drop_type_expr, + "statements.ddl_domain.CreateDomainExpression": _create_domain_expr, + "statements.ddl_domain.AlterDomainExpression": _alter_domain_expr, + "statements.ddl_domain.DropDomainExpression": _drop_domain_expr, + "statements.ddl_function.CreateFunctionExpression": _create_function_expr, + "statements.ddl_function.DropFunctionExpression": _drop_function_expr, + "statements.ddl_schema.CreateSchemaExpression": _create_schema_expr, + "statements.ddl_schema.DropSchemaExpression": _drop_schema_expr, + "statements.ddl_database.CreateDatabaseExpression": _create_database_expr, + "statements.ddl_database.DropDatabaseExpression": _drop_database_expr, "advanced_functions.JSONExpression": _json_expr, - "query_parts.JoinExpression": _join_expr, + "query_parts.JoinClause": _join_expr, "statements.ddl_partition.PartitionClause": _partition_clause, "statements.dml.OnConflictClause": _on_conflict, "statements.dml.UpdateExpression": _update_expr, @@ -142,6 +208,14 @@ def special_constructors(): "statements.dml.DeleteExpression": _delete_expr, "statements.ddl_alter.AlterTableExpression": _alter_table_expr, "statements.ddl_view.CreateViewExpression": _create_view_expr, + "graph.GraphVertex": _graph_vertex, + "graph.GraphEdge": _graph_edge, + "graph.VertexTable": _node_table, + "graph.EdgeTable": _edge_table, + "graph.GraphTableExpression": _graph_table_expr, + "graph.CreatePropertyGraphExpression": _create_property_graph_expr, + "graph.DropPropertyGraphExpression": _drop_property_graph_expr, + "graph.AlterPropertyGraphExpression": _alter_property_graph_expr, "datetime.ExtractExpression": _extract_expr, "datetime.DatePartExpression": _datepart_expr, "datetime.DateTruncExpression": _datetrunc_expr, @@ -156,7 +230,19 @@ def _column_definition(dialect): from rhosocial.activerecord.backend.expression.statements import ColumnDefinition from rhosocial.activerecord.backend.expression.types import IntegerType - return ColumnDefinition(dialect, "col", IntegerType()) + return ColumnDefinition(dialect, "col", IntegerType(dialect)) + + +def _values_source(dialect): + """An INSERT's ``VALUES`` source: one row, one literal. + + The generic placeholder cannot build this one -- ``values_list`` must be a + non-empty list of equal-length rows -- so it gets an explicit constructor. + """ + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.statements.dml import ValuesSource + + return ValuesSource(dialect, [[Literal(dialect, 1)]]) def _json_expr(dialect): @@ -166,21 +252,300 @@ def _json_expr(dialect): return JSONExpression(dialect, Literal(dialect, '{"a": 1}'), path="$.a") +def _sequence(dialect): + """A sequence, by kind.""" + from rhosocial.activerecord.backend.expression.objects import Sequence + + return Sequence(dialect, "s") + + +def _create_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + CreateSequenceExpression, + ) + + return CreateSequenceExpression(dialect, _sequence(dialect)) + + +def _drop_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + DropSequenceExpression, + ) + + return DropSequenceExpression(dialect, _sequence(dialect)) + + +def _alter_sequence_expr(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_sequence import ( + AlterSequenceExpression, + ) + + return AlterSequenceExpression(dialect, _sequence(dialect)) + + +def _create_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeDefinition + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + CreateTypeExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateTypeExpression( + dialect, Type(dialect, "t"), _DummyTypeDefinition(dialect, IntegerType(dialect)) + ) + + +def _alter_type_expr(dialect): + from rhosocial.activerecord.backend.impl.dummy.expression import _DummyTypeAlterAction + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + ) + + return AlterTypeExpression( + dialect, Type(dialect, "t"), [_DummyTypeAlterAction(dialect, "t2")] + ) + + +def _drop_type_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Type + from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeExpression, + ) + + return DropTypeExpression(dialect, Type(dialect, "t")) + + +def _create_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + CreateDomainExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + return CreateDomainExpression(dialect, Domain(dialect, "d"), IntegerType(dialect)) + + +def _alter_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.core import Literal + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + AlterDomainExpression, + SetDomainDefaultAction, + ) + + return AlterDomainExpression( + dialect, Domain(dialect, "d"), [SetDomainDefaultAction(dialect, Literal(dialect, 1))] + ) + + +def _drop_domain_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Domain + from rhosocial.activerecord.backend.expression.statements.ddl_domain import ( + DropDomainExpression, + ) + + return DropDomainExpression(dialect, Domain(dialect, "d")) + + +def _create_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + CreateFunctionExpression, + ) + + return CreateFunctionExpression(dialect, Function(dialect, "f")) + + +def _drop_function_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Function + from rhosocial.activerecord.backend.expression.statements.ddl_function import ( + DropFunctionExpression, + ) + + return DropFunctionExpression(dialect, Function(dialect, "f")) + + +def _create_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaExpression, + ) + + return CreateSchemaExpression(dialect, Schema(dialect, "s")) + + +def _drop_schema_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Schema + from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + DropSchemaExpression, + ) + + return DropSchemaExpression(dialect, Schema(dialect, "s")) + + +def _create_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + CreateDatabaseExpression, + ) + + return CreateDatabaseExpression(dialect, Database(dialect, "d")) + + +def _drop_database_expr(dialect): + from rhosocial.activerecord.backend.expression.objects import Database + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + DropDatabaseExpression, + ) + + return DropDatabaseExpression(dialect, Database(dialect, "d")) + + +def _node_table(dialect): + """A graph vertex table.""" + from rhosocial.activerecord.backend.expression.graph import VertexTable + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return VertexTable(dialect, NodeTable(dialect, "n")) + + +def _edge_table(dialect): + """A graph edge table declaration.""" + from rhosocial.activerecord.backend.expression.graph import EdgeTable + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return EdgeTable(dialect, EdgeTableObject(dialect, "e"), ["s"], ["d"]) + + +def _graph_vertex(dialect): + from rhosocial.activerecord.backend.expression.graph import GraphVertex + from rhosocial.activerecord.backend.expression.objects import NodeTable + + return GraphVertex(dialect, "v", NodeTable(dialect, "n")) + + +def _graph_edge(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + GraphEdge, + GraphEdgeDirection, + ) + from rhosocial.activerecord.backend.expression.objects import ( + EdgeTable as EdgeTableObject, + ) + + return GraphEdge(dialect, "e", EdgeTableObject(dialect, "e"), + GraphEdgeDirection.RIGHT) + + +def _graph_table_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + ColumnsClause, + GraphColumn, + GraphTableExpression, + MatchClause, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return GraphTableExpression( + dialect, + PropertyGraph(dialect, "g"), + MatchClause( + dialect, + _graph_vertex(dialect), + _graph_edge(dialect), + _graph_vertex(dialect), + ), + ColumnsClause(dialect, GraphColumn("v", "name")), + ) + + +def _create_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + CreatePropertyGraphExpression, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return CreatePropertyGraphExpression( + dialect, PropertyGraph(dialect, "g"), [_node_table(dialect)] + ) + + +def _drop_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + DropPropertyGraphExpression, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return DropPropertyGraphExpression(dialect, PropertyGraph(dialect, "g")) + + +def _alter_property_graph_expr(dialect): + from rhosocial.activerecord.backend.expression.graph import ( + AlterPropertyGraphExpression, + ) + from rhosocial.activerecord.backend.expression.objects import PropertyGraph + + return AlterPropertyGraphExpression( + dialect, + PropertyGraph(dialect, "g"), + "ADD", + "VERTEX TABLES", + vertex_tables=[_node_table(dialect)], + ) + + def _table(dialect, name="t"): - from rhosocial.activerecord.backend.expression.core import TableExpression + """The catalogue object a statement acts on.""" + from rhosocial.activerecord.backend.expression.objects import Table + + return Table(dialect, name) + + +def _relation(dialect, annotation): + """The object an annotation asks for, chosen by the kind it names. - return TableExpression(dialect, name) + The kind is not decoration: CREATE VIEW will not accept a Table, and DROP + MATERIALIZED VIEW will not accept a View. Longest name first, because + ``MaterializedView`` contains ``View`` and the more specific kind is the one + an annotation naming it means. + """ + import rhosocial.activerecord.backend.expression.objects as objects + + for kind in ("MaterializedView", "ForeignTable", "NodeTable", "EdgeTable", + "Sequence", "Index", "View", "Table", "Type", "Domain", + "Function", "Trigger", "Schema", "Database"): + if re.search(rf"\b{kind}\b", annotation): + return getattr(objects, kind)(dialect, "t") + return _table(dialect) + + +def _source(dialect, name="t"): + """A row source over the object a statement acts on. + + A statement that *names* a relation takes the object; a statement that + *reads rows from* one takes a source over it. The two are not + interchangeable, so they get their own helper rather than one that guesses + which position it is filling. + """ + from rhosocial.activerecord.backend.expression.objects import Table + from rhosocial.activerecord.backend.expression.sources import NamedRelationRef + + return NamedRelationRef(dialect, Table(dialect, name)) def _join_expr(dialect): - from rhosocial.activerecord.backend.expression.query_parts import JoinExpression + from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate from rhosocial.activerecord.backend.expression.core import Column - return JoinExpression( + return JoinClause( dialect, - left_table=_table(dialect, "a"), - right_table=_table(dialect, "b"), + left_table=_source(dialect, "a"), + right_table=_source(dialect, "b"), condition=ComparisonPredicate(dialect, "=", Column(dialect, "a"), Column(dialect, "b")), ) @@ -230,21 +595,24 @@ def _alter_table_expr(dialect): AlterTableExpression, DropColumn, ) + from rhosocial.activerecord.backend.expression.types import IntegerType + column = _column_definition(dialect) return AlterTableExpression( dialect, table=_table(dialect, "t"), - actions=[DropColumn(dialect, "a"), AddColumn(dialect, column=None)], + actions=[DropColumn(dialect, "a"), AddColumn(dialect, column=column)], ) def _create_view_expr(dialect): from rhosocial.activerecord.backend.expression.core import Column + from rhosocial.activerecord.backend.expression.objects import View from rhosocial.activerecord.backend.expression.statements.ddl_view import CreateViewExpression from rhosocial.activerecord.backend.expression.statements.dql import QueryExpression - query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=_table(dialect, "t")) - return CreateViewExpression(dialect, view_name="v", query=query) + query = QueryExpression(dialect, select=[Column(dialect, "id")], from_=_source(dialect, "t")) + return CreateViewExpression(dialect, view=View(dialect, "v"), query=query) def _extract_expr(dialect): From ddd8e99c914c0129c894bd9ca488c3f1cc2fed7d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 8 Oct 2026 02:56:52 +0800 Subject: [PATCH 2/3] fix(testsuite): make every protocol reference resolvable so the gated tests run `requires_protocol` resolves a string with `rsplit('.', 1)` before importing it. Two forms in this suite cannot resolve, and both make the test SKIP rather than fail: * the bare name `"JoinSupport"` and `"SchemaSupport"` -- no dot, so `rsplit` raises ValueError and the resolver skips; * the dotted path `...dialect.protocols.IndexSupport`, which stopped resolving when the core's single `protocols.py` became a `protocols/` package and the class was renamed. The effect was invisible: the full-text-search compatibility test skipped 11 of 11 parametrisations and every join test skipped, on every backend, with no failure to show for it. Verified after the fix -- full-text now runs 11 passed, and the join bridge files run 209 passed, with nothing newly failing. --- .../test_cross_database_compatibility.py | 2 +- .../test_cross_database_compatibility_async.py | 2 +- .../query/cross_schema/test_cross_schema_join.py | 6 +++--- .../query/cross_schema/test_cross_schema_join_async.py | 4 ++-- .../feature/query/cross_schema/test_mixed_schema.py | 8 ++++---- .../query/cross_schema/test_mixed_schema_async.py | 6 +++--- .../query/cross_schema/test_mixed_schema_qualifiers.py | 4 ++-- .../feature/query/joins/test_join_aggregate.py | 4 ++-- .../feature/query/joins/test_join_aggregate_async.py | 4 ++-- .../feature/query/joins/test_join_qualifiers.py | 4 ++-- .../feature/query/joins/test_join_subquery.py | 4 ++-- .../testsuite/feature/query/joins/test_join_types.py | 10 +++++----- .../feature/query/joins/test_join_types_async.py | 10 +++++----- .../testsuite/feature/query/joins/test_join_using.py | 4 ++-- .../feature/query/joins/test_join_using_async.py | 4 ++-- .../testsuite/feature/query/joins/test_self_join.py | 6 +++--- .../feature/query/joins/test_self_join_async.py | 4 ++-- src/rhosocial/activerecord/testsuite/utils/common.py | 2 +- 18 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility.py index fc69808..8f33aad 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility.py @@ -90,7 +90,7 @@ def test_common_sql_standard_features(order_fixtures): @pytest.mark.requires_protocol( - ('rhosocial.activerecord.backend.dialect.protocols.IndexSupport', + ('rhosocial.activerecord.backend.dialect.protocols.FulltextIndexSupport', 'supports_fulltext_search')) def test_fulltext_search_compatibility(annotated_query_fixtures): """ diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility_async.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility_async.py index 29577fe..e72557b 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_database/test_cross_database_compatibility_async.py @@ -90,7 +90,7 @@ async def test_common_sql_standard_features(async_order_fixtures): @pytest.mark.requires_protocol( - ('rhosocial.activerecord.backend.dialect.protocols.IndexSupport', + ('rhosocial.activerecord.backend.dialect.protocols.FulltextIndexSupport', 'supports_fulltext_search')) async def test_fulltext_search_compatibility(async_annotated_query_fixtures): """ diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join.py index e3ace70..7573c94 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join.py @@ -16,7 +16,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_cross_schema_join(schema_fixtures): """JOIN across two schemas with WHERE + ORDER BY.""" Customer, Order = schema_fixtures @@ -41,7 +41,7 @@ def test_cross_schema_join(schema_fixtures): "Expected joined rows for alice to be (100, 'alice') and (300, 'alice')" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_cross_schema_count_with_condition(schema_fixtures): """Scalar COUNT over a cross-schema join.""" Customer, Order = schema_fixtures @@ -60,7 +60,7 @@ def test_cross_schema_count_with_condition(schema_fixtures): assert total == 3, "Expected count over the cross-schema join to be 3" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_cross_schema_writes_are_scoped(schema_fixtures): """save() lands in the owning schema; update/delete stay scoped by qualifiers.""" Customer, Order = schema_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join_async.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join_async.py index cfefa26..9aeed30 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_cross_schema_join_async.py @@ -6,7 +6,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") async def test_async_cross_schema_join(async_schema_fixtures): """JOIN across two schemas with WHERE + ORDER BY (async).""" AsyncCustomer, AsyncOrder = async_schema_fixtures @@ -33,7 +33,7 @@ async def test_async_cross_schema_join(async_schema_fixtures): ], "Expected joined rows for async_alice to match the seeded amounts" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") async def test_async_cross_schema_writes_are_scoped(async_schema_fixtures): """Writes land in the owning schema and stay scoped by qualifiers (async).""" AsyncCustomer, AsyncOrder = async_schema_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema.py index 614ca00..ad316ab 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema.py @@ -11,7 +11,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_same_named_tables_coexist(mixed_schema_fixtures): """Identical table names in default and SCHEMA_A hold independent rows.""" User, Order, MixedSchemaOrder = mixed_schema_fixtures @@ -28,7 +28,7 @@ def test_same_named_tables_coexist(mixed_schema_fixtures): assert MixedSchemaOrder.query().count() == 3, "Expected 3 rows in schema-qualified orders" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_primary_key_lookup_is_namespace_scoped(mixed_schema_fixtures): """The same PK value resolves to different rows per namespace.""" User, Order, MixedSchemaOrder = mixed_schema_fixtures @@ -46,7 +46,7 @@ def test_primary_key_lookup_is_namespace_scoped(mixed_schema_fixtures): ), "Expected schema-qualified order lookup to return schema-row" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_update_all_and_delete_all_stay_scoped(mixed_schema_fixtures): """Bulk writes on one namespace never leak into the other.""" User, Order, MixedSchemaOrder = mixed_schema_fixtures @@ -71,7 +71,7 @@ def test_update_all_and_delete_all_stay_scoped(mixed_schema_fixtures): assert Order.query().count() == 1, "Expected default-schema count to remain at 1" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_join_default_user_with_schema_order(mixed_schema_fixtures): """JOIN from the default namespace to a schema-qualified table.""" User, Order, MixedSchemaOrder = mixed_schema_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_async.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_async.py index 65235e5..957ae95 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_async.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") async def test_async_same_named_tables_coexist(async_mixed_schema_fixtures): """Identical table names in default and SCHEMA_A hold independent rows.""" AsyncUser, AsyncOrder, AsyncMixedSchemaOrder = async_mixed_schema_fixtures @@ -23,7 +23,7 @@ async def test_async_same_named_tables_coexist(async_mixed_schema_fixtures): "Expected 1 row in schema-qualified orders" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") async def test_async_update_all_stays_scoped(async_mixed_schema_fixtures): """Bulk update on the schema-qualified namespace leaves defaults untouched.""" AsyncUser, AsyncOrder, AsyncMixedSchemaOrder = async_mixed_schema_fixtures @@ -47,7 +47,7 @@ async def test_async_update_all_stays_scoped(async_mixed_schema_fixtures): ), "Expected both schema-qualified orders to be shipped" -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") async def test_async_join_default_user_with_schema_order(async_mixed_schema_fixtures): """Async JOIN from the default namespace to a schema-qualified table.""" AsyncUser, AsyncOrder, AsyncMixedSchemaOrder = async_mixed_schema_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_qualifiers.py b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_qualifiers.py index a8b8dd1..8106a17 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_qualifiers.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/cross_schema/test_mixed_schema_qualifiers.py @@ -11,7 +11,7 @@ def _norm(sql: str) -> str: return re.sub(r"\s+", " ", cleaned).lower() -@requires_protocol("SchemaSupport", "supports_schema") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.CreateSchemaSupport", "supports_schema") def test_schema_model_qualifies_range_not_columns(mixed_schema_fixtures): """A schema-bound model qualifies its FROM range, never three-part cols.""" _, _, MixedSchemaOrder = mixed_schema_fixtures @@ -29,7 +29,7 @@ def test_schema_model_qualifies_range_not_columns(mixed_schema_fixtures): "Expected column refs to stay two-part" -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_schema_join_columns_stay_two_part(mixed_schema_fixtures): """Even joined against default-schema tables, schema-model columns stay two-part (TABLE.COLUMN) in ON/WHERE positions.""" diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate.py index 43dbb56..07e8d55 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate.py @@ -7,7 +7,7 @@ from rhosocial.activerecord.backend.expression import functions -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_join_group_by_count(order_fixtures): """Count orders per user across the join.""" User, Order, _ = order_fixtures @@ -32,7 +32,7 @@ def test_join_group_by_count(order_fixtures): assert counts == {"agg_alice": 2, "agg_bob": 1} -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_join_group_by_sum_having(order_fixtures): """HAVING filters aggregated groups after a join.""" User, Order, _ = order_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate_async.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate_async.py index cc4b1fe..92bec5b 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_aggregate_async.py @@ -7,7 +7,7 @@ from rhosocial.activerecord.backend.expression import functions -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") async def test_join_group_by_count(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures dialect = AsyncUser.__backend__.dialect @@ -31,7 +31,7 @@ async def test_join_group_by_count(async_order_fixtures): assert counts == {"agg_alice": 2, "agg_bob": 1} -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") async def test_join_group_by_sum_having(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures dialect = AsyncUser.__backend__.dialect diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_qualifiers.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_qualifiers.py index 152e9b4..3d1667d 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_qualifiers.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_qualifiers.py @@ -20,7 +20,7 @@ def _norm(sql: str) -> str: return re.sub(r"\s+", " ", cleaned).lower() -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_join_columns_are_table_qualified(order_fixtures): """ON predicates and select lists qualify columns with their table.""" User, Order, _ = order_fixtures @@ -46,7 +46,7 @@ def test_join_columns_are_table_qualified(order_fixtures): assert [r["order_number"] for r in rows] == ["q1"] -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") async def test_aliased_self_join_references_alias_only(tree_fixtures): """Self-join columns address the runtime alias, not the base table.""" NodeModel = tree_fixtures[0] diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_subquery.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_subquery.py index 6e3d1da..80d7256 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_subquery.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_subquery.py @@ -6,7 +6,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_join_filtered_by_in_subquery(order_fixtures): """Only users having a 'big' order survive an IN-subquery filter.""" User, Order, _ = order_fixtures @@ -29,7 +29,7 @@ def test_join_filtered_by_in_subquery(order_fixtures): assert [r["username"] for r in rows] == ["sub_rich"] -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_three_table_join_with_in_subquery(order_fixtures): """Chained joins remain composable with a subquery predicate.""" User, Order, OrderItem = order_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types.py index 618fb40..c9c9a09 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types.py @@ -16,7 +16,7 @@ def _user_without_orders(users): return lonely -@requires_protocol("JoinSupport", "supports_left_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_left_join") def test_left_join_preserves_unmatched_left_rows(order_fixtures): """Rows without a match still appear, right-side columns being NULL.""" User, Order, _ = order_fixtures @@ -33,7 +33,7 @@ def test_left_join_preserves_unmatched_left_rows(order_fixtures): assert lonely_rows[0]["order_id"] is None -@requires_protocol("JoinSupport", "supports_right_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_right_join") def test_right_join_preserves_right_side(order_fixtures): """RIGHT JOIN keeps every right-hand row even when left side has gaps.""" User, Order, _ = order_fixtures @@ -49,7 +49,7 @@ def test_right_join_preserves_right_side(order_fixtures): assert len(joined) == total_orders -@requires_protocol("JoinSupport", "supports_full_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_full_join") def test_full_outer_join_covers_both_sides(order_fixtures): """FULL OUTER JOIN yields unmatched rows of both sides exactly once.""" User, Order, _ = order_fixtures @@ -74,7 +74,7 @@ def test_full_outer_join_covers_both_sides(order_fixtures): assert [r["username"] for r in null_extended] == ["lonely"] -@requires_protocol("JoinSupport", "supports_cross_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_cross_join") def test_cross_join_is_cartesian(order_fixtures): """CROSS JOIN produces |left| x |right| rows without an ON clause.""" User, Order, _ = order_fixtures @@ -86,7 +86,7 @@ def test_cross_join_is_cartesian(order_fixtures): assert len(rows) == users_total * orders_total -@requires_protocol("JoinSupport", "supports_straight_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_straight_join") def test_straight_join_returns_inner_equivalent(order_fixtures): """STRAIGHT_JOIN is result-equivalent to INNER JOIN (MySQL optimizer hint).""" User, Order, _ = order_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types_async.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types_async.py index f6cde46..483ea6b 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_types_async.py @@ -11,7 +11,7 @@ async def _user_without_orders(users): return lonely -@requires_protocol("JoinSupport", "supports_left_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_left_join") async def test_left_join_preserves_unmatched_left_rows(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures await _user_without_orders([AsyncUser]) @@ -27,7 +27,7 @@ async def test_left_join_preserves_unmatched_left_rows(async_order_fixtures): assert lonely_rows[0]["order_id"] is None -@requires_protocol("JoinSupport", "supports_right_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_right_join") async def test_right_join_preserves_right_side(async_order_fixtures): AsyncOrder, AsyncUser = async_order_fixtures[1], async_order_fixtures[0] await _user_without_orders([async_order_fixtures[0]]) @@ -42,7 +42,7 @@ async def test_right_join_preserves_right_side(async_order_fixtures): assert len(joined) == total_orders -@requires_protocol("JoinSupport", "supports_full_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_full_join") async def test_full_outer_join_covers_both_sides(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures await _user_without_orders([AsyncUser]) @@ -60,7 +60,7 @@ async def test_full_outer_join_covers_both_sides(async_order_fixtures): assert len(rows) == orders_total + (users_total - matched_users) -@requires_protocol("JoinSupport", "supports_cross_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_cross_join") async def test_cross_join_is_cartesian(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures @@ -71,7 +71,7 @@ async def test_cross_join_is_cartesian(async_order_fixtures): assert len(rows) == users_total * orders_total -@requires_protocol("JoinSupport", "supports_straight_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_straight_join") async def test_straight_join_returns_inner_equivalent(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py index 436998c..37b2bff 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using.py @@ -10,7 +10,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_inner_join_with_using_column(order_fixtures): """JOIN ... USING ("id") pairs the tables' identical surrogate keys. @@ -32,7 +32,7 @@ def test_inner_join_with_using_column(order_fixtures): assert [r["order_number"] for r in rows] == ["u1"] -@requires_protocol("JoinSupport", "supports_left_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_left_join") def test_left_join_with_using(order_fixtures): """LEFT JOIN ... USING keeps unmatched left rows like its ON counterpart.""" User, Order, _ = order_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using_async.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using_async.py index 2786ab8..c3f7d8a 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_join_using_async.py @@ -5,7 +5,7 @@ from rhosocial.activerecord.testsuite.utils import requires_protocol -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") async def test_inner_join_with_using_column(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures @@ -22,7 +22,7 @@ async def test_inner_join_with_using_column(async_order_fixtures): assert [r["order_number"] for r in rows] == ["u1"] -@requires_protocol("JoinSupport", "supports_left_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_left_join") async def test_left_join_with_using(async_order_fixtures): AsyncUser, AsyncOrder, _ = async_order_fixtures diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join.py index 9909f35..87fd237 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join.py @@ -32,7 +32,7 @@ def _seed_tree(tree_fixtures): return named -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_self_join_child_to_parent(tree_fixtures): _seed_tree(tree_fixtures) @@ -48,7 +48,7 @@ def test_self_join_child_to_parent(tree_fixtures): assert [(r["child_name"], r["parent_name"]) for r in rows] == [("a", "root"), ("b", "root")] -@requires_protocol("JoinSupport", "supports_left_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_left_join") def test_self_join_left_keeps_roots(tree_fixtures): """LEFT self-join keeps parents without parents of their own.""" _seed_tree(tree_fixtures) @@ -64,7 +64,7 @@ def test_self_join_left_keeps_roots(tree_fixtures): assert [(r["node"], r["child_name"]) for r in rows] == [("root", "a"), ("root", "b")] -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") def test_self_join_aggregates_children_per_parent(tree_fixtures): _seed_tree(tree_fixtures) diff --git a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join_async.py b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join_async.py index 5ba7081..53dae3e 100644 --- a/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join_async.py +++ b/src/rhosocial/activerecord/testsuite/feature/query/joins/test_self_join_async.py @@ -23,7 +23,7 @@ async def _seed_tree(async_tree_fixtures): return named -@requires_protocol("JoinSupport", "supports_inner_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_inner_join") async def test_async_self_join_child_to_parent(async_tree_fixtures): await _seed_tree(async_tree_fixtures) @@ -39,7 +39,7 @@ async def test_async_self_join_child_to_parent(async_tree_fixtures): assert [(r["child_name"], r["parent_name"]) for r in rows] == [("a", "root"), ("b", "root")] -@requires_protocol("JoinSupport", "supports_left_join") +@requires_protocol("rhosocial.activerecord.backend.dialect.protocols.JoinSupport", "supports_left_join") async def test_async_self_join_left_keeps_roots(async_tree_fixtures): await _seed_tree(async_tree_fixtures) diff --git a/src/rhosocial/activerecord/testsuite/utils/common.py b/src/rhosocial/activerecord/testsuite/utils/common.py index 167f1ff..177c04d 100644 --- a/src/rhosocial/activerecord/testsuite/utils/common.py +++ b/src/rhosocial/activerecord/testsuite/utils/common.py @@ -79,7 +79,7 @@ def skip_test_if_protocol_unsupported(model_class, protocol_class, method_name=N Args: model_class: A provider-configured model class protocol_class: The protocol class to check for (e.g., WindowFunctionSupport), - or a fully-qualified dotted string (e.g. 'rhosocial.activerecord.backend.dialect.protocols.IndexSupport'), + or a fully-qualified dotted string (e.g. 'rhosocial.activerecord.backend.dialect.protocols.FulltextIndexSupport'), or None for no specific protocol method_name: Optional specific method name to check for (e.g., 'supports_window_functions') From cad1a39143fd044dfd5ab9138f4ffc641082999e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 8 Oct 2026 11:39:34 +0800 Subject: [PATCH 3/3] chore: add changelog fragment for PR #23 --- changelog.d/23.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/23.fixed.md diff --git a/changelog.d/23.fixed.md b/changelog.d/23.fixed.md new file mode 100644 index 0000000..303b0e2 --- /dev/null +++ b/changelog.d/23.fixed.md @@ -0,0 +1 @@ +Fix two silent failures that left the suite reporting success while testing nothing. 44 protocol references could not be resolved -- the bare names `JoinSupport` and `SchemaSupport`, which carry no dot for the resolver to split, and `IndexSupport`, which stopped resolving when core's `protocols.py` became a `protocols/` package -- so the full-text-search compatibility test skipped all 11 of its parametrisations and every join test skipped, on every backend. And a `Sequence[...]` annotation was read as a relation name, because `typing.get_origin` yields `collections.abc.Sequence` rather than the concrete type the relation test matched, so a parameter asking for a list of columns received a `Sequence` object; seven expression classes were unconstructible for that reason, and the container check now matches the `collections.abc` ABCs before the relation test runs.