From f8ed06f4d5cefd646af5edcef2298bfb58196e99 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 04:09:09 +0800 Subject: [PATCH 01/15] perf(sqlserver): resolve backend exports lazily so expressions import without a driver Every backend __init__ imported all of its submodules eagerly, and .backend imports the DB driver. Python executes a parent package's __init__ before any submodule, so `impl.sqlserver.expression` could not be imported without the driver present - even though no expression class touches it. Auditing the expression namespace needed a working driver; pyodbc additionally needs the OS libodbc.so, so a plain pip install was not enough and sqlserver dropped out of the audit entirely. Public names are now resolved on first access through a PEP 562 module __getattr__, with the name -> (module, attribute) map derived from the existing import statements. The exported surface is unchanged: attribute access, __all__, dir() and `from impl.sqlserver import *` all behave as before, and the previous eager imports move into an `if TYPE_CHECKING:` block so mypy and IDEs still resolve them. The existing __getattr__ is preserved as _optional_dependency_getattr, so the async backends keep raising an install hint for aioodbc instead of a bare ImportError. Importing the package, or its .expression submodule, no longer loads the driver; the driver error now surfaces at first use of the name that needs it, where it is actionable. 55 public names verified to resolve. --- .../backend/impl/sqlserver/__init__.py | 234 ++++++++++++------ 1 file changed, 152 insertions(+), 82 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py index bf75230..74d2a98 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py @@ -1,76 +1,140 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py """SQL Server backend implementation for the Python ORM.""" -from .backend import SQLServerBackend -from .config import SQLServerConnectionConfig -from .collation import SQLServerCollation -from .dialect import SQLServerDialect -from .options import SQLServerExecutionOptions -from .transaction import SQLServerTransactionManager -from .adapters import ( - SQLServerUUIDAdapter, - SQLServerDateTimeAdapter, - SQLServerDateTimeOffsetAdapter, - SQLServerDateAdapter, - SQLServerTimeAdapter, - SQLServerJSONAdapter, - SQLServerXMLAdapter, - SQLServerSpatialAdapter, - SQLServerHierarchyIdAdapter, -) +# Public names are resolved on first access rather than at package-execution +# time. Importing this package used to import every submodule eagerly, which +# pulled in the DB driver through ``.backend`` and made the expression classes +# unreachable without it: Python executes a parent package's ``__init__`` before +# any submodule, so ``impl..expression`` could not be imported on its own. +# The exports themselves are unchanged -- attribute access, ``__all__``, ``dir()`` +# and ``from ... import *`` all behave as before. +from typing import TYPE_CHECKING -from .explain import SQLServerExplainResult, SQLServerExplainRow +#: Public name -> (submodule that defines it, attribute name within it). +#: The two differ when the original import was aliased. +_EXPORTS = { + "DIRECT_COMPATIBLE_CASTS": (".type_compatibility", "DIRECT_COMPATIBLE_CASTS"), + "OpenJsonColumn": (".expression", "OpenJsonColumn"), + "SQLServerAliasTypeDefinition": (".expression", "SQLServerAliasTypeDefinition"), + "SQLServerBackend": (".backend", "SQLServerBackend"), + "SQLServerBackendMixin": (".mixins", "SQLServerBackendMixin"), + "SQLServerBitType": (".expression.types", "SQLServerBitType"), + "SQLServerClrTypeDefinition": (".expression", "SQLServerClrTypeDefinition"), + "SQLServerCollation": (".collation", "SQLServerCollation"), + "SQLServerConcurrencyMixin": (".mixins", "SQLServerConcurrencyMixin"), + "SQLServerConnectionConfig": (".config", "SQLServerConnectionConfig"), + "SQLServerContainsPredicate": (".expression", "SQLServerContainsPredicate"), + "SQLServerDateAdapter": (".adapters", "SQLServerDateAdapter"), + "SQLServerDateTimeAdapter": (".adapters", "SQLServerDateTimeAdapter"), + "SQLServerDateTimeOffsetAdapter": (".adapters", "SQLServerDateTimeOffsetAdapter"), + "SQLServerDialect": (".dialect", "SQLServerDialect"), + "SQLServerDropTypeExpression": (".expression", "SQLServerDropTypeExpression"), + "SQLServerExecutionOptions": (".options", "SQLServerExecutionOptions"), + "SQLServerExplainResult": (".explain", "SQLServerExplainResult"), + "SQLServerExplainRow": (".explain", "SQLServerExplainRow"), + "SQLServerFreetextPredicate": (".expression", "SQLServerFreetextPredicate"), + "SQLServerHierarchyIdAdapter": (".adapters", "SQLServerHierarchyIdAdapter"), + "SQLServerImageType": (".expression.types", "SQLServerImageType"), + "SQLServerJSONAdapter": (".adapters", "SQLServerJSONAdapter"), + "SQLServerNCharType": (".expression.types", "SQLServerNCharType"), + "SQLServerNVarCharMaxType": (".expression.types", "SQLServerNVarCharMaxType"), + "SQLServerNVarCharType": (".expression.types", "SQLServerNVarCharType"), + "SQLServerNextValueForExpression": (".expression", "SQLServerNextValueForExpression"), + "SQLServerOpenJsonExpression": (".expression", "SQLServerOpenJsonExpression"), + "SQLServerOutputDeletedExpression": (".expression", "SQLServerOutputDeletedExpression"), + "SQLServerOutputInsertedExpression": (".expression", "SQLServerOutputInsertedExpression"), + "SQLServerReadPastHint": (".expression", "SQLServerReadPastHint"), + "SQLServerRenameTypeExpression": (".expression", "SQLServerRenameTypeExpression"), + "SQLServerSchemaDiffer": (".schema", "SQLServerSchemaDiffer"), + "SQLServerSpatialAdapter": (".adapters", "SQLServerSpatialAdapter"), + "SQLServerSystemVersioningClause": (".expression", "SQLServerSystemVersioningClause"), + "SQLServerTableHint": (".expression", "SQLServerTableHint"), + "SQLServerTableHintClause": (".expression", "SQLServerTableHintClause"), + "SQLServerTableTypeDefinition": (".expression", "SQLServerTableTypeDefinition"), + "SQLServerTemporalPeriodDefinition": (".expression", "SQLServerTemporalPeriodDefinition"), + "SQLServerTimeAdapter": (".adapters", "SQLServerTimeAdapter"), + "SQLServerTinyIntType": (".expression.types", "SQLServerTinyIntType"), + "SQLServerTransactionManager": (".transaction", "SQLServerTransactionManager"), + "SQLServerTryCastExpression": (".expression", "SQLServerTryCastExpression"), + "SQLServerTryConvertExpression": (".expression", "SQLServerTryConvertExpression"), + "SQLServerTypeDDLMixin": (".mixins", "SQLServerTypeDDLMixin"), + "SQLServerTypeNullability": (".expression", "SQLServerTypeNullability"), + "SQLServerTypeSupportMixin": (".mixins", "SQLServerTypeSupportMixin"), + "SQLServerUUIDAdapter": (".adapters", "SQLServerUUIDAdapter"), + "SQLServerUserDefinedTypeSupport": (".protocols", "SQLServerUserDefinedTypeSupport"), + "SQLServerVarBinaryMaxType": (".expression.types", "SQLServerVarBinaryMaxType"), + "SQLServerVarBinaryType": (".expression.types", "SQLServerVarBinaryType"), + "SQLServerXMLAdapter": (".adapters", "SQLServerXMLAdapter"), + "SQLServerXmlType": (".expression.types", "SQLServerXmlType"), + "check_cast_compatibility": (".type_compatibility", "check_cast_compatibility"), + "get_compatible_types": (".type_compatibility", "get_compatible_types"), +} -from .expression import ( - SQLServerOutputInsertedExpression, - SQLServerOutputDeletedExpression, - SQLServerTableHintClause, - SQLServerTableHint, - SQLServerReadPastHint, - SQLServerTemporalPeriodDefinition, - SQLServerSystemVersioningClause, - SQLServerOpenJsonExpression, - OpenJsonColumn, - SQLServerNextValueForExpression, - SQLServerTryCastExpression, - SQLServerTryConvertExpression, - SQLServerContainsPredicate, - SQLServerFreetextPredicate, - SQLServerTypeNullability, - SQLServerAliasTypeDefinition, - SQLServerTableTypeDefinition, - SQLServerClrTypeDefinition, - SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, -) - -from .expression.types import ( - SQLServerNVarCharType, - SQLServerNCharType, - SQLServerNVarCharMaxType, - SQLServerVarBinaryType, - SQLServerVarBinaryMaxType, - SQLServerXmlType, - SQLServerTinyIntType, - SQLServerBitType, - SQLServerImageType, -) - -from .mixins import ( - SQLServerBackendMixin, - SQLServerConcurrencyMixin, - SQLServerTypeSupportMixin, - SQLServerTypeDDLMixin, -) -from .protocols import SQLServerUserDefinedTypeSupport - -from .schema import SQLServerSchemaDiffer - -from .type_compatibility import ( - DIRECT_COMPATIBLE_CASTS, - check_cast_compatibility, - get_compatible_types, -) +if TYPE_CHECKING: # pragma: no cover - re-exports for type checkers + from .backend import SQLServerBackend + from .config import SQLServerConnectionConfig + from .collation import SQLServerCollation + from .dialect import SQLServerDialect + from .options import SQLServerExecutionOptions + from .transaction import SQLServerTransactionManager + from .adapters import ( + SQLServerUUIDAdapter, + SQLServerDateTimeAdapter, + SQLServerDateTimeOffsetAdapter, + SQLServerDateAdapter, + SQLServerTimeAdapter, + SQLServerJSONAdapter, + SQLServerXMLAdapter, + SQLServerSpatialAdapter, + SQLServerHierarchyIdAdapter, + ) + from .explain import SQLServerExplainResult, SQLServerExplainRow + from .expression import ( + SQLServerOutputInsertedExpression, + SQLServerOutputDeletedExpression, + SQLServerTableHintClause, + SQLServerTableHint, + SQLServerReadPastHint, + SQLServerTemporalPeriodDefinition, + SQLServerSystemVersioningClause, + SQLServerOpenJsonExpression, + OpenJsonColumn, + SQLServerNextValueForExpression, + SQLServerTryCastExpression, + SQLServerTryConvertExpression, + SQLServerContainsPredicate, + SQLServerFreetextPredicate, + SQLServerTypeNullability, + SQLServerAliasTypeDefinition, + SQLServerTableTypeDefinition, + SQLServerClrTypeDefinition, + SQLServerDropTypeExpression, + SQLServerRenameTypeExpression, + ) + from .expression.types import ( + SQLServerNVarCharType, + SQLServerNCharType, + SQLServerNVarCharMaxType, + SQLServerVarBinaryType, + SQLServerVarBinaryMaxType, + SQLServerXmlType, + SQLServerTinyIntType, + SQLServerBitType, + SQLServerImageType, + ) + from .mixins import ( + SQLServerBackendMixin, + SQLServerConcurrencyMixin, + SQLServerTypeSupportMixin, + SQLServerTypeDDLMixin, + ) + from .protocols import SQLServerUserDefinedTypeSupport + from .schema import SQLServerSchemaDiffer + from .type_compatibility import ( + DIRECT_COMPATIBLE_CASTS, + check_cast_compatibility, + get_compatible_types, + ) __all__ = [ "SQLServerBackend", @@ -110,7 +174,6 @@ "SQLServerClrTypeDefinition", "SQLServerDropTypeExpression", "SQLServerRenameTypeExpression", - # DDL DataType subclasses "SQLServerNVarCharType", "SQLServerNCharType", "SQLServerNVarCharMaxType", @@ -120,15 +183,12 @@ "SQLServerTinyIntType", "SQLServerBitType", "SQLServerImageType", - # Mixins "SQLServerBackendMixin", "SQLServerConcurrencyMixin", "SQLServerTypeSupportMixin", "SQLServerTypeDDLMixin", "SQLServerUserDefinedTypeSupport", - # Schema differ "SQLServerSchemaDiffer", - # Type compatibility "DIRECT_COMPATIBLE_CASTS", "check_cast_compatibility", "get_compatible_types", @@ -136,11 +196,22 @@ def __getattr__(name: str): - _lazy_imports = { - "AsyncSQLServerBackend": (".async_backend", "AsyncSQLServerBackend"), - "AsyncSQLServerTransactionManager": (".async_transaction", "AsyncSQLServerTransactionManager"), - } + """Resolve a public name by importing its defining submodule once.""" + target = _EXPORTS.get(name) + if target is not None: + import importlib + module_name, attr = target + value = getattr(importlib.import_module(module_name, __name__), attr) + globals()[name] = value # cache, so __getattr__ runs at most once per name + return value + return _optional_dependency_getattr(name) + + +def _optional_dependency_getattr(name: str): + # Preserved from the previous eager loader: optional async backends get a + # helpful install hint instead of a bare ImportError. + _lazy_imports = {'AsyncSQLServerBackend': ('.async_backend', 'AsyncSQLServerBackend'), 'AsyncSQLServerTransactionManager': ('.async_transaction', 'AsyncSQLServerTransactionManager')} if name in _lazy_imports: module_path, class_name = _lazy_imports[name] try: @@ -148,10 +219,9 @@ def __getattr__(name: str): module = importlib.import_module(module_path, __name__) return getattr(module, class_name) except ImportError as e: - raise ImportError( - f"{name} requires 'aioodbc' package. " - f"Install it with: pip install rhosocial-activerecord-sqlserver[async] " - f"or pip install aioodbc" - ) from e + raise ImportError(f"{name} requires 'aioodbc' package. Install it with: pip install rhosocial-activerecord-sqlserver[async] or pip install aioodbc") from e + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') + - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") \ No newline at end of file +def __dir__(): + return sorted(set(globals()) | set(_EXPORTS)) From c59ed4792739701ebc4481fa4d06ba57644fb9ef Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 15:23:29 +0800 Subject: [PATCH 02/15] refactor(backend): make impl. an open package Drop the top-level impl//__init__.py so the directory becomes a PEP 420 namespace portion and no longer re-exports every public name. Each name is now imported from the sub-package that defines it, so a symbol has one public path instead of two. The facade also made "expression is importable on its own" unenforceable: importing impl..expression executed the parent __init__, which pulled in the driver. With the file gone, the sub-packages stand alone by construction rather than by a lazy __getattr__ that each backend had to maintain separately. This is a breaking change for anyone importing `from rhosocial.activerecord.backend.impl. import X`; the replacement is `...impl.. import X`. --- .claude/commands/gen-model.md | 2 +- .claude/commands/validate-model.md | 2 +- README.md | 2 +- .../backend/impl/sqlserver/__init__.py | 227 ------------------ .../backend/impl/sqlserver/cli/connection.py | 2 +- .../backend/impl/sqlserver/cli/info.py | 2 +- .../backend/impl/sqlserver/cli/introspect.py | 4 +- .../impl/sqlserver/cli/named_expression.py | 4 +- .../impl/sqlserver/cli/named_migration.py | 3 +- .../impl/sqlserver/cli/named_procedure.py | 4 +- .../sqlserver/cli/named_procedure_graph.py | 4 +- .../backend/impl/sqlserver/cli/query.py | 4 +- .../backend/impl/sqlserver/cli/status.py | 4 +- .../examples/cli/named_procedure_demo.py | 3 +- .../examples/cli/named_query_demo.py | 3 +- .../examples/connection/quickstart.py | 6 +- .../sqlserver/examples/ddl/create_index.py | 6 +- .../sqlserver/examples/ddl/create_table.py | 6 +- .../impl/sqlserver/examples/insert/batch.py | 6 +- .../impl/sqlserver/examples/insert/output.py | 6 +- .../sqlserver/examples/insert/with_output.py | 6 +- .../named_expressions/order_expressions.py | 3 +- .../named_procedures/order_workflow.py | 3 +- .../impl/sqlserver/examples/query/basic.py | 6 +- .../impl/sqlserver/examples/query/join.py | 6 +- .../examples/schema_diff/add_column.py | 2 +- .../sqlserver/examples/transaction/basic.py | 6 +- .../sqlserver/examples/types/json_basic.py | 6 +- tests/conftest.py | 2 +- tests/providers/basic.py | 6 +- tests/providers/basic_connection.py | 4 +- tests/providers/events.py | 2 +- tests/providers/mixins.py | 2 +- tests/providers/query.py | 12 +- tests/providers/query_connection.py | 4 +- tests/providers/relation.py | 2 +- tests/providers/scenarios.py | 2 +- .../feature/backend/conftest.py | 8 +- .../feature/backend/test_async_concurrency.py | 2 +- .../backend/test_async_error_handling.py | 2 +- ...test_async_transaction_isolation_effect.py | 2 +- .../backend/test_backend_mixin_validation.py | 2 +- .../feature/backend/test_ddl_source_mixin.py | 2 +- .../feature/backend/test_execution_options.py | 8 +- .../test_introspection_sql_builders.py | 2 +- .../test_sqlserver_column_definition.py | 2 +- .../test_transaction_isolation_effect.py | 2 +- .../feature/connection/conftest.py | 3 +- .../query/test_collation_expression.py | 3 +- 49 files changed, 84 insertions(+), 328 deletions(-) delete mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py diff --git a/.claude/commands/gen-model.md b/.claude/commands/gen-model.md index 8763169..25a8b69 100644 --- a/.claude/commands/gen-model.md +++ b/.claude/commands/gen-model.md @@ -58,7 +58,7 @@ CREATE TABLE users ( 2. Show the SQL migration to create the table 3. Remind the user to configure the backend: ```python - from rhosocial.activerecord.backend.impl.sqlite import SQLiteBackend + from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend User.configure(SQLiteBackend("sqlite:///app.db")) ``` diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md index 5e95c97..e7266c8 100644 --- a/.claude/commands/validate-model.md +++ b/.claude/commands/validate-model.md @@ -66,7 +66,7 @@ user = User(name="John") # Will raise "No backend configured" user.save() # CORRECT -from rhosocial.activerecord.backend.impl.sqlite import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend User.configure(SQLiteBackend("sqlite:///app.db")) user = User(name="John") user.save() diff --git a/README.md b/README.md index 68cd8dc..ad133ab 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ pip install rhosocial-activerecord-sqlserver ```python from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from typing import Optional diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py deleted file mode 100644 index 74d2a98..0000000 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py +++ /dev/null @@ -1,227 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py -"""SQL Server backend implementation for the Python ORM.""" - -# Public names are resolved on first access rather than at package-execution -# time. Importing this package used to import every submodule eagerly, which -# pulled in the DB driver through ``.backend`` and made the expression classes -# unreachable without it: Python executes a parent package's ``__init__`` before -# any submodule, so ``impl..expression`` could not be imported on its own. -# The exports themselves are unchanged -- attribute access, ``__all__``, ``dir()`` -# and ``from ... import *`` all behave as before. -from typing import TYPE_CHECKING - -#: Public name -> (submodule that defines it, attribute name within it). -#: The two differ when the original import was aliased. -_EXPORTS = { - "DIRECT_COMPATIBLE_CASTS": (".type_compatibility", "DIRECT_COMPATIBLE_CASTS"), - "OpenJsonColumn": (".expression", "OpenJsonColumn"), - "SQLServerAliasTypeDefinition": (".expression", "SQLServerAliasTypeDefinition"), - "SQLServerBackend": (".backend", "SQLServerBackend"), - "SQLServerBackendMixin": (".mixins", "SQLServerBackendMixin"), - "SQLServerBitType": (".expression.types", "SQLServerBitType"), - "SQLServerClrTypeDefinition": (".expression", "SQLServerClrTypeDefinition"), - "SQLServerCollation": (".collation", "SQLServerCollation"), - "SQLServerConcurrencyMixin": (".mixins", "SQLServerConcurrencyMixin"), - "SQLServerConnectionConfig": (".config", "SQLServerConnectionConfig"), - "SQLServerContainsPredicate": (".expression", "SQLServerContainsPredicate"), - "SQLServerDateAdapter": (".adapters", "SQLServerDateAdapter"), - "SQLServerDateTimeAdapter": (".adapters", "SQLServerDateTimeAdapter"), - "SQLServerDateTimeOffsetAdapter": (".adapters", "SQLServerDateTimeOffsetAdapter"), - "SQLServerDialect": (".dialect", "SQLServerDialect"), - "SQLServerDropTypeExpression": (".expression", "SQLServerDropTypeExpression"), - "SQLServerExecutionOptions": (".options", "SQLServerExecutionOptions"), - "SQLServerExplainResult": (".explain", "SQLServerExplainResult"), - "SQLServerExplainRow": (".explain", "SQLServerExplainRow"), - "SQLServerFreetextPredicate": (".expression", "SQLServerFreetextPredicate"), - "SQLServerHierarchyIdAdapter": (".adapters", "SQLServerHierarchyIdAdapter"), - "SQLServerImageType": (".expression.types", "SQLServerImageType"), - "SQLServerJSONAdapter": (".adapters", "SQLServerJSONAdapter"), - "SQLServerNCharType": (".expression.types", "SQLServerNCharType"), - "SQLServerNVarCharMaxType": (".expression.types", "SQLServerNVarCharMaxType"), - "SQLServerNVarCharType": (".expression.types", "SQLServerNVarCharType"), - "SQLServerNextValueForExpression": (".expression", "SQLServerNextValueForExpression"), - "SQLServerOpenJsonExpression": (".expression", "SQLServerOpenJsonExpression"), - "SQLServerOutputDeletedExpression": (".expression", "SQLServerOutputDeletedExpression"), - "SQLServerOutputInsertedExpression": (".expression", "SQLServerOutputInsertedExpression"), - "SQLServerReadPastHint": (".expression", "SQLServerReadPastHint"), - "SQLServerRenameTypeExpression": (".expression", "SQLServerRenameTypeExpression"), - "SQLServerSchemaDiffer": (".schema", "SQLServerSchemaDiffer"), - "SQLServerSpatialAdapter": (".adapters", "SQLServerSpatialAdapter"), - "SQLServerSystemVersioningClause": (".expression", "SQLServerSystemVersioningClause"), - "SQLServerTableHint": (".expression", "SQLServerTableHint"), - "SQLServerTableHintClause": (".expression", "SQLServerTableHintClause"), - "SQLServerTableTypeDefinition": (".expression", "SQLServerTableTypeDefinition"), - "SQLServerTemporalPeriodDefinition": (".expression", "SQLServerTemporalPeriodDefinition"), - "SQLServerTimeAdapter": (".adapters", "SQLServerTimeAdapter"), - "SQLServerTinyIntType": (".expression.types", "SQLServerTinyIntType"), - "SQLServerTransactionManager": (".transaction", "SQLServerTransactionManager"), - "SQLServerTryCastExpression": (".expression", "SQLServerTryCastExpression"), - "SQLServerTryConvertExpression": (".expression", "SQLServerTryConvertExpression"), - "SQLServerTypeDDLMixin": (".mixins", "SQLServerTypeDDLMixin"), - "SQLServerTypeNullability": (".expression", "SQLServerTypeNullability"), - "SQLServerTypeSupportMixin": (".mixins", "SQLServerTypeSupportMixin"), - "SQLServerUUIDAdapter": (".adapters", "SQLServerUUIDAdapter"), - "SQLServerUserDefinedTypeSupport": (".protocols", "SQLServerUserDefinedTypeSupport"), - "SQLServerVarBinaryMaxType": (".expression.types", "SQLServerVarBinaryMaxType"), - "SQLServerVarBinaryType": (".expression.types", "SQLServerVarBinaryType"), - "SQLServerXMLAdapter": (".adapters", "SQLServerXMLAdapter"), - "SQLServerXmlType": (".expression.types", "SQLServerXmlType"), - "check_cast_compatibility": (".type_compatibility", "check_cast_compatibility"), - "get_compatible_types": (".type_compatibility", "get_compatible_types"), -} - -if TYPE_CHECKING: # pragma: no cover - re-exports for type checkers - from .backend import SQLServerBackend - from .config import SQLServerConnectionConfig - from .collation import SQLServerCollation - from .dialect import SQLServerDialect - from .options import SQLServerExecutionOptions - from .transaction import SQLServerTransactionManager - from .adapters import ( - SQLServerUUIDAdapter, - SQLServerDateTimeAdapter, - SQLServerDateTimeOffsetAdapter, - SQLServerDateAdapter, - SQLServerTimeAdapter, - SQLServerJSONAdapter, - SQLServerXMLAdapter, - SQLServerSpatialAdapter, - SQLServerHierarchyIdAdapter, - ) - from .explain import SQLServerExplainResult, SQLServerExplainRow - from .expression import ( - SQLServerOutputInsertedExpression, - SQLServerOutputDeletedExpression, - SQLServerTableHintClause, - SQLServerTableHint, - SQLServerReadPastHint, - SQLServerTemporalPeriodDefinition, - SQLServerSystemVersioningClause, - SQLServerOpenJsonExpression, - OpenJsonColumn, - SQLServerNextValueForExpression, - SQLServerTryCastExpression, - SQLServerTryConvertExpression, - SQLServerContainsPredicate, - SQLServerFreetextPredicate, - SQLServerTypeNullability, - SQLServerAliasTypeDefinition, - SQLServerTableTypeDefinition, - SQLServerClrTypeDefinition, - SQLServerDropTypeExpression, - SQLServerRenameTypeExpression, - ) - from .expression.types import ( - SQLServerNVarCharType, - SQLServerNCharType, - SQLServerNVarCharMaxType, - SQLServerVarBinaryType, - SQLServerVarBinaryMaxType, - SQLServerXmlType, - SQLServerTinyIntType, - SQLServerBitType, - SQLServerImageType, - ) - from .mixins import ( - SQLServerBackendMixin, - SQLServerConcurrencyMixin, - SQLServerTypeSupportMixin, - SQLServerTypeDDLMixin, - ) - from .protocols import SQLServerUserDefinedTypeSupport - from .schema import SQLServerSchemaDiffer - from .type_compatibility import ( - DIRECT_COMPATIBLE_CASTS, - check_cast_compatibility, - get_compatible_types, - ) - -__all__ = [ - "SQLServerBackend", - "SQLServerConnectionConfig", - "SQLServerExecutionOptions", - "SQLServerDialect", - "SQLServerCollation", - "SQLServerTransactionManager", - "SQLServerUUIDAdapter", - "SQLServerDateTimeAdapter", - "SQLServerDateTimeOffsetAdapter", - "SQLServerDateAdapter", - "SQLServerTimeAdapter", - "SQLServerJSONAdapter", - "SQLServerXMLAdapter", - "SQLServerSpatialAdapter", - "SQLServerHierarchyIdAdapter", - "SQLServerExplainResult", - "SQLServerExplainRow", - "SQLServerOutputInsertedExpression", - "SQLServerOutputDeletedExpression", - "SQLServerTableHintClause", - "SQLServerTableHint", - "SQLServerReadPastHint", - "SQLServerTemporalPeriodDefinition", - "SQLServerSystemVersioningClause", - "SQLServerOpenJsonExpression", - "OpenJsonColumn", - "SQLServerNextValueForExpression", - "SQLServerTryCastExpression", - "SQLServerTryConvertExpression", - "SQLServerContainsPredicate", - "SQLServerFreetextPredicate", - "SQLServerTypeNullability", - "SQLServerAliasTypeDefinition", - "SQLServerTableTypeDefinition", - "SQLServerClrTypeDefinition", - "SQLServerDropTypeExpression", - "SQLServerRenameTypeExpression", - "SQLServerNVarCharType", - "SQLServerNCharType", - "SQLServerNVarCharMaxType", - "SQLServerVarBinaryType", - "SQLServerVarBinaryMaxType", - "SQLServerXmlType", - "SQLServerTinyIntType", - "SQLServerBitType", - "SQLServerImageType", - "SQLServerBackendMixin", - "SQLServerConcurrencyMixin", - "SQLServerTypeSupportMixin", - "SQLServerTypeDDLMixin", - "SQLServerUserDefinedTypeSupport", - "SQLServerSchemaDiffer", - "DIRECT_COMPATIBLE_CASTS", - "check_cast_compatibility", - "get_compatible_types", -] - - -def __getattr__(name: str): - """Resolve a public name by importing its defining submodule once.""" - target = _EXPORTS.get(name) - if target is not None: - import importlib - - module_name, attr = target - value = getattr(importlib.import_module(module_name, __name__), attr) - globals()[name] = value # cache, so __getattr__ runs at most once per name - return value - return _optional_dependency_getattr(name) - - -def _optional_dependency_getattr(name: str): - # Preserved from the previous eager loader: optional async backends get a - # helpful install hint instead of a bare ImportError. - _lazy_imports = {'AsyncSQLServerBackend': ('.async_backend', 'AsyncSQLServerBackend'), 'AsyncSQLServerTransactionManager': ('.async_transaction', 'AsyncSQLServerTransactionManager')} - if name in _lazy_imports: - module_path, class_name = _lazy_imports[name] - try: - import importlib - module = importlib.import_module(module_path, __name__) - return getattr(module, class_name) - except ImportError as e: - raise ImportError(f"{name} requires 'aioodbc' package. Install it with: pip install rhosocial-activerecord-sqlserver[async] or pip install aioodbc") from e - raise AttributeError(f'module {__name__!r} has no attribute {name!r}') - - -def __dir__(): - return sorted(set(globals()) | set(_EXPORTS)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py index 92d8ee0..43abbdf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py @@ -221,7 +221,7 @@ def resolve_connection_config_from_args(args): def create_backend(args): """Create, connect, and introspect a SQL Server backend from parsed args.""" - from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend config = resolve_connection_config_from_args(args) backend = SQLServerBackend(connection_config=config) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py index 1869201..1fd3d4f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/info.py @@ -156,7 +156,7 @@ def handle(args): named_conn = getattr(args, "named_connection", None) if named_conn or args.database: try: - from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend config = resolve_connection_config_from_args(args) backend = SQLServerBackend(connection_config=config) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py index d040b3d..cecfa16 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py @@ -12,7 +12,7 @@ from enum import Enum from typing import Any -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.errors import ConnectionError, QueryError from .connection import add_connection_args, resolve_connection_config_from_args from .output import create_provider @@ -138,7 +138,7 @@ def handle(args): config = resolve_connection_config_from_args(args) if args.is_async: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_handle_introspect_async(args, backend, provider)) else: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py index e590d38..c2a266e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py @@ -4,7 +4,7 @@ named-expression requires connection arguments, output arguments, and --rich-ascii. """ -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from .connection import create_connection_parent_parser, resolve_connection_config_from_args @@ -67,7 +67,7 @@ def disconnect(): is_async = getattr(args, "is_async", False) if is_async: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend async_backend = None diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py index 2485f51..ff66a23 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py @@ -3,7 +3,8 @@ from __future__ import annotations -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from .connection import create_connection_parent_parser, resolve_connection_config_from_args from .output import create_provider diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py index 4a3401b..c39d75e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py @@ -4,7 +4,7 @@ named-procedure requires connection arguments, output arguments, and --rich-ascii. """ -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from .connection import create_connection_parent_parser, resolve_connection_config_from_args from .output import create_provider @@ -62,7 +62,7 @@ def disconnect(): is_async = getattr(args, "is_async", False) if is_async: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend async_backend = None def backend_async_factory(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py index 6aa8c6e..9044324 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py @@ -4,7 +4,7 @@ named-procedure-graph requires connection arguments, output arguments, and --rich-ascii. """ -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from .connection import create_connection_parent_parser, resolve_connection_config_from_args from .output import create_provider @@ -45,7 +45,7 @@ def disconnect(): is_async = getattr(args, "is_async", False) if is_async: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend async_backend = None diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py index 514428b..58cd50c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py @@ -9,7 +9,7 @@ import logging import sys -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.errors import ConnectionError, QueryError from .connection import add_connection_args, resolve_connection_config_from_args from .output import create_provider, RICH_AVAILABLE @@ -148,7 +148,7 @@ def handle(args): kwargs = {"use_ascii": args.rich_ascii} if args.is_async: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_execute_query_async(sql_source, backend, provider, **kwargs)) else: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py index 66cc1ab..4994e34 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py @@ -9,7 +9,7 @@ from enum import Enum from typing import Any -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.errors import ConnectionError, QueryError from .connection import add_connection_args, resolve_connection_config_from_args from .output import create_provider, RICH_AVAILABLE @@ -82,7 +82,7 @@ def handle(args): config = resolve_connection_config_from_args(args) if args.is_async: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_handle_status_async(args, backend, provider)) else: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py index e61cb6c..22c0429 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py @@ -162,7 +162,8 @@ def prepare_demo_schema(): """ code = ( "import os;" - "from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, " + "from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend + "from rhosocial.activerecord.backend.impl.sqlserver import " "SQLServerConnectionConfig;" "from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions." "order_expressions import prepare_orders_demo;" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py index 28017a3..e5c0e53 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py @@ -152,7 +152,8 @@ def prepare_demo_schema(): """ code = ( "import os;" - "from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, " + "from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend + "from rhosocial.activerecord.backend.impl.sqlserver import " "SQLServerConnectionConfig;" "from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions." "order_expressions import prepare_orders_demo;" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py index 1c3a986..e74833f 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/connection/quickstart.py @@ -29,10 +29,8 @@ ColumnConstraintType, ColumnDefinition, ) -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py index e52ebf2..748a439 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_index.py @@ -7,10 +7,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( host=os.environ.get('SQLSERVER_HOST', 'localhost'), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py index 3ed666f..344ca75 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/ddl/create_table.py @@ -7,10 +7,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( host=os.environ.get('SQLSERVER_HOST', 'localhost'), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py index f72ee33..3b70e33 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/batch.py @@ -7,10 +7,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( host=os.environ.get('SQLSERVER_HOST', 'localhost'), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/output.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/output.py index 13475bb..f1c637d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/output.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/output.py @@ -8,10 +8,8 @@ import os -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig def get_backend(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py index 4470d48..0f19b44 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/insert/with_output.py @@ -9,10 +9,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( host=os.environ.get('SQLSERVER_HOST', 'localhost'), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py index fa6f213..4392c71 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py @@ -166,7 +166,8 @@ def prepare_orders_demo(backend) -> None: if __name__ == "__main__": import os - from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, SQLServerConnectionConfig + from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/order_workflow.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/order_workflow.py index b7d61b1..700142e 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/order_workflow.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/order_workflow.py @@ -135,7 +135,8 @@ def run(self, ctx: ProcedureContext) -> None: if __name__ == "__main__": import os - from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, SQLServerConnectionConfig + from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions import ( prepare_orders_demo, ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py index 9ec486d..861d2b9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/basic.py @@ -7,10 +7,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py index fe3bf69..d963d87 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/query/join.py @@ -7,10 +7,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py index bf2f93e..a396d05 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/schema_diff/add_column.py @@ -8,7 +8,7 @@ """ import os -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py index b831684..a9276b5 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/transaction/basic.py @@ -7,10 +7,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( host=os.environ.get('SQLSERVER_HOST', 'localhost'), diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py index a9aef7d..0700146 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/types/json_basic.py @@ -10,10 +10,8 @@ # ============================================================ # SECTION: Setup (necessary for execution, reference only) # ============================================================ -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig config = SQLServerConnectionConfig( host=os.environ.get('SQLSERVER_HOST', 'localhost'), diff --git a/tests/conftest.py b/tests/conftest.py index 3fe232b..1f84e39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,7 +41,7 @@ def _prepare_read_committed_snapshot(): This fixture only checks and warns if the option is not yet on (e.g. when running locally without the workflow). """ - from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig scenarios = _load_scenario_map() diff --git a/tests/providers/basic.py b/tests/providers/basic.py index b6a4639..e16463a 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -520,7 +520,7 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N if pooled_db: config_dict = {**config_dict, "database": pooled_db} return { - "backend_module": "rhosocial.activerecord.backend.impl.sqlserver", + "backend_module": "rhosocial.activerecord.backend.impl.sqlserver.backend", "backend_class_name": backend_class_name, "config_class_module": "rhosocial.activerecord.backend.impl.sqlserver.config", "config_class_name": "SQLServerConnectionConfig", @@ -566,7 +566,7 @@ async def get_dialect(self, scenario_name: str = "default"): async def _setup_async_model( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) await model_class.configure(config, AsyncSQLServerBackend) backend_instance = model_class.__backend__ @@ -604,7 +604,7 @@ async def setup_type_case_model(self, scenario_name: str) -> Type[ActiveRecord]: async def setup_type_test_model(self, scenario_name: str) -> Type[ActiveRecord]: import pytest - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) temp_backend = AsyncSQLServerBackend(connection_config=config) await temp_backend.connect() diff --git a/tests/providers/basic_connection.py b/tests/providers/basic_connection.py index a83cbfb..9a3bba7 100644 --- a/tests/providers/basic_connection.py +++ b/tests/providers/basic_connection.py @@ -8,7 +8,7 @@ from typing import Type, Tuple, Optional, List from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.connection.pool import BackendPool, AsyncBackendPool, PoolConfig from rhosocial.activerecord.backend.options import ExecutionOptions @@ -99,7 +99,7 @@ def setup_sync_pool_and_model(self, scenario_name: str) -> Tuple[BackendPool, Ty async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: """Setup async connection pool and model for context tests.""" - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) diff --git a/tests/providers/events.py b/tests/providers/events.py index 17a6b23..1e96928 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -158,7 +158,7 @@ def __init__(self): async def _setup_async_model( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index 7e08bff..50c9a66 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -183,7 +183,7 @@ def __init__(self): async def _setup_async_model( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression diff --git a/tests/providers/query.py b/tests/providers/query.py index 9c6800f..0584020 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -474,7 +474,7 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = " if pooled_db: config_dict = {**config_dict, "database": pooled_db} return { - "backend_module": "rhosocial.activerecord.backend.impl.sqlserver", + "backend_module": "rhosocial.activerecord.backend.impl.sqlserver.backend", "backend_class_name": backend_class_name, "config_class_module": "rhosocial.activerecord.backend.impl.sqlserver.config", "config_class_name": "SQLServerConnectionConfig", @@ -527,7 +527,7 @@ def __init__(self): async def _setup_model_async( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression @@ -595,7 +595,7 @@ async def setup_blog_fixtures( async def setup_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: import pytest - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.query.fixtures.async_json_models import AsyncJsonUser _, config = get_scenario(scenario_name) await AsyncJsonUser.configure(config, AsyncSQLServerBackend) @@ -685,7 +685,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -740,7 +740,7 @@ async def cleanup_after_test(self, scenario_name: str) -> None: async def setup_mixed_schema_fixtures(self, scenario_name: str): """(AsyncUser, AsyncOrder, AsyncMixedSchemaOrder) with orders also in SCHEMA_A.""" - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.query.cross_schema.mixed_schema_models import ( AsyncMixedSchemaOrder, ) @@ -764,7 +764,7 @@ async def setup_mixed_schema_fixtures(self, scenario_name: str): async def setup_schema_fixtures(self, scenario_name: str): """Two async models in two distinct schemas.""" - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( AsyncSchemaCustomer, AsyncSchemaOrder, diff --git a/tests/providers/query_connection.py b/tests/providers/query_connection.py index 2376338..ecd3fcf 100644 --- a/tests/providers/query_connection.py +++ b/tests/providers/query_connection.py @@ -8,7 +8,7 @@ from typing import Type, Tuple, Optional, List from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.connection.pool import BackendPool, AsyncBackendPool, PoolConfig from rhosocial.activerecord.backend.options import ExecutionOptions @@ -99,7 +99,7 @@ def setup_sync_pool_and_model(self, scenario_name: str) -> Tuple[BackendPool, Ty async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: """Setup async connection pool and model for query context tests.""" - from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) diff --git a/tests/providers/relation.py b/tests/providers/relation.py index 21f45e8..ebcbea7 100644 --- a/tests/providers/relation.py +++ b/tests/providers/relation.py @@ -2,7 +2,7 @@ from typing import Dict, List, Tuple, Type, Set from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord -from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.relation.interfaces import ( IRelationSyncProvider, IRelationAsyncProvider, diff --git a/tests/providers/scenarios.py b/tests/providers/scenarios.py index 537d450..f22f617 100644 --- a/tests/providers/scenarios.py +++ b/tests/providers/scenarios.py @@ -4,7 +4,7 @@ import os from dataclasses import replace from typing import Dict, Any, Tuple, Type -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.testsuite.core.pool import pooled_database_name diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py index 4133a3c..0b5f9fb 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py @@ -5,11 +5,9 @@ import os from typing import Dict, Any, Tuple, Type -from rhosocial.activerecord.backend.impl.sqlserver import ( - SQLServerBackend, - AsyncSQLServerBackend, - SQLServerConnectionConfig, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend SCENARIO_MAP: Dict[str, Dict[str, Any]] = {} diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py index 96a8bba..4dee96d 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py @@ -32,7 +32,7 @@ from rhosocial.activerecord.model import AsyncActiveRecord from rhosocial.activerecord.base.field_proxy import FieldProxy -from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend logger = logging.getLogger(__name__) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py index 5e54247..eda47de 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py @@ -7,7 +7,7 @@ DeadlockError, OperationalError, ) -from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend @pytest_asyncio.fixture diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py index fc63825..7698454 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py @@ -10,7 +10,7 @@ import asyncio from decimal import Decimal -from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_mixin_validation.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_mixin_validation.py index d70d090..df5ae53 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_mixin_validation.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_mixin_validation.py @@ -9,7 +9,7 @@ import pytest -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py index c0c0119..18c1070 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ddl_source_mixin.py @@ -22,7 +22,7 @@ PartitionClause, PartitionStrategy, ) -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect from rhosocial.activerecord.backend.impl.sqlserver.expression.column import ( SQLServerColumnOptions, ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py index 1ada365..d0efa2b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py @@ -5,11 +5,9 @@ from rhosocial.activerecord.backend.base import StorageBackend from rhosocial.activerecord.backend.errors import DatabaseError -from rhosocial.activerecord.backend.impl.sqlserver import ( - AsyncSQLServerBackend, - SQLServerBackend, - SQLServerExecutionOptions, -) +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.options import SQLServerExecutionOptions +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py index c3cebe8..96424b5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py @@ -5,7 +5,7 @@ now returns parameterized SQL (``?`` placeholders) with separate params, instead of string-interpolated values. """ -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.impl.sqlserver.introspection import ( SyncSQLServerIntrospector, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py index 0dcb7e7..bb93bde 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_column_definition.py @@ -13,7 +13,7 @@ @pytest.fixture def dialect(): - from rhosocial.activerecord.backend.impl.sqlserver import SQLServerDialect + from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect return SQLServerDialect((2022, 0, 0)) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_isolation_effect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_isolation_effect.py index ae266b2..89a5231 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_isolation_effect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_isolation_effect.py @@ -16,7 +16,7 @@ import time from decimal import Decimal -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.transaction import IsolationLevel diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py index 0915d3a..48fcca7 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py @@ -14,7 +14,8 @@ import pytest_asyncio import yaml -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.connection.pool import ( PoolConfig, diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py b/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py index ae70d77..7bd0a0f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/query/test_collation_expression.py @@ -6,7 +6,8 @@ import pytest from rhosocial.activerecord.backend.expression import Column, Literal -from rhosocial.activerecord.backend.impl.sqlserver import SQLServerCollation, SQLServerDialect +from rhosocial.activerecord.backend.impl.sqlserver.collation import SQLServerCollation +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @pytest.fixture From d2178f4cc858453fc217db21195fe111c5e5fbe1 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 15:56:16 +0800 Subject: [PATCH 03/15] fix(providers): resolve the async backend across sibling modules The provider gave the worker tasks a single module path and the task code looked for the async counterpart one level up. That was enough while the sync and async classes shared the impl. facade; with the facade gone they live in different modules and the one-level lookup fell through to the sync class, so an async worker would silently run the sync backend. Walk up the chain first, then try the backend's own async_backend module. No package scanning is involved: both candidates are named explicitly. --- tests/providers/worker_tasks.py | 50 ++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/tests/providers/worker_tasks.py b/tests/providers/worker_tasks.py index 9d1e30c..ff3f877 100644 --- a/tests/providers/worker_tasks.py +++ b/tests/providers/worker_tasks.py @@ -13,6 +13,43 @@ import importlib +def _find_async_backend(module_path, module, sync_class_name): + """Locate ``Async`` for a backend whose classes span modules. + + ``impl.`` is a namespace package, so the sync and async classes no + longer share a facade. Walk up from the module that defined the sync class + until the async counterpart shows up, then fall back to the sync class. + """ + wanted = f'Async{sync_class_name}' + candidate = module + name = module_path + while candidate is not None: + found = getattr(candidate, wanted, None) + if found is not None: + return found + parent_name = name.rpartition('.')[0] + if not parent_name or parent_name == name: + break + name = parent_name + try: + candidate = importlib.import_module(parent_name) + except ImportError: + break + # The async class conventionally lives in the backend's own + # ``async_backend`` module, which the upward walk cannot reach. + parent_name = module_path.rpartition('.')[0] + for candidate_name in (f'{parent_name}.async_backend', + f'{module_path.rsplit(".", 1)[0]}.async_backend'): + try: + sibling = importlib.import_module(candidate_name) + except ImportError: + continue + found = getattr(sibling, wanted, None) + if found is not None: + return found + return getattr(module, sync_class_name) + + def _configure_model_from_params(params: dict, model_class) -> None: """ Configure model from connection parameters. @@ -60,16 +97,9 @@ async def _async_configure_model_from_params(params: dict, model_class) -> None: # Convert sync backend to async backend if needed # e.g., MariaDBBackend -> AsyncMariaDBBackend if not backend_class.__name__.startswith('Async'): - async_backend_class_name = f'Async{backend_class.__name__}' - # Try to get from same module - if hasattr(backend_module, async_backend_class_name): - backend_class = getattr(backend_module, async_backend_class_name) - else: - # Try to get from parent module (e.g., mariadb.backend.sync -> mariadb.backend) - parent_module_name = '.'.join(params['backend_module'].split('.')[:-1]) - parent_module = importlib.import_module(parent_module_name) - if hasattr(parent_module, async_backend_class_name): - backend_class = getattr(parent_module, async_backend_class_name) + backend_class = _find_async_backend( + params['backend_module'], backend_module, backend_class.__name__ + ) # Dynamically import config class config_module = importlib.import_module(params['config_module']) From 20c0d92182b455f70a703bed26fb768b5be8ba4a Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 17:09:22 +0800 Subject: [PATCH 04/15] refactor(backend): keep both backend classes in impl..backend Move the sync and async classes into a backend/ package with the fixed layout: backend/backend.py and backend/async_backend.py. Eight backends kept them as two modules beside backend/, and sqlite and postgres used backend/sync.py, so the class locations differed three ways between backends. With the layout fixed the worker tasks derive the async class from the sync module's own package instead of searching for it, so the _find_async_backend helper is gone. --- .../impl/sqlserver/backend/__init__.py | 10 +++++ .../sqlserver/{ => backend}/async_backend.py | 10 ++--- .../impl/sqlserver/{ => backend}/backend.py | 10 ++--- .../backend/impl/sqlserver/cli/introspect.py | 2 +- .../impl/sqlserver/cli/named_expression.py | 2 +- .../impl/sqlserver/cli/named_migration.py | 2 +- .../impl/sqlserver/cli/named_procedure.py | 2 +- .../sqlserver/cli/named_procedure_graph.py | 2 +- .../backend/impl/sqlserver/cli/query.py | 2 +- .../backend/impl/sqlserver/cli/status.py | 2 +- tests/providers/basic.py | 4 +- tests/providers/basic_connection.py | 2 +- tests/providers/events.py | 2 +- tests/providers/mixins.py | 2 +- tests/providers/query.py | 10 ++--- tests/providers/query_connection.py | 2 +- tests/providers/relation.py | 2 +- tests/providers/worker_tasks.py | 40 ++----------------- .../feature/backend/conftest.py | 2 +- .../feature/backend/test_async_concurrency.py | 2 +- .../backend/test_async_error_handling.py | 2 +- ...test_async_transaction_isolation_effect.py | 2 +- .../feature/backend/test_execution_options.py | 2 +- .../backend/test_sqlserver_type_ddl.py | 2 +- .../feature/connection/conftest.py | 2 +- 25 files changed, 49 insertions(+), 73 deletions(-) create mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py rename src/rhosocial/activerecord/backend/impl/sqlserver/{ => backend}/async_backend.py (98%) rename src/rhosocial/activerecord/backend/impl/sqlserver/{ => backend}/backend.py (98%) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py new file mode 100644 index 0000000..025ad14 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py @@ -0,0 +1,10 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py +"""Sqlserver backend implementations. + +Every backend keeps both classes in this package: the sync class in +``backend.py`` and the async class in ``async_backend.py``. So the sync class +is at ``impl.sqlserver.backend.backend`` and the async class at +``impl.sqlserver.backend.async_backend``. +""" + +from .backend import SQLServerBackend diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py similarity index 98% rename from src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py rename to src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index 9843219..b0e0d6d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -1,4 +1,4 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py +# src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py """ SQL Server asynchronous backend implementation. @@ -25,11 +25,11 @@ ) from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin -from .config import SQLServerConnectionConfig -from .dialect import SQLServerDialect -from .async_transaction import AsyncSQLServerTransactionManager +from ..config import SQLServerConnectionConfig +from ..dialect import SQLServerDialect +from ..async_transaction import AsyncSQLServerTransactionManager from .backend import SQLServerUnicodeDialect -from .mixins import SQLServerBackendMixin +from ..mixins import SQLServerBackendMixin try: import aioodbc diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py similarity index 98% rename from src/rhosocial/activerecord/backend/impl/sqlserver/backend.py rename to src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index b3cda41..afe0ec7 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -1,4 +1,4 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/backend.py +# src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py """ SQL Server-specific implementation of the StorageBackend. @@ -28,10 +28,10 @@ from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin -from .config import SQLServerConnectionConfig -from .dialect import SQLServerDialect -from .transaction import SQLServerTransactionManager -from .mixins import SQLServerBackendMixin, SQLServerConcurrencyMixin +from ..config import SQLServerConnectionConfig +from ..dialect import SQLServerDialect +from ..transaction import SQLServerTransactionManager +from ..mixins import SQLServerBackendMixin, SQLServerConcurrencyMixin class SQLServerUnicodeDialect(SQLServerDialect): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py index cecfa16..06f2cd9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py @@ -138,7 +138,7 @@ def handle(args): config = resolve_connection_config_from_args(args) if args.is_async: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_handle_introspect_async(args, backend, provider)) else: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py index c2a266e..b98877a 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_expression.py @@ -67,7 +67,7 @@ def disconnect(): is_async = getattr(args, "is_async", False) if is_async: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend async_backend = None diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py index ff66a23..380fed8 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_migration.py @@ -4,7 +4,7 @@ from __future__ import annotations from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from .connection import create_connection_parent_parser, resolve_connection_config_from_args from .output import create_provider diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py index c39d75e..5283ccd 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py @@ -62,7 +62,7 @@ def disconnect(): is_async = getattr(args, "is_async", False) if is_async: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend async_backend = None def backend_async_factory(): diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py index 9044324..c422006 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure_graph.py @@ -45,7 +45,7 @@ def disconnect(): is_async = getattr(args, "is_async", False) if is_async: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend async_backend = None diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py index 58cd50c..afba301 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py @@ -148,7 +148,7 @@ def handle(args): kwargs = {"use_ascii": args.rich_ascii} if args.is_async: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_execute_query_async(sql_source, backend, provider, **kwargs)) else: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py index 4994e34..819553c 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py @@ -82,7 +82,7 @@ def handle(args): config = resolve_connection_config_from_args(args) if args.is_async: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_handle_status_async(args, backend, provider)) else: diff --git a/tests/providers/basic.py b/tests/providers/basic.py index e16463a..aac1bfc 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -566,7 +566,7 @@ async def get_dialect(self, scenario_name: str = "default"): async def _setup_async_model( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) await model_class.configure(config, AsyncSQLServerBackend) backend_instance = model_class.__backend__ @@ -604,7 +604,7 @@ async def setup_type_case_model(self, scenario_name: str) -> Type[ActiveRecord]: async def setup_type_test_model(self, scenario_name: str) -> Type[ActiveRecord]: import pytest - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) temp_backend = AsyncSQLServerBackend(connection_config=config) await temp_backend.connect() diff --git a/tests/providers/basic_connection.py b/tests/providers/basic_connection.py index 9a3bba7..9e17fa0 100644 --- a/tests/providers/basic_connection.py +++ b/tests/providers/basic_connection.py @@ -99,7 +99,7 @@ def setup_sync_pool_and_model(self, scenario_name: str) -> Tuple[BackendPool, Ty async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: """Setup async connection pool and model for context tests.""" - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) diff --git a/tests/providers/events.py b/tests/providers/events.py index 1e96928..efba4ca 100644 --- a/tests/providers/events.py +++ b/tests/providers/events.py @@ -158,7 +158,7 @@ def __init__(self): async def _setup_async_model( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression diff --git a/tests/providers/mixins.py b/tests/providers/mixins.py index 50c9a66..a943e57 100644 --- a/tests/providers/mixins.py +++ b/tests/providers/mixins.py @@ -183,7 +183,7 @@ def __init__(self): async def _setup_async_model( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression diff --git a/tests/providers/query.py b/tests/providers/query.py index 0584020..aa3ada1 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -527,7 +527,7 @@ def __init__(self): async def _setup_model_async( self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str, shared_backend=None ) -> Type[ActiveRecord]: - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression @@ -595,7 +595,7 @@ async def setup_blog_fixtures( async def setup_json_user_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: import pytest - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.query.fixtures.async_json_models import AsyncJsonUser _, config = get_scenario(scenario_name) await AsyncJsonUser.configure(config, AsyncSQLServerBackend) @@ -685,7 +685,7 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType from rhosocial.activerecord.backend.expression import DropTableExpression, TableExpression - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from providers.fixtures.basic import TABLE_EXPRESSIONS as BASIC_EXPRS from providers.fixtures._common import to_sqlserver_ddl_sql @@ -740,7 +740,7 @@ async def cleanup_after_test(self, scenario_name: str) -> None: async def setup_mixed_schema_fixtures(self, scenario_name: str): """(AsyncUser, AsyncOrder, AsyncMixedSchemaOrder) with orders also in SCHEMA_A.""" - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.query.cross_schema.mixed_schema_models import ( AsyncMixedSchemaOrder, ) @@ -764,7 +764,7 @@ async def setup_mixed_schema_fixtures(self, scenario_name: str): async def setup_schema_fixtures(self, scenario_name: str): """Two async models in two distinct schemas.""" - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( AsyncSchemaCustomer, AsyncSchemaOrder, diff --git a/tests/providers/query_connection.py b/tests/providers/query_connection.py index ecd3fcf..56e0c4d 100644 --- a/tests/providers/query_connection.py +++ b/tests/providers/query_connection.py @@ -99,7 +99,7 @@ def setup_sync_pool_and_model(self, scenario_name: str) -> Tuple[BackendPool, Ty async def setup_async_pool_and_model(self, scenario_name: str) -> Tuple[AsyncBackendPool, Type[AsyncActiveRecord]]: """Setup async connection pool and model for query context tests.""" - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) diff --git a/tests/providers/relation.py b/tests/providers/relation.py index ebcbea7..0b03ae2 100644 --- a/tests/providers/relation.py +++ b/tests/providers/relation.py @@ -2,7 +2,7 @@ from typing import Dict, List, Tuple, Type, Set from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.testsuite.feature.relation.interfaces import ( IRelationSyncProvider, IRelationAsyncProvider, diff --git a/tests/providers/worker_tasks.py b/tests/providers/worker_tasks.py index ff3f877..10ef206 100644 --- a/tests/providers/worker_tasks.py +++ b/tests/providers/worker_tasks.py @@ -13,41 +13,6 @@ import importlib -def _find_async_backend(module_path, module, sync_class_name): - """Locate ``Async`` for a backend whose classes span modules. - - ``impl.`` is a namespace package, so the sync and async classes no - longer share a facade. Walk up from the module that defined the sync class - until the async counterpart shows up, then fall back to the sync class. - """ - wanted = f'Async{sync_class_name}' - candidate = module - name = module_path - while candidate is not None: - found = getattr(candidate, wanted, None) - if found is not None: - return found - parent_name = name.rpartition('.')[0] - if not parent_name or parent_name == name: - break - name = parent_name - try: - candidate = importlib.import_module(parent_name) - except ImportError: - break - # The async class conventionally lives in the backend's own - # ``async_backend`` module, which the upward walk cannot reach. - parent_name = module_path.rpartition('.')[0] - for candidate_name in (f'{parent_name}.async_backend', - f'{module_path.rsplit(".", 1)[0]}.async_backend'): - try: - sibling = importlib.import_module(candidate_name) - except ImportError: - continue - found = getattr(sibling, wanted, None) - if found is not None: - return found - return getattr(module, sync_class_name) def _configure_model_from_params(params: dict, model_class) -> None: @@ -97,8 +62,9 @@ async def _async_configure_model_from_params(params: dict, model_class) -> None: # Convert sync backend to async backend if needed # e.g., MariaDBBackend -> AsyncMariaDBBackend if not backend_class.__name__.startswith('Async'): - backend_class = _find_async_backend( - params['backend_module'], backend_module, backend_class.__name__ + backend_class = getattr( + importlib.import_module(f"{params['backend_module']}.async_backend"), + f"Async{backend_class.__name__}", ) # Dynamically import config class diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py index 0b5f9fb..ed8800e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py @@ -7,7 +7,7 @@ from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend SCENARIO_MAP: Dict[str, Dict[str, Any]] = {} diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py index 4dee96d..433df70 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_concurrency.py @@ -32,7 +32,7 @@ from rhosocial.activerecord.model import AsyncActiveRecord from rhosocial.activerecord.base.field_proxy import FieldProxy -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend logger = logging.getLogger(__name__) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py index eda47de..ef308e5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_error_handling.py @@ -7,7 +7,7 @@ DeadlockError, OperationalError, ) -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend @pytest_asyncio.fixture diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py index 7698454..d25446f 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_async_transaction_isolation_effect.py @@ -10,7 +10,7 @@ import asyncio from decimal import Decimal -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.transaction import IsolationLevel, TransactionMode diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py index d0efa2b..14a9124 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_execution_options.py @@ -7,7 +7,7 @@ from rhosocial.activerecord.backend.errors import DatabaseError from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.options import SQLServerExecutionOptions -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py index ce6ec0e..cf583b5 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_type_ddl.py @@ -140,7 +140,7 @@ def test_backend_passes_deployment_target_from_connection_config(): def test_async_backend_passes_deployment_target_from_connection_config(): pytest.importorskip("aioodbc") - from rhosocial.activerecord.backend.impl.sqlserver.async_backend import ( + from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import ( AsyncSQLServerBackend, ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py index 48fcca7..60e1b02 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py @@ -15,7 +15,7 @@ import yaml from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend -from rhosocial.activerecord.backend.impl.sqlserver.async_backend import AsyncSQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.backend.async_backend import AsyncSQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig from rhosocial.activerecord.connection.pool import ( PoolConfig, From 90ae248daa223ae18478597264ac92b8bfb2c1e6 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 17:25:03 +0800 Subject: [PATCH 05/15] fix(backend): correct relative import depth inside backend/ The move into backend/ shifted every relative import by one level, and the function-local ones were missed, so impl.bigquery could not reach its adapters module. Resolve each target against the filesystem instead of counting dots. --- .../backend/impl/sqlserver/backend/async_backend.py | 4 ++-- .../activerecord/backend/impl/sqlserver/backend/backend.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index b0e0d6d..d2563a9 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -145,7 +145,7 @@ def dialect(self) -> SQLServerDialect: def _register_sqlserver_adapters(self) -> None: """Register SQL Server-specific type adapters (async mirror).""" - from .adapters import ( + from ..adapters import ( SQLServerUUIDAdapter, SQLServerDateTimeAdapter, SQLServerDateTimeOffsetAdapter, @@ -641,6 +641,6 @@ async def _handle_auto_commit_if_needed(self) -> None: def _create_introspector(self): """Create a SQL Server introspector.""" - from .introspection import AsyncSQLServerIntrospector + from ..introspection import AsyncSQLServerIntrospector from rhosocial.activerecord.backend.introspection.executor import AsyncIntrospectorExecutor return AsyncSQLServerIntrospector(self, AsyncIntrospectorExecutor(self)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index afe0ec7..88446b8 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -177,7 +177,7 @@ def transaction_manager(self) -> SQLServerTransactionManager: def _register_sqlserver_adapters(self) -> None: """Register SQL Server-specific type adapters.""" - from .adapters import ( + from ..adapters import ( SQLServerUUIDAdapter, SQLServerDateTimeAdapter, SQLServerDateTimeOffsetAdapter, @@ -650,7 +650,7 @@ def _create_introspector(self): Returns: SyncSQLServerIntrospector instance """ - from .introspection import SyncSQLServerIntrospector + from ..introspection import SyncSQLServerIntrospector from rhosocial.activerecord.backend.introspection.executor import SyncIntrospectorExecutor return SyncSQLServerIntrospector(self, SyncIntrospectorExecutor(self)) @@ -665,6 +665,6 @@ def _parse_explain_result(self, raw_rows, sql, duration): Returns: SQLServerExplainResult instance """ - from .explain import SQLServerExplainResult, SQLServerExplainRow + from ..explain import SQLServerExplainResult, SQLServerExplainRow rows = [SQLServerExplainRow(**r) for r in raw_rows] return SQLServerExplainResult(raw_rows=raw_rows, sql=sql, duration=duration, rows=rows) From d357a3c62e45e3ba17af9b220c645f3c31897b0f Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 17:58:20 +0800 Subject: [PATCH 06/15] refactor(backend): re-export the async class from the backend package impl..backend already re-exported the sync class, so leaving the async one out made the two classes reachable by different rules. Export both. The eight backends whose async driver is a hard requirement import it eagerly. SQLite and SQL Server keep a PEP 562 __getattr__, because aiosqlite and aioodbc ship as the [async] extra and importing either eagerly would make the async class a requirement for sync-only users. --- .../impl/sqlserver/backend/__init__.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py index 025ad14..2a7ea56 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py @@ -2,9 +2,33 @@ """Sqlserver backend implementations. Every backend keeps both classes in this package: the sync class in -``backend.py`` and the async class in ``async_backend.py``. So the sync class -is at ``impl.sqlserver.backend.backend`` and the async class at -``impl.sqlserver.backend.async_backend``. +``backend.py`` and the async class in ``async_backend.py``. The async class is +re-exported here too, but resolved on first access, because it needs the +``aioodbc`` package that the ``rhosocial-activerecord-sqlserver[async]`` extra installs. """ from .backend import SQLServerBackend + +__all__ = [ + "SQLServerBackend", + "AsyncSQLServerBackend", +] + + +def __getattr__(name: str): + """Resolve the async class on first access. + + Raises: + ImportError: if aioodbc is missing, naming the extra that provides it. + AttributeError: for any other name. + """ + if name == "AsyncSQLServerBackend": + try: + from .async_backend import AsyncSQLServerBackend as backend + except ImportError as e: + raise ImportError( + "AsyncSQLServerBackend requires the 'aioodbc' package. " + "Install it with: pip install rhosocial-activerecord-sqlserver[async] or pip install aioodbc" + ) from e + return backend + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From bf4621b02350d2e47319e289990643b1eee4cbe0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 18:28:34 +0800 Subject: [PATCH 07/15] fix(test): patch the backend class on the module the CLI reads The CLI tests monkeypatched SQLServerBackend on impl.sqlserver, which no longer carries the name now that the directory is a namespace. Patch impl.sqlserver.backend instead, and the async class on impl.sqlserver.backend.async_backend, which is where each is defined. --- .../feature/backend/cli/test_cli_info_query.py | 7 ++++--- .../feature/backend/cli/test_cli_introspect.py | 5 +++-- .../feature/backend/cli/test_cli_status.py | 9 +++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_info_query.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_info_query.py index 9f588e9..bbfaa92 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_info_query.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_info_query.py @@ -16,7 +16,8 @@ from rhosocial.activerecord.backend.errors import ConnectionError, QueryError from rhosocial.activerecord.backend.dialect.protocols import ExplainSupport -from rhosocial.activerecord.backend.impl import sqlserver as backend_pkg +from rhosocial.activerecord.backend.impl.sqlserver import backend as backend_pkg +from rhosocial.activerecord.backend.impl.sqlserver.backend import async_backend as async_backend_mod from rhosocial.activerecord.backend.impl.sqlserver.cli import info as info_mod from rhosocial.activerecord.backend.impl.sqlserver.cli import query as query_mod from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect @@ -426,7 +427,7 @@ def test_error_paths_exit_nonzero(self, monkeypatch, connect_error, execute_erro class TestQueryAsync: def test_async_dispatch_executes_query(self, monkeypatch, capsys): cls = _tracking_class(FakeAsyncQueryBackend, "QAsync") - monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", cls, raising=False) + monkeypatch.setattr(async_backend_mod, "AsyncSQLServerBackend", cls, raising=False) query_mod.handle( make_args( query_mod.create_parser, @@ -444,7 +445,7 @@ def test_async_errors_exit_nonzero(self, monkeypatch, connect_error, execute_err cls = _tracking_class(FakeAsyncQueryBackend, "QAsyncErr") cls.connect_error = connect_error cls.execute_error = execute_error - monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", cls, raising=False) + monkeypatch.setattr(async_backend_mod, "AsyncSQLServerBackend", cls, raising=False) with pytest.raises(SystemExit) as exc: query_mod.handle( make_args(query_mod.create_parser, ["query", "--database", "db", "SELECT 1", "--async"]) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py index 2033eda..21d988b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py @@ -11,7 +11,8 @@ import pytest from rhosocial.activerecord.backend.errors import ConnectionError, QueryError -from rhosocial.activerecord.backend.impl import sqlserver as backend_pkg +from rhosocial.activerecord.backend.impl.sqlserver import backend as backend_pkg +from rhosocial.activerecord.backend.impl.sqlserver.backend import async_backend as async_backend_mod from rhosocial.activerecord.backend.impl.sqlserver.cli import introspect as introspect_mod @@ -305,7 +306,7 @@ def test_async_tables_dispatch(self, monkeypatch, capsys): def test_async_connection_error_exits_nonzero(self, monkeypatch): cls = _tracking_class(FakeAsyncBackendBase, "AsyncErr") cls.connect_error = ConnectionError("refused") - monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", cls, raising=False) + monkeypatch.setattr(async_backend_mod, "AsyncSQLServerBackend", cls, raising=False) with pytest.raises(SystemExit) as exc: introspect_mod.handle( make_args(["introspect", "tables", "--database", "db", "--async"]) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_status.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_status.py index cbce24c..3c6e073 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_status.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_status.py @@ -12,7 +12,8 @@ from rhosocial.activerecord.backend.errors import ConnectionError, QueryError from rhosocial.activerecord.backend.impl.sqlserver.cli import status as status_mod -from rhosocial.activerecord.backend.impl import sqlserver as backend_pkg +from rhosocial.activerecord.backend.impl.sqlserver import backend as backend_pkg +from rhosocial.activerecord.backend.impl.sqlserver.backend import async_backend as async_backend_mod STATUS_INTROSPECTOR_MODULE = ( "rhosocial.activerecord.backend.impl.sqlserver.introspection.status_introspector" @@ -441,7 +442,7 @@ def test_async_overview_dispatch(self, fake_async_backend_cls, monkeypatch, caps f"{STATUS_INTROSPECTOR_MODULE}.AsyncSQLServerStatusIntrospector", AsyncRecordingIntrospector, ) - monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) + monkeypatch.setattr(async_backend_mod, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) status_mod.handle( make_args(["status", "all", "--database", "db", "-o", "json", "--async"]) ) @@ -450,7 +451,7 @@ def test_async_overview_dispatch(self, fake_async_backend_cls, monkeypatch, caps def test_async_connection_error_exits_nonzero(self, fake_async_backend_cls, monkeypatch): fake_async_backend_cls.connect_error = ConnectionError("refused") - monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) + monkeypatch.setattr(async_backend_mod, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) with pytest.raises(SystemExit) as exc: status_mod.handle(make_args(["status", "all", "--database", "db", "--async"])) assert exc.value.code == 1 @@ -463,7 +464,7 @@ async def get_overview(self): monkeypatch.setattr( f"{STATUS_INTROSPECTOR_MODULE}.AsyncSQLServerStatusIntrospector", ExplodingAsync ) - monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) + monkeypatch.setattr(async_backend_mod, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) with pytest.raises(SystemExit) as exc: status_mod.handle(make_args(["status", "all", "--database", "db", "--async"])) assert exc.value.code == 1 From 50747c150649ab7a39e2f8c5a0c52d9cb6c4ccf5 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 19:00:01 +0800 Subject: [PATCH 08/15] fix(test): patch the async backend class on the module the CLI reads The remaining multi-line monkeypatch still targeted impl.sqlserver.backend for AsyncSQLServerBackend, but the CLI imports it from impl.sqlserver.backend.async_backend inside the handler, so the patch never reached it and the real backend was constructed. --- .../feature/backend/cli/test_cli_introspect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py index 21d988b..c57df5b 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py @@ -292,7 +292,7 @@ def init_with_exploding(self, connection_config=None): class TestHandleAsync: def test_async_tables_dispatch(self, monkeypatch, capsys): monkeypatch.setattr( - backend_pkg, + async_backend_mod, "AsyncSQLServerBackend", _tracking_class(FakeAsyncBackendBase, "AsyncFake"), raising=False, From 10feb31edb9dbb542daae32d5d190559ec8126b0 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Mon, 28 Sep 2026 22:23:05 +0800 Subject: [PATCH 09/15] fix(sqlserver): repair the import line the package move truncated The two CLI demos embed runnable Python as a string literal, and the import migration rewrote the module path inside it without keeping the statement terminator, leaving the string unterminated: "from rhosocial.activerecord.backend.impl.oracle.backend.async_backend import OracleBackend ast.parse rejects all four demo modules with "unterminated string literal", so they could not even be imported, let alone run. The migration also dropped the trailing OracleConnectionConfig from the original single-statement import; the surrounding lines already import it from its own module, so restoring the ";" recovers the intended statements. The SQL Server demos additionally reached SQLServerConnectionConfig through the impl.sqlserver facade, which no longer exists now that the package is open, so they now name impl.sqlserver.config like every other backend. Verified: ast.parse is clean across every src/ and tests/ module in both repos. --- .../impl/sqlserver/examples/cli/named_procedure_demo.py | 4 ++-- .../backend/impl/sqlserver/examples/cli/named_query_demo.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py index 22c0429..5904699 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py @@ -162,8 +162,8 @@ def prepare_demo_schema(): """ code = ( "import os;" - "from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend - "from rhosocial.activerecord.backend.impl.sqlserver import " + "from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend;" + "from rhosocial.activerecord.backend.impl.sqlserver.config import " "SQLServerConnectionConfig;" "from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions." "order_expressions import prepare_orders_demo;" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py index e5c0e53..65a7bb0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py @@ -152,8 +152,8 @@ def prepare_demo_schema(): """ code = ( "import os;" - "from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend - "from rhosocial.activerecord.backend.impl.sqlserver import " + "from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend;" + "from rhosocial.activerecord.backend.impl.sqlserver.config import " "SQLServerConnectionConfig;" "from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions." "order_expressions import prepare_orders_demo;" From b69f713bb8bd68002516486b5562aea7b9cb1c3b Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 29 Sep 2026 00:37:36 +0800 Subject: [PATCH 10/15] chore: remove two legacy re-export shims shadowed by same-named packages mixins.py and protocols.py each sat next to a package of the same name, holding nothing but a re-export of that package's contents. A regular package takes precedence over a same-named module, so both shims were unreachable: importing impl.sqlserver.mixins or impl.sqlserver.protocols always resolved to the package's __init__.py. Each re-export list is a strict subset of the package's, so nothing that resolved through a shim can break. Also corrects the path comment on mixins/ddl_table.py, which still named the file before it was renamed from table.py. --- .../backend/impl/sqlserver/mixins.py | 16 ------- .../impl/sqlserver/mixins/ddl_table.py | 2 +- .../backend/impl/sqlserver/protocols.py | 42 ------------------- 3 files changed, 1 insertion(+), 59 deletions(-) delete mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py delete mode 100644 src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py deleted file mode 100644 index a78a5e7..0000000 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py +++ /dev/null @@ -1,16 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/mixins.py -"""Backward-compatible re-exports from mixins/ package.""" - -from .mixins import ( - SQLServerBackendMixin, - SQLServerConcurrencyMixin, - SQLServerTypeSupportMixin, - SQLServerTypeDDLMixin, -) - -__all__ = [ - "SQLServerBackendMixin", - "SQLServerConcurrencyMixin", - "SQLServerTypeSupportMixin", - "SQLServerTypeDDLMixin", -] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py index 6d169f0..3fab1f2 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py @@ -1,4 +1,4 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/table.py +# src/rhosocial/activerecord/backend/impl/sqlserver/mixins/ddl_table.py from typing import Tuple, TYPE_CHECKING from .version_constants import SQL_SERVER_2016 diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py b/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py deleted file mode 100644 index e980912..0000000 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py +++ /dev/null @@ -1,42 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/sqlserver/protocols.py -"""Backward-compatible re-exports from protocols/ package.""" - -from .protocols import ( - SQLServerIdentitySupport, - SQLServerIndexedViewSupport, - SQLServerTableHintSupport, - SQLServerOutputSupport, - SQLServerTableSupport, - SQLServerLockingSupport, - SQLServerJSONSupport, - SQLServerSetTypeSupport, - SQLServerSpatialSupport, - SQLServerTemporalTableSupport, - SQLServerSequenceSupport, - SQLServerFullTextSearchSupport, - SQLServerTryCastSupport, - SQLServerPaginationSupport, - SQLServerMergeSupport, - SQLServerPartitionSupport, - SQLServerUserDefinedTypeSupport, -) - -__all__ = [ - "SQLServerIdentitySupport", - "SQLServerIndexedViewSupport", - "SQLServerTableHintSupport", - "SQLServerOutputSupport", - "SQLServerTableSupport", - "SQLServerLockingSupport", - "SQLServerJSONSupport", - "SQLServerSetTypeSupport", - "SQLServerSpatialSupport", - "SQLServerTemporalTableSupport", - "SQLServerSequenceSupport", - "SQLServerFullTextSearchSupport", - "SQLServerTryCastSupport", - "SQLServerPaginationSupport", - "SQLServerMergeSupport", - "SQLServerPartitionSupport", - "SQLServerUserDefinedTypeSupport", -] From c54e813c22afe3c7b1e8869e2b6f69af6ec51d1c Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 29 Sep 2026 12:18:32 +0800 Subject: [PATCH 11/15] docs: rewrite the README and correct the .claude/ knowledge base Removed the __partition__ model attribute from the partitioning example: no such attribute exists, and table partitioning is in fact unsupported by this backend -- add_partition, attach_partition, detach_partition and the three partitioning switches are all False at every probed version. The generated version table is what surfaces that. Corrected the core dependency floor, documented as >=1.0.0 and unresolvable against the 1.0.0.devN pre-release line. In the agent docs: replaced the invented DatabaseCapabilities / _initialize_capabilities / requires_capability system with the actual supports_* protocol switches, and corrected the PYTHONPATH guidance, which omitted tests. --- .claude/backend_development.md | 54 +++--- .claude/code_style.md | 27 ++- .claude/testing.md | 8 +- README.md | 290 ++++++++++++++++----------------- 4 files changed, 172 insertions(+), 207 deletions(-) diff --git a/.claude/backend_development.md b/.claude/backend_development.md index aa45235..9eb5e35 100644 --- a/.claude/backend_development.md +++ b/.claude/backend_development.md @@ -32,7 +32,7 @@ rhosocial-activerecord-{backend}/ │ └── impl/ │ └── {backend}/ │ ├── __init__.py -│ ├── backend.py # Main backend implementation +│ ├── backend/ # sync + async backend classes │ ├── adapters.py # Backend-specific type adapters │ ├── config.py # Connection configuration │ ├── dialect.py # SQL dialect handling @@ -114,7 +114,7 @@ Expression.to_sql() -> Dialect.format_*() -> SQL string and parameters - `operators.py`: SQL operations (binary, unary, arithmetic expressions) - `predicates.py`: SQL predicate expressions (WHERE clause conditions) - `query_parts.py`: SQL query clauses (WHERE, GROUP BY, HAVING, ORDER BY, etc.) -- `statements.py`: DML/DQL/DDL statements (SELECT, INSERT, UPDATE, DELETE, etc.) +- `statements/`: DML/DQL/DDL statements (a package, not a module) (SELECT, INSERT, UPDATE, DELETE, etc.) - `functions.py`: Standalone factory functions for creating SQL expressions - `aggregates.py`: SQL aggregation expressions and functions - `advanced_functions.py`: Advanced SQL functions (CASE, CAST, EXISTS, window functions) @@ -149,8 +149,9 @@ Every backend must implement the `StorageBackend` abstract base class. This is t ```python # backend.py from typing import Any, Dict, List, Optional, Tuple, Type -from ...base import StorageBackend, ConnectionConfig -from ...type_adapter import SQLTypeAdapter +from rhosocial.activerecord.backend.base import StorageBackend +from rhosocial.activerecord.backend.config import ConnectionConfig +from rhosocial.activerecord.backend.type_adapter import SQLTypeAdapter class MyBackend(StorageBackend): """Implementation of MyDatabase backend using native driver.""" @@ -175,7 +176,8 @@ class MyBackend(StorageBackend): """Check if connection is valid.""" pass - def execute(self, sql: str, params: Optional[Tuple] = None, returning: Optional[Union[bool, List[str], ReturningOptions]] = None, column_adapters: Optional[Dict[str, Tuple[SQLTypeAdapter, Type]]] = None) -> QueryResult: + def execute(self, sql: str, params: Optional[Tuple] = None, *, + options: Optional[ExecutionOptions] = None) -> QueryResult: """Executes a SQL query.""" pass @@ -197,10 +199,6 @@ class MyBackend(StorageBackend): """ pass - def _initialize_capabilities(self) -> DatabaseCapabilities: - """Declare all features supported by this backend.""" - pass - def _handle_error(self, error: Exception) -> None: """Catch driver-specific exceptions and raise standard errors.""" pass @@ -241,10 +239,10 @@ Define an immutable configuration class for your backend. # config.py from dataclasses import dataclass, field from typing import Optional, Dict, Any -from rhosocial.activerecord.backend.config import BaseConfig +from rhosocial.activerecord.backend.config import ConnectionConfig -@dataclass(frozen=True) -class MyDatabaseConfig(BaseConfig): +@dataclass +class MyDatabaseConfig(ConnectionConfig): host: str = "localhost" port: int = 5432 database: str @@ -366,34 +364,20 @@ Every dialect inherits from: ```python # dialect.py -from rhosocial.activerecord.backend.dialect import ( - SQLDialect, ReturningClauseHandler, TypeMapping -) -from typing import Dict, List, Optional +from rhosocial.activerecord.backend.dialect.base import SQLDialectBase +from typing import Optional, Tuple -class MyDatabaseDialect(SQLDialect): +class MyDatabaseDialect(SQLDialectBase): def __init__(self): super().__init__() self.returning_handler = MyDatabaseReturningHandler() - def quote_identifier(self, identifier: str) -> str: return f'"{identifier}"' - def get_placeholder(self, name: str = None) -> str: return "?" - - def format_limit_offset(self, sql: str, limit: Optional[int], offset: Optional[int]) -> str: - if limit is not None: sql += f" LIMIT {limit}" - if offset is not None: sql += f" OFFSET {offset}" - return sql - - def get_type_mappings(self) -> Dict[str, TypeMapping]: - return {"INTEGER": TypeMapping("INTEGER"), "TEXT": TypeMapping("TEXT"), ...} + def get_parameter_placeholder(self, position: int = 0) -> str: + return "?" -class MyDatabaseReturningHandler(ReturningClauseHandler): - @property - def is_supported(self) -> bool: return True - - def format_clause(self, columns: Optional[List[str]] = None) -> str: - cols = "*" if not columns else ', '.join(self.dialect.quote_identifier(c) for c in columns) - return f"RETURNING {cols}" +# Identifier quoting is inherited from SQLDialectBase.format_identifier; +# LIMIT/OFFSET come from the DQL mixins; type mapping is not a dialect +# method but a set of SQLTypeAdapter registrations on the backend. ``` ### 4. The Type Adaptation System @@ -565,7 +549,7 @@ This checklist summarizes all the required and recommended steps for creating a - [ ] Implement `get_default_adapter_suggestions` to define the conversion strategy. - [ ] Implement `Transaction Management`. - [ ] Implement `Error Handling` to map driver exceptions. -- [ ] Implement `Feature Detection` to declare capabilities. +- [ ] Implement the `supports_*` capability switches your dialect needs. - [ ] If providing an `AsyncStorageBackend`, ensure it has **Functional Equivalence** with the sync version. ### Required Tests diff --git a/.claude/code_style.md b/.claude/code_style.md index 048b351..8dc35fc 100644 --- a/.claude/code_style.md +++ b/.claude/code_style.md @@ -56,7 +56,7 @@ from pydantic import BaseModel, Field # Local application imports from rhosocial.activerecord.model import ActiveRecord -from rhosocial.activerecord.backend import StorageBackend +from rhosocial.activerecord.backend.base import StorageBackend # Relative imports (within same package) from .base import BaseActiveRecord @@ -65,7 +65,7 @@ from ..interface import IActiveRecord ### Line Length and Wrapping -- Maximum line length: 100 characters +- Maximum line length: 120 characters (ruff `line-length = 120`; black formats to 100, but 100 is not the limit) - Wrap long lines using parentheses: ```python @@ -291,7 +291,7 @@ from typing import annotations import third_party -from rhosocial.activerecord import local +from rhosocial.activerecord.model import ActiveRecord from . import relative # Constants @@ -414,12 +414,12 @@ raise DatabaseError( """Test module for specific feature.""" import pytest -from rhosocial.activerecord import Model +from rhosocial.activerecord.model import ActiveRecord # Fixtures at top @pytest.fixture def sample_model(): - return Model(name="test") + return ActiveRecord(name="test") # Test classes for organization class TestCRUDOperations: @@ -603,13 +603,12 @@ def transaction(backend: StorageBackend): ### Mixin Classes ```python -class TimestampMixin: - """Add timestamp fields to models.""" - created_at: datetime = Field(default_factory=datetime.now) - updated_at: Optional[datetime] = None - - def before_save(self): - self.updated_at = datetime.now() +# TimestampMixin declares NO fields: it hooks +# ModelEvent.BEFORE_INSERT / BEFORE_UPDATE and requires the model to +# declare the fields itself, named per __created_at_field__ / +# __updated_at_field__. Use DefaultTimestampMixin to get them declared +# for you. There is no before_save() hook -- lifecycle is registered +# with instance.on(ModelEvent.X, handler). ``` ## Expression-Dialect System Coding Standards @@ -687,7 +686,7 @@ class MyExpression(mixins.ArithmeticMixin, mixins.ComparisonMixin, bases.SQLValu def to_sql(self) -> 'bases.SQLQueryAndParams': # Delegate formatting to dialect - formatted_value = self.dialect.format_string_literal(self.value) + formatted_value = self.dialect.format_literal(self.value) return formatted_value, (self.value,) ``` @@ -705,7 +704,7 @@ def format_identifier(self, identifier: str) -> str: - [ ] Path comment at file start matches actual location - [ ] All functions/classes have docstrings - [ ] Type hints on all function signatures -- [ ] No line exceeds 100 characters +- [ ] No line exceeds 120 characters - [ ] Imports sorted and grouped correctly - [ ] No commented-out code - [ ] Exception messages are informative diff --git a/.claude/testing.md b/.claude/testing.md index a5830cc..ae4dd3b 100644 --- a/.claude/testing.md +++ b/.claude/testing.md @@ -23,8 +23,8 @@ dependencies = [ ```bash cd /mnt/i/GitHubRepositories/rhosocial/python-activerecord-sqlserver -source .venv/bin/activate -export PYTHONPATH=src +source .venv3.14-ubuntu26.04/bin/activate +export PYTHONPATH=src:tests pytest tests/ -v ``` @@ -36,5 +36,5 @@ pytest tests/ -v ## Reference -- [Core testing guide](../python-activerecord/.claude/testing.md) -- [SQL Server backend development](../python-activerecord/.claude/backend_development.md) \ No newline at end of file +- [Core testing guide](../../python-activerecord/.claude/testing.md) +- [SQL Server backend development](../../../python-activerecord/.claude/backend_development.md) \ No newline at end of file diff --git a/README.md b/README.md index ad133ab..e485fa5 100644 --- a/README.md +++ b/README.md @@ -5,229 +5,211 @@ [![Tests](https://github.com/rhosocial/python-activerecord-sqlserver/actions/workflows/test.yml/badge.svg)](https://github.com/rhosocial/python-activerecord-sqlserver/actions) [![Coverage Status](https://codecov.io/gh/rhosocial/python-activerecord-sqlserver/branch/main/graph/badge.svg)](https://app.codecov.io/gh/rhosocial/python-activerecord-sqlserver/tree/main) [![Apache 2.0 License](https://img.shields.io/github/license/rhosocial/python-activerecord-sqlserver.svg)](https://github.com/rhosocial/python-activerecord-sqlserver/blob/main/LICENSE) -[![Powered by vistart](https://img.shields.io/badge/Powered_by-vistart-blue.svg)](https://github.com/vistart)
rhosocial ActiveRecord Logo

