diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13276ae..e46a578 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,7 @@ on: - main - "release/v**" - "maint/**" + workflow_dispatch: permissions: contents: read @@ -58,8 +59,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -104,7 +105,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py39-sqlserver2017: name: Run tests with Python 3.9 and SQL Server 2017 @@ -147,8 +148,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -193,7 +194,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py310-sqlserver2019: name: Run tests with Python 3.10 and SQL Server 2019 @@ -236,8 +237,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -282,7 +283,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py311-sqlserver2019: name: Run tests with Python 3.11 and SQL Server 2019 @@ -325,8 +326,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -371,7 +372,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py312-sqlserver2025: name: Run tests with Python 3.12 and SQL Server 2025 @@ -414,8 +415,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -460,7 +461,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py313-sqlserver2022: name: Run tests with Python 3.13 and SQL Server 2022 @@ -503,8 +504,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -549,7 +550,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py313t-sqlserver2019: name: Run tests with Python 3.13t (free-threaded) and SQL Server 2019 @@ -593,8 +594,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -639,7 +640,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x test-py314-sqlserver2025: name: Run tests with Python 3.14 and SQL Server 2025 @@ -682,8 +683,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -728,9 +729,7 @@ jobs: - name: Run tests with coverage run: | export PYTHONPATH=src:tests - pip install coverage - coverage run --source=src -m pytest tests/ --tb=short - coverage xml + python -m pytest tests/ --tb=short -n 8 --dist=loadgroup --cov=src/rhosocial --cov-branch --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 @@ -739,7 +738,7 @@ jobs: files: ./coverage.xml flags: unittests,python-3.14,sqlserver-2025 name: codecov-sqlserver-3.14-2025 - fail_ci_if_error: false + fail_ci_if_error: true test-py314t-sqlserver2022: name: Run tests with Python 3.14t (free-threaded) and SQL Server 2022 @@ -782,8 +781,8 @@ jobs: - name: Install activerecord and testsuite from PyPI run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Create test database run: | @@ -828,4 +827,4 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ + python -m pytest tests/ -n 8 --dist=loadgroup --tb=short -x diff --git a/changelog.d/2.added.md b/changelog.d/2.added.md new file mode 100644 index 0000000..c8d768f --- /dev/null +++ b/changelog.d/2.added.md @@ -0,0 +1 @@ +Add parallel testing infrastructure and SQL Server backend improvements \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6ef3e64..52f31e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ namespaces = true [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q" +addopts = "-ra -q -p rhosocial.activerecord.testsuite.conftest" asyncio_mode = "auto" testpaths = [ "tests", diff --git a/pytest.ini b/pytest.ini index 234ff8e..abebe18 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,7 +5,7 @@ testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* -addopts = -v --tb=short +addopts = -v --tb=short -p rhosocial.activerecord.testsuite.conftest filterwarnings = ignore::DeprecationWarning markers = diff --git a/requirements-dev.txt b/requirements-dev.txt index c45e179..9d5975c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,4 +4,5 @@ pytest pytest-asyncio pytest-cov pytest-benchmark +pytest-xdist coverage>=7.0.0 \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/__main__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/__main__.py index 1f95024..cbb79cf 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/__main__.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/__main__.py @@ -22,18 +22,18 @@ def _build_parser(): return parser -def parse_args(): +def parse_args(argv=None): """Parse command-line arguments. Provided for test compatibility. In production, main() is used instead. """ parser = _build_parser() - return parser.parse_args() + return parser.parse_args(argv) -def main(): +def main(argv=None): parser = _build_parser() - args = parser.parse_args() + args = parser.parse_args(argv) if args.command is None: cmd_list = ", ".join(f"'{c}'" for c in COMMAND_NAMES[:-1]) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/adapters.py b/src/rhosocial/activerecord/backend/impl/sqlserver/adapters.py index d61b2ab..83f8fce 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/adapters.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/adapters.py @@ -8,6 +8,7 @@ import json import uuid +import xml.etree.ElementTree as ET from abc import ABC, abstractmethod from datetime import date, datetime, time, timezone from typing import Any, Dict, List, Type, Union @@ -203,19 +204,21 @@ def supported_types(self) -> Dict[Type, List[str]]: def to_database(self, value: Any, target_type: Type = None, options: Dict = None) -> Any: if value is None: return None - if isinstance(value, date): - return value + # datetime is an isinstance of date, so it must be checked first + # to truncate to the date component. if isinstance(value, datetime): return value.date() + if isinstance(value, date): + return value raise TypeError(f"Expected date, got {type(value)}") def from_database(self, value: Any, target_type: Type = None, options: Dict = None) -> date: if value is None: return None - if isinstance(value, date): - return value if isinstance(value, datetime): return value.date() + if isinstance(value, date): + return value if isinstance(value, str): return date.fromisoformat(value) raise TypeError(f"Cannot convert {type(value)} to date") @@ -335,8 +338,6 @@ def from_database(self, value: Any, target_type: Type = None, options: Dict = No raise TypeError(f"Cannot convert {type(value)} to XML") def _dict_to_xml(self, data: dict, root: str = "root") -> str: - import xml.etree.ElementTree as ET - root_elem = ET.Element(root) self._build_xml(root_elem, data) return ET.tostring(root_elem, encoding='unicode') @@ -411,14 +412,14 @@ def _dict_to_wkt(self, data: dict) -> str: elif geom_type == 'LineString': coords = data.get('coordinates', []) - points = " ".join(f"{c[0]} {c[1]}" for c in coords) + points = ", ".join(f"{c[0]} {c[1]}" for c in coords) return f"LINESTRING({points})" elif geom_type == 'Polygon': coords = data.get('coordinates', [[]]) rings = [] for ring in coords: - points = " ".join(f"{c[0]} {c[1]}" for c in ring) + points = ", ".join(f"{c[0]} {c[1]}" for c in ring) rings.append(f"({points})") return f"POLYGON({','.join(rings)})" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py index 2e14f6b..92d8ee0 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py @@ -1,10 +1,27 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/cli/connection.py -"""Connection argument parsing and backend creation for SQL Server CLI.""" +"""Connection argument parsing and backend creation for SQL Server CLI. + +Encryption defaults matrix -- applied whenever the corresponding explicit +flag is absent. ``--encrypt``/``--no-encrypt`` and +``--trust-server-certificate``/``--no-trust-server-certificate`` always take +precedence over values derived from ``--ssl``: + +=============== ========= ========================== +--ssl encrypt trust_server_certificate +=============== ========= ========================== +auto (default) False True +disabled False True +require True True +verify-ca True False +verify-full True False +=============== ========= ========================== + +In particular, the default ``--ssl auto`` no longer implies +``encrypt=True``, matching the ``--encrypt`` flag's own default of off. +""" import os -from .output import RICH_AVAILABLE - def add_connection_args(parser): """Add SQL Server connection arguments to a subcommand parser. @@ -28,15 +45,24 @@ def add_connection_args(parser): help="Database name (env: SQLSERVER_DATABASE, optional for some operations)", ) parser.add_argument( + "--user", "--username", + dest="username", default=os.getenv("SQLSERVER_USERNAME", "sa"), - help="Database username (env: SQLSERVER_USERNAME, default: sa)", + help="Database username (env: SQLSERVER_USERNAME, default: sa). " + "--username is an alias for --user.", ) parser.add_argument( "--password", default=os.getenv("SQLSERVER_PASSWORD", ""), help="Database password (env: SQLSERVER_PASSWORD)", ) + parser.add_argument( + "--ssl", + choices=["auto", "require", "verify-ca", "verify-full", "disabled"], + default="auto", + help="SSL mode (env: SQLSERVER_SSL, default: auto)", + ) parser.add_argument( "--trusted-connection", dest="trusted_connection", @@ -53,32 +79,33 @@ def add_connection_args(parser): encrypt_group.add_argument( "--encrypt", action="store_true", - default=False, + default=None, dest="encrypt", - help="Encrypt connection", + help="Encrypt connection (takes precedence over --ssl)", ) encrypt_group.add_argument( "--no-encrypt", action="store_false", dest="encrypt", - help="Do not encrypt connection", + help="Do not encrypt connection (takes precedence over --ssl)", ) parser.add_argument( "--trust-server-certificate", dest="trust_server_certificate", action="store_true", - default=True, - help="Trust server certificate (default: True for development)", + default=None, + help="Trust server certificate (takes precedence over --ssl)", ) parser.add_argument( "--no-trust-server-certificate", dest="trust_server_certificate", action="store_false", - help="Do not trust server certificate", + help="Do not trust server certificate (takes precedence over --ssl)", ) parser.add_argument( - "--use-async", + "--async", action="store_true", + dest="is_async", help="Use asynchronous backend", ) parser.add_argument( @@ -158,6 +185,27 @@ def resolve_connection_config_from_args(args): return resolver.resolve(conn_params) return resolver.resolve({}) + # SSL parameter mapping. Explicit --encrypt/--no-encrypt and + # --trust-server-certificate/--no-trust-server-certificate flags win over + # the values derived from --ssl; see the matrix in the module docstring. + ssl_param = getattr(args, "ssl", None) + if ssl_param in ("require", "verify-ca", "verify-full"): + ssl_encrypt = True + # verify-ca/verify-full require a CA check; require trusts the cert + ssl_trust_cert = ssl_param == "require" + else: + # "auto" (default) and "disabled" do not force encryption + ssl_encrypt = False + ssl_trust_cert = True + + explicit_encrypt = getattr(args, "encrypt", None) + if explicit_encrypt is not None: + ssl_encrypt = explicit_encrypt + + explicit_trust = getattr(args, "trust_server_certificate", None) + if explicit_trust is not None: + ssl_trust_cert = explicit_trust + return SQLServerConnectionConfig( host=args.host or "localhost", port=args.port or 1433, @@ -166,8 +214,8 @@ def resolve_connection_config_from_args(args): password=args.password, trusted_connection=getattr(args, "trusted_connection", False), driver=getattr(args, "driver", "ODBC Driver 18 for SQL Server"), - encrypt=getattr(args, "encrypt", False), - trust_server_certificate=getattr(args, "trust_server_certificate", True), + encrypt=ssl_encrypt, + trust_server_certificate=ssl_trust_cert, ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py index eccc8bf..d040b3d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/introspect.py @@ -137,7 +137,7 @@ def handle(args): config = resolve_connection_config_from_args(args) - if args.use_async: + if args.is_async: from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_handle_introspect_async(args, backend, 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 4b23361..4a3401b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/named_procedure.py @@ -5,7 +5,7 @@ """ from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend -from rhosocial.activerecord.backend.options import ExecutionOptions + from .connection import create_connection_parent_parser, resolve_connection_config_from_args from .output import create_provider @@ -56,12 +56,6 @@ def backend_factory(): backend.introspect_and_adapt() return backend - def get_dialect(b): - return b.dialect - - def execute_query(sql, params, stmt_type): - return backend.execute(sql, params, options=ExecutionOptions(stmt_type=stmt_type)) - def disconnect(): if backend and backend._connection: backend.disconnect() @@ -77,12 +71,6 @@ def backend_async_factory(): async_backend = AsyncSQLServerBackend(connection_config=config) return async_backend - async def get_dialect_async(b): - return b.dialect - - async def execute_query_async(sql, params, stmt_type): - return await async_backend.execute(sql, params, options=ExecutionOptions(stmt_type=stmt_type)) - async def disconnect_async(): if async_backend and async_backend._connection: await async_backend.disconnect() @@ -91,12 +79,8 @@ async def disconnect_async(): args, provider, backend_factory=backend_factory, - get_dialect=get_dialect, - execute_query=execute_query, disconnect=disconnect, backend_async_factory=backend_async_factory, - get_dialect_async=get_dialect_async, - execute_query_async=execute_query_async, disconnect_async=disconnect_async, ) return @@ -105,7 +89,5 @@ async def disconnect_async(): args, provider, backend_factory=backend_factory, - get_dialect=get_dialect, - execute_query=execute_query, disconnect=disconnect, ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py index 5c407c4..514428b 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/query.py @@ -22,7 +22,15 @@ def create_parser(subparsers): parser = subparsers.add_parser( 'query', help='Execute SQL query', - epilog="""Examples: + epilog="""Statement input forms (exactly one is used, checked in this order): + 1. Positional argument: %(prog)s "SELECT * FROM users" + 2. File via -f/--file: %(prog)s -f query.sql + 3. Stdin pipe: cat query.sql | %(prog)s + +Note: there is deliberately no -q short option for the statement; use the +forms above. Only a single SQL statement is accepted per invocation. + +Examples: # Query a database %(prog)s --host localhost --database mydb "SELECT * FROM users" @@ -139,7 +147,7 @@ def handle(args): config = resolve_connection_config_from_args(args) kwargs = {"use_ascii": args.rich_ascii} - if args.use_async: + if args.is_async: from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_execute_query_async(sql_source, backend, provider, **kwargs)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py index 2d09465..66cc1ab 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/cli/status.py @@ -81,7 +81,7 @@ def handle(args): config = resolve_connection_config_from_args(args) - if args.use_async: + if args.is_async: from rhosocial.activerecord.backend.impl.sqlserver import AsyncSQLServerBackend backend = AsyncSQLServerBackend(connection_config=config) asyncio.run(_handle_status_async(args, backend, provider)) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py index fbceeb5..fd2ba85 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/dialect.py @@ -1003,10 +1003,18 @@ def supports_table_partitioning(self) -> bool: def supports_create_schema(self) -> bool: """SQL Server supports CREATE SCHEMA.""" return True - + def supports_drop_schema(self) -> bool: """SQL Server supports DROP SCHEMA.""" return True + + def supports_schema(self) -> bool: + """SQL Server models named schema namespaces natively (e.g. dbo).""" + return True + + def supports_schema_authorization(self) -> bool: + """SQL Server accepts CREATE SCHEMA ... AUTHORIZATION owner.""" + return True def supports_schema_if_not_exists(self) -> bool: """SQL Server doesn't support CREATE SCHEMA IF NOT EXISTS.""" diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/README.md b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/README.md index 96defc5..9a4c345 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/README.md +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/README.md @@ -7,6 +7,11 @@ This directory contains example code demonstrating how to use the rhosocial-acti ``` examples/ ├── README.md # This file +├── cli/ # CLI demo scripts (named connection/query/procedure) +│ ├── __init__.py +│ ├── named_connection_demo.py +│ ├── named_query_demo.py +│ └── named_procedure_demo.py ├── connection/ # Connection examples │ ├── __init__.py │ └── quickstart.py # Quick start guide - connect, query, result handling @@ -18,6 +23,16 @@ examples/ │ ├── __init__.py │ ├── with_output.py # Insert with OUTPUT clause (SQL Server's RETURNING) │ └── batch.py # Batch insert multiple rows +├── named_connections/ # Named Connection examples +│ ├── __init__.py +│ ├── development.py # Local/dev named connections +│ └── production.py # Production named connections +├── named_expressions/ # Named Expression (Named Query) examples +│ ├── __init__.py +│ └── order_expressions.py +├── named_procedures/ # Named Procedure examples +│ ├── __init__.py +│ └── order_workflow.py # Order processing workflow procedure ├── query/ # Query (SELECT) examples │ ├── __init__.py │ ├── basic.py # Basic SELECT with WHERE, ORDER BY, OFFSET FETCH @@ -108,6 +123,9 @@ export SQLSERVER_PASSWORD=YourPassword # Run a specific example python -m rhosocial.activerecord.backend.impl.sqlserver.examples.ddl.create_table + +# Run a CLI demo script +python src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py ``` ## For LLM Context diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/__init__.py new file mode 100644 index 0000000..1081d8b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/__init__.py +"""CLI example scripts for SQL Server backend.""" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_connection_demo.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_connection_demo.py new file mode 100644 index 0000000..1cf192b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_connection_demo.py @@ -0,0 +1,134 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_connection_demo.py +""" +Named Connection CLI demo script. + +Demonstrates how to invoke named connections (Named Connection) via SQL Server CLI. + +Usage: + cd src/rhosocial/activerecord/backend/impl/sqlserver/examples + python3 cli/named_connection_demo.py + +Or use CLI directly: + python -m rhosocial.activerecord.backend.impl.sqlserver named-connection \ + --show rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import subprocess +import sys +from pathlib import Path + +# Setup: ensure the package is importable from source (if not installed) +project_root = Path(__file__).resolve().parents[8] +src_dir = project_root / "src" +sys.path.insert(0, str(src_dir)) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +""" +This section demonstrates typical Named Connection CLI usage. + +### 1. List all named connections in a module + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-connection \ + --list rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections +``` + +### 2. View single connection configuration details + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-connection \ + --show rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev +``` + +### 3. Dry-run: resolve connection configuration + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-connection \ + --describe rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev +``` + +### 4. Use named connection in query + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver query \ + --named-connection rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev \ + "SELECT 1 AS test" +``` +""" + + +def run_cli_command(args): + """Execute CLI command and print output.""" + cmd = [sys.executable, "-m", "rhosocial.activerecord.backend.impl.sqlserver"] + args + print(f"\n{'=' * 60}") + print(f"Running: {' '.join(cmd)}") + print("=" * 60) + result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(project_root)) + print(result.stdout) + if result.stderr: + print(f"STDERR: {result.stderr}") + return result.returncode + + +def main(): + print("Named Connection CLI Demo") + print("=" * 60) + + # 1. List all named connections in a module + print("\n【1】List all named connections in module") + run_cli_command( + [ + "named-connection", + "--list", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections", + ] + ) + + # 2. View single connection configuration details + print("\n【2】View single connection configuration details") + run_cli_command( + [ + "named-connection", + "--show", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev", + ] + ) + + # 3. Dry-run: resolve connection configuration + print("\n【3】Dry-run: resolve connection configuration") + run_cli_command( + [ + "named-connection", + "--describe", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev", + ] + ) + + # 4. Use named connection in query + print("\n【4】Use named connection in query") + run_cli_command( + [ + "query", + "--named-connection", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.local_dev", + "SELECT 1 AS test", + ] + ) + + print("\n" + "=" * 60) + print("Demo complete!") + print("=" * 60) + + +if __name__ == "__main__": + main() + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +# No cleanup needed - CLI commands are self-contained \ No newline at end of file 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 new file mode 100644 index 0000000..e61cb6c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py @@ -0,0 +1,270 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_procedure_demo.py +""" +Named Procedure CLI demo script. + +Demonstrates how to invoke named procedures (Named Procedure) via SQL Server CLI. + +Usage: + cd src/rhosocial/activerecord/backend/impl/sqlserver/examples + python3 cli/named_procedure_demo.py + +Or use CLI directly: + python -m rhosocial.activerecord.backend.impl.sqlserver named-procedure \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow. + OrderProcessingProcedure \ + --param order_id=1 \ + --param user_id=100 \ + --param amount=99.99 +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import subprocess +import sys +from pathlib import Path + +# Setup: ensure the package is importable from source (if not installed) +project_root = Path(__file__).resolve().parents[8] +src_dir = project_root / "src" +sys.path.insert(0, str(src_dir)) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +""" +This section demonstrates typical Named Procedure CLI usage. + +### 1. List all named procedures in a module + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-procedure \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow \ + --list +``` + +### 2. View single procedure signature and parameters + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-procedure \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure \ + --describe +``` + +### 3. Dry-run: render each step's SQL without executing + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-procedure \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure \ + --dry-run \ + --param order_id=1 \ + --param user_id=100 \ + --param amount=99.99 +``` + +### 4. Execute named procedure (AUTO transaction mode) + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-procedure \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure \ + --param order_id=1 \ + --param user_id=100 \ + --param amount=99.99 \ + --transaction auto +``` + +### 5. Execute named procedure (STEP transaction mode) + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-procedure \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure \ + --param order_id=1 \ + --param user_id=100 \ + --param amount=99.99 \ + --transaction step +``` +""" + + +def connection_args(): + """Build connection arguments from environment (or defaults). + + pyodbc may resolve 'localhost' to ::1 where the SQL Server container + does not listen, so the demos pass an explicit host/port. + """ + import os + + args = [] + for key, flag in ( + ("SQLSERVER_HOST", "--host"), + ("SQLSERVER_PORT", "--port"), + ("SQLSERVER_USERNAME", "--user"), + ("SQLSERVER_PASSWORD", "--password"), + ("SQLSERVER_DRIVER", "--driver"), + ("SQLSERVER_DATABASE", "--database"), + ): + value = os.getenv(key) + if value: + args += [flag, value] + return args + + +def run_cli_command(args): + """Execute CLI command and print output. + + Connection args are inserted after the subcommand name because they are + parsed at the subcommand level. + """ + cmd = ( + [sys.executable, "-m", "rhosocial.activerecord.backend.impl.sqlserver"] + + [args[0]] + + connection_args() + + args[1:] + ) + print(f"\n{'=' * 60}") + print(f"Running: {' '.join(cmd)}") + print("=" * 60) + result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(project_root)) + print(result.stdout) + if result.stderr: + print(f"STDERR: {result.stderr}") + return result.returncode + + +def run_python_snippet(code): + """Run a plain Python snippet (schema preparation helper). + + The subprocess runs with cwd=project_root so the module is importable + from source via PYTHONPATH. + """ + print(f"\n{'=' * 60}") + print("Running: python -c ") + print("=" * 60) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + cwd=str(project_root), + ) + print(result.stdout) + if result.stderr: + print(f"STDERR: {result.stderr}") + return result.returncode + + +def prepare_demo_schema(): + """Create and seed the workflow tables via the prepare_orders_demo helper. + + The SQL Server CLI's query subcommand executes a single statement only + (no --executescript), so the demo uses the order_expressions module's own + prepare_orders_demo helper to create the schema and seed data. This is + the SQL Server equivalent of the SQLite demo's query --executescript step. + """ + code = ( + "import os;" + "from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, " + "SQLServerConnectionConfig;" + "from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions." + "order_expressions import prepare_orders_demo;" + "c = SQLServerConnectionConfig(host=os.getenv('SQLSERVER_HOST', '127.0.0.1'), " + "port=int(os.getenv('SQLSERVER_PORT', '1433')), username=os.getenv('SQLSERVER_USERNAME', 'sa'), " + "password=os.getenv('SQLSERVER_PASSWORD', 'Password123!'), " + "database=os.getenv('SQLSERVER_DATABASE', 'master'), " + "driver=os.getenv('SQLSERVER_DRIVER', 'ODBC Driver 17 for SQL Server'), " + "encrypt=False, trust_server_certificate=True);" + "b = SQLServerBackend(connection_config=c);" + "b.connect();" + "prepare_orders_demo(b);" + "b.disconnect()" + ) + run_python_snippet(code) + + +def main(): + print("Named Procedure CLI Demo") + print("=" * 60) + + # 1. List all named procedures in a module + print("\n【1】List all named procedures in module") + run_cli_command( + [ + "named-procedure", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow", + "--list", + ] + ) + + # 2. View single procedure signature + print("\n【2】View single procedure signature and parameters") + run_cli_command( + [ + "named-procedure", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure", + "--describe", + ] + ) + + # 3. Dry-run + print("\n【3】Dry-run: render each step's SQL, don't execute") + run_cli_command( + [ + "named-procedure", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure", + "--dry-run", + "--param", + "order_id=1", + "--param", + "user_id=100", + "--param", + "amount=99.99", + ] + ) + + # 4. Execute named procedure (AUTO) + print("\n【4】Execute named procedure (AUTO transaction mode)") + prepare_demo_schema() + run_cli_command( + [ + "named-procedure", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure", + "--param", + "order_id=1", + "--param", + "user_id=100", + "--param", + "amount=99.99", + "--transaction", + "auto", + ] + ) + + # 5. Execute named procedure (STEP) + print("\n【5】Execute named procedure (STEP transaction mode)") + prepare_demo_schema() + run_cli_command( + [ + "named-procedure", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure", + "--param", + "order_id=1", + "--param", + "user_id=100", + "--param", + "amount=99.99", + "--transaction", + "step", + ] + ) + + print("\n" + "=" * 60) + print("Demo complete!") + print("=" * 60) + + +if __name__ == "__main__": + main() + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +# No cleanup needed - CLI commands are self-contained \ No newline at end of file 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 new file mode 100644 index 0000000..28017a3 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py @@ -0,0 +1,245 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/cli/named_query_demo.py +""" +Named Query CLI demo script. + +Demonstrates how to invoke named queries (Named Query) via SQL Server CLI. + +Usage: + cd src/rhosocial/activerecord/backend/impl/sqlserver/examples + python3 cli/named_query_demo.py + +Or use CLI directly: + python -m rhosocial.activerecord.backend.impl.sqlserver named-expression \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order \ + --param order_id=1 +""" + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +import subprocess +import sys +from pathlib import Path + +# Setup: ensure the package is importable from source (if not installed) +project_root = Path(__file__).resolve().parents[8] +src_dir = project_root / "src" +sys.path.insert(0, str(src_dir)) + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +""" +This section demonstrates typical Named Query CLI usage. + +### 1. List all named queries in a module + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-expression \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions \ + --list +``` + +### 2. View single query signature and parameters + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-expression \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order \ + --describe +``` + +### 3. Dry-run: render SQL without executing + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-expression \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order \ + --dry-run \ + --param order_id=1 +``` + +### 4. Execute a named query + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-expression \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order \ + --param order_id=1 +``` + +### 5. Execute EXPLAIN plan + +```bash +python -m rhosocial.activerecord.backend.impl.sqlserver named-expression \ + rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order \ + --explain \ + --param order_id=1 +``` +""" + + +def connection_args(): + """Build connection arguments from environment (or defaults). + + pyodbc may resolve 'localhost' to ::1 where the SQL Server container + does not listen, so the demos pass an explicit host/port. + """ + import os + + args = [] + for key, flag in ( + ("SQLSERVER_HOST", "--host"), + ("SQLSERVER_PORT", "--port"), + ("SQLSERVER_USERNAME", "--user"), + ("SQLSERVER_PASSWORD", "--password"), + ("SQLSERVER_DRIVER", "--driver"), + ("SQLSERVER_DATABASE", "--database"), + ): + value = os.getenv(key) + if value: + args += [flag, value] + return args + + +def run_cli_command(args): + """Execute CLI command and print output. + + Connection args are inserted after the subcommand name because they are + parsed at the subcommand level. + """ + cmd = ( + [sys.executable, "-m", "rhosocial.activerecord.backend.impl.sqlserver"] + + [args[0]] + + connection_args() + + args[1:] + ) + print(f"\n{'=' * 60}") + print(f"Running: {' '.join(cmd)}") + print("=" * 60) + result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(project_root)) + print(result.stdout) + if result.stderr: + print(f"STDERR: {result.stderr}") + return result.returncode + + +def run_python_snippet(code): + """Run a plain Python snippet (schema preparation helper). + + The subprocess runs with cwd=project_root so the module is importable + from source via PYTHONPATH. + """ + print(f"\n{'=' * 60}") + print("Running: python -c ") + print("=" * 60) + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + cwd=str(project_root), + ) + print(result.stdout) + if result.stderr: + print(f"STDERR: {result.stderr}") + return result.returncode + + +def prepare_demo_schema(): + """Create and seed the demo tables via the prepare_orders_demo helper. + + The SQL Server CLI's query subcommand executes a single statement only + (no --executescript), so the demo uses the order_expressions module's own + prepare_orders_demo helper to create the schema and seed data. This is + the SQL Server equivalent of the SQLite demo's query --executescript step. + """ + code = ( + "import os;" + "from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, " + "SQLServerConnectionConfig;" + "from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions." + "order_expressions import prepare_orders_demo;" + "c = SQLServerConnectionConfig(host=os.getenv('SQLSERVER_HOST', '127.0.0.1'), " + "port=int(os.getenv('SQLSERVER_PORT', '1433')), username=os.getenv('SQLSERVER_USERNAME', 'sa'), " + "password=os.getenv('SQLSERVER_PASSWORD', 'Password123!'), " + "database=os.getenv('SQLSERVER_DATABASE', 'master'), " + "driver=os.getenv('SQLSERVER_DRIVER', 'ODBC Driver 17 for SQL Server'), " + "encrypt=False, trust_server_certificate=True);" + "b = SQLServerBackend(connection_config=c);" + "b.connect();" + "prepare_orders_demo(b);" + "b.disconnect()" + ) + run_python_snippet(code) + + +def main(): + print("Named Query CLI Demo") + print("=" * 60) + + # 1. List all named queries in a module + print("\n【1】List all named queries in module") + run_cli_command( + [ + "named-expression", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions", + "--list", + ] + ) + + # 2. View single query signature + print("\n【2】View single query signature and parameters") + run_cli_command( + [ + "named-expression", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order", + "--describe", + ] + ) + + # 3. Dry-run: render SQL only + print("\n【3】Dry-run: render SQL, don't execute") + run_cli_command( + [ + "named-expression", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order", + "--dry-run", + "--param", + "order_id=1", + ] + ) + + # 4. Execute named query + # The named expression only builds SQL; the tables must exist in the + # target database. Pre-populate the schema via the order_expressions + # module's own prepare_orders_demo helper. + print("\n【4】Execute named query") + prepare_demo_schema() + run_cli_command( + [ + "named-expression", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order", + "--param", + "order_id=1", + ] + ) + + # 5. View another query + print("\n【5】View check_inventory query") + run_cli_command( + [ + "named-expression", + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.check_inventory", + "--describe", + ] + ) + + print("\n" + "=" * 60) + print("Demo complete!") + print("=" * 60) + + +if __name__ == "__main__": + main() + +# ============================================================ +# SECTION: Teardown (necessary for execution, reference only) +# ============================================================ +# No cleanup needed - CLI commands are self-contained \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/__init__.py new file mode 100644 index 0000000..43dfd72 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/__init__.py @@ -0,0 +1,30 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/__init__.py +"""Named connection examples for SQL Server backend. + +This module provides example named connection configurations +that can be used with the named connection system. + +Examples: + >>> from rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections import local_dev + >>> config = local_dev() +""" + +__all__ = [ + "local_dev", + "local_dev_no_trust", + "local_dev_docker", + "prod_db", + "prod_db_ssl", + "prod_read_replica", +] + +from rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.development import ( + local_dev, + local_dev_no_trust, + local_dev_docker, +) +from rhosocial.activerecord.backend.impl.sqlserver.examples.named_connections.production import ( + prod_db, + prod_db_ssl, + prod_read_replica, +) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/development.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/development.py new file mode 100644 index 0000000..7044290 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/development.py @@ -0,0 +1,90 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/development.py +"""Development environment connection examples. + +All configuration values can be overridden via environment variables: + SQLSERVER_HOST, SQLSERVER_PORT, SQLSERVER_USERNAME, SQLSERVER_PASSWORD, + SQLSERVER_DATABASE, SQLSERVER_DRIVER +""" + +import os + +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig + + +def _env_or_default(key: str, default: str) -> str: + return os.environ.get(key, default) + + +def _env_int_or_default(key: str, default: int) -> int: + return int(os.environ.get(key, str(default))) + + +def _env_bool_or_default(key: str, default: bool) -> bool: + return os.environ.get(key, str(default)).lower() == "true" + + +def local_dev(): + """Local development SQL Server database connection. + + Reads connection parameters from environment variables with + fallback to localhost defaults. + + Returns: + SQLServerConnectionConfig: Development database configuration. + """ + return SQLServerConnectionConfig( + host=_env_or_default("SQLSERVER_HOST", "127.0.0.1"), + port=_env_int_or_default("SQLSERVER_PORT", 1433), + username=_env_or_default("SQLSERVER_USERNAME", "sa"), + password=_env_or_default("SQLSERVER_PASSWORD", "Password123!"), + database=_env_or_default("SQLSERVER_DATABASE", "master"), + driver=_env_or_default("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=_env_bool_or_default("SQLSERVER_ENCRYPT", False), + trust_server_certificate=_env_bool_or_default( + "SQLSERVER_TRUST_SERVER_CERTIFICATE", True + ), + autocommit=True, + ) + + +def local_dev_no_trust(): + """Local SQL Server connection with full encryption verification. + + Uses full SSL verification against the server certificate. + + Returns: + SQLServerConnectionConfig: SSL-verified database configuration. + """ + return SQLServerConnectionConfig( + host=_env_or_default("SQLSERVER_HOST", "127.0.0.1"), + port=_env_int_or_default("SQLSERVER_PORT", 1433), + username=_env_or_default("SQLSERVER_USERNAME", "sa"), + password=_env_or_default("SQLSERVER_PASSWORD", "Password123!"), + database=_env_or_default("SQLSERVER_DATABASE", "master"), + driver=_env_or_default("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=True, + trust_server_certificate=False, + autocommit=True, + ) + + +def local_dev_docker(): + """SQL Server connection for a Docker container. + + Docker containers typically expose SQL Server on a high port with + encryption disabled and the certificate trusted. + + Returns: + SQLServerConnectionConfig: Docker database configuration. + """ + return SQLServerConnectionConfig( + host=_env_or_default("SQLSERVER_HOST", "127.0.0.1"), + port=_env_int_or_default("SQLSERVER_PORT", 11435), + username=_env_or_default("SQLSERVER_USERNAME", "sa"), + password=_env_or_default("SQLSERVER_PASSWORD", "Password123!"), + database=_env_or_default("SQLSERVER_DATABASE", "master"), + driver=_env_or_default("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=False, + trust_server_certificate=True, + autocommit=True, + ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/production.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/production.py new file mode 100644 index 0000000..5fac94e --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/production.py @@ -0,0 +1,89 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_connections/production.py +"""Production environment connection examples. + +All configuration values can be overridden via environment variables: + SQLSERVER_HOST, SQLSERVER_PORT, SQLSERVER_USERNAME, SQLSERVER_PASSWORD, + SQLSERVER_DATABASE, SQLSERVER_DRIVER +""" + +import os + +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig + + +def _env_or_default(key: str, default: str) -> str: + return os.environ.get(key, default) + + +def _env_int_or_default(key: str, default: int) -> int: + return int(os.environ.get(key, str(default))) + + +def _env_bool_or_default(key: str, default: bool) -> bool: + return os.environ.get(key, str(default)).lower() == "true" + + +def prod_db(): + """Production SQL Server database connection. + + Reads connection parameters from environment variables with + fallback to documentation defaults. + + Returns: + SQLServerConnectionConfig: Production database configuration. + """ + return SQLServerConnectionConfig( + host=_env_or_default("SQLSERVER_HOST", "prod-sqlserver.example.com"), + port=_env_int_or_default("SQLSERVER_PORT", 1433), + username=_env_or_default("SQLSERVER_USERNAME", "app_user"), + password=_env_or_default("SQLSERVER_PASSWORD", ""), + database=_env_or_default("SQLSERVER_DATABASE", "production"), + driver=_env_or_default("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=True, + trust_server_certificate=False, + autocommit=True, + ) + + +def prod_db_ssl(): + """Production SQL Server database with forced encryption. + + Uses TLS encryption with full certificate verification for + secure production connections. + + Returns: + SQLServerConnectionConfig: SSL-verified database configuration. + """ + return SQLServerConnectionConfig( + host=_env_or_default("SQLSERVER_HOST", "prod-sqlserver.example.com"), + port=_env_int_or_default("SQLSERVER_PORT", 1433), + username=_env_or_default("SQLSERVER_USERNAME", "app_user"), + password=_env_or_default("SQLSERVER_PASSWORD", ""), + database=_env_or_default("SQLSERVER_DATABASE", "production"), + driver=_env_or_default("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=True, + trust_server_certificate=False, + autocommit=True, + ) + + +def prod_read_replica(): + """Production SQL Server read replica connection. + + For read-heavy workloads, connect to a read replica + to distribute load. + + Returns: + SQLServerConnectionConfig: Read replica database configuration. + """ + return SQLServerConnectionConfig( + host=_env_or_default("SQLSERVER_REPLICA_HOST", "prod-sqlserver-replica.example.com"), + port=_env_int_or_default("SQLSERVER_REPLICA_PORT", 1433), + username=_env_or_default("SQLSERVER_REPLICA_USER", "app_user"), + password=_env_or_default("SQLSERVER_REPLICA_PASSWORD", ""), + database=_env_or_default("SQLSERVER_DATABASE", "production"), + driver=_env_or_default("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=True, + trust_server_certificate=False, + autocommit=True, + ) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/__init__.py new file mode 100644 index 0000000..9880183 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/__init__.py +"""Named query examples for SQL Server backend.""" \ No newline at end of file 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 new file mode 100644 index 0000000..fa6f213 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py @@ -0,0 +1,199 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_expressions/order_expressions.py +""" +Order-related named query examples. + +This file demonstrates how to define named queries (Named Query) for encapsulating +reusable SQL query logic. Named queries are backend features, independent of +ActiveRecord models. + +SQL Server notes +---------------- +Unlike the SQLite examples, this module does NOT create any tables or data at +import time. Importing this module must stay side-effect free (the CLI imports +it for --list/--describe without a live database). Use ``prepare_orders_demo`` +to create the schema and seed data, then the CLI named-expression / +named-procedure commands to execute the queries. +""" + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + Column, + Literal, + QueryExpression, + TableExpression, +) + + +def get_order(dialect, order_id: int): + """Get order details by ID.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], + from_=TableExpression(dialect, "orders"), + where=Column(dialect, "id") == Literal(dialect, order_id), + ) + + +def check_inventory(dialect, order_id: int): + """Check available inventory for an order.""" + return QueryExpression( + dialect, + select=[Column(dialect, "available")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def reserve_inventory(dialect, order_id: int): + """Reserve inventory for an order.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "available")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def send_notification(dialect, user_id: int, type: str): + """Send notification to a user.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "notifications"), + where=Column(dialect, "user_id") == Literal(dialect, user_id), + ) + + +def process_payment(dialect, order_id: int, amount: float): + """Process payment for an order.""" + return QueryExpression( + dialect, + select=[Column(dialect, "status"), Column(dialect, "transaction_id")], + from_=TableExpression(dialect, "payments"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def release_inventory(dialect, order_id: int): + """Release reserved inventory.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def create_order_record(dialect, order_id: int, user_id: int, amount: float): + """Create an order record.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "created_at")], + from_=TableExpression(dialect, "order_records"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def confirm_inventory(dialect, order_id: int): + """Confirm inventory (final confirmation).""" + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +# The setup below is import-safe: it only prepares the schema when this module +# is run directly, so importing it for --list/--describe never touches a database. + +_PREPARE_SCRIPT = """ +IF OBJECT_ID(N'dbo.order_records', N'U') IS NOT NULL DROP TABLE dbo.order_records; +IF OBJECT_ID(N'dbo.payments', N'U') IS NOT NULL DROP TABLE dbo.payments; +IF OBJECT_ID(N'dbo.notifications', N'U') IS NOT NULL DROP TABLE dbo.notifications; +IF OBJECT_ID(N'dbo.inventory', N'U') IS NOT NULL DROP TABLE dbo.inventory; +IF OBJECT_ID(N'dbo.orders', N'U') IS NOT NULL DROP TABLE dbo.orders; +CREATE TABLE dbo.orders ( + id INT PRIMARY KEY, + status NVARCHAR(20) DEFAULT 'pending', + user_id INT NOT NULL +); +CREATE TABLE dbo.inventory ( + id INT PRIMARY KEY, + order_id INT NOT NULL, + available INT DEFAULT 0 +); +CREATE TABLE dbo.notifications ( + id INT PRIMARY KEY, + user_id INT NOT NULL, + type NVARCHAR(20) +); +CREATE TABLE dbo.payments ( + id INT PRIMARY KEY, + order_id INT NOT NULL, + status NVARCHAR(20), + transaction_id NVARCHAR(40) +); +CREATE TABLE dbo.order_records ( + id INT PRIMARY KEY, + order_id INT NOT NULL, + created_at NVARCHAR(30) +); +INSERT INTO dbo.orders (id, status, user_id) VALUES (1, 'pending', 100); +INSERT INTO dbo.inventory (id, order_id, available) VALUES (1, 1, 10); +""" + + +def prepare_orders_demo(backend) -> None: + """Create the demo schema and seed data. + + Drops any existing demo tables first so the demo is idempotent. + + Args: + backend: Connected SQLServerBackend instance. + """ + backend.executescript(_PREPARE_SCRIPT) + + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +if __name__ == "__main__": + import os + + from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, SQLServerConnectionConfig + from rhosocial.activerecord.backend.options import ExecutionOptions + from rhosocial.activerecord.backend.schema import StatementType + + config = SQLServerConnectionConfig( + host=os.getenv("SQLSERVER_HOST", "127.0.0.1"), + port=int(os.getenv("SQLSERVER_PORT", "1433")), + username=os.getenv("SQLSERVER_USERNAME", "sa"), + password=os.getenv("SQLSERVER_PASSWORD", "Password123!"), + database=os.getenv("SQLSERVER_DATABASE", "master"), + driver=os.getenv("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=False, + trust_server_certificate=True, + ) + backend = SQLServerBackend(connection_config=config) + backend.connect() + dialect = backend.dialect + + prepare_orders_demo(backend) + + print("=== Named Query Examples ===\n") + query = get_order(dialect, order_id=1) + sql, params = query.to_sql() + print(f"get_order SQL: {sql}") + print(f"Params: {params}\n") + + options = ExecutionOptions(stmt_type=StatementType.DQL) + result = backend.execute(sql, params, options=options) + print(f"Execution result: {result.data}\n") + + backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/__init__.py b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/__init__.py new file mode 100644 index 0000000..b35f6bb --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/__init__.py +"""Named procedure examples for SQL Server backend.""" \ No newline at end of file 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 new file mode 100644 index 0000000..b7d61b1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/order_workflow.py @@ -0,0 +1,177 @@ +# src/rhosocial/activerecord/backend/impl/sqlserver/examples/named_procedures/order_workflow.py +""" +Order processing workflow example - demonstrates Named Procedure flowchart capabilities. + +This procedure includes: +- Conditional branching (inventory check) +- Parallel execution (inventory reservation + notification) +- Conditional rollback (payment failure) + +Named Procedure is a backend feature, independent of ActiveRecord models. + +SQL Server notes +---------------- +This module is import-safe: no database connection is made at import time. +Prepare the schema and seed data with +``rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.prepare_orders_demo`` +before running the procedure. +""" + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.named_expression import ( # noqa: E402 + Procedure, + ProcedureContext, + ParallelStep, +) + + +class OrderProcessingProcedure(Procedure): + """Complete order processing workflow. + + Flow: + 1. Query order details + 2. Check inventory (abort if insufficient) + 3. Parallel: reserve inventory + send notification + 4. Process payment (rollback inventory on failure) + 5. Create order record + 6. Final inventory confirmation + """ + + order_id: int + user_id: int + amount: float = 0.0 + + def run(self, ctx: ProcedureContext) -> None: + ctx.log(f"Starting order processing: {self.order_id}", "INFO") + + ctx.execute( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.get_order", + params={"order_id": self.order_id}, + bind="order", + ) + + order = ctx.scalar("order", "status") + if order is None: + ctx.log(f"Order {self.order_id} not found", "ERROR") + ctx.abort("OrderProcessingProcedure", f"Order {self.order_id} not found") + + ctx.log(f"Order status: {order}", "DEBUG") + + ctx.execute( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.check_inventory", + params={"order_id": self.order_id}, + bind="inventory", + ) + + available = ctx.scalar("inventory", "available") + if not available or available < 1: + ctx.log("Insufficient inventory, aborting", "WARNING") + ctx.abort("OrderProcessingProcedure", "Insufficient inventory") + + ctx.parallel( + ParallelStep( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.reserve_inventory", + params={"order_id": self.order_id}, + bind="reserved", + ), + ParallelStep( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.send_notification", + params={"user_id": self.user_id, "type": "order_started"}, + ), + max_concurrency=1, + ) + + ctx.execute( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.process_payment", + params={"order_id": self.order_id, "amount": self.amount}, + bind="payment", + ) + + payment_status = ctx.scalar("payment", "status") + if payment_status != "success": + ctx.log(f"Payment failed: {payment_status}, rolling back inventory", "ERROR") + ctx.execute( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.release_inventory", + params={"order_id": self.order_id}, + output=True, + ) + ctx.abort("OrderProcessingProcedure", f"Payment failed: {payment_status}") + + ctx.execute( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.create_order_record", + params={ + "order_id": self.order_id, + "user_id": self.user_id, + "amount": self.amount, + }, + bind="order_record", + output=True, + ) + + ctx.execute( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions.confirm_inventory", + params={"order_id": self.order_id}, + output=True, + ) + + ctx.log(f"Order {self.order_id} processing complete", "INFO") + + +# Demo: Generate static diagram +if __name__ == "__main__": + print("=== Order Processing Procedure ===\n") + print("Static Diagram (Flowchart):") + print(OrderProcessingProcedure.static_diagram("flowchart")) + print("\nStatic Diagram (Sequence):") + print(OrderProcessingProcedure.static_diagram("sequence")) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +from rhosocial.activerecord.backend.named_expression import ProcedureRunner, TransactionMode # noqa: E402 + +if __name__ == "__main__": + import os + + from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend, SQLServerConnectionConfig + from rhosocial.activerecord.backend.impl.sqlserver.examples.named_expressions.order_expressions import ( + prepare_orders_demo, + ) + + config = SQLServerConnectionConfig( + host=os.getenv("SQLSERVER_HOST", "127.0.0.1"), + port=int(os.getenv("SQLSERVER_PORT", "1433")), + username=os.getenv("SQLSERVER_USERNAME", "sa"), + password=os.getenv("SQLSERVER_PASSWORD", "Password123!"), + database=os.getenv("SQLSERVER_DATABASE", "master"), + driver=os.getenv("SQLSERVER_DRIVER", "ODBC Driver 17 for SQL Server"), + encrypt=False, + trust_server_certificate=True, + ) + backend = SQLServerBackend(connection_config=config) + backend.connect() + + prepare_orders_demo(backend) + + runner = ProcedureRunner( + "rhosocial.activerecord.backend.impl.sqlserver.examples.named_procedures.order_workflow.OrderProcessingProcedure" + ).load() + + result = runner.run( + backend, + user_params={"order_id": 1, "user_id": 100, "amount": 99.99}, + transaction_mode=TransactionMode.AUTO, + ) + + print(f"Procedure completed. Aborted: {result.aborted}") + if result.aborted: + print(f"Abort reason: {result.abort_reason}") + for log in result.logs: + print(f"[{log.level}] {log.message}") + + # ============================================================ + # SECTION: Teardown (necessary for execution, reference only) + # ============================================================ + backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py index db6b975..ac3208d 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/introspection/introspector.py @@ -62,9 +62,11 @@ def _build_table_list_sql( include_views: bool = True, table_type: Optional[str] = None, ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - conditions = [f"TABLE_SCHEMA = '{target}'"] + params: List[str] = [target] + conditions = ["TABLE_SCHEMA = ?"] if table_type: - conditions.append(f"TABLE_TYPE = '{table_type}'") + conditions.append("TABLE_TYPE = ?") + params.append(table_type) elif not include_views: conditions.append("TABLE_TYPE = 'BASE TABLE'") where = " AND ".join(conditions) @@ -73,13 +75,13 @@ def _build_table_list_sql( FROM INFORMATION_SCHEMA.TABLES WHERE {where} ORDER BY TABLE_NAME - """, ()) + """, tuple(params)) def _build_column_info_sql( self, table_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (f""" + return (""" SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT, c.CHARACTER_MAXIMUM_LENGTH, @@ -90,29 +92,29 @@ def _build_column_info_sql( c.COLUMN_NAME, 'IsIdentity' ) AS IS_IDENTITY FROM INFORMATION_SCHEMA.COLUMNS c - WHERE c.TABLE_SCHEMA = '{target}' AND c.TABLE_NAME = '{table_name}' + WHERE c.TABLE_SCHEMA = ? AND c.TABLE_NAME = ? ORDER BY c.ORDINAL_POSITION - """, ()) + """, (target, table_name)) def _build_primary_key_sql( self, table_name: str, schema: str - ) -> str: - return f""" + ) -> Tuple[str, tuple]: + return (""" SELECT ccu.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu ON tc.CONSTRAINT_NAME = ccu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = ccu.TABLE_SCHEMA - WHERE tc.TABLE_SCHEMA = '{schema}' - AND tc.TABLE_NAME = '{table_name}' + WHERE tc.TABLE_SCHEMA = ? + AND tc.TABLE_NAME = ? AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY' - """ + """, (schema, table_name)) def _build_index_info_sql( self, table_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (f""" + return (""" SELECT i.name AS index_name, i.is_unique, @@ -128,16 +130,16 @@ def _build_index_info_sql( ON i.object_id = ic.object_id AND i.index_id = ic.index_id JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id - WHERE s.name = '{target}' AND t.name = '{table_name}' + WHERE s.name = ? AND t.name = ? AND i.name IS NOT NULL ORDER BY i.name, ic.key_ordinal - """, ()) + """, (target, table_name)) def _build_foreign_key_sql( self, table_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (f""" + return (""" SELECT fk.name AS fk_name, COL_NAME(fc.parent_object_id, fc.parent_column_id) AS col_name, @@ -151,41 +153,42 @@ def _build_foreign_key_sql( ON fk.object_id = fc.constraint_object_id JOIN sys.tables t ON fk.parent_object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = '{target}' AND t.name = '{table_name}' + WHERE s.name = ? AND t.name = ? ORDER BY fk.name, fc.constraint_column_id - """, ()) + """, (target, table_name)) def _build_view_list_sql( self, schema: Optional[str], include_system: bool ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (f""" + return (""" SELECT TABLE_NAME AS view_name FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_SCHEMA = '{target}' + WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME - """, ()) + """, (target,)) def _build_view_info_sql( self, view_name: str, schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - return (f""" + return (""" SELECT TABLE_NAME AS view_name, VIEW_DEFINITION AS definition, IS_UPDATABLE FROM INFORMATION_SCHEMA.VIEWS - WHERE TABLE_SCHEMA = '{target}' AND TABLE_NAME = '{view_name}' - """, ()) + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + """, (target, view_name)) def _build_trigger_list_sql( self, table_name: Optional[str], schema: Optional[str] ) -> Tuple[str, tuple]: target = schema or self._get_default_schema() - table_filter = ( - f"AND t.name = '{table_name}'" if table_name else "" - ) + params: List[str] = [target] + table_filter = "AND t.name = ?" if table_name else "" + if table_name: + params.append(table_name) return (f""" SELECT tr.name AS trigger_name, @@ -196,9 +199,9 @@ def _build_trigger_list_sql( FROM sys.triggers tr JOIN sys.tables t ON tr.parent_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id - WHERE s.name = '{target}' {table_filter} + WHERE s.name = ? {table_filter} ORDER BY tr.name - """, ()) + """, tuple(params)) # ------------------------------------------------------------------ # Parse methods — pure Python, no I/O @@ -444,8 +447,8 @@ def list_columns( if cached is not None: return cached - pk_sql = self._build_primary_key_sql(table_name, target) - pk_rows = self._executor.execute(pk_sql) + pk_sql, pk_params = self._build_primary_key_sql(table_name, target) + pk_rows = self._executor.execute(pk_sql, pk_params) primary_keys = [r.get("COLUMN_NAME") for r in pk_rows] sql, params = self._build_column_info_sql(table_name, schema) @@ -504,8 +507,8 @@ async def list_columns( if cached is not None: return cached - pk_sql = self._build_primary_key_sql(table_name, target) - pk_rows = await self._executor.execute(pk_sql) + pk_sql, pk_params = self._build_primary_key_sql(table_name, target) + pk_rows = await self._executor.execute(pk_sql, pk_params) primary_keys = [r.get("COLUMN_NAME") for r in pk_rows] sql, params = self._build_column_info_sql(table_name, schema) diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py index 3a0c35a..6748cfb 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py @@ -1,6 +1,6 @@ # src/rhosocial/activerecord/backend/impl/sqlserver/mixins/backend_mixin.py import re -from typing import Any, Dict, List, Optional, Set, Tuple, Type, TYPE_CHECKING +from typing import Any, Dict, Optional, Set, Tuple, Type, TYPE_CHECKING if TYPE_CHECKING: from ..dialect import SQLServerDialect @@ -27,6 +27,21 @@ rf"(\[[^\]]+\]|[A-Za-z_][\w$]*)(\s+(?:AS\s+)?(?!(?:{_CLAUSE_KEYWORDS})\b)[A-Za-z_][\w$]*)?" ) +_SQLSERVER_DATE_FORMATS = frozenset({"mdy", "dmy", "ymd", "ydm", "myd", "dym"}) + +_SQLSERVER_DEADLOCK_PRIORITIES = frozenset({"LOW", "NORMAL", "HIGH"}) + +_SQLSERVER_LANGUAGES = frozenset({ + "us_english", "english", "british english", "bokmål", "brazilian", + "simplified chinese", "traditional chinese", + "japanese", "korean", "french", "german", "italian", + "spanish", "portuguese", "dutch", "swedish", "norwegian", + "danish", "finnish", "polish", "czech", "hungarian", + "romanian", "turkish", "greek", "russian", "arabic", + "thai", "slovak", "slovenian", "croatian", "lithuanian", + "latvian", "estonian", "bulgarian", +}) + class SQLServerBackendMixin: _dialect: "SQLServerDialect" @@ -34,17 +49,14 @@ class SQLServerBackendMixin: _identity_columns_cache: Dict[str, Set[str]] = {} def _prepare_sql_and_params(self, sql: str, params: Optional[Tuple]) -> Tuple[str, Optional[Tuple]]: - """Convert ``%s`` placeholders to pyodbc ``?`` markers. + """Prepare SQL Server-specific SQL before execution. - SQL Server uses ODBC qmark placeholders. Raw SQL coming from MySQL-style - tests or generic expression renderers may use ``%s``; translate them here - (``%%`` is first collapsed to a literal ``%``) so pyodbc never receives a - mismatched marker count. + This method only performs the SQL Server-specific table hint relocation + (``_relocate_table_hint``). Parameter placeholders are provided by the + caller according to the ``dialect.get_parameter_placeholder()`` contract + (SQL Server returns ``?``, matching pyodbc's native qmark style), so no + guessing-style placeholder conversion is done at the execute layer. """ - if params: - sql = re.sub(r"%%", "\x00", sql) - sql = sql.replace("%s", "?") - sql = sql.replace("\x00", "%") sql = self._relocate_table_hint(sql) return sql, params @@ -174,7 +186,7 @@ def get_scope_identity(self) -> Optional[int]: def get_identity_current(self, table: str) -> Optional[int]: cursor = self._get_cursor() - cursor.execute(f"SELECT IDENT_CURRENT('{table}')") + cursor.execute("SELECT IDENT_CURRENT(?)", (table,)) row = cursor.fetchone() cursor.close() if row and row[0] is not None: @@ -183,7 +195,7 @@ def get_identity_current(self, table: str) -> Optional[int]: def reset_identity(self, table: str, seed: int = 1) -> None: cursor = self._get_cursor() - cursor.execute(f"DBCC CHECKIDENT('{table}', RESEED, {seed})") + cursor.execute("DBCC CHECKIDENT(?, RESEED, ?)", (table, seed)) cursor.close() def set_lock_timeout(self, timeout_ms: int) -> None: @@ -193,9 +205,19 @@ def set_lock_timeout(self, timeout_ms: int) -> None: def set_deadlock_priority(self, priority) -> None: if isinstance(priority, int): + if not -10 <= priority <= 10: + raise ValueError( + f"Invalid DEADLOCK_PRIORITY: {priority!r} (must be -10 to 10)" + ) sql = f"SET DEADLOCK_PRIORITY {priority}" else: - sql = f"SET DEADLOCK_PRIORITY {priority.upper()}" + name = priority.upper() + if name not in _SQLSERVER_DEADLOCK_PRIORITIES: + raise ValueError( + f"Invalid DEADLOCK_PRIORITY: {priority!r} " + f"(must be one of {sorted(_SQLSERVER_DEADLOCK_PRIORITIES)})" + ) + sql = f"SET DEADLOCK_PRIORITY {name}" cursor = self._get_cursor() cursor.execute(sql) cursor.close() @@ -224,13 +246,22 @@ def set_textsize(self, size: int) -> None: cursor.close() def set_language(self, language: str) -> None: + key = language.strip().lower() + if key not in _SQLSERVER_LANGUAGES: + raise ValueError(f"Unsupported SQL Server language: {language!r}") cursor = self._get_cursor() - cursor.execute(f"SET LANGUAGE {language}") + cursor.execute(f"SET LANGUAGE '{key}'") cursor.close() def set_dateformat(self, format: str) -> None: + key = format.strip().lower() + if key not in _SQLSERVER_DATE_FORMATS: + raise ValueError( + f"Invalid DATEFORMAT: {format!r} " + f"(must be one of {sorted(_SQLSERVER_DATE_FORMATS)})" + ) cursor = self._get_cursor() - cursor.execute(f"SET DATEFORMAT {format}") + cursor.execute(f"SET DATEFORMAT {key}") cursor.close() def get_product_version(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/sqlserver/transaction.py b/src/rhosocial/activerecord/backend/impl/sqlserver/transaction.py index 9120ca5..9993d33 100644 --- a/src/rhosocial/activerecord/backend/impl/sqlserver/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/sqlserver/transaction.py @@ -271,19 +271,16 @@ def set_lock_timeout(self, timeout: int) -> None: def set_deadlock_priority(self, priority) -> None: """ Set the deadlock priority for the current session. - + Args: priority: "LOW", "NORMAL", "HIGH", or -10 to 10 """ if self._state != TransactionState.ACTIVE: raise TransactionError("No active transaction") - - if isinstance(priority, int): - sql = f"SET DEADLOCK_PRIORITY {priority}" - else: - sql = f"SET DEADLOCK_PRIORITY {priority.upper()}" - - self._backend.execute(sql) + + # Delegate to the backend, which validates the priority against its + # whitelist (-10..10 int, or LOW/NORMAL/HIGH) to prevent injection. + self._backend.set_deadlock_priority(priority) self.log(logging.DEBUG, f"Deadlock priority set to {priority}") diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 03b2370..b6a4639 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -515,6 +515,10 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N else: raise ValueError("No scenarios registered") config_dict = SCENARIO_MAP[scenario_name] + from providers.pooling import resolve_database_name + pooled_db = resolve_database_name(scenario_name) + if pooled_db: + config_dict = {**config_dict, "database": pooled_db} return { "backend_module": "rhosocial.activerecord.backend.impl.sqlserver", "backend_class_name": backend_class_name, diff --git a/tests/providers/pooling.py b/tests/providers/pooling.py new file mode 100644 index 0000000..dc2014a --- /dev/null +++ b/tests/providers/pooling.py @@ -0,0 +1,116 @@ +# tests/providers/pooling.py +"""Database pooling helpers for the SQL Server test providers. + +Under parallel (pytest-xdist) runs with a positive pool size the providers +reuse a per-worker pooled database ``{database}_{index}`` on the scenario's SQL +Server instance instead of the shared scenario ``database`` schema, so scenario +variants of the same test can run concurrently on different workers without +conflicting. The pool name prefix is derived from the scenario's configured +``database`` (the YAML ``database`` field), so e.g. ``database: master`` +produces pooled databases ``master_0``, ``master_1``, ... Serial runs (no +``-n``) keep the previous behaviour: the provider connects to the scenario's +configured ``database``. + +The scenario name selects the server (host/port); the pool index selects the +database name. The two are deliberately unrelated. +""" + +import pyodbc +from dataclasses import replace + +from rhosocial.activerecord.testsuite.core.pool import ( + pooled_database_name, + register_base_database, + register_pool_reset_handler, +) + +from .scenarios import SCENARIO_MAP, get_scenario_raw + +# Derive each scenario's pooled-database base name from its configured +# ``database`` (YAML ``database`` field). Registered at import time so any +# caller of pooled_database_name() / resolve_database_name() resolves names +# consistent with the scenario configuration. +for _scenario_name, _scenario_config in SCENARIO_MAP.items(): + register_base_database(_scenario_name, _scenario_config["database"]) + + +def resolve_database_name(scenario_name: str): + """ + Return the pooled database name (e.g. ``master_3``) used by a test for + the given scenario, or ``None`` when pooling is inactive (callers then fall + back to the scenario's configured database). + """ + return pooled_database_name(scenario_name) + + +def _escape_literal(name: str) -> str: + """Escape a SQL Server string literal (single quotes doubled).""" + return name.replace("'", "''") + + +def _escape_bracket(name: str) -> str: + """Escape a SQL Server identifier for use inside square brackets.""" + return name.replace("]", "]]") + + +def _connect_admin(config) -> pyodbc.Connection: + """Connect to the ``master`` maintenance database of the scenario's server.""" + admin_config = replace(config, database="master") + return pyodbc.connect( + admin_config.build_connection_string(), + autocommit=True, + timeout=config.timeout or 10, + ) + + +def _reset_sqlserver_database(scenario_name: str, db_name: str) -> None: + """Ensure the pooled database exists and is empty on the scenario's server. + + Connects to ``master`` on the server selected by ``scenario_name``, drops + the pooled ``db_name`` database if it exists (forcing existing connections + closed) and recreates it empty, so the test starts from a clean state. + Errors are swallowed: a failed reset must not hide the underlying test + failure. + """ + if scenario_name not in SCENARIO_MAP: + return + _, config = get_scenario_raw(scenario_name) + try: + conn = _connect_admin(config) + try: + with conn.cursor() as cursor: + db_escaped = _escape_bracket(db_name) + cursor.execute( + f"SELECT COUNT(*) FROM sys.databases " + f"WHERE name = N'{_escape_literal(db_name)}'" + ) + if not cursor.fetchone()[0]: + cursor.execute(f"CREATE DATABASE [{db_escaped}]") + cursor.execute( + f"ALTER DATABASE [{db_escaped}] " + "SET READ_COMMITTED_SNAPSHOT ON" + ) + return + cursor.execute( + f"SELECT COUNT(*) FROM [{db_escaped}].sys.tables " + f"WHERE is_ms_shipped = 0" + ) + if not cursor.fetchone()[0]: + return + cursor.execute( + f"ALTER DATABASE [{db_escaped}] " + "SET SINGLE_USER WITH ROLLBACK IMMEDIATE" + ) + cursor.execute(f"DROP DATABASE [{db_escaped}]") + cursor.execute(f"CREATE DATABASE [{db_escaped}]") + cursor.execute( + f"ALTER DATABASE [{db_escaped}] " + "SET READ_COMMITTED_SNAPSHOT ON" + ) + finally: + conn.close() + except Exception: + pass + + +register_pool_reset_handler(_reset_sqlserver_database) diff --git a/tests/providers/query.py b/tests/providers/query.py index e64b9a7..9c6800f 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -129,10 +129,101 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s class QueryProviderBase: def __init__(self): self._scenario_db_files = {} + self._schema_fixtures_provisioned = False + self._mixed_schema_provisioned = False + + @staticmethod + def _ddl_options(): + from rhosocial.activerecord.backend.options import ExecutionOptions + from rhosocial.activerecord.backend.schema import StatementType + return ExecutionOptions(stmt_type=StatementType.DDL) def get_test_scenarios(self) -> List[str]: return list(get_enabled_scenarios().keys()) + def _cross_schema_statements(self) -> List[str]: + """Provision SCHEMA_A / SCHEMA_B with their tables (T-SQL).""" + return [ + "IF OBJECT_ID('ar_crm.customers', 'U') IS NOT NULL DROP TABLE ar_crm.customers", + "IF SCHEMA_ID('ar_crm') IS NOT NULL DROP SCHEMA ar_crm", + "CREATE SCHEMA ar_crm", + "IF OBJECT_ID('ar_shop.orders', 'U') IS NOT NULL DROP TABLE ar_shop.orders", + "IF OBJECT_ID('ar_crm.orders', 'U') IS NOT NULL DROP TABLE ar_crm.orders", + "IF SCHEMA_ID('ar_shop') IS NOT NULL DROP SCHEMA ar_shop", + "CREATE SCHEMA ar_shop", + "CREATE TABLE ar_crm.customers (" + "id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL)", + "CREATE TABLE ar_shop.orders (" + "id INT IDENTITY(1,1) PRIMARY KEY, customer_id INT NOT NULL, amount INT NOT NULL)", + ] + + def _drop_cross_schema_schemas_sync(self, backend_instance) -> None: + for sql in ( + "IF OBJECT_ID('ar_crm.customers', 'U') IS NOT NULL DROP TABLE ar_crm.customers", + "IF OBJECT_ID('ar_shop.orders', 'U') IS NOT NULL DROP TABLE ar_shop.orders", + "IF OBJECT_ID('ar_crm.orders', 'U') IS NOT NULL DROP TABLE ar_crm.orders", + "IF SCHEMA_ID('ar_crm') IS NOT NULL DROP SCHEMA ar_crm", + "IF SCHEMA_ID('ar_shop') IS NOT NULL DROP SCHEMA ar_shop", + ): + try: + backend_instance.execute(sql, options=self._ddl_options()) + except Exception: + pass + self._schema_fixtures_provisioned = False + + async def _drop_cross_schema_schemas_async(self, backend_instance) -> None: + for sql in ( + "IF OBJECT_ID('ar_crm.customers', 'U') IS NOT NULL DROP TABLE ar_crm.customers", + "IF OBJECT_ID('ar_shop.orders', 'U') IS NOT NULL DROP TABLE ar_shop.orders", + "IF OBJECT_ID('ar_crm.orders', 'U') IS NOT NULL DROP TABLE ar_crm.orders", + "IF SCHEMA_ID('ar_crm') IS NOT NULL DROP SCHEMA ar_crm", + "IF SCHEMA_ID('ar_shop') IS NOT NULL DROP SCHEMA ar_shop", + ): + try: + await backend_instance.execute(sql, options=self._ddl_options()) + except Exception: + pass + self._schema_fixtures_provisioned = False + + def _provision_cross_schema_sync(self, backend_instance) -> None: + options = self._ddl_options() + for sql in self._cross_schema_statements(): + backend_instance.execute(sql, options=options) + self._schema_fixtures_provisioned = True + + async def _provision_cross_schema_async(self, backend_instance) -> None: + options = self._ddl_options() + for sql in self._cross_schema_statements(): + await backend_instance.execute(sql, options=options) + self._schema_fixtures_provisioned = True + + def _mixed_schema_statements(self) -> List[str]: + """Provision an identical orders table in SCHEMA_A (T-SQL, no FK).""" + return [ + "IF OBJECT_ID('ar_crm.orders', 'U') IS NOT NULL DROP TABLE ar_crm.orders", + "IF SCHEMA_ID('ar_crm') IS NOT NULL DROP SCHEMA ar_crm", + "CREATE SCHEMA ar_crm", + "CREATE TABLE ar_crm.orders (" + "[id] INT IDENTITY(1,1) PRIMARY KEY, " + "[user_id] INT NOT NULL, " + "[order_number] NVARCHAR(255) NOT NULL, " + "[total_amount] DECIMAL(10,2) NOT NULL DEFAULT 0.00, " + "[status] NVARCHAR(50) NOT NULL DEFAULT 'pending', " + "[created_at] DATETIME2 NULL, [updated_at] DATETIME2 NULL)", + ] + + def _provision_mixed_schema_sync(self, backend_instance) -> None: + options = self._ddl_options() + for sql in self._mixed_schema_statements(): + backend_instance.execute(sql, options=options) + self._mixed_schema_provisioned = True + + async def _provision_mixed_schema_async(self, backend_instance) -> None: + options = self._ddl_options() + for sql in self._mixed_schema_statements(): + await backend_instance.execute(sql, options=options) + self._mixed_schema_provisioned = True + def _load_sqlserver_schema(self, filename: str) -> str: schema_dir = os.path.join( os.path.dirname(__file__), "..", "rhosocial", "activerecord_sqlserver_test", "feature", "query", "schema" @@ -294,6 +385,42 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: backend_instance.execute(create_sql, params, options=options) return CompositeOrderItemBase + def setup_schema_fixtures(self, scenario_name: str): + """Two models in two distinct schemas (see testsuite schema_models).""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SchemaCustomer, + SchemaOrder, + ) + + backend_class, config = get_scenario(scenario_name) + SchemaCustomer.configure(config, backend_class) + SchemaOrder.configure(config, backend_class) + shared_backend = SchemaCustomer.__backend__ + if shared_backend not in self._active_backends: + self._active_backends.append(shared_backend) + self._provision_cross_schema_sync(shared_backend) + return SchemaCustomer, SchemaOrder + + def setup_mixed_schema_fixtures(self, scenario_name: str): + """(User, Order, MixedSchemaOrder): default users/orders plus orders in SCHEMA_A.""" + from rhosocial.activerecord.testsuite.feature.query.cross_schema.mixed_schema_models import ( + MixedSchemaOrder, + ) + from rhosocial.activerecord.testsuite.feature.query.fixtures.models import Order, User + + user_model, order_model = self._setup_multiple_models( + [(User, "users"), (Order, "orders")], scenario_name + ) + shared_backend = order_model.__backend__ + backend_class, config = get_scenario(scenario_name) + MixedSchemaOrder.__connection_config__ = config + MixedSchemaOrder.__backend_class__ = backend_class + MixedSchemaOrder.__backend__ = shared_backend + if shared_backend not in self._active_backends: + self._active_backends.append(shared_backend) + self._provision_mixed_schema_sync(shared_backend) + return user_model, order_model, MixedSchemaOrder + def cleanup_after_test(self, scenario_name: str): for backend_instance in self._active_backends: for table_name in [ @@ -313,6 +440,17 @@ def cleanup_after_test(self, scenario_name: str): safe_drop_table(backend_instance, backend_instance.dialect, table_name) except Exception: pass + if self._schema_fixtures_provisioned: + try: + self._drop_cross_schema_schemas_sync(backend_instance) + except Exception: + pass + if self._mixed_schema_provisioned: + try: + self._drop_cross_schema_schemas_sync(backend_instance) + except Exception: + pass + self._mixed_schema_provisioned = False try: backend_instance.disconnect() except Exception: @@ -331,6 +469,10 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = " else: raise ValueError("No scenarios registered") config_dict = SCENARIO_MAP[scenario_name] + from providers.pooling import resolve_database_name + pooled_db = resolve_database_name(scenario_name) + if pooled_db: + config_dict = {**config_dict, "database": pooled_db} return { "backend_module": "rhosocial.activerecord.backend.impl.sqlserver", "backend_class_name": backend_class_name, @@ -579,8 +721,62 @@ async def cleanup_after_test(self, scenario_name: str) -> None: await safe_drop_table_async(backend_instance, backend_instance.dialect, table_name) except Exception: pass + if self._schema_fixtures_provisioned: + try: + await self._drop_cross_schema_schemas_async(backend_instance) + except Exception: + pass + if self._mixed_schema_provisioned: + try: + await self._drop_cross_schema_schemas_async(backend_instance) + except Exception: + pass + self._mixed_schema_provisioned = False try: await backend_instance.disconnect() except Exception: pass self._active_async_backends.clear() + + 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.testsuite.feature.query.cross_schema.mixed_schema_models import ( + AsyncMixedSchemaOrder, + ) + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import ( + AsyncOrder, + AsyncUser, + ) + + _, config = get_scenario(scenario_name) + user_model = await self._setup_async_model(AsyncUser, scenario_name, "users") + shared_backend = user_model.__backend__ + if shared_backend not in self._active_async_backends: + self._active_async_backends.append(shared_backend) + order_model = await self._setup_async_model(AsyncOrder, scenario_name, "orders", shared_backend) + + AsyncMixedSchemaOrder.__connection_config__ = config + AsyncMixedSchemaOrder.__backend_class__ = AsyncSQLServerBackend + AsyncMixedSchemaOrder.__backend__ = shared_backend + await self._provision_mixed_schema_async(shared_backend) + return AsyncUser, order_model, AsyncMixedSchemaOrder + + 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.testsuite.feature.query.fixtures.schema_models import ( + AsyncSchemaCustomer, + AsyncSchemaOrder, + ) + + _, config = get_scenario(scenario_name) + await AsyncSchemaCustomer.configure(config, AsyncSQLServerBackend) + AsyncSchemaOrder.__connection_config__ = config + AsyncSchemaOrder.__backend_class__ = AsyncSQLServerBackend + AsyncSchemaOrder.__backend__ = AsyncSchemaCustomer.__backend__ + shared_backend = AsyncSchemaCustomer.__backend__ + if shared_backend not in self._active_async_backends: + self._active_async_backends.append(shared_backend) + await self._provision_cross_schema_async(shared_backend) + return AsyncSchemaCustomer, AsyncSchemaOrder diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 7b9c2fb..2dc5963 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -17,6 +17,10 @@ from .basic_connection import BasicConnectionProvider from .query_connection import QueryConnectionProvider +# Importing the pooling module registers the SQL Server-specific pool reset +# handler (a side effect of the import), mirroring MySQL's tests/providers/pooling.py. +from . import pooling # noqa: F401 + provider_registry = ProviderRegistry() provider_registry.register("feature.basic.IBasicProvider", BasicSyncProvider) diff --git a/tests/providers/scenarios.py b/tests/providers/scenarios.py index c96c7a4..537d450 100644 --- a/tests/providers/scenarios.py +++ b/tests/providers/scenarios.py @@ -1,7 +1,12 @@ +# tests/providers/scenarios.py +"""SQL Server backend test scenario configuration mapping table""" + 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.config import SQLServerConnectionConfig +from rhosocial.activerecord.testsuite.core.pool import pooled_database_name SCENARIO_MAP: Dict[str, Dict[str, Any]] = {} @@ -10,7 +15,13 @@ def register_scenario(name: str, config: Dict[str, Any]): SCENARIO_MAP[name] = config -def get_scenario(name: str) -> Tuple[Type[SQLServerBackend], SQLServerConnectionConfig]: +def get_scenario_raw(name: str) -> Tuple[Type[SQLServerBackend], SQLServerConnectionConfig]: + """Return the backend class and connection config for a scenario. + + The config uses the scenario's configured ``database``; no pool override is + applied. Used by the pool reset handler, which must reach the server + regardless of whether a pooled database exists yet. + """ if name not in SCENARIO_MAP: if SCENARIO_MAP: name = next(iter(SCENARIO_MAP)) @@ -20,6 +31,22 @@ def get_scenario(name: str) -> Tuple[Type[SQLServerBackend], SQLServerConnection return SQLServerBackend, config +def get_scenario(name: str) -> Tuple[Type[SQLServerBackend], SQLServerConnectionConfig]: + """ + Retrieves the backend class and a connection configuration object for a given + scenario name. This is called by the provider to set up the database for a test. + + Under an active database pool the config's ``database`` is replaced by the + worker's pooled database name (``{database}_{index}``) so concurrent workers + never share a schema. Serial runs keep the scenario's configured database. + """ + backend_class, config = get_scenario_raw(name) + pooled_db = pooled_database_name(name) + if pooled_db: + config = replace(config, database=pooled_db) + return backend_class, config + + def get_enabled_scenarios() -> Dict[str, Any]: return SCENARIO_MAP diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_blackbox.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_blackbox.py new file mode 100644 index 0000000..f1f6e99 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_blackbox.py @@ -0,0 +1,111 @@ +# tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py +"""Black-box CLI tests for the SQL Server backend. + +Live server scenarios (127.0.0.1:11433-11435) may be unavailable in the +current environment; live cases skip via a reachability guard while static +cases (command surface, info) always run. +""" + +import io +import json +import socket +from contextlib import redirect_stderr, redirect_stdout + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.__main__ import main +from providers.scenarios import get_scenario_raw + +COMMANDS = [ + "info", "query", "introspect", "status", + "named-expression", "named-procedure", "named-procedure-graph", + "named-migration", "named-connection", +] + + +@pytest.fixture(scope="module") +def conn_args(): + backend_cls, config = get_scenario_raw("sqlserver_2025") + _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _s.settimeout(2) + try: + _s.connect((config.host, int(config.port))) + except OSError: + pytest.skip(f"Scenario server unreachable: {config.host}:{config.port}") + finally: + _s.close() + args = [ + "--host", config.host, + "--port", str(config.port), + "--database", config.database, + "--user", config.username, + "--password", config.password, + ] + if getattr(config, "driver", None): + args += ["--driver", config.driver] + return args + + +def run_cli(argv): + out, err = io.StringIO(), io.StringIO() + exc = None + with redirect_stdout(out), redirect_stderr(err): + try: + main(argv) + except SystemExit as e: + exc = e + return out.getvalue(), err.getvalue(), exc + + +class TestCommandSurface: + """Static: always run, no server needed.""" + + def test_help_lists_all_commands(self): + out, _, _ = run_cli(["--help"]) + for cmd in COMMANDS: + assert cmd in out + + def test_missing_command_errors(self): + _, _, exc = run_cli([]) + assert exc is not None and exc.code == 1 + + def test_help_shows_unified_args(self): + out, _, _ = run_cli(["query", "--help"]) + assert "--user" in out + assert "--username" in out + assert "--ssl" in out + assert "--async" in out + assert "--named-connection" in out + assert "--conn-param" in out + + +class TestInfo: + """Static: info needs no server.""" + + def test_info(self): + out, _, exc = run_cli(["info"]) + assert exc is None + assert "SQL Server" in out or "sqlserver" in out or "sql server" in out.lower() + + +class TestQuery: + """Live: requires the scenario server.""" + + def test_query_json(self, conn_args): + out, err, exc = run_cli(["query"] + conn_args + ["SELECT 1 AS one", "-o", "json"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert json.loads(out) == [{"one": 1}] + + def test_query_async(self, conn_args): + out, _, exc = run_cli(["query"] + conn_args + ["SELECT 1 AS one", "-o", "json", "--async"]) + assert exc is None + assert json.loads(out) == [{"one": 1}] + + +class TestStatus: + """Live: requires the scenario server.""" + + def test_status(self, conn_args): + out, err, exc = run_cli(["status"] + conn_args + ["-o", "json"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert json.loads(out) 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 new file mode 100644 index 0000000..9f588e9 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_info_query.py @@ -0,0 +1,452 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_info_query.py +"""Offline black-box tests for the ``info`` and ``query`` subcommands. + +``info`` runs against its default offline dialect (explicit ``--version``), +a faked successful connection, and a failing connection fallback. +``query`` covers every SQL-source dispatch branch (positional / --file / +stdin / none), the sync/async split, result-shape branches and error exits +with a fake backend; no live server is required. +""" +import argparse +import json +import sys +from types import SimpleNamespace + +import pytest + +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.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 + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + import os + + for key in list(os.environ): + if key.startswith("SQLSERVER_"): + monkeypatch.delenv(key, raising=False) + + +def make_args(create_parser, argv): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + create_parser(sub) + return parser.parse_args(argv) + + +# --------------------------------------------------------------------------- +# info subcommand +# --------------------------------------------------------------------------- + + +class TestInfoParserContract: + def test_output_choices_table_json(self): + assert make_args(info_mod.create_parser, ["info"]).output == "table" + assert make_args(info_mod.create_parser, ["info", "-o", "json"]).output == "json" + with pytest.raises(SystemExit): + make_args(info_mod.create_parser, ["info", "-o", "csv"]) + + def test_version_arg_and_verbose(self): + args = make_args(info_mod.create_parser, ["info", "--version", "14.0.0", "-vv"]) + assert args.version == "14.0.0" + assert args.verbose == 2 + + def test_port_defaults_to_1433(self): + assert make_args(info_mod.create_parser, ["info"]).port == 1433 + + +class TestInfoOffline: + def test_default_version_is_2022(self, capsys): + info = info_mod.handle(make_args(info_mod.create_parser, ["info"])) + assert info["database"]["version"] == "16.0.0" + assert info["database"]["version_tuple"] == [16, 0, 0] + assert info["database"]["connected"] is False + payload = json.loads(capsys.readouterr().out) + assert payload["database"] == info["database"] + + def test_explicit_version_flag(self, capsys): + info = info_mod.handle( + make_args(info_mod.create_parser, ["info", "--version", "14.0.7"]) + ) + assert info["database"]["version"] == "14.0.7" + assert info["database"]["connected"] is False + + def test_json_contains_all_protocol_groups(self, capsys): + info_mod.handle(make_args(info_mod.create_parser, ["info", "-o", "json"])) + payload = json.loads(capsys.readouterr().out) + expected_groups = { + "Query Features", "JOIN Support", "Data Types", "DML Features", + "Transaction & Locking", "Query Analysis", "DDL - Table", "DDL - View", + "DDL - Schema & Index", "DDL - Sequence & Trigger", "String Matching", + "SQL Server Native", + } + assert set(payload["protocols"]) == expected_groups + + def test_verbose_two_includes_method_details(self, capsys): + info_mod.handle(make_args(info_mod.create_parser, ["info", "-vv", "-o", "json"])) + payload = json.loads(capsys.readouterr().out) + native = payload["protocols"]["SQL Server Native"] + stats = next(iter(native.values())) + assert "methods" in stats + + def test_verbose_one_omits_method_details(self, capsys): + info_mod.handle(make_args(info_mod.create_parser, ["info", "-v", "-o", "json"])) + payload = json.loads(capsys.readouterr().out) + entry = payload["protocols"]["Query Features"]["WindowFunctionSupport"] + assert set(entry) == {"supported", "total", "percentage"} + + def test_version_bits_differ_between_versions(self): + old = info_mod.handle( + make_args(info_mod.create_parser, ["info", "--version", "11.0.0", "-o", "json"]) + ) + new = info_mod.handle( + make_args(info_mod.create_parser, ["info", "--version", "13.0.0", "-o", "json"]) + ) + old_json_support = old["protocols"]["Data Types"]["JSONSupport"] + new_json_support = new["protocols"]["Data Types"]["JSONSupport"] + assert old_json_support["supported"] < new_json_support["supported"] + + def test_parse_version_variants(self): + assert info_mod.parse_version("16") == (16, 0, 0) + assert info_mod.parse_version("15.2") == (15, 2, 0) + assert info_mod.parse_version("16.0.4104") == (16, 0, 4104) + + +class TestInfoProtocolHelpers: + @pytest.fixture + def dialect(self): + return SQLServerDialect((16, 0, 0)) + + def test_get_protocol_support_methods_sorted(self): + methods = info_mod.get_protocol_support_methods(ExplainSupport) + assert methods == sorted(methods) + assert "supports_explain_format" in methods + + def test_check_protocol_support_multi_arg_dict(self, dialect): + results = info_mod.check_protocol_support(dialect, ExplainSupport) + fmt = results["supports_explain_format"] + assert fmt == {"supported": 2, "total": 2, "args": {"TEXT": True, "XML": True}} + + def test_calculate_protocol_stats_mixed_values(self): + supported, total = info_mod._calculate_protocol_stats({"a": True, "b": False}) + assert (supported, total) == (1, 2) + supported, total = info_mod._calculate_protocol_stats( + {"fmt": {"supported": 3, "total": 5}} + ) + assert (supported, total) == (3, 5) + + def test_status_style_boundaries(self): + assert info_mod._get_status_style(100) == ("green", "[OK]") + assert info_mod._get_status_style(50)[0] == "yellow" + assert info_mod._get_status_style(10) == ("red", "[~]") + assert info_mod._get_status_style(0) == ("red", "[X]") + + +class TestInfoConnectionBranches: + def test_successful_connection_reports_server_version(self, monkeypatch, capsys): + class ConnectedBackend: + instances = [] + + def __init__(self, connection_config=None): + self.connection_config = connection_config + self.disconnected = False + type(self).instances.append(self) + + def connect(self): + pass + + def introspect_and_adapt(self): + pass + + def get_server_version(self): + return (15, 0, 4202) + + def disconnect(self): + self.disconnected = True + + @property + def dialect(self): + return SQLServerDialect((15, 0, 4202)) + + monkeypatch.setattr(backend_pkg, "SQLServerBackend", ConnectedBackend) + info = info_mod.handle( + make_args(info_mod.create_parser, ["info", "--host", "srv1", "--database", "db"]) + ) + assert info["database"]["connected"] is True + assert info["database"]["version"] == "15.0.4202" + assert ConnectedBackend.instances[-1].disconnected is True + + def test_connection_failure_falls_back_to_default_dialect(self, monkeypatch): + class RefusedBackend: + def __init__(self, connection_config=None): + pass + + def connect(self): + raise ConnectionError("refused") + + monkeypatch.setattr(backend_pkg, "SQLServerBackend", RefusedBackend) + info = info_mod.handle( + make_args(info_mod.create_parser, ["info", "--database", "db"]) + ) + assert info["database"]["connected"] is False + assert info["database"]["version"] == "16.0.0" # default fallback + + +# --------------------------------------------------------------------------- +# query subcommand +# --------------------------------------------------------------------------- + + +class FakeQueryBackendBase: + created = [] + instances = [] + executed = [] + connect_error = None + execute_error = None + result_factory = staticmethod(lambda sql: SimpleNamespace(data=[{"one": 1}], affected_rows=1, duration=0.01)) + + def __init__(self, connection_config=None): + self.connection_config = connection_config + self._connection = object() + self.disconnects = 0 + type(self).created.append(connection_config) + type(self).instances.append(self) + + def connect(self): + if self.connect_error is not None: + raise self.connect_error + + def execute(self, sql): + FakeQueryBackendBase.executed.append(sql) + if self.execute_error is not None: + raise self.execute_error + return type(self).result_factory(sql) + + def disconnect(self): + self.disconnects += 1 + + +class FakeAsyncQueryBackend(FakeQueryBackendBase): + async def connect(self): + FakeQueryBackendBase.connect(self) + + async def execute(self, sql): + return FakeQueryBackendBase.execute(self, sql) + + async def disconnect(self): + self.disconnects += 1 + + +def _tracking_class(base, name): + return type(name, (base,), {"created": [], "instances": []}) + + +@pytest.fixture(autouse=True) +def _reset_fake_state(): + FakeQueryBackendBase.executed = [] + + +class TestQueryParserContract: + def test_sql_positional_optional_with_file_flag(self): + args = make_args(query_mod.create_parser, ["query", "-f", "q.sql"]) + assert args.sql is None and args.file == "q.sql" + + def test_log_level_default_info(self): + assert make_args(query_mod.create_parser, ["query", "SELECT 1"]).log_level == "INFO" + + +class TestQuerySqlSourceDispatch: + def test_invalid_log_level_raises_value_error(self): + with pytest.raises(ValueError, match="Invalid log level"): + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--log-level", "LOUD", "SELECT 1"]) + ) + + def test_no_sql_anywhere_exits_nonzero(self, monkeypatch, capsys): + class TtyStdin: + def isatty(self): + return True + + monkeypatch.setattr(sys, "stdin", TtyStdin()) + with pytest.raises(SystemExit) as exc: + query_mod.handle(make_args(query_mod.create_parser, ["query", "--database", "db"])) + assert exc.value.code == 1 + assert "No SQL query provided" in capsys.readouterr().err + + def test_positional_sql_executed(self, tmp_path, monkeypatch, capsys): + cls = _tracking_class(FakeQueryBackendBase, "QPos") + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--database", "db", "SELECT 1 AS one"]) + ) + assert FakeQueryBackendBase.executed == ["SELECT 1 AS one"] + assert json.loads(capsys.readouterr().out) == [{"one": 1}] + + def test_positional_sql_takes_precedence_over_file(self, tmp_path, monkeypatch): + cls = _tracking_class(FakeQueryBackendBase, "QPrecedence") + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + sql_file = tmp_path / "from_file.sql" + sql_file.write_text("SELECT 'file'", encoding="utf-8") + query_mod.handle( + make_args( + query_mod.create_parser, + ["query", "--database", "db", "SELECT 'positional'", "-f", str(sql_file)], + ) + ) + assert FakeQueryBackendBase.executed == ["SELECT 'positional'"] + + def test_file_source_executed(self, tmp_path, monkeypatch): + cls = _tracking_class(FakeQueryBackendBase, "QFile") + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + sql_file = tmp_path / "q.sql" + sql_file.write_text("SELECT TOP 1 name FROM sys.objects", encoding="utf-8") + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--database", "db", "-f", str(sql_file)]) + ) + assert FakeQueryBackendBase.executed == ["SELECT TOP 1 name FROM sys.objects"] + + def test_missing_file_exits_nonzero(self, monkeypatch, caplog, tmp_path): + monkeypatch.setattr(query_mod, "SQLServerBackend", _tracking_class(FakeQueryBackendBase, "QMiss")) + with pytest.raises(SystemExit) as exc: + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--database", "db", "-f", str(tmp_path / "nope.sql")]) + ) + assert exc.value.code == 1 + assert any("File not found" in r.message for r in caplog.records) + + def test_stdin_source_executed(self, monkeypatch): + class PipeStdin: + def isatty(self): + return False + + def read(self): + return "SELECT COUNT(*) FROM sys.tables" + + monkeypatch.setattr(sys, "stdin", PipeStdin()) + monkeypatch.setattr(query_mod, "SQLServerBackend", _tracking_class(FakeQueryBackendBase, "QStdin")) + query_mod.handle(make_args(query_mod.create_parser, ["query", "--database", "db"])) + assert FakeQueryBackendBase.executed == ["SELECT COUNT(*) FROM sys.tables"] + + def test_empty_stdin_exits_nonzero(self, monkeypatch, capsys): + class EmptyStdin: + def isatty(self): + return False + + def read(self): + return "" + + monkeypatch.setattr(sys, "stdin", EmptyStdin()) + monkeypatch.setattr(query_mod, "SQLServerBackend", _tracking_class(FakeQueryBackendBase, "QEmpty")) + with pytest.raises(SystemExit) as exc: + query_mod.handle(make_args(query_mod.create_parser, ["query", "--database", "db"])) + assert exc.value.code == 1 + assert "No SQL query provided" in capsys.readouterr().err + + def test_multiple_statements_rejected(self, monkeypatch, caplog): + class TtyStdin: + def isatty(self): + return True + + monkeypatch.setattr(sys, "stdin", TtyStdin()) + monkeypatch.setattr(query_mod, "SQLServerBackend", _tracking_class(FakeQueryBackendBase, "QMulti")) + with pytest.raises(SystemExit) as exc: + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--database", "db", "SELECT 1; SELECT 2"]) + ) + assert exc.value.code == 1 + assert any("Multiple SQL statements" in r.message for r in caplog.records) + + def test_trailing_semicolon_single_statement_allowed(self, monkeypatch): + monkeypatch.setattr(query_mod, "SQLServerBackend", _tracking_class(FakeQueryBackendBase, "QTerm")) + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--database", "db", "SELECT 1;"]) + ) + assert FakeQueryBackendBase.executed == ["SELECT 1;"] + + +class TestQueryResultBranches: + def test_result_without_data_writes_empty_json_array(self, monkeypatch, capsys): + cls = _tracking_class(FakeQueryBackendBase, "QNoData") + + def no_data_result(sql): + return SimpleNamespace(data=[], affected_rows=3, duration=0.02) + + cls.result_factory = staticmethod(no_data_result) + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + query_mod.handle(make_args(query_mod.create_parser, ["query", "--database", "db", "UPDATE t SET x=1"])) + assert json.loads(capsys.readouterr().out) == [] + + def test_none_result_object_handled(self, monkeypatch, capsys): + cls = _tracking_class(FakeQueryBackendBase, "QNone") + cls.result_factory = staticmethod(lambda sql: None) + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + query_mod.handle(make_args(query_mod.create_parser, ["query", "--database", "db", "USE master"])) + assert capsys.readouterr().out == "" # only logger output, nothing on stdout + + def test_disconnect_called_in_finally(self, monkeypatch): + cls = _tracking_class(FakeQueryBackendBase, "QDisc") + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + query_mod.handle(make_args(query_mod.create_parser, ["query", "--database", "db", "SELECT 1"])) + assert cls.instances[-1].disconnects >= 1 + + def test_connection_config_resolved_from_args(self, monkeypatch): + cls = _tracking_class(FakeQueryBackendBase, "QCfg") + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + argv = [ + "query", "--host", "h1", "--port", "1444", "--database", "db", + "--username", "alice", "--encrypt", + ] + query_mod.handle(make_args(query_mod.create_parser, argv + ["SELECT 1"])) + config = cls.created[-1] + assert config.host == "h1" and config.port == 1444 and config.database == "db" + assert config.username == "alice" + + +class TestQueryErrors: + @pytest.mark.parametrize( + ("connect_error", "execute_error"), + [(ConnectionError("login failed"), None), (None, QueryError("invalid column")), (None, RuntimeError("boom"))], + ) + def test_error_paths_exit_nonzero(self, monkeypatch, connect_error, execute_error): + cls = _tracking_class(FakeQueryBackendBase, "QErr") + cls.connect_error = connect_error + cls.execute_error = execute_error + monkeypatch.setattr(query_mod, "SQLServerBackend", cls) + with pytest.raises(SystemExit) as exc: + query_mod.handle( + make_args(query_mod.create_parser, ["query", "--database", "db", "SELECT 1"]) + ) + assert exc.value.code != 0 + + +class TestQueryAsync: + def test_async_dispatch_executes_query(self, monkeypatch, capsys): + cls = _tracking_class(FakeAsyncQueryBackend, "QAsync") + monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", cls, raising=False) + query_mod.handle( + make_args( + query_mod.create_parser, + ["query", "--database", "db", "SELECT 1 AS one", "-o", "json", "--async"], + ) + ) + assert FakeQueryBackendBase.executed == ["SELECT 1 AS one"] + assert json.loads(capsys.readouterr().out) == [{"one": 1}] + + @pytest.mark.parametrize( + ("connect_error", "execute_error"), + [(ConnectionError("refused"), None), (None, QueryError("timeout"))], + ) + def test_async_errors_exit_nonzero(self, monkeypatch, connect_error, execute_error): + cls = _tracking_class(FakeAsyncQueryBackend, "QAsyncErr") + cls.connect_error = connect_error + cls.execute_error = execute_error + monkeypatch.setattr(backend_pkg, "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"]) + ) + assert exc.value.code != 0 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 new file mode 100644 index 0000000..2033eda --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py @@ -0,0 +1,313 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_introspect.py +"""Offline black-box tests for the ``introspect`` subcommand. + +All ``cli/introspect.py`` dispatch branches run against a fake backend whose +``introspector`` returns fixed data, so every type (including the SQL Server +specific ``procedures``/``functions``) is covered without a live server. +""" +import argparse +import json + +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.cli import introspect as introspect_mod + + +class FakeIntrospector: + """Fixed-data stand-in recording calls with their kwargs.""" + + instances = [] + # When set, ``get_tables`` returns this instead of the default payload. + tables_override = None + + def __init__(self): + self.calls = [] + type(self).instances.append(self) + + def _record(self, method, **kwargs): + self.calls.append((method, kwargs)) + return [{"method": method, **kwargs}] + + def get_tables(self, schema=None): + if self.tables_override is not None: + return self.tables_override + return self._record("get_tables", schema=schema) + + def get_views(self, schema=None): + return self._record("get_views", schema=schema) + + def get_columns(self, table, schema=None): + return self._record("get_columns", table=table, schema=schema) + + def get_indexes(self, table, schema=None): + return self._record("get_indexes", table=table, schema=schema) + + def get_foreign_keys(self, table, schema=None): + return self._record("get_foreign_keys", table=table, schema=schema) + + def get_triggers(self, table=None, schema=None): + if table == "explode": + raise RuntimeError("trigger probe failed") + return self._record("get_triggers", table=table, schema=schema) + + def get_constraints(self, table=None, schema=None): + return self._record("get_constraints", table=table, schema=schema) + + def get_procedures(self, schema=None): + return self._record("get_procedures", schema=schema) + + def get_functions(self, schema=None): + return self._record("get_functions", schema=schema) + + def get_sequences(self, schema=None): + return self._record("get_sequences", schema=schema) + + +class FakeBackendBase: + created = [] + instances = [] + connect_error = None + + def __init__(self, connection_config=None): + self.connection_config = connection_config + self._connection = object() + self.disconnects = 0 + self.introspector = FakeIntrospector() + type(self).created.append(connection_config) + type(self).instances.append(self) + + def connect(self): + if self.connect_error is not None: + raise self.connect_error + + def disconnect(self): + self.disconnects += 1 + + def introspect_and_adapt(self): + pass + + +class FakeAsyncBackendBase(FakeBackendBase): + async def connect(self): + FakeBackendBase.connect(self) + + async def disconnect(self): + self.disconnects += 1 + + async def introspect_and_adapt(self): + pass + + +def _tracking_class(base, name): + return type(name, (base,), {"created": [], "instances": []}) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + import os + + for key in list(os.environ): + if key.startswith("SQLSERVER_"): + monkeypatch.delenv(key, raising=False) + + +@pytest.fixture(autouse=True) +def _patched(monkeypatch): + """Patch sync backend construction and reset per-test recording state.""" + monkeypatch.setattr( + introspect_mod, "SQLServerBackend", _tracking_class(FakeBackendBase, "FakeIntrospectBackend") + ) + FakeIntrospector.instances = [] + FakeIntrospector.tables_override = None + + +def make_args(argv): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + introspect_mod.create_parser(sub) + return parser.parse_args(argv) + + +class TestParserContract: + def test_sqlserver_specific_types_accepted(self): + assert make_args(["introspect", "procedures"]).type == "procedures" + assert make_args(["introspect", "functions"]).type == "functions" + + def test_schema_defaults_to_dbo(self): + args = make_args(["introspect", "tables"]) + assert args.schema == "dbo" + assert make_args(["introspect", "tables", "--schema", "sales"]).schema == "sales" + + def test_name_optional_and_include_system_flag(self): + args = make_args(["introspect", "columns", "users"]) + assert (args.type, args.name) == ("columns", "users") + assert make_args(["introspect", "triggers"]).include_system is False + + def test_unknown_type_rejected(self): + with pytest.raises(SystemExit): + make_args(["introspect", "grants"]) + + def test_missing_database_exits_before_backend_creation(self, capsys): + with pytest.raises(SystemExit) as exc: + introspect_mod.handle(make_args(["introspect", "tables"])) + assert exc.value.code == 1 + assert "--database is required" in capsys.readouterr().err + assert introspect_mod.SQLServerBackend.created == [] + + +class TestHandleDispatch: + @pytest.mark.parametrize( + "argv", + [ + ["tables"], + ["views"], + ["columns", "users"], + ["indexes", "users"], + ["foreign-keys", "users"], + ["procedures"], + ["functions"], + ["sequences"], + ], + ) + def test_types_dispatch_and_render_json(self, argv, capsys): + introspect_mod.handle(make_args(["introspect"] + argv + ["--database", "db"])) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) and payload[0]["method"] + + def test_tables_receives_schema(self, capsys): + introspect_mod.handle( + make_args(["introspect", "tables", "--database", "db", "--schema", "sales"]) + ) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert method == "get_tables" + assert kwargs["schema"] == "sales" + + def test_table_detail_shows_columns_indexes_fks(self, capsys): + introspect_mod.handle(make_args(["introspect", "table", "users", "--database", "db"])) + inst = FakeIntrospector.instances[-1] + methods = [m for m, _ in inst.calls] + assert methods == ["get_columns", "get_indexes", "get_foreign_keys"] + assert all(kwargs["table"] == "users" for _, kwargs in inst.calls) + + @pytest.mark.parametrize("kind", ["table", "columns", "indexes", "foreign-keys"]) + def test_missing_name_exits_for_table_scoped_types(self, kind, capsys): + with pytest.raises(SystemExit) as exc: + introspect_mod.handle(make_args(["introspect", kind, "--database", "db"])) + assert exc.value.code == 1 + assert "Table name is required" in capsys.readouterr().err + + def test_triggers_with_table_name(self, capsys): + introspect_mod.handle( + make_args(["introspect", "triggers", "users", "--database", "db"]) + ) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert method == "get_triggers" + assert kwargs["table"] == "users" + + def test_triggers_without_name_iterates_all_tables(self): + FakeIntrospector.tables_override = [ + type("T", (), {"name": "users"}), + type("T", (), {"name": "orders"}), + ] + introspect_mod.handle(make_args(["introspect", "triggers", "--database", "db"])) + trigger_calls = [ + k["table"] for m, k in FakeIntrospector.instances[-1].calls if m == "get_triggers" + ] + assert trigger_calls == ["users", "orders"] + + def test_triggers_iteration_swallows_per_table_errors(self): + FakeIntrospector.tables_override = [ + type("T", (), {"name": "explode"}), + type("T", (), {"name": "safe"}), + ] + introspect_mod.handle(make_args(["introspect", "triggers", "--database", "db"])) + trigger_calls = [ + k["table"] for m, k in FakeIntrospector.instances[-1].calls if m == "get_triggers" + ] + assert trigger_calls == ["safe"] # failure probing 'explode' is not fatal + + def test_database_without_name_lists_tables_and_schema(self, capsys): + introspect_mod.handle(make_args(["introspect", "database", "--database", "db"])) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["schema"] == "dbo" + assert payload[0]["tables"][0]["method"] == "get_tables" + + def test_database_with_name_shows_constraints(self, capsys): + introspect_mod.handle( + make_args(["introspect", "database", "users", "--database", "db"]) + ) + method, kwargs = FakeIntrospector.instances[-1].calls[-1] + assert method == "get_constraints" + assert kwargs["table"] == "users" + + +class TestHandleLifecycleAndConfig: + def test_connection_config_resolved_from_args(self): + introspect_mod.handle( + make_args( + ["introspect", "tables", "--host", "srv9", "--port", "1500", "--database", "db"] + ) + ) + config = introspect_mod.SQLServerBackend.created[-1] + assert config.host == "srv9" + assert config.port == 1500 + assert config.database == "db" + + def test_disconnect_called_in_finally(self): + introspect_mod.handle(make_args(["introspect", "views", "--database", "db"])) + assert introspect_mod.SQLServerBackend.instances[-1].disconnects >= 1 + + +class TestHandleErrors: + @pytest.mark.parametrize( + ("backend_error", "introspector_exc"), + [(ConnectionError("refused"), None), (None, QueryError("syntax")), (None, RuntimeError("boom"))], + ) + def test_error_paths_exit_nonzero(self, monkeypatch, backend_error, introspector_exc): + cls = _tracking_class(FakeBackendBase, "ErrBackend") + if introspector_exc is not None: + + class Exploding(FakeIntrospector): + def get_tables(self, schema=None): + raise introspector_exc + + orig_init = cls.__init__ + + def init_with_exploding(self, connection_config=None): + orig_init(self, connection_config=connection_config) + self.introspector = Exploding() + + cls.__init__ = init_with_exploding + else: + cls.connect_error = backend_error + monkeypatch.setattr(introspect_mod, "SQLServerBackend", cls) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle(make_args(["introspect", "tables", "--database", "db"])) + assert exc.value.code != 0 + + +class TestHandleAsync: + def test_async_tables_dispatch(self, monkeypatch, capsys): + monkeypatch.setattr( + backend_pkg, + "AsyncSQLServerBackend", + _tracking_class(FakeAsyncBackendBase, "AsyncFake"), + raising=False, + ) + introspect_mod.handle( + make_args(["introspect", "tables", "--database", "db", "-o", "json", "--async"]) + ) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["method"] == "get_tables" + + 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) + with pytest.raises(SystemExit) as exc: + introspect_mod.handle( + make_args(["introspect", "tables", "--database", "db", "--async"]) + ) + assert exc.value.code != 0 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 new file mode 100644 index 0000000..cbce24c --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_status.py @@ -0,0 +1,469 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/cli/test_cli_status.py +"""Offline black-box tests for the ``status`` subcommand. + +``cli/status.py`` exposes ``create_parser(subparsers) + handle(args)``. The +SQLServerBackend construction point and the SQL Server status introspectors +are monkeypatched with minimal fakes so no live server is required. +""" +import argparse +import json + +import pytest + +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 + +STATUS_INTROSPECTOR_MODULE = ( + "rhosocial.activerecord.backend.impl.sqlserver.introspection.status_introspector" +) +BACKEND_PACKAGE = "rhosocial.activerecord.backend.impl.sqlserver" + + +def _tracking_class(base, name): + """Create a fresh subclass with per-class construction tracking.""" + cls = type(name, (base,), {"created": [], "instances": [], "__init__": base._tracked_init}) + return cls + + +class FakeBackendBase: + """Minimal backend lifecycle: fixed connect/disconnect + captured config.""" + + created = [] + instances = [] + + def _tracked_init(self, connection_config=None): + self.connection_config = connection_config + self._connection = object() # truthy so the handler's finally disconnects + self.disconnects = 0 + type(self).created.append(connection_config) + type(self).instances.append(self) + + connect_error = None + + def connect(self): + if self.connect_error is not None: + raise self.connect_error + + def disconnect(self): + self.disconnects += 1 + + def introspect_and_adapt(self): + pass + + +class FakeAsyncBackendBase(FakeBackendBase): + async def connect(self): + FakeBackendBase.connect(self) + + async def disconnect(self): + self.disconnects += 1 + + async def introspect_and_adapt(self): + pass + + +class RecordingIntrospector: + """Fixed-data introspector stand-in recording dispatched method names.""" + + instances = [] + + def __init__(self, backend): + self.backend = backend + self.calls = [] + type(self).instances.append(self) + + def get_overview(self): + self.calls.append("get_overview") + return { + "server_version": "16.0.0", + "server_vendor": "Microsoft", + "configuration": [], + "databases": [{"name": "master", "size_bytes": 1024}], + } + + def list_configuration(self, category=None): + self.calls.append("list_configuration") + return [{"name": "max server memory", "value": 2147483648, "category": str(category)}] + + def list_performance_metrics(self): + self.calls.append("list_performance_metrics") + return [{"name": "batch requests/sec", "value": 120}] + + def get_connection_info(self): + self.calls.append("get_connection_info") + return {"user": "sa", "database": "master"} + + def get_storage_info(self): + self.calls.append("get_storage_info") + return {"size_bytes": 2048} + + def list_databases(self): + self.calls.append("list_databases") + return [{"name": "master", "size_bytes": 1024}, {"name": "tempdb", "size_bytes": None}] + + def list_users(self): + self.calls.append("list_users") + return [{"name": "dbo"}] + + +class AsyncRecordingIntrospector(RecordingIntrospector): + async def get_overview(self): + return super().get_overview() + + async def list_configuration(self, category=None): + return super().list_configuration(category) + + async def list_performance_metrics(self): + return super().list_performance_metrics() + + async def get_connection_info(self): + return super().get_connection_info() + + async def get_storage_info(self): + return super().get_storage_info() + + async def list_databases(self): + return super().list_databases() + + async def list_users(self): + return super().list_users() + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Remove SQLSERVER_* env vars so parser defaults are deterministic.""" + import os + + for key in list(os.environ): + if key.startswith("SQLSERVER_"): + monkeypatch.delenv(key, raising=False) + + +@pytest.fixture +def fake_backend_cls(): + return _tracking_class(FakeBackendBase, "FakeStatusBackend") + + +@pytest.fixture +def fake_async_backend_cls(): + return _tracking_class(FakeAsyncBackendBase, "FakeStatusAsyncBackend") + + +def make_args(argv): + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + status_mod.create_parser(sub) + return parser.parse_args(argv) + + +class TestParserContract: + def test_port_defaults_to_1433(self): + assert make_args(["status"]).port == 1433 + + def test_driver_default_is_odbc_18(self): + assert make_args(["status"]).driver == "ODBC Driver 18 for SQL Server" + + def test_encrypt_flags_default_to_none_sentinel(self): + # None means "flag absent": resolution then derives from --ssl. + assert make_args(["status"]).encrypt is None + assert make_args(["status", "--encrypt"]).encrypt is True + assert make_args(["status", "--no-encrypt"]).encrypt is False + + def test_encrypt_flags_are_mutually_exclusive(self): + with pytest.raises(SystemExit): + make_args(["status", "--encrypt", "--no-encrypt"]) + + def test_trust_server_certificate_defaults_to_none_sentinel(self): + assert make_args(["status"]).trust_server_certificate is None + assert ( + make_args(["status", "--trust-server-certificate"]).trust_server_certificate + is True + ) + assert ( + make_args(["status", "--no-trust-server-certificate"]).trust_server_certificate + is False + ) + + def test_output_choices_and_default(self): + assert make_args(["status", "-o", "csv"]).output == "csv" + assert make_args(["status"]).output == "table" + with pytest.raises(SystemExit): + make_args(["status", "-o", "yaml"]) + + def test_type_choices_accept_known_reject_unknown(self): + assert make_args(["status", "storage"]).type == "storage" + with pytest.raises(SystemExit): + make_args(["status", "bogus"]) + + def test_verbose_counts_and_rich_ascii_flag(self): + args = make_args(["status", "-vv", "--rich-ascii"]) + assert args.verbose == 2 + assert args.rich_ascii is True + + +class TestHandleGuards: + def test_missing_database_exits_before_backend_creation( + self, fake_backend_cls, monkeypatch, capsys + ): + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + with pytest.raises(SystemExit) as exc: + status_mod.handle(make_args(["status"])) + assert exc.value.code == 1 + assert "--database is required" in capsys.readouterr().err + assert fake_backend_cls.created == [] + + +class TestHandleSyncDispatch: + @pytest.mark.parametrize( + ("status_type", "method"), + [ + ("config", "list_configuration"), + ("performance", "list_performance_metrics"), + ("connections", "get_connection_info"), + ("storage", "get_storage_info"), + ("databases", "list_databases"), + ("users", "list_users"), + ], + ) + def test_type_dispatches_to_introspector_method( + self, fake_backend_cls, monkeypatch, capsys, status_type, method + ): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + RecordingIntrospector.instances = [] + status_mod.handle(make_args(["status", status_type, "--database", "db", "-o", "json"])) + json.loads(capsys.readouterr().out) # well-formed JSON emitted + assert method in RecordingIntrospector.instances[-1].calls + + def test_overview_dispatch_and_json_payload(self, fake_backend_cls, monkeypatch, capsys): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + RecordingIntrospector.instances = [] + status_mod.handle(make_args(["status", "all", "--database", "db"])) + payload = json.loads(capsys.readouterr().out) + assert payload["server_version"] == "16.0.0" + assert payload["server_vendor"] == "Microsoft" + assert "get_overview" in RecordingIntrospector.instances[-1].calls + + @pytest.mark.parametrize("fmt", ["csv", "tsv"]) + def test_csv_tsv_fall_back_to_json_for_overview( + self, fake_backend_cls, monkeypatch, capsys, fmt + ): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle(make_args(["status", "all", "--database", "db", "-o", fmt])) + payload = json.loads(capsys.readouterr().out) + assert payload["server_version"] == "16.0.0" + + def test_table_format_without_rich_renders_json( + self, fake_backend_cls, monkeypatch, capsys + ): + assert status_mod.RICH_AVAILABLE is False # rich not installed in this env + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle( + make_args(["status", "databases", "--database", "db", "-o", "table"]) + ) + payload = json.loads(capsys.readouterr().out) + assert {row["name"] for row in payload} == {"master", "tempdb"} + + def test_config_csv_output_writes_header_row( + self, fake_backend_cls, monkeypatch, capsys + ): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle(make_args(["status", "config", "--database", "db", "-o", "csv"])) + first_line = capsys.readouterr().out.splitlines()[0] + assert first_line == "name,value,category" + + def test_disconnect_called_in_finally(self, fake_backend_cls, monkeypatch, capsys): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle(make_args(["status", "users", "--database", "db"])) + assert fake_backend_cls.instances[-1].disconnects >= 1 + + def test_connection_config_resolved_from_args(self, fake_backend_cls, monkeypatch, capsys): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + argv = [ + "status", "users", "--host", "srv1", "--port", "1444", "--database", "db", + "--user", "bob", "--password", "pw", "--driver", "ODBC Driver 17 for SQL Server", + ] + status_mod.handle(make_args(argv)) + config = fake_backend_cls.created[-1] + assert (config.host, config.port, config.database) == ("srv1", 1444, "db") + assert (config.username, config.password) == ("bob", "pw") + assert config.driver == "ODBC Driver 17 for SQL Server" + + @pytest.mark.parametrize( + ("ssl_mode", "expected_encrypt", "expected_trust"), + [ + ("auto", False, True), + ("disabled", False, True), + ("require", True, True), + ("verify-ca", True, False), + ("verify-full", True, False), + ], + ) + def test_ssl_matrix_without_explicit_flags( + self, fake_backend_cls, monkeypatch, ssl_mode, expected_encrypt, expected_trust + ): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle( + make_args(["status", "users", "--database", "db", "--ssl", ssl_mode]) + ) + config = fake_backend_cls.created[-1] + assert config.encrypt is expected_encrypt + assert config.trust_server_certificate is expected_trust + + @pytest.mark.parametrize( + ("extra_args", "expected_encrypt", "expected_trust"), + [ + (["--ssl", "require", "--no-encrypt"], False, True), + (["--ssl", "disabled", "--encrypt"], True, True), + (["--ssl", "verify-full", "--trust-server-certificate"], True, True), + (["--ssl", "verify-ca", "--no-trust-server-certificate"], True, False), + (["--ssl", "auto", "--no-trust-server-certificate"], False, False), + (["--ssl", "auto", "--encrypt"], True, True), + ], + ) + def test_explicit_flags_take_precedence_over_ssl_derivation( + self, fake_backend_cls, monkeypatch, extra_args, expected_encrypt, expected_trust + ): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle(make_args(["status", "users", "--database", "db"] + extra_args)) + config = fake_backend_cls.created[-1] + assert config.encrypt is expected_encrypt + assert config.trust_server_certificate is expected_trust + + def test_ssl_disabled_disables_encrypt(self, fake_backend_cls, monkeypatch): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle( + make_args(["status", "users", "--database", "db", "--ssl", "disabled"]) + ) + config = fake_backend_cls.created[-1] + assert config.encrypt is False + assert config.trust_server_certificate is True + + def test_ssl_verify_full_requires_cert_check(self, fake_backend_cls, monkeypatch): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle( + make_args(["status", "users", "--database", "db", "--ssl", "verify-full"]) + ) + config = fake_backend_cls.created[-1] + assert config.encrypt is True + assert config.trust_server_certificate is False + + def test_trusted_connection_passthrough(self, fake_backend_cls, monkeypatch): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", RecordingIntrospector + ) + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + status_mod.handle( + make_args(["status", "users", "--database", "db", "--trusted-connection"]) + ) + assert fake_backend_cls.created[-1].trusted_connection is True + + +class TestHandleErrors: + def _run_with(self, fake_backend_cls, monkeypatch, backend_error=None, introspector_exc=None): + if backend_error is not None: + backend_cls = _tracking_class(FakeBackendBase, "ErrBackend") + backend_cls.connect_error = backend_error + monkeypatch.setattr(status_mod, "SQLServerBackend", backend_cls) + else: + monkeypatch.setattr(status_mod, "SQLServerBackend", fake_backend_cls) + if introspector_exc is not None: + + class ExplodingIntrospector(RecordingIntrospector): + def get_overview(self): + raise introspector_exc + + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", + ExplodingIntrospector, + ) + else: + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.SyncSQLServerStatusIntrospector", + RecordingIntrospector, + ) + with pytest.raises(SystemExit) as exc: + status_mod.handle(make_args(["status", "all", "--database", "db", "-o", "json"])) + return exc.value.code + + def test_connection_error_exits_nonzero(self, fake_backend_cls, monkeypatch): + code = self._run_with(fake_backend_cls, monkeypatch, backend_error=ConnectionError("no route")) + assert code != 0 + + def test_query_error_exits_nonzero(self, fake_backend_cls, monkeypatch): + code = self._run_with(fake_backend_cls, monkeypatch, introspector_exc=QueryError("bad query")) + assert code != 0 + + def test_unexpected_error_exits_nonzero_with_stderr( + self, fake_backend_cls, monkeypatch, capsys + ): + code = self._run_with(fake_backend_cls, monkeypatch, introspector_exc=RuntimeError("boom")) + assert code != 0 + assert "Error during status retrieval" in capsys.readouterr().err + + +class TestHandleAsync: + def test_async_overview_dispatch(self, fake_async_backend_cls, monkeypatch, capsys): + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.AsyncSQLServerStatusIntrospector", + AsyncRecordingIntrospector, + ) + monkeypatch.setattr(backend_pkg, "AsyncSQLServerBackend", fake_async_backend_cls, raising=False) + status_mod.handle( + make_args(["status", "all", "--database", "db", "-o", "json", "--async"]) + ) + payload = json.loads(capsys.readouterr().out) + assert payload["server_vendor"] == "Microsoft" + + 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) + with pytest.raises(SystemExit) as exc: + status_mod.handle(make_args(["status", "all", "--database", "db", "--async"])) + assert exc.value.code == 1 + + def test_async_query_error_exits_nonzero(self, fake_async_backend_cls, monkeypatch): + class ExplodingAsync(AsyncRecordingIntrospector): + async def get_overview(self): + raise QueryError("timeout") + + monkeypatch.setattr( + f"{STATUS_INTROSPECTOR_MODULE}.AsyncSQLServerStatusIntrospector", ExplodingAsync + ) + monkeypatch.setattr(backend_pkg, "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 ac6169f..4133a3c 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/conftest.py @@ -74,6 +74,12 @@ def get_scenario(name: str) -> Tuple[Type[SQLServerBackend], SQLServerConnection else: raise ValueError("No scenarios registered") scenario_config = SCENARIO_MAP[name].copy() + # Apply the pooled database name so parallel workers never share a schema. + from providers.pooling import resolve_database_name + + pooled_db = resolve_database_name(name) + if pooled_db: + scenario_config["database"] = pooled_db config = SQLServerConnectionConfig(**scenario_config) return SQLServerBackend, config diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapter_suggestions.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapter_suggestions.py new file mode 100644 index 0000000..650622c --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapter_suggestions.py @@ -0,0 +1,51 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapter_suggestions.py +"""Offline coverage for SQLServerBackend type-adapter suggestions. + +``get_default_adapter_suggestions`` is pure logic over the adapter registry; +exercising it needs no live server and closes a noted coverage gap in +backend_mixin (see .claude/plan/2026-08-26/coverage-restoration.md). +""" +import uuid +from datetime import date, datetime, time +from decimal import Decimal + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.backend import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig + + +@pytest.fixture(scope="module") +def backend(): + config = SQLServerConnectionConfig( + host="127.0.0.1", + port=11433, + database="master", + username="sa", + password="not-used-offline", + ) + return SQLServerBackend(connection_config=config) + + +class TestDefaultAdapterSuggestions: + def test_returns_mapping_for_registered_types(self, backend): + suggestions = backend.get_default_adapter_suggestions() + for py_type in (datetime, date, time, Decimal): + assert py_type in suggestions, f"missing suggestion for {py_type}" + + def test_primitives_rely_on_default_binding(self, backend): + """bool/int/float/str/bytes are intentionally not pre-registered.""" + suggestions = backend.get_default_adapter_suggestions() + for py_type in (bool, int, float, str, bytes): + assert py_type not in suggestions + + def test_complex_types_map_to_string_storage(self, backend): + suggestions = backend.get_default_adapter_suggestions() + for py_type in (uuid.UUID, dict, list): + _, db_type = suggestions[py_type] + assert db_type is str + + def test_result_is_cached(self, backend): + first = backend.get_default_adapter_suggestions() + second = backend.get_default_adapter_suggestions() + assert first is second diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapters_offline.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapters_offline.py new file mode 100644 index 0000000..8368c95 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapters_offline.py @@ -0,0 +1,374 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_adapters_offline.py +"""Offline bidirectional conversion tests for ``impl/sqlserver/adapters.py``. + +Every adapter is exercised for to_database / from_database round trips, +NULL semantics, empty-string semantics and error paths. No backend or +server is involved. +""" +import json +import uuid +import xml.etree.ElementTree as ET +from datetime import date, datetime, time, timezone +from decimal import Decimal + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.adapters import ( + SQLServerDateAdapter, + SQLServerDateTimeAdapter, + SQLServerDateTimeOffsetAdapter, + SQLServerDecimalAdapter, + SQLServerHierarchyIdAdapter, + SQLServerJSONAdapter, + SQLServerSpatialAdapter, + SQLServerTimeAdapter, + SQLServerUUIDAdapter, + SQLServerXMLAdapter, + _parse_sql_datetime, +) + + +class TestParseSqlDatetime: + def test_seven_digit_fraction_is_truncated_and_padded(self): + # ODBC delivers datetime2 with 100ns precision (7 fractional digits). + result = _parse_sql_datetime("2026-08-09 05:25:26.5205700") + assert result == datetime(2026, 8, 9, 5, 25, 26, 520570, tzinfo=timezone.utc) + + def test_naive_value_gets_utc(self): + assert _parse_sql_datetime("2026-08-09 05:25:26").tzinfo == timezone.utc + + def test_z_suffix_becomes_utc(self): + result = _parse_sql_datetime("2026-08-09T05:25:26Z") + assert result.utcoffset().total_seconds() == 0 + + def test_explicit_offset_preserved(self): + result = _parse_sql_datetime("2026-08-09T05:25:26.1234567+05:30") + assert (result.hour, result.minute, result.second, result.microsecond) == (5, 25, 26, 123456) + assert result.tzinfo is not None and result.utcoffset().total_seconds() == 5 * 3600 + 1800 + + +class TestSQLServerUUIDAdapter: + @pytest.fixture + def adapter(self): + return SQLServerUUIDAdapter() + + def test_supported_types(self, adapter): + assert adapter.supported_types[uuid.UUID] == ["uniqueidentifier"] + assert "uniqueidentifier" in adapter.supported_types[str] + + def test_uuid_to_database_renders_canonical_string(self, adapter): + value = uuid.UUID("12345678123456781234567812345678") + assert adapter.to_database(value) == "12345678-1234-5678-1234-567812345678" + + def test_valid_string_passthrough_keeps_case(self, adapter): + upper = "12345678-1234-5678-1234-567812345678".upper() + assert adapter.to_database(upper) == upper # validated but not normalized + + def test_invalid_uuid_string_raises(self, adapter): + with pytest.raises(ValueError, match="Invalid UUID string"): + adapter.to_database("not-a-uuid") + + def test_unsupported_type_raises(self, adapter): + with pytest.raises(TypeError): + adapter.to_database(42) + + def test_from_database_roundtrip(self, adapter): + value = uuid.uuid4() + assert adapter.from_database(value) is value + assert adapter.from_database(str(value).upper()) == value + + def test_null_semantics(self, adapter): + assert adapter.to_database(None) is None + assert adapter.from_database(None) is None + + +class TestSQLServerDateTimeAdapter: + @pytest.fixture + def adapter(self): + return SQLServerDateTimeAdapter() + + def test_supported_types(self, adapter): + assert set(adapter.supported_types[datetime]) == {"datetime2", "datetime", "smalldatetime"} + + def test_datetime_instance_passthrough(self, adapter): + value = datetime(2026, 8, 9, 5, 25, 26) + assert adapter.to_database(value) is value + + def test_iso_string_accepted(self, adapter): + assert adapter.to_database("2026-08-09T05:25:26") == datetime(2026, 8, 9, 5, 25, 26) + + def test_epoch_number_becomes_utc_aware(self, adapter): + result = adapter.to_database(0) + assert (result.year, result.tzname()) in {(1970, "UTC"), (1970, "UTC+00:00")} + + def test_invalid_string_falls_through_to_type_error(self, adapter): + with pytest.raises(TypeError): + adapter.to_database("") # not ISO, not numeric -> TypeError + + def test_from_database_naive_gets_utc(self, adapter): + naive = datetime(2026, 8, 9, 5, 25, 26) + assert adapter.from_database(naive).tzinfo == timezone.utc + + def test_from_database_string_uses_sql_parser(self, adapter): + result = adapter.from_database("2026-08-09 05:25:26.5205700") + assert result.microsecond == 520570 + + def test_roundtrip(self, adapter): + # Aware values survive unchanged; naive values come back UTC-qualified. + aware = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + assert adapter.from_database(adapter.to_database(aware)) == aware + naive = datetime(2026, 1, 2, 3, 4, 5) + roundtripped = adapter.from_database(adapter.to_database(naive)) + assert (roundtripped.year, roundtripped.month, roundtripped.day) == (2026, 1, 2) + + +class TestSQLServerDateTimeOffsetAdapter: + @pytest.fixture + def adapter(self): + return SQLServerDateTimeOffsetAdapter() + + def test_supported_types(self, adapter): + assert adapter.supported_types == {datetime: ["datetimeoffset"]} + + def test_naive_input_normalized_to_utc(self, adapter): + result = adapter.to_database(datetime(2026, 8, 9)) + assert result.tzinfo == timezone.utc + + def test_aware_input_offset_preserved(self, adapter): + from datetime import timedelta + + aware = datetime(2026, 8, 9, tzinfo=timezone(timedelta(hours=5))) + assert adapter.to_database(aware) is aware + + def test_non_datetime_raises(self, adapter): + with pytest.raises(TypeError): + adapter.to_database("2026-08-09") # strings rejected on the way in + + def test_from_database_accepts_datetime_and_sql_strings(self, adapter): + value = datetime(2026, 8, 9, 5, 25, 26, tzinfo=timezone.utc) + assert adapter.from_database(value) is value + assert adapter.from_database("2026-08-09 05:25:26.5205700").microsecond == 520570 + + def test_null_semantics(self, adapter): + assert adapter.to_database(None) is None + assert adapter.from_database(None) is None + + +class TestSQLServerDateAdapter: + @pytest.fixture + def adapter(self): + return SQLServerDateAdapter() + + def test_date_roundtrip(self, adapter): + value = date(2026, 8, 9) + assert adapter.to_database(value) is value + assert adapter.from_database(value) is value + + def test_iso_string_parsed(self, adapter): + assert adapter.from_database("2026-08-09") == date(2026, 8, 9) + + def test_datetime_truncated_to_date_both_directions(self, adapter): + # datetime must be checked before date so the time component is cut. + value = datetime(2026, 8, 9, 5) + assert adapter.to_database(value) == date(2026, 8, 9) + assert adapter.to_database(value) is not value + assert adapter.from_database(value) == date(2026, 8, 9) + + def test_date_only_bidirectional_passthrough(self, adapter): + value = date(2026, 8, 26) + assert adapter.to_database(value) is value + assert adapter.from_database(value) is value + + def test_error_paths(self, adapter): + with pytest.raises(TypeError): + adapter.to_database("2026-08-09") + with pytest.raises(TypeError): + adapter.from_database(12345) + + +class TestSQLServerTimeAdapter: + @pytest.fixture + def adapter(self): + return SQLServerTimeAdapter() + + def test_time_roundtrip(self, adapter): + value = time(5, 25, 26) + assert adapter.to_database(value) is value + assert adapter.from_database(value) is value + + def test_datetime_extracts_time_component(self, adapter): + assert adapter.to_database(datetime(2026, 8, 9, 5, 25, 26)) == time(5, 25, 26) + assert adapter.from_database(datetime(2026, 8, 9, 5, 25, 26)) == time(5, 25, 26) + + def test_iso_string_parsed(self, adapter): + assert adapter.from_database("05:25:26") == time(5, 25, 26) + + def test_null_and_error_paths(self, adapter): + assert adapter.to_database(None) is None + with pytest.raises(TypeError): + adapter.to_database(12.5) + with pytest.raises(TypeError): + adapter.from_database(object()) + + +class TestSQLServerJSONAdapter: + @pytest.fixture + def adapter(self): + return SQLServerJSONAdapter() + + def test_dict_and_list_serialize_to_json_text(self, adapter): + assert adapter.to_database({"a": 1}) == '{"a": 1}' + assert adapter.to_database([1, 2]) == "[1, 2]" + + def test_valid_json_string_passthrough_after_validation(self, adapter): + raw = '{"a": [1, 2]}' + assert adapter.to_database(raw) is raw + + def test_invalid_json_string_rejected(self, adapter): + with pytest.raises(json.JSONDecodeError): + adapter.to_database("") + with pytest.raises(json.JSONDecodeError): + adapter.to_database("{nope}") + + def test_from_database_parses_strings_only(self, adapter): + assert adapter.from_database('{"a": 1}') == {"a": 1} + assert adapter.from_database({"a": 1}) == {"a": 1} + with pytest.raises(TypeError): + adapter.from_database(15) + + def test_storage_target_is_nvarchar_max(self, adapter): + # SQL Server has no native JSON type; NVARCHAR(MAX) is the storage. + assert adapter.supported_types[dict][0] == "nvarchar(max)" + + def test_null_semantics(self, adapter): + assert adapter.to_database(None) is None + assert adapter.from_database(None) is None + + +class TestSQLServerXMLAdapter: + @pytest.fixture + def adapter(self): + return SQLServerXMLAdapter() + + def test_str_and_bytes_both_directions(self, adapter): + assert adapter.to_database("") == "" + assert adapter.to_database(b"") == "" + assert adapter.from_database("") == "" + assert adapter.from_database(b"") == "" + + def test_empty_string_is_valid_xml_document(self, adapter): + # Empty string passes through unchanged (unlike JSON, no parse step). + assert adapter.to_database("") == "" + assert adapter.from_database("") == "" + + def test_unsupported_type_raises(self, adapter): + with pytest.raises(TypeError): + adapter.to_database(15) + with pytest.raises(TypeError): + adapter.from_database(15) + + def test_scalar_dict_renders_text_node(self, adapter): + assert adapter.to_database({"a": 1}) == "1" + + def test_dict_roundtrip_covers_nested_list_and_text_nodes(self, adapter): + payload = { + "name": "users", # text node + "meta": {"created": "2026"}, # nested element + "tag": ["a", "b"], # repeated child elements + } + root = ET.fromstring(adapter.to_database(payload)) + assert root.tag == "root" + assert root.findtext("name") == "users" + assert root.find("meta").findtext("created") == "2026" + assert [elem.text for elem in root.findall("tag")] == ["a", "b"] + + +class TestSQLServerSpatialAdapter: + @pytest.fixture + def adapter(self): + return SQLServerSpatialAdapter() + + def test_point_wkt(self, adapter): + assert adapter._dict_to_wkt({"type": "Point", "coordinates": [1, 2]}) == "POINT(1 2)" + + def test_linestring_separates_pairs_with_commas(self, adapter): + assert ( + adapter._dict_to_wkt({"type": "LineString", "coordinates": [[1, 2], [3, 4]]}) + == "LINESTRING(1 2, 3 4)" + ) + + def test_polygon_wkt_single_ring(self, adapter): + ring = [[[0, 0], [0, 1], [1, 1], [0, 0]]] + assert ( + adapter._dict_to_wkt({"type": "Polygon", "coordinates": ring}) + == "POLYGON((0 0, 0 1, 1 1, 0 0))" + ) + + def test_unknown_geometry_type_raises(self, adapter): + with pytest.raises(ValueError, match="Unsupported geometry type"): + adapter._dict_to_wkt({"type": "MultiSurface"}) + + def test_str_bytes_passthrough_on_the_way_in(self, adapter): + wkt = "POINT(1 2)" + assert adapter.to_database(wkt) is wkt + assert adapter.to_database(b"WKB-BLOB") == b"WKB-BLOB" # bytes kept binary + + def test_from_database_decodes_bytes(self, adapter): + assert adapter.from_database(b"POINT(1 2)") == "POINT(1 2)" + + def test_null_semantics(self, adapter): + assert adapter.to_database(None) is None + assert adapter.from_database(None) is None + + +class TestSQLServerHierarchyIdAdapter: + @pytest.fixture + def adapter(self): + return SQLServerHierarchyIdAdapter() + + def test_list_rendered_as_slash_path(self, adapter): + assert adapter.to_database([1, 2, 3]) == "/1/2/3/" + assert adapter.to_database([]) == "//" # empty path still fully delimited + + def test_string_passthrough(self, adapter): + assert adapter.to_database("/1/2/") == "/1/2/" + + def test_from_database_stringifies_anything(self, adapter): + assert adapter.from_database(5) == "5" + assert adapter.from_database("/1/") == "/1/" + + def test_null_semantics(self, adapter): + assert adapter.to_database(None) is None + assert adapter.from_database(None) is None + + +class TestSQLServerDecimalAdapter: + @pytest.fixture + def adapter(self): + return SQLServerDecimalAdapter() + + def test_supported_types_include_money(self, adapter): + types = adapter.supported_types + assert types[Decimal] == ["decimal", "numeric", "money", "smallmoney"] + + def test_decimal_converted_to_float_for_driver(self, adapter): + assert isinstance(adapter.to_database(Decimal("1.50")), float) + assert adapter.to_database(Decimal("1.50")) == 1.5 + + def test_numeric_passthrough(self, adapter): + assert adapter.to_database(7) == 7 + assert adapter.to_database(2.5) == 2.5 + + def test_string_rejected_on_the_way_in(self, adapter): + with pytest.raises(TypeError): + adapter.to_database("1.50") + + def test_from_database_normalizes_everything_to_decimal(self, adapter): + assert adapter.from_database(Decimal("1.5")) == Decimal("1.5") + assert adapter.from_database(15) == Decimal("15") + assert adapter.from_database(1.5) == Decimal(str(1.5)) + assert adapter.from_database("2.25") == Decimal("2.25") + + def test_null_semantics(self, adapter): + assert adapter.to_database(None) is None + assert adapter.from_database(None) is None 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 e5cbc00..fc63825 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 @@ -36,7 +36,7 @@ async def async_isolation_test_table(async_sqlserver_backend): ) """) await async_sqlserver_backend.execute( - "insert into async_isolation_test (name, balance) values (%s, %s)", + "insert into async_isolation_test (name, balance) values (?, ?)", ("user1", Decimal("100.00")) ) yield "async_isolation_test" @@ -55,7 +55,7 @@ async def async_mode_test_table(async_sqlserver_backend): ) """) await async_sqlserver_backend.execute( - "insert into async_mode_test (name, balance) values (%s, %s)", + "insert into async_mode_test (name, balance) values (?, ?)", ("account1", Decimal("1000.00")) ) yield "async_mode_test" @@ -74,7 +74,7 @@ async def async_combo_test_table(async_sqlserver_backend): ) """) await async_sqlserver_backend.execute( - "insert into async_combo_test (name, balance) values (%s, %s)", + "insert into async_combo_test (name, balance) values (?, ?)", ("account1", Decimal("1000.00")) ) yield "async_combo_test" @@ -116,7 +116,7 @@ async def transaction1(): async with async_sqlserver_backend.transaction(): await asyncio.wait_for(updated_event.wait(), timeout=5) rows = await async_sqlserver_backend.fetch_all( - "select balance from async_isolation_test where name = %s", + "select balance from async_isolation_test where name = ?", ("user1",) ) if rows and rows[0]["balance"] == Decimal("200.00"): @@ -130,7 +130,7 @@ async def transaction2(): async_sqlserver_control_backend.transaction_manager.isolation_level = IsolationLevel.READ_UNCOMMITTED async with async_sqlserver_control_backend.transaction(): await async_sqlserver_control_backend.execute( - "update async_isolation_test set balance = %s where name = %s", + "update async_isolation_test set balance = ? where name = ?", (Decimal("200.00"), "user1") ) updated_event.set() @@ -154,7 +154,7 @@ async def transaction1(): async with async_sqlserver_backend.transaction(): await asyncio.sleep(0.15) rows = await async_sqlserver_backend.fetch_all( - "select balance from async_isolation_test where name = %s", + "select balance from async_isolation_test where name = ?", ("user1",) ) if rows and rows[0]["balance"] != Decimal("200.00"): @@ -170,7 +170,7 @@ async def transaction2(): async_sqlserver_control_backend.transaction_manager.isolation_level = IsolationLevel.READ_COMMITTED async with async_sqlserver_control_backend.transaction(): await async_sqlserver_control_backend.execute( - "update async_isolation_test set balance = %s where name = %s", + "update async_isolation_test set balance = ? where name = ?", (Decimal("200.00"), "user1") ) await asyncio.sleep(0.2) @@ -198,7 +198,7 @@ async def transaction1(): async with async_sqlserver_backend.transaction(): # First read rows1 = await async_sqlserver_backend.fetch_all( - "select balance from async_isolation_test where name = %s", + "select balance from async_isolation_test where name = ?", ("user1",) ) read_values.append(rows1[0]["balance"]) @@ -208,7 +208,7 @@ async def transaction1(): # Second read (should be same as first) rows2 = await async_sqlserver_backend.fetch_all( - "select balance from async_isolation_test where name = %s", + "select balance from async_isolation_test where name = ?", ("user1",) ) read_values.append(rows2[0]["balance"]) @@ -222,7 +222,7 @@ async def transaction2(): async_sqlserver_control_backend.transaction_manager.isolation_level = IsolationLevel.READ_COMMITTED async with async_sqlserver_control_backend.transaction(): await async_sqlserver_control_backend.execute( - "update async_isolation_test set balance = %s where name = %s", + "update async_isolation_test set balance = ? where name = ?", (Decimal("200.00"), "user1") ) except Exception as e: @@ -253,7 +253,7 @@ async def transaction1(): async with async_sqlserver_backend.transaction(): # First count rows1 = await async_sqlserver_backend.fetch_all( - "select count(*) as cnt from async_isolation_test where balance > %s", + "select count(*) as cnt from async_isolation_test where balance > ?", (Decimal("50.00"),) ) initial_count.append(rows1[0]["cnt"]) @@ -263,7 +263,7 @@ async def transaction1(): # Second count (should be same) rows2 = await async_sqlserver_backend.fetch_all( - "select count(*) as cnt from async_isolation_test where balance > %s", + "select count(*) as cnt from async_isolation_test where balance > ?", (Decimal("50.00"),) ) second_count.append(rows2[0]["cnt"]) @@ -278,7 +278,7 @@ async def transaction2(): async with async_sqlserver_control_backend.transaction(): # Try to insert a row that matches the condition await async_sqlserver_control_backend.execute( - "insert into async_isolation_test (name, balance) values (%s, %s)", + "insert into async_isolation_test (name, balance) values (?, ?)", ("user2", Decimal("75.00")) ) insert_blocked.append(False) @@ -321,7 +321,7 @@ async def test_read_only_rejects_writes(self, async_sqlserver_backend, async_mod with pytest.raises(Exception): async with async_sqlserver_backend.transaction(): await async_sqlserver_backend.execute( - "update async_mode_test set balance = %s where name = %s", + "update async_mode_test set balance = ? where name = ?", (Decimal("500.00"), "account1") ) @@ -332,12 +332,12 @@ async def test_read_write_allows_writes(self, async_sqlserver_backend, async_mod async with async_sqlserver_backend.transaction(): await async_sqlserver_backend.execute( - "update async_mode_test set balance = %s where name = %s", + "update async_mode_test set balance = ? where name = ?", (Decimal("500.00"), "account1") ) rows = await async_sqlserver_backend.fetch_all( - "select balance from async_mode_test where name = %s", + "select balance from async_mode_test where name = ?", ("account1",) ) assert rows[0]["balance"] == Decimal("500.00") @@ -509,14 +509,14 @@ async def test_nested_transaction_with_isolation_level(self, async_sqlserver_bac async with async_sqlserver_backend.transaction(): await async_sqlserver_backend.execute( - "insert into async_nested_test (name, value) values (%s, %s)", + "insert into async_nested_test (name, value) values (?, ?)", ("outer", 1) ) sp = await async_sqlserver_backend.transaction_manager.savepoint("sp1") await async_sqlserver_backend.execute( - "insert into async_nested_test (name, value) values (%s, %s)", + "insert into async_nested_test (name, value) values (?, ?)", ("inner", 2) ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py new file mode 100644 index 0000000..2a97af5 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py @@ -0,0 +1,81 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_auto_increment_ddl.py +"""Regression tests for DDL compilation of auto-increment, standard types, +boolean defaults, and timestamps. + +Covers bugs found in rbac cross-backend testing: +- is_auto_increment=True on PRIMARY_KEY must produce IDENTITY(1,1). +- format_create_table_statement must return a string for all types. +- BooleanType with DEFAULT True must be accepted. +- TimestampType must map to DATETIME2. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + BooleanType, IntegerType, TextType, TimestampType, VarCharType, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + +@pytest.fixture +def dialect(): + return SQLServerDialect() + + +def _pk(): + return [ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + + +def _build_table(dialect): + return CreateTableExpression( + dialect=dialect, table="test_tbl", + columns=[ + ColumnDefinition("id", IntegerType(), constraints=_pk()), + ColumnDefinition("name", TextType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition("flag", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition("code", VarCharType(16)), + ColumnDefinition("created_at", TimestampType()), + ], + ) + + +class TestAutoIncrementDDL: + + def test_auto_increment_in_ddl(self, dialect): + sql, _ = dialect.format_create_table_statement(_build_table(dialect)) + assert "IDENTITY(1,1)" in sql.upper(), f"auto-increment missing: {sql}" + + def test_all_standard_types_compile(self, dialect): + sql, params = dialect.format_create_table_statement(_build_table(dialect)) + assert isinstance(sql, str) + assert isinstance(params, tuple) + + def test_boolean_default_true(self, dialect): + expr = CreateTableExpression( + dialect=dialect, table="bool_test", + columns=[ + ColumnDefinition("id", IntegerType(), constraints=_pk()), + ColumnDefinition("flag", BooleanType(), + constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), + ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ], + ) + sql, _ = dialect.format_create_table_statement(expr) + assert "BIT" in sql.upper() + + def test_timestamp_type(self, dialect): + expr = CreateTableExpression( + dialect=dialect, table="ts_test", + columns=[ + ColumnDefinition("id", IntegerType(), constraints=_pk()), + ColumnDefinition("created_at", TimestampType()), + ], + ) + sql, _ = dialect.format_create_table_statement(expr) + assert "DATETIME2" in sql.upper() 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 new file mode 100644 index 0000000..d70d090 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_mixin_validation.py @@ -0,0 +1,103 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_backend_mixin_validation.py +"""Offline tests for SQLServerBackendMixin validation and parameterization. + +Covers the whitelist/range validation branches of ``set_deadlock_priority``, +``set_language`` and ``set_dateformat``, plus the parameterized identity +queries, without requiring a live server. +""" +from unittest.mock import MagicMock + +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver import SQLServerBackend +from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig + + +def make_backend() -> SQLServerBackend: + config = SQLServerConnectionConfig( + host="localhost", port=1433, database="master", + username="sa", password="", + ) + backend = SQLServerBackend(connection_config=config) + backend._connection = object() + return backend + + +def make_cursor(backend): + cursor = MagicMock() + backend._get_cursor = lambda: cursor + return cursor + + +class TestSetDeadlockPriority: + def test_integer_in_range(self): + backend = make_backend() + cursor = make_cursor(backend) + backend.set_deadlock_priority(-5) + cursor.execute.assert_called_once_with("SET DEADLOCK_PRIORITY -5") + + def test_integer_out_of_range_raises(self): + backend = make_backend() + make_cursor(backend) + for value in (-11, 11): + with pytest.raises(ValueError, match="DEADLOCK_PRIORITY"): + backend.set_deadlock_priority(value) + + def test_valid_name(self): + backend = make_backend() + cursor = make_cursor(backend) + backend.set_deadlock_priority("high") + cursor.execute.assert_called_once_with("SET DEADLOCK_PRIORITY HIGH") + + def test_invalid_name_raises(self): + backend = make_backend() + make_cursor(backend) + with pytest.raises(ValueError, match="DEADLOCK_PRIORITY"): + backend.set_deadlock_priority("EVIL") + + +class TestSetLanguage: + def test_valid_language_lowercased(self): + backend = make_backend() + cursor = make_cursor(backend) + backend.set_language("US_ENGLISH") + cursor.execute.assert_called_once_with("SET LANGUAGE 'us_english'") + + def test_invalid_language_raises(self): + backend = make_backend() + make_cursor(backend) + with pytest.raises(ValueError, match="Unsupported SQL Server language"): + backend.set_language("klingon; DROP TABLE--") + + +class TestSetDateformat: + def test_valid_format_lowercased(self): + backend = make_backend() + cursor = make_cursor(backend) + backend.set_dateformat("MDY") + cursor.execute.assert_called_once_with("SET DATEFORMAT mdy") + + def test_invalid_format_raises(self): + backend = make_backend() + make_cursor(backend) + with pytest.raises(ValueError, match="DATEFORMAT"): + backend.set_dateformat("XYZ") + + +class TestIdentityQueries: + def test_get_identity_current_parameterized(self): + backend = make_backend() + cursor = make_cursor(backend) + cursor.fetchone.return_value = (42,) + assert backend.get_identity_current("users") == 42 + cursor.execute.assert_called_once_with( + "SELECT IDENT_CURRENT(?)", ("users",) + ) + + def test_reset_identity_parameterized(self): + backend = make_backend() + cursor = make_cursor(backend) + backend.reset_identity("users", 5) + cursor.execute.assert_called_once_with( + "DBCC CHECKIDENT(?, RESEED, ?)", ("users", 5) + ) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_crud_backend.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_crud_backend.py index d548696..3b85986 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_crud_backend.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_crud_backend.py @@ -31,13 +31,13 @@ def test_table(self, sqlserver_backend): def test_insert_and_fetch(self, sqlserver_backend, test_table): """Test inserting data and fetching it back.""" result = sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("Alice", 25, Decimal("100.50")) ) assert result.affected_rows == 1 row = sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("Alice",) ) assert row is not None @@ -48,18 +48,18 @@ def test_insert_and_fetch(self, sqlserver_backend, test_table): def test_update_and_verify(self, sqlserver_backend, test_table): """Test updating data and verifying the change.""" sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("Bob", 30, Decimal("200.00")) ) result = sqlserver_backend.execute( - "UPDATE test_crud_table SET age = %s, balance = %s WHERE name = %s", + "UPDATE test_crud_table SET age = ?, balance = ? WHERE name = ?", (31, Decimal("250.75"), "Bob") ) assert result.affected_rows == 1 row = sqlserver_backend.fetch_one( - "SELECT age, balance FROM test_crud_table WHERE name = %s", + "SELECT age, balance FROM test_crud_table WHERE name = ?", ("Bob",) ) assert row["age"] == 31 @@ -68,18 +68,18 @@ def test_update_and_verify(self, sqlserver_backend, test_table): def test_delete_and_confirm(self, sqlserver_backend, test_table): """Test deleting data and confirming removal.""" sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("Charlie", 35, Decimal("300.00")) ) result = sqlserver_backend.execute( - "DELETE FROM test_crud_table WHERE name = %s", + "DELETE FROM test_crud_table WHERE name = ?", ("Charlie",) ) assert result.affected_rows == 1 row = sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("Charlie",) ) assert row is None @@ -88,7 +88,7 @@ def test_fetch_all(self, sqlserver_backend, test_table): """Test fetching multiple rows.""" for i in range(3): sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", (f"User{i}", 20 + i, Decimal(f"{100 + i * 50}")) ) @@ -104,12 +104,12 @@ def test_transaction_via_context_manager(self, sqlserver_backend, test_table): """Test transaction using context manager.""" with sqlserver_backend.transaction() as tx: sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("TransactionTest", 40, Decimal("500.00")) ) row = sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("TransactionTest",) ) assert row is not None @@ -118,7 +118,7 @@ def test_transaction_via_context_manager(self, sqlserver_backend, test_table): def test_fetch_none(self, sqlserver_backend, test_table): """Test fetching when no results exist.""" row = sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("NonExistent",) ) assert row is None @@ -145,13 +145,13 @@ async def async_test_table(self, async_sqlserver_backend): async def test_insert_and_fetch_async(self, async_sqlserver_backend, async_test_table): """Test inserting data and fetching it back (async).""" result = await async_sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("Alice", 25, Decimal("100.50")) ) assert result.affected_rows == 1 row = await async_sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("Alice",) ) assert row is not None @@ -161,18 +161,18 @@ async def test_insert_and_fetch_async(self, async_sqlserver_backend, async_test_ async def test_update_and_verify_async(self, async_sqlserver_backend, async_test_table): """Test updating data and verifying the change (async).""" await async_sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("Bob", 30, Decimal("200.00")) ) result = await async_sqlserver_backend.execute( - "UPDATE test_crud_table SET age = %s, balance = %s WHERE name = %s", + "UPDATE test_crud_table SET age = ?, balance = ? WHERE name = ?", (31, Decimal("250.75"), "Bob") ) assert result.affected_rows == 1 row = await async_sqlserver_backend.fetch_one( - "SELECT age, balance FROM test_crud_table WHERE name = %s", + "SELECT age, balance FROM test_crud_table WHERE name = ?", ("Bob",) ) assert row["age"] == 31 @@ -181,18 +181,18 @@ async def test_update_and_verify_async(self, async_sqlserver_backend, async_test async def test_delete_and_confirm_async(self, async_sqlserver_backend, async_test_table): """Test deleting data and confirming removal (async).""" await async_sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("Charlie", 35, Decimal("300.00")) ) result = await async_sqlserver_backend.execute( - "DELETE FROM test_crud_table WHERE name = %s", + "DELETE FROM test_crud_table WHERE name = ?", ("Charlie",) ) assert result.affected_rows == 1 row = await async_sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("Charlie",) ) assert row is None @@ -201,7 +201,7 @@ async def test_fetch_all_async(self, async_sqlserver_backend, async_test_table): """Test fetching multiple rows (async).""" for i in range(3): await async_sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", (f"User{i}", 20 + i, Decimal(f"{100 + i * 50}")) ) @@ -217,12 +217,12 @@ async def test_async_transaction_via_context_manager(self, async_sqlserver_backe """Test transaction using context manager (async).""" async with async_sqlserver_backend.transaction() as tx: await async_sqlserver_backend.execute( - "INSERT INTO test_crud_table (name, age, balance) VALUES (%s, %s, %s)", + "INSERT INTO test_crud_table (name, age, balance) VALUES (?, ?, ?)", ("TransactionTest", 40, Decimal("500.00")) ) row = await async_sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("TransactionTest",) ) assert row is not None @@ -231,7 +231,7 @@ async def test_async_transaction_via_context_manager(self, async_sqlserver_backe async def test_async_fetch_none(self, async_sqlserver_backend, async_test_table): """Test fetching when no results exist (async).""" row = await async_sqlserver_backend.fetch_one( - "SELECT * FROM test_crud_table WHERE name = %s", + "SELECT * FROM test_crud_table WHERE name = ?", ("NonExistent",) ) assert row is None diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py new file mode 100644 index 0000000..e5b0edf --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py @@ -0,0 +1,412 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_dialect_version_branches.py +"""Version-gated dialect branch coverage, fully offline. + +The SQL Server dialect is instantiated at explicit versions +(2008=10.0 / 2012=11.0 / 2016=13.0 / 2019=15.0 / 2022=16.0) so no server +probe ever happens. Rendering snapshots pin OFFSET-FETCH pagination, +STRING_SPLIT/JSON capability bits, temporary tables, table hints, +MERGE OUTPUT and EXPLAIN/OPTION/TOP clause rendering per version. +""" +from types import SimpleNamespace + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.impl.sqlserver.dialect import ( + SQL_SERVER_2012, + SQL_SERVER_2016, + SQL_SERVER_2019, + SQL_SERVER_2022, + SQLServerDialect, +) + +VERSIONS = { + "2008": (10, 0, 0), + "2012": SQL_SERVER_2012, + "2016": SQL_SERVER_2016, + "2019": SQL_SERVER_2019, + "2022": SQL_SERVER_2022, +} + + +@pytest.fixture(params=VERSIONS.values(), ids=VERSIONS.keys()) +def dialect(request): + return SQLServerDialect(request.param) + + +class TestPaginationOffsetFetch: + """OFFSET-FETCH exists only on 2012+, rendered identically above.""" + + @pytest.mark.parametrize("version", [(11, 0, 0), (13, 0, 0), (15, 0, 0), (16, 0, 0)]) + def test_offset_fetch_snapshot(self, version): + sql, params = SQLServerDialect(version).format_limit_offset(10, 5) + assert sql == "OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY" + assert params == [] + + @pytest.mark.parametrize("version", [(11, 0, 0), (13, 0, 0), (15, 0, 0), (16, 0, 0)]) + def test_limit_only_defaults_offset_to_zero(self, version): + sql, _ = SQLServerDialect(version).format_limit_offset(limit=7) + assert sql == "OFFSET 0 ROWS FETCH NEXT 7 ROWS ONLY" + + @pytest.mark.parametrize("version", [(11, 0, 0), (13, 0, 0), (15, 0, 0), (16, 0, 0)]) + def test_offset_only_has_no_fetch(self, version): + sql, _ = SQLServerDialect(version).format_limit_offset(offset=9) + assert sql == "OFFSET 9 ROWS" + assert "FETCH" not in sql + + @pytest.mark.parametrize("version", [(11, 0, 0), (13, 0, 0), (15, 0, 0), (16, 0, 0)]) + def test_both_none_renders_nothing(self, version): + assert SQLServerDialect(version).format_limit_offset() == (None, []) + + def test_pre_2012_raises_with_row_number_suggestion(self): + with pytest.raises(UnsupportedFeatureError) as exc: + SQLServerDialect((10, 0, 0)).format_limit_offset(10, 5) + assert "ROW_NUMBER()" in str(exc.value.suggestion) + + def test_clause_variant_pre_2012_raises(self): + from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + + d = SQLServerDialect((10, 0, 0)) + clause = LimitOffsetClause(d, limit=10, offset=0) + with pytest.raises(UnsupportedFeatureError): + d.format_limit_offset_clause(clause) + + def test_clause_variant_snapshot(self): + from rhosocial.activerecord.backend.expression.query_parts import LimitOffsetClause + + d = SQLServerDialect((16, 0, 0)) + clause = LimitOffsetClause(d, limit=10, offset=20) + sql, params = d.format_limit_offset_clause(clause) + assert sql == "OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY" + assert params == () + + @pytest.mark.parametrize( + ("version", "expected"), + [((10, 0, 0), False), ((11, 0, 0), True), ((13, 0, 0), True)], + ) + def test_supports_offset_fetch_protocol_bit(self, version, expected): + assert SQLServerDialect(version).supports_offset_fetch() is expected + + +class TestFunctionCapabilityMatrix: + """STRING_SPLIT/JSON style capability bits across the version grid.""" + + @pytest.mark.parametrize( + ("function", "expected"), + [ + # Exact gate per (2012, 2016, 2019): + ("string_split", (False, True, True)), # 2016+ + ("string_agg", (False, False, True)), # 2017+ + ("json_value", (False, True, True)), # 2016+ + ("json_object", (False, False, False)), # 2022+ only + ("approx_count_distinct", (False, False, True)), # 2019+ + ("iif", (True, True, True)), # 2012+ + ], + ) + def test_function_gate_at_2012_2016_2019(self, function, expected): + results = tuple( + SQLServerDialect(v)._is_sqlserver_function_supported(function) + for v in ((11, 0, 0), (13, 0, 0), (15, 0, 0)) + ) + assert results == expected + + @pytest.mark.parametrize( + ("version", "string_split_ok"), + [((11, 0, 0), False), ((13, 0, 0), True), ((15, 0, 0), True), ((16, 0, 0), True)], + ) + def test_string_split_gate_exact(self, version, string_split_ok): + assert SQLServerDialect(version)._is_sqlserver_function_supported("string_split") is string_split_ok + + @pytest.mark.parametrize( + ("function", "version", "expected"), + [ + ("JSON_VALUE", (13, 0, 0), True), + ("JSON_VALUE", (11, 0, 0), False), + ("JSON_OBJECT", (13, 0, 0), False), + ("JSON_OBJECT", (16, 0, 0), True), + ("JSON_PATH_EXISTS", (15, 0, 0), False), + ("JSON_PATH_EXISTS", (16, 0, 0), True), + ("JSON_TABLE", (16, 0, 0), False), # OPENJSON instead, never supported + ], + ) + def test_json_function_gates(self, function, version, expected): + assert SQLServerDialect(version).supports_json_function(function) is expected + + @pytest.mark.parametrize( + ("version", "json_bits"), + [ + ((11, 0, 0), {"supports_json_type": False, "supports_json_table": False}), + ((13, 0, 0), {"supports_json_type": True, "supports_json_table": True}), + ((15, 0, 0), {"supports_json_type": True, "supports_json_table": True}), + ((16, 0, 0), {"supports_json_type": True, "supports_json_table": True}), + ], + ) + def test_json_capability_bits_per_version(self, version, json_bits): + d = SQLServerDialect(version) + for method, expected in json_bits.items(): + assert getattr(d, method)() is expected + + +class TestOtherVersionGates: + @pytest.mark.parametrize( + ("version", "window_frame"), + [((10, 0, 0), False), ((11, 0, 0), True), ((16, 0, 0), True)], + ) + def test_window_frame_clause_2012_gate(self, version, window_frame): + assert SQLServerDialect(version).supports_window_frame_clause() is window_frame + + @pytest.mark.parametrize( + ("version", "temporal"), + [((11, 0, 0), False), ((13, 0, 0), True), ((15, 0, 0), True), ((16, 0, 0), True)], + ) + def test_temporal_tables_2016_gate(self, version, temporal): + assert SQLServerDialect(version).supports_temporal_tables() is temporal + + @pytest.mark.parametrize( + ("version", "readpast"), + [((13, 0, 0), False), ((15, 0, 0), True), ((16, 0, 0), True)], + ) + def test_skip_locked_readpast_2019_gate(self, version, readpast): + d = SQLServerDialect(version) + assert d.supports_for_update_skip_locked() is readpast + assert d.supports_readpast() is readpast + + def test_if_exists_rendering_turns_on_in_2016(self): + from rhosocial.activerecord.backend.expression.statements.ddl_view import DropViewExpression + + pre = DropViewExpression(SQLServerDialect((11, 0, 0)), "v", if_exists=True).to_sql() + post = DropViewExpression(SQLServerDialect((13, 0, 0)), "v", if_exists=True).to_sql() + assert pre[0] == "DROP VIEW [v]" + assert post[0] == "DROP VIEW IF EXISTS [v]" + + @pytest.mark.parametrize( + ("version", "if_exists_bit"), + [((11, 0, 0), False), ((13, 0, 0), True), ((15, 0, 0), True), ((16, 0, 0), True)], + ) + def test_drop_table_if_exists_capability(self, version, if_exists_bit): + d = SQLServerDialect(version) + assert d.supports_if_exists_table() is if_exists_bit + assert d.supports_if_exists_view() is if_exists_bit + assert d.supports_index_if_exists() is if_exists_bit + + @pytest.mark.parametrize( + ("version", "sequence"), + [((10, 0, 0), False), ((11, 0, 0), True), ((16, 0, 0), True)], + ) + def test_sequence_objects_2012_gate(self, version, sequence): + assert SQLServerDialect(version).supports_create_sequence() is sequence + + +class TestRenderingSnapshots: + def test_temporary_table_gets_hash_prefix(self): + from rhosocial.activerecord.backend.expression.core import TableExpression + from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + d = SQLServerDialect((16, 0, 0)) + expr = CreateTableExpression( + dialect=d, + table=TableExpression(d, "stage_users"), + columns=[ColumnDefinition(name="id", data_type=IntegerType())], + temporary=True, + ) + sql, params = expr.to_sql() + assert sql == "CREATE TABLE [#stage_users] ([id] INT)" + assert params == () + + def test_temporary_table_keeps_schema_prefix_order(self): + from rhosocial.activerecord.backend.expression.core import TableExpression + from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + d = SQLServerDialect((16, 0, 0)) + expr = CreateTableExpression( + dialect=d, + table=TableExpression(d, "t", "dbo"), + columns=[ColumnDefinition(name="id", data_type=IntegerType())], + temporary=True, + ) + assert expr.to_sql()[0] == "CREATE TABLE [dbo].[#t] ([id] INT)" + + def test_create_table_like_is_unsupported(self): + from rhosocial.activerecord.backend.expression.core import TableExpression + from rhosocial.activerecord.backend.expression.statements import ( + ColumnDefinition, + CreateTableExpression, + ) + from rhosocial.activerecord.backend.expression.types import IntegerType + + d = SQLServerDialect((16, 0, 0)) + expr = CreateTableExpression( + dialect=d, + table=TableExpression(d, "copy"), + columns=[ColumnDefinition(name="id", data_type=IntegerType())], + ) + expr.dialect_options["like_table"] = "original" + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() + + def test_table_hint_single_and_multiple(self): + d = SQLServerDialect((16, 0, 0)) + assert d.format_table_hint(["NOLOCK"]) == "WITH (NOLOCK)" + assert d.format_table_hint(["NOLOCK", "READCOMMITTED"]) == "WITH (NOLOCK, READCOMMITTED)" + + def test_locking_hints_gain_readpast_only_on_2019_plus(self): + base = SQLServerDialect((13, 0, 0)).format_table_hint_locking("UPDLOCK, READPAST") + newer = SQLServerDialect((15, 0, 0)).format_table_hint_locking("UPDLOCK, READPAST") + assert base == "WITH (UPDLOCK, ROWLOCK)" # READPAST silently dropped pre-2019 + assert newer == "WITH (UPDLOCK, ROWLOCK, READPAST)" + + def test_for_update_clause_hint_rendering(self): + from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause + + d16 = SQLServerDialect((16, 0, 0)) + sql, params = d16.format_for_update_clause(ForUpdateClause(d16)) + assert (sql, params) == ("WITH (UPDLOCK, ROWLOCK)", ()) + sql, params = d16.format_for_update_clause(ForUpdateClause(d16, skip_locked=True)) + assert sql == "WITH (UPDLOCK, ROWLOCK, READPAST)" + + def test_merge_output_action_snapshot(self): + from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.statements import ( + MergeAction, + MergeActionType, + MergeExpression, + ) + + d = SQLServerDialect((16, 0, 0)) + merge = MergeExpression( + dialect=d, + target_table=TableExpression(d, "target"), + source=QueryExpression( + dialect=d, + select=[Column(d, "id"), Column(d, "name")], + from_=TableExpression(d, "source"), + ), + on_condition=ComparisonPredicate( + d, "=", Column(d, "id", "target"), Column(d, "id", "source") + ), + when_matched=[ + MergeAction( + action_type=MergeActionType.UPDATE, + assignments={"name": Column(d, "name", "source")}, + ) + ], + when_not_matched=[ + MergeAction(action_type=MergeActionType.INSERT, assignments={"id": Column(d, "id", "source")}) + ], + ) + # MergeExpression carries no dialect_options of its own; the dialect + # reads them via getattr, so the test injects the mapping directly. + merge.dialect_options = {"output": ["inserted.id"], "output_action": True, "holdlock": True} + sql, params = merge.to_sql() + assert sql.endswith("OUTPUT $action, inserted.id;") + assert sql.startswith("MERGE INTO [target] WITH (HOLDLOCK)") + assert params == () + + def test_merge_without_options_has_no_output(self): + from rhosocial.activerecord.backend.expression import Column, QueryExpression, TableExpression + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + from rhosocial.activerecord.backend.expression.statements import ( + MergeAction, + MergeActionType, + MergeExpression, + ) + + d = SQLServerDialect((16, 0, 0)) + merge = MergeExpression( + dialect=d, + target_table=TableExpression(d, "t"), + source=QueryExpression(dialect=d, select=[Column(d, "id")], from_=TableExpression(d, "s")), + on_condition=ComparisonPredicate(d, "=", Column(d, "id", "t"), Column(d, "id", "s")), + when_matched=[MergeAction(action_type=MergeActionType.DELETE)], + ) + sql, _ = merge.to_sql() + assert sql.startswith("MERGE INTO [t]") + assert "OUTPUT" not in sql + assert sql.endswith(";") # T-SQL requires the trailing semicolon + + def test_explain_statement_variants(self): + stmt = SimpleNamespace(to_sql=lambda: ("SELECT 1", ())) + d = SQLServerDialect((16, 0, 0)) + + none_opts = d.format_explain_statement(SimpleNamespace(statement=stmt, options=None))[0] + xml_opts = d.format_explain_statement( + SimpleNamespace(statement=stmt, options=SimpleNamespace(analyze=False, format=SimpleNamespace(name="XML"))) + )[0] + analyze_profile = d.format_explain_statement( + SimpleNamespace(statement=stmt, options=SimpleNamespace(analyze=True, format=None)) + )[0] + analyze_xml = d.format_explain_statement( + SimpleNamespace(statement=stmt, options=SimpleNamespace(analyze=True, format=SimpleNamespace(name="XML"))) + )[0] + assert none_opts == "SET SHOWPLAN_TEXT ON; SELECT 1" + assert xml_opts == "SET SHOWPLAN_XML ON; SELECT 1" + assert analyze_profile == "SET STATISTICS PROFILE ON; SELECT 1; SET STATISTICS PROFILE OFF" + assert analyze_xml == "SET STATISTICS XML ON; SELECT 1; SET STATISTICS XML OFF" + + def test_explain_format_support_matrix(self): + d = SQLServerDialect((16, 0, 0)) + assert d.supports_explain_format("TEXT") is True + assert d.supports_explain_format("XML") is True + assert d.supports_explain_format("JSON") is False + + def test_option_query_hint_clause(self): + clause = SimpleNamespace(hints=["RECOMPILE", "MAXDOP 4"]) + sql, params = SQLServerDialect((16, 0, 0)).format_query_option_clause(clause) + assert (sql, params) == ("OPTION (RECOMPILE, MAXDOP 4)", ()) + + def test_top_n_clause_variants(self): + d = SQLServerDialect((16, 0, 0)) + assert d.format_top_n_clause(10) == "TOP 10" + assert d.format_top_n_clause(5, percentage=True) == "TOP 5 PERCENT" + + def test_lateral_renders_as_apply(self): + d = SQLServerDialect((16, 0, 0)) + cross = d.format_lateral_expression("SELECT 1", (), "x") + outer = d.format_lateral_expression("SELECT 1", (), "x", join_type="LEFT") + assert cross == ("CROSS APPLY (SELECT 1) AS [x]", ()) + assert outer == ("OUTER APPLY (SELECT 1) AS [x]", ()) + + +class TestGroupingAndUpsertBasics: + def test_grouping_operations_use_tsql_syntax(self): + d = SQLServerDialect((16, 0, 0)) + assert d.format_grouping_expression("ROLLUP", []) == ("WITH ROLLUP", ()) + assert d.format_grouping_expression("CUBE", []) == ("WITH CUBE", ()) + assert d.format_grouping_expression("GROUPING SETS", []) == ("GROUPING SETS", ()) + with pytest.raises(UnsupportedFeatureError): + d.format_grouping_expression("PIVOT", []) + + def test_upsert_targets_merge_syntax(self): + for version in VERSIONS.values(): + d = SQLServerDialect(version) + assert d.get_upsert_syntax_type() == "MERGE" + + def test_sequence_ddl_snapshots(self): + from rhosocial.activerecord.backend.expression.statements import ( + CreateSequenceExpression, + DropSequenceExpression, + ) + + d16 = SQLServerDialect((16, 0, 0)) + create_sql, create_params = CreateSequenceExpression( + d16, "seq", start=1, increment=2, minvalue=1, maxvalue=100, cycle=False, cache=10 + ).to_sql() + assert create_sql == ( + "CREATE SEQUENCE [seq] START WITH 1 INCREMENT BY 2 " + "MINVALUE 1 MAXVALUE 100 NO CYCLE CACHE 10" + ) + assert create_params == () + # IF EXISTS only appears from 2012 onwards. + assert DropSequenceExpression(SQLServerDialect((10, 0, 0)), "seq", if_exists=True).to_sql()[0] == ( + "DROP SEQUENCE [seq]" + ) + assert DropSequenceExpression(d16, "seq", if_exists=True).to_sql()[0] == "DROP SEQUENCE IF EXISTS [seq]" diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py new file mode 100644 index 0000000..1185e04 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py @@ -0,0 +1,93 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_expression_roundtrip_all.py +""" +Functional serialization coverage for SQL Server expression classes. + +Every expression class defined in ``rhosocial.activerecord.backend.impl +.sqlserver.expression`` must round-trip losslessly through dict / JSON / XML +encodings, and produce identical ``to_sql()`` where the SQL Server dialect +supports it. +""" + +import pytest + +from rhosocial.activerecord.testsuite.utils.expression import ( + collect_expression_classes, + make_instance, + register_all, + register_special_constructor, + roundtrip_expression, + sql_consistent, +) + +SS_EXPR_PKG = "rhosocial.activerecord.backend.impl.sqlserver.expression" + +CLASSES = collect_expression_classes(SS_EXPR_PKG) +register_all(CLASSES) + + +def _register_sqlserver_specials(): + from rhosocial.activerecord.backend.expression.core import Column + + def pivot(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.pivot import ( + PivotExpression, + ) + return PivotExpression(d, aggregate_function="SUM", value_column="amount", pivot_column="q") + + def columnstore(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.columnstore import ( + SQLServerColumnstoreIndexExpression, + ) + return SQLServerColumnstoreIndexExpression(d, "cci", "t", columns=["a"]) + + def partition(d): + from rhosocial.activerecord.backend.impl.sqlserver.expression.partition import ( + SQLServerPartitionByRangeClause, + ) + return SQLServerPartitionByRangeClause( + d, keys=[Column(d, "id")], partition_scheme="ps" + ) + + register_special_constructor("pivot.PivotExpression", pivot) + register_special_constructor( + "columnstore.SQLServerColumnstoreIndexExpression", columnstore + ) + register_special_constructor( + "partition.SQLServerPartitionByRangeClause", partition + ) + + +_register_sqlserver_specials() + + +@pytest.fixture(params=[fqn for fqn in sorted(CLASSES)], ids=sorted(CLASSES)) +def sqlserver_expr_case(request, sqlserver_dialect): + fqn = request.param + cls = CLASSES[fqn] + instance, source = make_instance(cls, sqlserver_dialect) + if instance is None: + pytest.skip(f"{fqn}: {source}") + return fqn, instance + + +class TestSQLServerExpressionRoundtrip: + """All constructible SQL Server expression classes round-trip losslessly.""" + + def test_get_params_roundtrip(self, sqlserver_expr_case, sqlserver_dialect): + fqn, instance = sqlserver_expr_case + roundtrip_expression(fqn, instance, sqlserver_dialect) + + def test_to_sql_consistent(self, sqlserver_expr_case, sqlserver_dialect): + fqn, instance = sqlserver_expr_case + sql_consistent(fqn, instance, sqlserver_dialect) + + +def test_core_expressions_also_roundtrip(sqlserver_dialect): + from rhosocial.activerecord.backend.expression.core import Column, Literal + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + expr = ComparisonPredicate( + sqlserver_dialect, "=", Column(sqlserver_dialect, "a"), Literal(sqlserver_dialect, 1) + ) + roundtrip_expression("core", expr, sqlserver_dialect) + sql_consistent("core", expr, sqlserver_dialect) \ No newline at end of file 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 new file mode 100644 index 0000000..c3cebe8 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py @@ -0,0 +1,112 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_introspection_sql_builders.py +"""Offline tests for the parameterized SQL builder methods in the introspector. + +Covers the SQL injection hardening changes: every ``_build_*_sql`` method +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.config import SQLServerConnectionConfig +from rhosocial.activerecord.backend.impl.sqlserver.introspection import ( + SyncSQLServerIntrospector, +) + + +def make_introspector() -> SyncSQLServerIntrospector: + config = SQLServerConnectionConfig( + host="localhost", port=1433, database="master", username="sa", password="", + ) + backend = SQLServerBackend(connection_config=config) + backend._version = (16, 0, 0) + introspector = SyncSQLServerIntrospector(backend, executor=object()) + return introspector + + +class TestBuildTableListSql: + def test_default_schema_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_table_list_sql(None, False) + assert "TABLE_SCHEMA = ?" in sql + assert params == ("dbo",) + + def test_with_table_type_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_table_list_sql("sys", False, table_type="BASE TABLE") + assert "TABLE_TYPE = ?" in sql + assert params == ("sys", "BASE TABLE") + + def test_without_views_no_table_type(self): + introspector = make_introspector() + sql, params = introspector._build_table_list_sql("dbo", False, include_views=False) + assert "BASE TABLE" in sql + assert params == ("dbo",) + + +class TestBuildColumnInfoSql: + def test_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_column_info_sql("users", "dbo") + assert "c.TABLE_SCHEMA = ?" in sql + assert params == ("dbo", "users") + + +class TestBuildPrimaryKeySql: + def test_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_primary_key_sql("users", "dbo") + assert "tc.TABLE_SCHEMA = ?" in sql + assert params == ("dbo", "users") + + +class TestBuildIndexInfoSql: + def test_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_index_info_sql("users", "dbo") + assert "s.name = ?" in sql + assert params == ("dbo", "users") + + +class TestBuildForeignKeySql: + def test_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_foreign_key_sql("users", "dbo") + assert "s.name = ?" in sql + assert params == ("dbo", "users") + + +class TestBuildViewListSql: + def test_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_view_list_sql("dbo", False) + assert "TABLE_SCHEMA = ?" in sql + assert params == ("dbo",) + + +class TestBuildViewInfoSql: + def test_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_view_info_sql("active_users", "dbo") + assert "TABLE_SCHEMA = ?" in sql + assert params == ("dbo", "active_users") + + +class TestBuildTriggerListSql: + def test_with_table_name_parameterized(self): + introspector = make_introspector() + sql, params = introspector._build_trigger_list_sql("orders", "dbo") + assert "AND t.name = ?" in sql + assert params == ("dbo", "orders") + + def test_without_table_name(self): + introspector = make_introspector() + sql, params = introspector._build_trigger_list_sql(None, "dbo") + assert "AND t.name = ?" not in sql + assert params == ("dbo",) + + +class TestBuildDatabaseInfoSql: + def test_returns_sql(self): + introspector = make_introspector() + sql, params = introspector._build_database_info_sql() + assert "DB_NAME()" in sql + assert params == () \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_main_parse_args.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_main_parse_args.py new file mode 100644 index 0000000..78010b6 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_main_parse_args.py @@ -0,0 +1,20 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_main_parse_args.py +"""Offline tests for ``__main__.parse_args`` with custom argv.""" +import pytest + +from rhosocial.activerecord.backend.impl.sqlserver.__main__ import parse_args + + +class TestParseArgs: + def test_parse_args_with_help_exits(self): + with pytest.raises(SystemExit) as excinfo: + parse_args(["--help"]) + assert excinfo.value.code == 0 + + def test_parse_args_with_query(self): + args = parse_args(["query", "SELECT 1"]) + assert args.command == "query" + + def test_parse_args_with_info(self): + args = parse_args(["info"]) + assert args.command == "info" \ No newline at end of file diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py new file mode 100644 index 0000000..7ed7336 --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py @@ -0,0 +1,62 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_schema_support.py +"""Tests for the SchemaSupport capability declared on the SQL Server dialect. + +SQL Server models named schema namespaces natively (e.g. dbo), so the dialect +must report ``supports_schema()`` as True. IF NOT EXISTS / IF EXISTS variants +are not supported by the server and must stay False. +""" +from rhosocial.activerecord.backend.dialect.protocols import SchemaSupport +from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaExpression, + DropSchemaExpression, +) +from rhosocial.activerecord.backend.impl.sqlserver.dialect import SQLServerDialect + + +class TestSchemaCapability: + """Umbrella flag and granular schema DDL capability bits.""" + + def _dialect(self) -> SQLServerDialect: + # Pin the version so the constructor never probes a live server. + return SQLServerDialect((16, 0, 0)) + + def test_supports_schema_is_true(self): + assert self._dialect().supports_schema() is True + + def test_implements_schema_support_protocol(self): + assert isinstance(self._dialect(), SchemaSupport) + + def test_granular_schema_ddl_capabilities(self): + d = self._dialect() + assert d.supports_create_schema() is True + assert d.supports_drop_schema() is True + assert d.supports_schema_if_not_exists() is False + assert d.supports_schema_if_exists() is False + assert d.supports_schema_cascade() is False + + def test_schema_authorization_capability(self): + assert self._dialect().supports_schema_authorization() is True + + +class TestSchemaDDLFormatting: + """CREATE/DROP SCHEMA rendering through the standard core formatters.""" + + def _dialect(self) -> SQLServerDialect: + # Pin the version so the constructor never probes a live server. + return SQLServerDialect((16, 0, 0)) + + def test_create_schema(self): + sql, params = CreateSchemaExpression(self._dialect(), "app").to_sql() + assert sql == "[app]" or sql == "CREATE SCHEMA [app]" + assert "IF NOT EXISTS" not in sql + assert params == () + + def test_drop_schema(self): + sql, _ = DropSchemaExpression(self._dialect(), "app").to_sql() + assert sql == "DROP SCHEMA [app]" or sql == "DROP SCHEMA app" + assert "IF EXISTS" not in sql + + def test_drop_schema_rejects_unsupported_if_exists(self): + """The formatter must never emit IF EXISTS even if requested.""" + sql, _ = DropSchemaExpression(self._dialect(), "app", if_exists=True).to_sql() + assert "IF EXISTS" not in sql diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py index 0bb2ee2..a6d3a4e 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_sqlserver_dialect_security.py @@ -79,3 +79,66 @@ def test_escape_sql_string_inherited(dialect): """Test SQL Server inherits _escape_sql_string from base dialect.""" result = dialect._escape_sql_string("test's value") assert result == "test''s value" + + +# ── SET statement whitelist ─────────────────────────────────────────── + + +def test_set_language_whitelist(): + """SET LANGUAGE whitelist rejects invalid names, accepts valid ones.""" + from rhosocial.activerecord.backend.impl.sqlserver.mixins.backend_mixin import _SQLSERVER_LANGUAGES + # Valid languages + assert "us_english" in _SQLSERVER_LANGUAGES + assert "simplified chinese" in _SQLSERVER_LANGUAGES + # Invalid / injection payloads must NOT be in the whitelist + for payload in ("malicious; DROP TABLE--", "", "us_english; DROP TABLE--"): + assert payload.strip().lower() not in _SQLSERVER_LANGUAGES, \ + f"injection payload must not be in whitelist: {payload!r}" + + +def test_set_dateformat_whitelist(): + """SET DATEFORMAT whitelist rejects invalid formats.""" + from rhosocial.activerecord.backend.impl.sqlserver.mixins.backend_mixin import _SQLSERVER_DATE_FORMATS + for fmt in ("mdy", "dmy", "ymd", "ydm", "myd", "dym"): + assert fmt in _SQLSERVER_DATE_FORMATS + # Validation lowercases input, so mdY == mdy is valid + assert "mdY".strip().lower() in _SQLSERVER_DATE_FORMATS + # Genuinely invalid formats + for payload in ("xyz", "XYZ; DROP TABLE--", ""): + assert payload.strip().lower() not in _SQLSERVER_DATE_FORMATS, \ + f"invalid format must not be in whitelist: {payload!r}" + + +def test_set_deadlock_priority_whitelist(): + """SET DEADLOCK_PRIORITY whitelist rejects invalid values.""" + from rhosocial.activerecord.backend.impl.sqlserver.mixins.backend_mixin import ( + _SQLSERVER_DEADLOCK_PRIORITIES, + ) + for name in ("LOW", "NORMAL", "HIGH"): + assert name in _SQLSERVER_DEADLOCK_PRIORITIES + for payload in ("INJECTION", "", "HIGH; DROP TABLE--"): + assert payload.upper() not in _SQLSERVER_DEADLOCK_PRIORITIES, \ + f"invalid priority must not be in whitelist: {payload!r}" + + +# ── Identifier quoting ──────────────────────────────────────────────── + + +def test_identifier_quoting_balanced(dialect): + """format_identifier always produces syntactically valid brackets.""" + payloads = ["]", "a]b", "a]]b", "a]b]c"] + for payload in payloads: + result = dialect.format_identifier(payload) + assert result.startswith("["), f"starts with [: {result}" + assert result.endswith("]"), f"ends with ]: {result}" + # Every ] inside the brackets must be escaped as ]] to be valid + inner = result[1:-1] # strip outer brackets + i = 0 + while i < len(inner): + if inner[i] == "]": + # an embedded ] must be part of an escaped ]] pair + assert i + 1 < len(inner) and inner[i + 1] == "]", \ + f"unescaped ] in {result} for payload {payload!r}" + i += 2 # skip the ]] pair + else: + i += 1 diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_deadlock_priority.py b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_deadlock_priority.py new file mode 100644 index 0000000..301c26a --- /dev/null +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_deadlock_priority.py @@ -0,0 +1,37 @@ +# tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_transaction_deadlock_priority.py +"""Offline tests for SQLServerTransactionManager.set_deadlock_priority delegation.""" +from unittest.mock import MagicMock + +import pytest + +from rhosocial.activerecord.backend.transaction import TransactionState +from rhosocial.activerecord.backend.impl.sqlserver.transaction import ( + SQLServerTransactionManager, +) + + +def make_manager() -> tuple[SQLServerTransactionManager, MagicMock]: + backend = MagicMock() + mgr = SQLServerTransactionManager(backend) + return mgr, backend + + +class TestSetDeadlockPriority: + def test_delegates_to_backend(self): + mgr, backend = make_manager() + mgr._state = TransactionState.ACTIVE + mgr.set_deadlock_priority("LOW") + backend.set_deadlock_priority.assert_called_once_with("LOW") + + def test_integer_delegates_to_backend(self): + mgr, backend = make_manager() + mgr._state = TransactionState.ACTIVE + mgr.set_deadlock_priority(-5) + backend.set_deadlock_priority.assert_called_once_with(-5) + + def test_raises_without_active_transaction(self): + mgr, backend = make_manager() + mgr._state = TransactionState.INACTIVE + with pytest.raises(Exception, match="No active transaction"): + mgr.set_deadlock_priority("LOW") + backend.set_deadlock_priority.assert_not_called() \ No newline at end of file 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 f4c905c..ae266b2 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 @@ -49,7 +49,7 @@ def test_table(self, sqlserver_backend): ) """) sqlserver_backend.execute( - "INSERT INTO isolation_test (name, balance) VALUES (%s, %s)", + "INSERT INTO isolation_test (name, balance) VALUES (?, ?)", ("user1", Decimal("100.00")) ) yield "isolation_test" @@ -80,7 +80,7 @@ def transaction1(): updated_event.wait(timeout=5) # Read potentially uncommitted data rows = backend1.fetch_all( - "SELECT balance FROM isolation_test WHERE name = %s", + "SELECT balance FROM isolation_test WHERE name = ?", ("user1",) ) if rows and rows[0]["balance"] == Decimal("200.00"): @@ -95,7 +95,7 @@ def transaction2(): with backend2.transaction(): # Update balance backend2.execute( - "UPDATE isolation_test SET balance = %s WHERE name = %s", + "UPDATE isolation_test SET balance = ? WHERE name = ?", (Decimal("200.00"), "user1") ) # Signal transaction 1 that the uncommitted change is in place @@ -136,7 +136,7 @@ def transaction1(): time.sleep(0.15) # Should NOT see the uncommitted change rows = backend1.fetch_all( - "SELECT balance FROM isolation_test WHERE name = %s", + "SELECT balance FROM isolation_test WHERE name = ?", ("user1",) ) if rows and rows[0]["balance"] != Decimal("200.00"): @@ -152,7 +152,7 @@ def transaction2(): backend2.transaction_manager.isolation_level = IsolationLevel.READ_COMMITTED with backend2.transaction(): backend2.execute( - "UPDATE isolation_test SET balance = %s WHERE name = %s", + "UPDATE isolation_test SET balance = ? WHERE name = ?", (Decimal("200.00"), "user1") ) time.sleep(0.2) @@ -191,7 +191,7 @@ def transaction1(): with backend1.transaction(): # First read rows1 = backend1.fetch_all( - "SELECT balance FROM isolation_test WHERE name = %s", + "SELECT balance FROM isolation_test WHERE name = ?", ("user1",) ) read_values.append(rows1[0]["balance"]) @@ -201,7 +201,7 @@ def transaction1(): # Second read (should be same as first) rows2 = backend1.fetch_all( - "SELECT balance FROM isolation_test WHERE name = %s", + "SELECT balance FROM isolation_test WHERE name = ?", ("user1",) ) read_values.append(rows2[0]["balance"]) @@ -215,7 +215,7 @@ def transaction2(): backend2.transaction_manager.isolation_level = IsolationLevel.READ_COMMITTED with backend2.transaction(): backend2.execute( - "UPDATE isolation_test SET balance = %s WHERE name = %s", + "UPDATE isolation_test SET balance = ? WHERE name = ?", (Decimal("200.00"), "user1") ) except Exception as e: @@ -255,7 +255,7 @@ def transaction1(): with backend1.transaction(): # First count rows1 = backend1.fetch_all( - "SELECT COUNT(*) as cnt FROM isolation_test WHERE balance > %s", + "SELECT COUNT(*) as cnt FROM isolation_test WHERE balance > ?", (Decimal("50.00"),) ) initial_count.append(rows1[0]["cnt"]) @@ -265,7 +265,7 @@ def transaction1(): # Second count (should be same) rows2 = backend1.fetch_all( - "SELECT COUNT(*) as cnt FROM isolation_test WHERE balance > %s", + "SELECT COUNT(*) as cnt FROM isolation_test WHERE balance > ?", (Decimal("50.00"),) ) second_count.append(rows2[0]["cnt"]) @@ -280,7 +280,7 @@ def transaction2(): with backend2.transaction(): # Try to insert a row that matches the condition backend2.execute( - "INSERT INTO isolation_test (name, balance) VALUES (%s, %s)", + "INSERT INTO isolation_test (name, balance) VALUES (?, ?)", ("user2", Decimal("75.00")) ) insert_blocked.append(False) @@ -318,7 +318,7 @@ def test_table(self, sqlserver_backend): ) """) sqlserver_backend.execute( - "INSERT INTO mode_test (name, value) VALUES (%s, %s)", + "INSERT INTO mode_test (name, value) VALUES (?, ?)", ("item1", 100) ) yield "mode_test" @@ -341,7 +341,7 @@ def test_read_only_mode_rejects_writes(self, sqlserver_backend, test_table): with sqlserver_backend.transaction(): # Attempt to insert (should fail) sqlserver_backend.execute( - "INSERT INTO mode_test (name, value) VALUES (%s, %s)", + "INSERT INTO mode_test (name, value) VALUES (?, ?)", ("item2", 200) ) except Exception as e: @@ -369,7 +369,7 @@ def test_read_write_mode_allows_writes(self, sqlserver_backend, test_table): sqlserver_backend.transaction_manager.transaction_mode = TransactionMode.READ_WRITE with sqlserver_backend.transaction(): sqlserver_backend.execute( - "INSERT INTO mode_test (name, value) VALUES (%s, %s)", + "INSERT INTO mode_test (name, value) VALUES (?, ?)", ("item2", 200) ) @@ -393,7 +393,7 @@ def test_table(self, sqlserver_backend): ) """) sqlserver_backend.execute( - "INSERT INTO combo_test (name, balance) VALUES (%s, %s)", + "INSERT INTO combo_test (name, balance) VALUES (?, ?)", ("account1", Decimal("1000.00")) ) yield "combo_test" @@ -577,7 +577,7 @@ def test_nested_transaction_with_isolation_level(self, sqlserver_backend, test_t with sqlserver_backend.transaction(): # Insert first record sqlserver_backend.execute( - "INSERT INTO nested_test (name, value) VALUES (%s, %s)", + "INSERT INTO nested_test (name, value) VALUES (?, ?)", ("outer", 1) ) @@ -586,7 +586,7 @@ def test_nested_transaction_with_isolation_level(self, sqlserver_backend, test_t # Insert second record sqlserver_backend.execute( - "INSERT INTO nested_test (name, value) VALUES (%s, %s)", + "INSERT INTO nested_test (name, value) VALUES (?, ?)", ("inner", 2) ) diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py index de72668..0915d3a 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/conftest.py @@ -88,7 +88,14 @@ def get_scenario_config(name: str) -> Dict[str, Any]: name = next(iter(SCENARIO_MAP)) else: raise ValueError("No scenarios registered or enabled") - return SCENARIO_MAP[name] + scenario_config = SCENARIO_MAP[name].copy() + # Apply the pooled database name so parallel workers never share a schema. + from providers.pooling import resolve_database_name + + pooled_db = resolve_database_name(name) + if pooled_db: + scenario_config["database"] = pooled_db + return scenario_config def get_scenario_names(): diff --git a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/test_connection_pool_concurrent.py b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/test_connection_pool_concurrent.py index 7e8dfbd..48bef56 100644 --- a/tests/rhosocial/activerecord_sqlserver_test/feature/connection/test_connection_pool_concurrent.py +++ b/tests/rhosocial/activerecord_sqlserver_test/feature/connection/test_connection_pool_concurrent.py @@ -32,8 +32,9 @@ class TestBackendPoolConcurrent: def test_concurrent_connections_are_distinct(self, sqlserver_pool: BackendPool): """Verify that concurrently acquired connections are distinct.""" num_threads = 5 - connection_ids: List[int] = [] - connection_ids_lock = threading.Lock() + held_ids: set = set() + held_lock = threading.Lock() + double_checkouts: List[int] = [] barrier = threading.Barrier(num_threads) errors: List[Exception] = [] errors_lock = threading.Lock() @@ -45,11 +46,15 @@ def acquire_and_record(thread_id: int): backend = sqlserver_pool.acquire() try: conn_id = id(backend) - with connection_ids_lock: - connection_ids.append((thread_id, conn_id)) + with held_lock: + if conn_id in held_ids: + double_checkouts.append(conn_id) + held_ids.add(conn_id) time.sleep(0.1) finally: + with held_lock: + held_ids.discard(id(backend)) sqlserver_pool.release(backend) except Exception as e: with errors_lock: @@ -61,11 +66,8 @@ def acquire_and_record(thread_id: int): future.result() assert len(errors) == 0, f"Errors occurred: {errors}" - assert len(connection_ids) == num_threads - unique_ids = set(conn_id for _, conn_id in connection_ids) - assert len(unique_ids) == num_threads, ( - f"Expected {num_threads} distinct connections, " - f"but got {len(unique_ids)}: {connection_ids}" + assert not double_checkouts, ( + f"Pool handed the same connection to two threads concurrently: {double_checkouts}" ) def test_concurrent_operations_no_deadlock(self, sqlserver_pool_with_tables: BackendPool): @@ -85,17 +87,17 @@ def complex_operation(thread_id: int): with pool.connection() as backend: for i in range(10): backend.execute( - "INSERT INTO concurrent_test_users (thread_id, name) VALUES (%s, %s)", + "INSERT INTO concurrent_test_users (thread_id, name) VALUES (?, ?)", [thread_id, f"user_{thread_id}_{i}"] ) query_result = backend.execute( - "SELECT * FROM concurrent_test_users WHERE thread_id = %s", + "SELECT * FROM concurrent_test_users WHERE thread_id = ?", [thread_id] ) backend.execute( - "UPDATE concurrent_test_users SET name = %s WHERE thread_id = %s AND id <= %s", + "UPDATE concurrent_test_users SET name = ? WHERE thread_id = ? AND id <= ?", [f"updated_{thread_id}", thread_id, thread_id + 5] ) @@ -134,7 +136,7 @@ def test_concurrent_query_results_not_mixed(self, sqlserver_pool_with_tables: Ba for thread_id in range(num_threads): for i in range(records_per_thread): backend.execute( - "INSERT INTO concurrent_test_posts (thread_id, title, content) VALUES (%s, %s, %s)", + "INSERT INTO concurrent_test_posts (thread_id, title, content) VALUES (?, ?, ?)", [thread_id, f"title_{thread_id}_{i}", f"content_{thread_id}_{i}"] ) @@ -152,7 +154,7 @@ def read_thread_data(thread_id: int): time.sleep(0.05) result = backend.execute( - "SELECT * FROM concurrent_test_posts WHERE thread_id = %s ORDER BY id", + "SELECT * FROM concurrent_test_posts WHERE thread_id = ? ORDER BY id", [thread_id] ) @@ -195,7 +197,7 @@ def test_concurrent_transactions_isolated(self, sqlserver_pool_with_tables: Back with pool.transaction() as backend: for i in range(num_threads): backend.execute( - "INSERT INTO concurrent_test_users (thread_id, name) VALUES (%s, 'initial')", + "INSERT INTO concurrent_test_users (thread_id, name) VALUES (?, 'initial')", [i] ) @@ -211,7 +213,7 @@ def transaction_test(thread_id: int): with pool.transaction() as backend: backend.execute( - "UPDATE concurrent_test_users SET name = %s WHERE thread_id = %s", + "UPDATE concurrent_test_users SET name = ? WHERE thread_id = ?", [f'modified_by_{thread_id}', thread_id] ) @@ -222,7 +224,7 @@ def transaction_test(thread_id: int): ) own_row = backend.execute( - "SELECT * FROM concurrent_test_users WHERE thread_id = %s", + "SELECT * FROM concurrent_test_users WHERE thread_id = ?", [thread_id] ) @@ -301,8 +303,9 @@ class TestAsyncBackendPoolConcurrent: async def test_concurrent_connections_are_distinct(self, async_sqlserver_pool: AsyncBackendPool): """Verify that concurrently acquired async connections are distinct.""" num_concurrent = 5 - connection_ids: List[int] = [] - connection_ids_lock = asyncio.Lock() + held_ids: set = set() + held_lock = asyncio.Lock() + double_checkouts: List[int] = [] start_event = asyncio.Event() async def acquire_and_record(task_id: int): @@ -310,20 +313,22 @@ async def acquire_and_record(task_id: int): async with async_sqlserver_pool.connection() as backend: conn_id = id(backend) - async with connection_ids_lock: - connection_ids.append((task_id, conn_id)) - - await asyncio.sleep(0.1) + async with held_lock: + if conn_id in held_ids: + double_checkouts.append(conn_id) + held_ids.add(conn_id) + try: + await asyncio.sleep(0.1) + finally: + async with held_lock: + held_ids.discard(conn_id) tasks = [acquire_and_record(i) for i in range(num_concurrent)] start_event.set() await asyncio.gather(*tasks) - assert len(connection_ids) == num_concurrent - unique_ids = set(conn_id for _, conn_id in connection_ids) - assert len(unique_ids) == num_concurrent, ( - f"Expected {num_concurrent} distinct connections, " - f"but got {len(unique_ids)}" + assert not double_checkouts, ( + f"Pool handed the same connection to two tasks concurrently: {double_checkouts}" ) @pytest.mark.asyncio @@ -341,12 +346,12 @@ async def complex_operation(task_id: int): async with pool.connection() as backend: for i in range(10): await backend.execute( - "INSERT INTO concurrent_test_users (task_id, name) VALUES (%s, %s)", + "INSERT INTO concurrent_test_users (task_id, name) VALUES (?, ?)", [task_id, f"user_{task_id}_{i}"] ) query_result = await backend.execute( - "SELECT * FROM concurrent_test_users WHERE task_id = %s", + "SELECT * FROM concurrent_test_users WHERE task_id = ?", [task_id] ) @@ -374,7 +379,7 @@ async def test_concurrent_query_results_not_mixed(self, async_sqlserver_pool_wit for task_id in range(num_concurrent): for i in range(records_per_task): await backend.execute( - "INSERT INTO concurrent_test_posts (task_id, title, content) VALUES (%s, %s, %s)", + "INSERT INTO concurrent_test_posts (task_id, title, content) VALUES (?, ?, ?)", [task_id, f"title_{task_id}_{i}", f"content_{task_id}_{i}"] ) @@ -388,7 +393,7 @@ async def read_task_data(task_id: int): async with pool.connection() as backend: await asyncio.sleep(0.05) result = await backend.execute( - "SELECT * FROM concurrent_test_posts WHERE task_id = %s ORDER BY id", + "SELECT * FROM concurrent_test_posts WHERE task_id = ? ORDER BY id", [task_id] ) await asyncio.sleep(0.05)