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/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/.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/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index de1c276..abc4ec9 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -1,8 +1,32 @@ +# .github/workflows/changelog-check.yml +# +# 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 +# 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 +41,201 @@ 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 + + # 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 - - # 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 + + 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 PR is marked as trivial - if [[ "$PR_TITLE" =~ \[trivial\] ]]; then - echo "✓ PR marked as trivial, 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 - - 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 + + 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 "OK: fragment(s) added for PR #$PR_NUMBER ->$types" diff --git a/README.md b/README.md index 68cd8dc..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 rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +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 diff --git a/changelog.d/6.added.md b/changelog.d/6.added.md new file mode 100644 index 0000000..b8c99ae --- /dev/null +++ b/changelog.d/6.added.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/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py deleted file mode 100644 index bf75230..0000000 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py +++ /dev/null @@ -1,157 +0,0 @@ -# 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, -) - -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", - # DDL DataType subclasses - "SQLServerNVarCharType", - "SQLServerNCharType", - "SQLServerNVarCharMaxType", - "SQLServerVarBinaryType", - "SQLServerVarBinaryMaxType", - "SQLServerXmlType", - "SQLServerTinyIntType", - "SQLServerBitType", - "SQLServerImageType", - # Mixins - "SQLServerBackendMixin", - "SQLServerConcurrencyMixin", - "SQLServerTypeSupportMixin", - "SQLServerTypeDDLMixin", - "SQLServerUserDefinedTypeSupport", - # Schema differ - "SQLServerSchemaDiffer", - # Type compatibility - "DIRECT_COMPATIBLE_CASTS", - "check_cast_compatibility", - "get_compatible_types", -] - - -def __getattr__(name: str): - _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. " - f"Install it with: pip install rhosocial-activerecord-sqlserver[async] " - f"or pip install aioodbc" - ) from e - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") \ No newline at end of file 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..2a7ea56 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/__init__.py @@ -0,0 +1,34 @@ +# 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``. 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}") 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 97% 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..ed3effa 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 @@ -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', ] @@ -145,7 +148,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 +644,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.py b/src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py similarity index 97% rename from src/rhosocial/activerecord/backend/impl/sqlserver/backend.py rename to src/rhosocial/activerecord/backend/impl/sqlserver/backend/backend.py index b3cda41..0c1e3bc 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): @@ -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', ] @@ -177,7 +180,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 +653,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 +668,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) 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..06f2cd9 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.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 e590d38..b98877a 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.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 2485f51..380fed8 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.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 4a3401b..5283ccd 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.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 6aa8c6e..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 @@ -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.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 514428b..afba301 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.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 66cc1ab..819553c 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.backend.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/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/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..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,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.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 28017a3..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,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.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/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/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", -] 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..aac1bfc 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.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 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 a83cbfb..9e17fa0 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.backend.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) diff --git a/tests/providers/events.py b/tests/providers/events.py index 17a6b23..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 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 7e08bff..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 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 9c6800f..aa3ada1 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.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 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 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 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 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 2376338..56e0c4d 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.backend.async_backend import AsyncSQLServerBackend _, config = get_scenario(scenario_name) diff --git a/tests/providers/relation.py b/tests/providers/relation.py index 21f45e8..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 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/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/providers/worker_tasks.py b/tests/providers/worker_tasks.py index 9d1e30c..10ef206 100644 --- a/tests/providers/worker_tasks.py +++ b/tests/providers/worker_tasks.py @@ -13,6 +13,8 @@ import importlib + + def _configure_model_from_params(params: dict, model_class) -> None: """ Configure model from connection parameters. @@ -60,16 +62,10 @@ 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 = getattr( + importlib.import_module(f"{params['backend_module']}.async_backend"), + f"Async{backend_class.__name__}", + ) # Dynamically import config class config_module = importlib.import_module(params['config_module']) 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..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 @@ -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 @@ -291,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, @@ -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 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py index 4133a3c..ed8800e 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.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 96a8bba..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 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 5e54247..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 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 fc63825..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 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_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..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 @@ -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.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_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_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/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() 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..60e1b02 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.backend.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