SQL Server Backend for rhosocial-activerecord

-

T-SQL Support · Enterprise Features · Sync & Async

+

OUTPUT clause · Temporal tables · SQL Graph · Sync & async

-> **Note**: This is a backend implementation for [rhosocial-activerecord](https://github.com/rhosocial/python-activerecord). It cannot be used standalone. +This is a backend implementation for [rhosocial-activerecord](https://github.com/rhosocial/python-activerecord). +It cannot be used standalone. -## Why This Backend? - -### 1. SQL Server-Specific Optimizations - -| Feature | This Backend | Generic Solutions | -|---------|-------------|-------------------| -| **RETURNING/OUTPUT** | Native `OUTPUT` clause | Manual SELECT-after-write | -| **Temporal Tables** | `FOR SYSTEM_TIME` | Application-level audit logs | -| **Partitioning** | `PARTITION` schemes/functions | Manual sharding | -| **Sequences** | Native `CREATE SEQUENCE` | Identity/application counters | - -### 2. True Sync-Async Parity - -Same API surface for both sync and async operations: - -```python -# Sync -users = User.query().where(User.c.age >= 18).all() - -# Async - just add await -users = await User.query().where(User.c.age >= 18).all() -``` +## Requirements -### 3. Built for Production + -- **Connection pooling** with configurable pool sizes -- **Transaction support** with proper isolation levels -- **Error mapping** from SQL Server error codes to Python exceptions -- **Type adapters** for SQL Server-specific data types +_b69f713_ -## Quick Start +- **Python**: `>=3.9` +- **Core**: `rhosocial-activerecord>=1.0.0.dev0,<2.0.0` +- **Driver**: `pyodbc>=5.3.0` (with an installed ODBC Driver 17+) +- **CI-tested server versions**: 2017, 2019, 2022, 2025 +- **Published**: PyPI `1.0.0.dev2` -### Installation +## Installation ```bash pip install rhosocial-activerecord-sqlserver ``` -### Basic Usage +## Quick Start ```python -from rhosocial.activerecord.model import ActiveRecord +from typing import ClassVar, Optional + +from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig -from typing import Optional +from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraint, + ColumnConstraintType, + ColumnDefinition, +) +from rhosocial.activerecord.backend.expression.types import IntegerType, VarCharType +from rhosocial.activerecord.model import ActiveRecord + class User(ActiveRecord): __table_name__ = "users" + id: Optional[int] = None name: str email: str -# Configure -config = SQLServerConnectionConfig( - host="localhost", - port=1433, - database="myapp", - username="sa", - password="YourStrong!Passw0rd" + c: ClassVar[FieldProxy] = FieldProxy() + + +User.configure( + SQLServerConnectionConfig( + host="localhost", + port=1433, + database="myapp", + username="sa", + password="YourStrong!Passw0rd", + driver="{ODBC Driver 17 for SQL Server}", + ), + SQLServerBackend, ) -User.configure(config, SQLServerBackend) -# Use +dialect = User.__backend__.dialect +User.__backend__.execute(*CreateTableExpression( + dialect, + "users", + columns=[ + ColumnDefinition( + dialect, "id", IntegerType(dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, + is_auto_increment=True)], + ), + ColumnDefinition(dialect, "name", VarCharType(dialect, 100)), + ColumnDefinition(dialect, "email", VarCharType(dialect, 255)), + ], +).to_sql()) + user = User(name="Alice", email="alice@example.com") user.save() - -# Query with parameter binding -results = User.query().where("email = ?", ("alice@example.com",)).all() +rows = User.query().where(User.c.email == "alice@example.com").all() ``` -> 💡 **AI Prompt**: "How do I configure connection pooling for SQL Server?" - -## SQL Server-Specific Features - -### OUTPUT / RETURNING +### Async -Retrieve inserted or updated rows with the `OUTPUT` clause: +`AsyncSQLServerBackend` exposes the same API. Method names are identical; add +`await`. ```python -# INSERT with OUTPUT -user = User(name="Alice", email="alice@example.com") -user.save() -print(user.id) # Populated automatically via OUTPUT -``` +from rhosocial.activerecord.backend.impl.sqlserver.backend import AsyncSQLServerBackend +from rhosocial.activerecord.model import AsyncActiveRecord -### Temporal Tables -Query historical state with `FOR SYSTEM_TIME`: +class User(AsyncActiveRecord): + ... -```python -# As of a specific time -User.query().where( - "user_id = ? FOR SYSTEM_TIME AS OF ?", - (1, "2026-06-30T12:00:00"), -).all() + +User.configure(config, AsyncSQLServerBackend) +rows = await User.query().where(User.c.email == "alice@example.com").all() ``` -### Partitioned Tables +## SQL Server-Specific Features -SQL Server partition schemes and functions: +### `OUTPUT` instead of `RETURNING` -```python -# Partitioned table DDL support -class Orders(ActiveRecord): - __table_name__ = "orders" - __partition__ = { - "column": "order_date", - "scheme": "ps_order_date", - } - order_id: int - order_date: datetime -``` +SQL Server has no `RETURNING`; the equivalent is the `OUTPUT` clause, which +the backend emits for DML that needs to return the affected row. -### Sequences +### Temporal tables -Native sequence support: +Query historical state with `FOR SYSTEM_TIME`. The table must already be +declared as a system-versioned temporal table; the backend does not add the +`PERIOD FOR` clause for you. ```python -# Use a sequence-backed column -class Ticket(ActiveRecord): - __table_name__ = "tickets" - ticket_no: int # NEXT VALUE FOR ticket_seq +User.query().where( + "id = ? FOR SYSTEM_TIME AS OF ?", + (1, "2026-06-30T12:00:00"), +).all() ``` -## Requirements +### Memory-optimized and columnstore tables -- **Python**: 3.9+ (including 3.13t/3.14t free-threaded builds) -- **Core**: `rhosocial-activerecord>=1.0.0` -- **Driver**: `pyodbc>=5.0.0` (with ODBC Driver 17+ for SQL Server) +Set through the DDL expression's table options, not a model attribute: -## SQL Server Version Compatibility +```python +from rhosocial.activerecord.backend.impl.sqlserver.expression import ( + SQLServerCreateTableOptions, +) -| Feature | Min Version | Notes | -|---------|-------------|-------| -| Basic operations | 2017+ | Core functionality | -| Temporal tables | 2016+ | `FOR SYSTEM_TIME` | -| Sequences | 2012+ | `CREATE SEQUENCE` | -| Partitioning | 2008+ | Partition functions/schemes | -| Columnstore indexes | 2014+ | Analytics acceleration | -| Memory-optimized tables | 2014+ | In-memory OLTP | -| PIVOT/UNPIVOT | 2005+ | Row-to-column transforms | -| OUTPUT clause | 2005+ | RETURNING equivalent | -| JSON functions | 2016+ | `JSON_VALUE`, `JSON_QUERY` | -| STRING_AGG | 2017+ | String aggregation | -| UTF-8 collation | 2019+ | `_UTF8` collations | +CreateTableExpression( + dialect, + "sessions", + columns=[...], + table_options=SQLServerCreateTableOptions(dialect, memory_optimized=True), +) +``` -**Recommended**: SQL Server 2019+ for optimal feature support. +## Version Gates + +Gates below are the versions at which this backend's own `supports_*` +switch returns `True`, probed at 10, 11, 12, 13, 14, 15, 16. +A version is the first probe at or above the real gate, so each is an +upper bound within one ladder step. Switches already `True` at the +oldest release carry no gate and are omitted. + +| Min version | Capabilities that turn on | +|---|---| +| 11 (2012) | `create_sequence`, `drop_sequence`, `nonclustered_columnstore`, `offset_fetch`, `order_by_in_subquery`, `try_cast`, `try_convert`, `window_frame_clause` | +| 12 (2014) | `clustered_columnstore`, `memory_optimized_table_types`, `memory_optimized_tables` | +| 13 (2016) | `drop_type_if_exists`, `if_exists_table`, `if_exists_view`, `index_if_exists`, `json_table`, `json_type`, `system_versioning`, `temporal_tables` | +| 14 (2017) | `graph_pseudo_columns`, `graph_table_kind`, `sql_graph` | +| 15 (2019) | `edge_constraints`, `for_update_skip_locked`, `graph_path_aggregates`, `readpast`, `shortest_path` | +| 16 (2022) | `columnstore_order` | + +## Known Limitations + +- **No connection pooling.** `pool_size` and `pool_timeout` are accepted by + the config class but not consumed; `connect()` opens one persistent + connection. +- **No native arrays, and no JSON *type*.** JSON is reachable through + functions (`JSON_VALUE`, `JSON_QUERY`) on 2016+, not through a column + type, and `supports_json_type` gates on that. +- **Table partitioning is not implemented.** `add_partition`, + `attach_partition`, `detach_partition`, `list_table_partitioning`, + `range_table_partitioning`, `hash_table_partitioning` and + `subpartitioning` are all `False` at every probed version. A model does + not take a `__partition__` attribute. +- Also unsupported: `alter_type`, `constraint_enforced`, `create_or_replace_view`, + `deferrable_*`, `fetch_with_ties`, `fk_match`, `returning_alias`, + `returning_wildcard`, `set_type`, `transaction_mode`. -## Get Started with AI Code Agents +## Testing -This project supports AI-assisted development. Clone and open in your preferred tool: +Tests require a reachable SQL Server instance and must run serially. ```bash -git clone https://github.com/rhosocial/python-activerecord-sqlserver.git -cd python-activerecord-sqlserver +export PYTHONPATH=src:tests +.venv3.14-ubuntu26.04/bin/pytest tests/ ``` -### Example AI Prompts - -- "How do I configure connection pooling for SQL Server?" -- "Show me how to use temporal tables" -- "How do I use the OUTPUT clause with INSERT?" -- "Create a model with a partitioned table" - -### For Any LLM - -Feed the documentation files in `docs/` to your preferred LLM for context-aware assistance. - -## Testing - -> ⚠️ **CRITICAL**: Tests MUST run serially. Do NOT use `pytest -n auto` or parallel execution. +The shared contract suite lives in +[python-activerecord-testsuite](https://github.com/rhosocial/python-activerecord-testsuite): ```bash -# Run all tests -PYTHONPATH=src pytest tests/ - -# Run specific feature tests -PYTHONPATH=src pytest tests/rhosocial/activerecord_sqlserver_test/feature/basic/ -PYTHONPATH=src pytest tests/rhosocial/activerecord_sqlserver_test/feature/query/ +PYTHONPATH=tests .venv3.14-ubuntu26.04/bin/pytest \ + ../python-activerecord-testsuite/src/rhosocial/activerecord/testsuite/feature/ ``` -See the [Testing Documentation](https://github.com/rhosocial/python-activerecord/blob/main/.claude/testing.md) for details. - ## Documentation -- **[Getting Started](docs/en_US/getting_started/)** — Installation and configuration -- **[SQL Server Features](docs/en_US/sqlserver_specific_features/)** — SQL Server-specific capabilities -- **[Type Adapters](docs/en_US/type_adapters/)** — Data type handling -- **[Transaction Support](docs/en_US/transaction_support/)** — Transaction management - -## Comparison with Other Backends - -| Feature | SQL Server | PostgreSQL | SQLite | -|---------|-----------|------------|--------| -| **RETURNING** | ✅ OUTPUT | ✅ RETURNING | ✅ RETURNING | -| **Temporal Tables** | ✅ | ❌ | ❌ | -| **Sequences** | ✅ Native | ✅ Native | ⚠️ Emulated | -| **Arrays** | ❌ | ✅ Native | ❌ | -| **JSON Type** | ⚠️ Functions only | ✅ JSONB | ⚠️ JSON1 extension | - -> 💡 **AI Prompt**: "When should I choose SQL Server over PostgreSQL for my project?" +This repository has no `docs/` tree. Backend notes live in +[.claude/architecture.md](.claude/architecture.md) and +[.claude/testing.md](.claude/testing.md); core library documentation is at +[python-activerecord/docs](https://github.com/rhosocial/python-activerecord/tree/main/docs). ## Contributing -We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +See [CONTRIBUTING.md](https://github.com/rhosocial/python-activerecord/blob/main/CONTRIBUTING.md). ## License [Apache License 2.0](LICENSE) — Copyright © 2026 [vistart](https://github.com/vistart) - ---- - -
-

Built with ❤️ by the rhosocial team

-

GitHub · Documentation · PyPI

-
\ No newline at end of file From ef3a3bf59e3d39504de2913ff59933554f72717d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 29 Sep 2026 15:54:52 +0800 Subject: [PATCH 12/15] feat(sqlserver): support client certificates on the ODBC connection string encrypt and trust_server_certificate only cover server-side encryption, so a client certificate could not be presented at all. Add client_certificate, client_key, client_key_password and host_name_in_certificate to SQLServerConnectionConfig and emit them as the ClientCertificate/ClientKey/ClientKeyPassword/HostNameInCertificate ODBC keywords from build_connection_string(), with the same four names accepted as backend kwargs. validate_config() now rejects a client_key given without a client_certificate, so the mismatch surfaces as a clear ValueError instead of an opaque driver failure at connect time. --- changelog.d/5.fixed.md | 1 + .../impl/sqlserver/backend/async_backend.py | 5 +- .../backend/impl/sqlserver/backend/backend.py | 5 +- .../backend/impl/sqlserver/config.py | 25 ++++++- .../backend/test_ssl_connection_params.py | 65 +++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 changelog.d/5.fixed.md create mode 100644 tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py diff --git a/changelog.d/5.fixed.md b/changelog.d/5.fixed.md new file mode 100644 index 0000000..b8c99ae --- /dev/null +++ b/changelog.d/5.fixed.md @@ -0,0 +1 @@ +Add client-certificate (mutual TLS) support to the ODBC connection string. `SQLServerConnectionConfig` could only express server-side encryption via `encrypt` and `trust_server_certificate`; there was no way to present a client certificate. The new `client_certificate`, `client_key`, `client_key_password` and `host_name_in_certificate` fields are emitted as the `ClientCertificate`, `ClientKey`, `ClientKeyPassword` and `HostNameInCertificate` ODBC keywords by `build_connection_string()`, and are accepted as backend kwargs. `validate_config()` now rejects a `client_key` supplied without a `client_certificate`, so the mismatch surfaces as a clear error instead of an opaque driver failure. diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py index d2563a9..ed3effa 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/async_backend.py @@ -100,7 +100,10 @@ def __init__(self, **kwargs): sqlserver_params = [ 'host', 'port', 'database', 'deployment_target', 'username', 'password', 'trusted_connection', 'driver', 'encrypt', - 'trust_server_certificate', 'timeout', 'query_timeout', + 'trust_server_certificate', + 'client_certificate', 'client_key', 'client_key_password', + 'host_name_in_certificate', + 'timeout', 'query_timeout', 'autocommit', 'charset', 'pool_size', 'pool_timeout', ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index 88446b8..0c1e3bc 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py @@ -122,7 +122,10 @@ def __init__(self, **kwargs): sqlserver_params = [ 'host', 'port', 'database', 'deployment_target', 'username', 'password', 'trusted_connection', 'driver', 'encrypt', - 'trust_server_certificate', 'timeout', 'query_timeout', + 'trust_server_certificate', + 'client_certificate', 'client_key', 'client_key_password', + 'host_name_in_certificate', + 'timeout', 'query_timeout', 'autocommit', 'charset', 'pool_size', 'pool_timeout', ] diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/config.py b/src/rhosocial/activerecord/backend/impl/sqlserver/config.py index 895db27..5130884 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/config.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/config.py @@ -72,6 +72,15 @@ class SQLServerConnectionConfig(ConnectionConfig): encrypt_connection: Optional[bool] = None trust_server_certificate: bool = False + # Client certificate (mutual TLS). The ODBC driver reads these straight + # from the filesystem, so they are PEM/PFX paths rather than material. + client_certificate: Optional[str] = None + client_key: Optional[str] = None + client_key_password: Optional[str] = None + # Overrides the name matched against the server certificate. Required when + # the certificate CN is not the host name being connected to. + host_name_in_certificate: Optional[str] = None + timeout: int = 30 query_timeout: int = 0 @@ -136,7 +145,16 @@ def build_connection_string(self) -> str: if self.charset and self.charset.upper() != "UTF-8": parts.append(f"CharacterSet={self.charset}") - + + if self.client_certificate: + parts.append(f"ClientCertificate={self.client_certificate}") + if self.client_key: + parts.append(f"ClientKey={self.client_key}") + if self.client_key_password: + parts.append(f"ClientKeyPassword={self.client_key_password}") + if self.host_name_in_certificate: + parts.append(f"HostNameInCertificate={self.host_name_in_certificate}") + for key, value in self.options.items(): if value is not None: parts.append(f"{key}={value}") @@ -164,6 +182,11 @@ def validate_config(self) -> None: UserWarning, stacklevel=2 ) + + if self.client_key and not self.client_certificate: + raise ValueError( + "client_key requires client_certificate for SQL Server connections" + ) def __repr__(self) -> str: """Return safe string representation (password masked).""" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py new file mode 100644 index 0000000..faaab9f --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py @@ -0,0 +1,65 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py +"""Regression tests: SQL Server TLS settings must reach the ODBC connection string. + +``build_connection_string()`` is the only place ``pyodbc`` gets its +configuration, so anything the config exposes for TLS has to appear there. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig + + +def make_config(**kwargs): + defaults = dict(host="db.example.com", database="test_db", username="sa", password="secret") + defaults.update(kwargs) + return SQLServerConnectionConfig(**defaults) + + +def test_encryption_is_on_by_default(): + assert "Encrypt=yes" in make_config().build_connection_string() + assert "TrustServerCertificate=no" in make_config().build_connection_string() + + +def test_encrypt_can_be_disabled(): + conn_str = make_config(encrypt=False).build_connection_string() + assert "Encrypt=no" in conn_str + + +def test_trust_server_certificate_is_forwarded(): + conn_str = make_config(trust_server_certificate=True).build_connection_string() + assert "TrustServerCertificate=yes" in conn_str + + +def test_client_certificate_is_forwarded(): + conn_str = make_config( + client_certificate="/certs/client.pem", + client_key="/certs/client.key", + ).build_connection_string() + assert "ClientCertificate=/certs/client.pem" in conn_str + assert "ClientKey=/certs/client.key" in conn_str + + +def test_host_name_in_certificate_is_forwarded(): + """Needed when the server certificate CN is not the connect host.""" + conn_str = make_config(host_name_in_certificate="db.internal").build_connection_string() + assert "HostNameInCertificate=db.internal" in conn_str + + +def test_client_key_password_is_forwarded(): + conn_str = make_config( + client_certificate="/certs/client.pfx", + client_key_password="pfx-secret", + ).build_connection_string() + assert "ClientKeyPassword=pfx-secret" in conn_str + + +def test_no_client_tls_keys_when_unset(): + conn_str = make_config().build_connection_string() + for key in ("ClientCertificate", "ClientKey", "ClientKeyPassword", "HostNameInCertificate"): + assert key not in conn_str + + +def test_client_key_without_certificate_is_rejected(): + with pytest.raises(ValueError, match="client_key requires client_certificate"): + make_config(client_key="/certs/client.key").validate_config() From 5539ccbb1a78573db6204f3218d402c2a9c14a2e Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 29 Sep 2026 16:38:36 +0800 Subject: [PATCH 13/15] chore: rename changelog fragment to PR #6 --- changelog.d/{5.fixed.md => 6.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{5.fixed.md => 6.added.md} (100%) diff --git a/changelog.d/5.fixed.md b/changelog.d/6.added.md similarity index 100% rename from changelog.d/5.fixed.md rename to changelog.d/6.added.md From 4bbc17ee7fd675eccdeb36d48ae7e9c84599ee16 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 29 Sep 2026 17:08:12 +0800 Subject: [PATCH 14/15] ci: unify the changelog fragment rules Replace the six divergent changelog-check variants with one implementation that encodes the documented merge rules. feature/*|fix/* -> release/*|maint/*|main: - the PR must add at least one changelog.d/..md - every added fragment's must equal the PR number - fragments already present from previously merged branches are ignored, since only files ADDED by this PR are considered - escape hatches ([trivial] title, skip-changelog label, docs: update CHANGELOG) waive only the "at least one" requirement; the number-must-match rule is always enforced on fragments that were added release/* -> main: - CHANGELOG.md must have changed (content is not inspected) - changelog.d/ must hold no fragments at the merge commit - no escape hatches apply to this path The number-must-match rule was the one documented requirement that none of the previous variants implemented. Two latent bugs are also fixed: the paths-ignore of "**.md" meant a CHANGELOG.md-only push never ran the check at all, and three backends skipped release merges outright instead of applying the release rules. --- .github/workflows/changelog-check.yml | 250 +++++++++++++++++++------- 1 file changed, 190 insertions(+), 60 deletions(-) diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index de1c276..60ee008 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -1,8 +1,31 @@ +# .github/workflows/changelog-check.yml +# +# Enforces the rhosocial changelog fragment rules. +# +# feature/* or fix/* -> release/* | maint/*| main +# - the PR must add at least one changelog.d/..md fragment +# - every added fragment's must equal the PR number +# - fragments already present in changelog.d/ from previously merged +# branches are ignored; only files ADDED by this PR are considered +# +# release/* -> main +# - CHANGELOG.md must have changed (content is not inspected) +# - changelog.d/ must contain no fragments at the merge commit +# +# Escape hatches (all three retained): a [trivial] marker in the PR title, +# the skip-changelog label, or a "docs: update CHANGELOG" title. An escape +# hatch waives the "at least one fragment" requirement only -- the +# number-must-match-PR rule is always enforced on fragments that were added. name: Changelog Fragment Check on: pull_request: - branches: + branches: + - main + - 'release/v**' + - 'maint/**' + push: + branches: - main - 'release/v**' - 'maint/**' @@ -17,72 +40,179 @@ jobs: steps: - uses: actions/checkout@v6 with: - fetch-depth: 0 # Ensure full history is fetched + fetch-depth: 0 - - name: Check for changelog fragment + - name: Validate changelog fragments env: - PR_TITLE: ${{ github.event.pull_request.title }} - HEAD_REF: ${{ github.head_ref }} + EVENT_NAME: ${{ github.event_name }} + HEAD_REF: ${{ github.head_ref || '' }} + BASE_REF: ${{ github.base_ref || '' }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + PR_TITLE: ${{ github.event.pull_request.title || '' }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} run: | - # Fetch the base branch explicitly to ensure it's available for comparison - git fetch origin ${{ github.base_ref }} - - # --- EXEMPTION FOR RELEASE BRANCH MERGES --- - # Release branches already contain changelog fragments from development - # Skip fragment check for PRs from release/* branches to main - if [[ "$HEAD_REF" =~ ^release/ ]]; then - echo "✓ Release branch PR, fragment check skipped (fragments already in branch history)" + set -uo pipefail + + # Towncrier fragment types. Advisory only: a project may legitimately + # configure a different set in pyproject.toml [tool.towncrier.type]. + KNOWN_TYPES="added changed deprecated removed fixed performance security docs internal" + + # Files that live in changelog.d/ but are not fragments. + is_boilerplate() { + case "${1##*/}" in + README.md|template.md|.gitkeep) return 0 ;; + *) return 1 ;; + esac + } + + # Validate against (empty = don't care). + # Prints the fragment type on success. + validate_fragment() { + local path="$1" expected="$2" + local base num type + + base="${path##*/}" + + if [[ ! "$base" =~ ^([0-9]+)\.([a-zA-Z]+)\.md$ ]]; then + echo "::error file=$path::fragment must be named ..md, got '$base'" + return 1 + fi + num="${BASH_REMATCH[1]}" + type="${BASH_REMATCH[2]}" + + if [[ -n "$expected" && "$num" != "$expected" ]]; then + echo "::error file=$path::fragment number '$num' does not match PR #$expected" + echo " Rename it to changelog.d/$expected.$type.md, or open a PR for #$num." + return 1 + fi + + if [[ " $KNOWN_TYPES " != *" $type "* ]]; then + echo "::warning file=$path::'$type' is not a standard towncrier type" >&2 + fi + + echo "$type" + return 0 + } + + is_exempt() { + [[ "$PR_TITLE" == *"[trivial]"* ]] && return 0 + [[ ",$PR_LABELS," == *",skip-changelog,"* ]] && return 0 + [[ "$PR_TITLE" == "docs: update CHANGELOG"* ]] && return 0 + return 1 + } + + # ------------------------------------------------------------------ + # release/* -> main : CHANGELOG must change, changelog.d/ must be empty + # ------------------------------------------------------------------ + check_release_merge() { + local scope="$1" changed remaining failed=0 + + changed=$(git diff --name-only "origin/main...HEAD" -- CHANGELOG.md) + if [[ -z "$changed" ]]; then + echo "::error::CHANGELOG.md was not updated by this $scope." + echo " A release merge must regenerate the changelog (e.g. \`towncrier build --yes --keep\`)" + echo " before the fragments under changelog.d/ are discarded." + failed=1 + else + echo "OK: CHANGELOG.md updated" + fi + + # Checked against the merge result, not the diff: leftover fragments + # from earlier in the release cycle must be gone by this point. + remaining=$(git ls-tree --name-only HEAD changelog.d/ \ + | while read -r f; do + if ! is_boilerplate "$f" \ + && [[ "${f##*/}" =~ ^[0-9]+\.[a-zA-Z]+\.md$ ]]; then + echo "$f" + fi + done) + if [[ -n "$remaining" ]]; then + echo "::error::changelog.d/ still contains fragments after the release merge:" + echo "$remaining" | while read -r f; do echo " - $f"; done + echo " Delete them; their content is already folded into CHANGELOG.md." + failed=1 + else + echo "OK: changelog.d/ is clear of fragments" + fi + + return $failed + } + + # ------------------------------------------------------------------ + # push event: no PR number available, so only structural checks + # ------------------------------------------------------------------ + if [[ "$EVENT_NAME" == "push" ]]; then + if [[ "$GITHUB_REF_NAME" == "main" ]]; then + touched=$(git diff-tree --no-commit-id --name-only -r HEAD) + if ! echo "$touched" | grep -qE '^(CHANGELOG\.md|changelog\.d/|src/)'; then + echo "OK: no changelog-relevant changes in this push" + exit 0 + fi + check_release_merge "push to main" || exit 1 + exit $? + fi + echo "OK: push to $GITHUB_REF_NAME (fragment numbering is validated on the PR)" exit 0 fi - # --- END RELEASE BRANCH EXEMPTION --- - - # Compare the head of the PR (current HEAD) with the base branch's remote tracking ref - # This compares the changes introduced by the PR branch relative to its base. - # Use --diff-filter=A to only consider added files for fragment check - CHANGED_FILES=$(git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD) - - # Debugging: Print the diff command and its output - echo "Executing: git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD" - echo "Changed files (added only): $CHANGED_FILES" - - # --- EXEMPTION FOR CHANGELOG BUILD PRS --- - # Check if PR title indicates a changelog update (e.g., "docs: update CHANGELOG") - # This exempts PRs that are specifically for building the changelog (which remove fragments) - if [[ "$PR_TITLE" =~ ^docs:\ update\ CHANGELOG ]]; then - echo "✓ PR title indicates changelog update, fragment check skipped" + + # ------------------------------------------------------------------ + # pull_request + # ------------------------------------------------------------------ + echo "head=$HEAD_REF base=$BASE_REF pr=#$PR_NUMBER" + + if [[ "$HEAD_REF" == release/* && "$BASE_REF" == "main" ]]; then + git fetch origin main --quiet + echo "== release merge into main ==" + check_release_merge "release merge" || exit 1 exit 0 fi - # --- END EXEMPTION --- - - # Check if any changelog fragment was added - if echo "$CHANGED_FILES" | grep -q "^changelog.d/[0-9].*\.md$"; then - echo "✓ Changelog fragment found" - exit 0 + + git fetch origin "$BASE_REF" --quiet + + # Fragments ADDED by this PR. Fragments that were already in + # changelog.d/ (carried in by previously merged branches) are not in + # this list and are therefore ignored. + added=$(git diff --name-only --diff-filter=A "origin/$BASE_REF...HEAD" -- 'changelog.d/*.md') + + failed=0 + types="" + if [[ -n "$added" ]]; then + while read -r f; do + [[ -z "$f" ]] && continue + is_boilerplate "$f" && continue + t=$(validate_fragment "$f" "$PR_NUMBER") || failed=1 + [[ -n "$t" ]] && types="$types $t" + done <<< "$added" fi - - # Check if this is an internal change (exempt from fragment requirement) - if echo "$CHANGED_FILES" | grep -qE "^(tests/|docs/|\.github/)"; then - echo "✓ Internal change, fragment not required" - exit 0 + + # The number-must-match rule is enforced even for exempt PRs; only + # the presence requirement is waived. + if [[ "$failed" -ne 0 ]]; then + echo "" + echo "✗ Fragment numbering does not match PR #$PR_NUMBER" + exit 1 fi - - # Check if PR is marked as trivial - if [[ "$PR_TITLE" =~ \[trivial\] ]]; then - echo "✓ PR marked as trivial, fragment not required" - exit 0 + + if [[ -z "$added" ]]; then + if is_exempt; then + echo "OK: no fragment required (exempt)" + exit 0 + fi + if git diff --name-only "origin/$BASE_REF...HEAD" \ + | grep -qE '^(tests/|docs/|\.github/)' \ + && ! git diff --name-only "origin/$BASE_REF...HEAD" | grep -qE '^src/'; then + echo "OK: internal-only change, no fragment required" + exit 0 + fi + echo "::error::No changelog fragment added by this PR." + echo " Add at least one changelog.d/$PR_NUMBER..md" + echo "" + echo "Types: added | changed | deprecated | removed | fixed |" + echo " performance | security | docs | internal" + echo "" + echo "Or waive it: add [trivial] to the PR title, apply the" + echo "skip-changelog label, or title the PR 'docs: update CHANGELOG'." + exit 1 fi - - echo "✗ Missing changelog fragment" - echo "Please add a changelog fragment in changelog.d/" - echo "Example: changelog.d/${{ github.event.number }}.fixed.md" - echo "" - echo "Fragment types:" - echo " - added.md - New features" - echo " - fixed.md - Bug fixes" - echo " - changed.md - Behavior changes" - echo " - deprecated.md - Deprecations" - echo " - removed.md - Removed features" - echo " - security.md - Security fixes" - echo "" - echo "If this PR does not need a changelog entry, add [trivial] to the title." - exit 1 \ No newline at end of file + + echo "OK: fragment(s) added for PR #$PR_NUMBER ->$types" From e2e03390525391da3c3f272e491d7691e8bb84ca Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Tue, 29 Sep 2026 17:31:40 +0800 Subject: [PATCH 15/15] ci: reject feature/fix branches that target main directly A feature/fix branch merges into a release or maintenance branch; only release/* merges into main. Opening a second PR at main from the same branch is a duplicate, and it also breaks the changelog fragment, which is numbered for exactly one PR. This pattern already cost a wasted PR in six repositories (a ->release PR merged alongside a ->main PR that was then closed), and the fragment numbering check only exposed it indirectly, by failing the leftover PR. hotfix/* -> main stays allowed; the version control policy calls for hotfixes to be merged back to main and the active release branch. --- .github/workflows/changelog-check.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 60ee008..abc4ec9 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -3,6 +3,7 @@ # Enforces the rhosocial changelog fragment rules. # # feature/* or fix/* -> release/* | maint/*| main +# - a feature/fix branch must not target main directly # - the PR must add at least one changelog.d/..md fragment # - every added fragment's must equal the PR number # - fragments already present in changelog.d/ from previously merged @@ -167,6 +168,28 @@ jobs: exit 0 fi + # A feature/fix branch never merges straight into main; it targets a + # release or maintenance branch. Opening a second PR at main from the + # same branch is a duplicate, and it also invalidates the fragment + # numbering, which is bound to a single PR number. + if [[ ("$HEAD_REF" == feature/* || "$HEAD_REF" == fix/* || "$HEAD_REF" == bugfix/* \ + || "$HEAD_REF" == test/* || "$HEAD_REF" == docs/* || "$HEAD_REF" == chore/*) \ + && "$BASE_REF" == "main" ]]; then + echo "::error::'$HEAD_REF' must not target \`main\` directly." + echo "" + echo "Per the rhosocial merge rules:" + echo " feature/* fix/* bugfix/* test/* docs/* chore/* -> release/* | maint/*" + echo " release/* -> main" + echo "" + echo "Retarget this PR at the active release branch, or close it if" + echo "another PR from '$HEAD_REF' already targets the release branch." + echo "The changelog fragment is numbered for one PR only, so a duplicate" + echo "PR at main would also fail the fragment numbering check." + echo "" + git branch -r --list "origin/release/*" | sed 's#.*origin/##' | sed 's/^/ /' + exit 1 + fi + git fetch origin "$BASE_REF" --quiet # Fragments ADDED by this PR. Fragments that were already in