Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
32b769e
feat(sqlserver): adapt to node-based expression system and declarativ…
vistart Sep 10, 2026
8e4f10d
refactor: enforce (self, expr) signature on format_* methods
vistart Sep 11, 2026
6793dbe
fix: Rename JoinExpression→JoinClause, fix format_grouping_expression…
vistart Sep 11, 2026
72cce3e
ci: upgrade to ODBC Driver 18 for SQL Server 2025 jobs
vistart Sep 11, 2026
b566f96
feat(dialect-protocol): formalize SQL Server type support and suggest…
vistart Sep 12, 2026
079b4b7
fix(test): adapt all tests to dialect-first parameter-inline convention
vistart Sep 12, 2026
cd30168
fix(ci): prevent xdist worker deadlock on READ_COMMITTED_SNAPSHOT
vistart Sep 12, 2026
49522c5
fix(fixtures): add dialect to ColumnConstraint in all SQL Server fixt…
vistart Sep 12, 2026
90ae071
fix(dialect): use to_sql() without arguments in _format_column_defini…
vistart Sep 12, 2026
0aef8b6
fix(dialect): migrate format_set_operation_expression to (self, expr)
vistart Sep 12, 2026
261bb20
fix(test): complete dialect-first migration in SQL Server tests
vistart Sep 12, 2026
314e04c
test(protocol): add generic protocol conformance test (positive + neg…
vistart Sep 12, 2026
d740155
feat(dialect): declare GeneratedColumn protocol support
vistart Sep 12, 2026
0e3e83b
fix(test): migrate remaining tests to parameter-inline calling conven…
vistart Sep 12, 2026
0700de0
fix(test): add missing dialect= to MergeAction in remaining test files
vistart Sep 12, 2026
d33e802
fix(dialect): adapt format_fulltext_match/array_expression to express…
vistart Sep 13, 2026
8424d20
feat(protocol): override supports_cte_order_by to False
vistart Sep 13, 2026
aefc569
refactor(protocol): rename supports_cte_order_by to supports_uncondit…
vistart Sep 13, 2026
96715fd
fix(dialect): remove stale cast_types handling from format_json_expre…
vistart Sep 13, 2026
db5b933
feat(identifier): add reserved words and opt-out identifier quoting
vistart Sep 13, 2026
2eff107
refactor(identifier): remove IdentifierMixin and fix method signatures
vistart Sep 13, 2026
bb91243
refactor(sqlserver): add missing global mixins + remove 29 duplicate …
vistart Sep 13, 2026
e349b6b
test(protocol): move Function/ILIKE to positive protocol list
vistart Sep 13, 2026
9e1b011
refactor(sqlserver): split dialect methods into 25 feature mixins
vistart Sep 13, 2026
9fe5b88
feat(sqlserver): add JSON/SET/spatial expression classes
vistart Sep 14, 2026
ae203fe
refactor(sqlserver): rename protocols/json_support.py to json.py
vistart Sep 14, 2026
1191d24
fix(sqlserver): declare format_identifier on the dialect
vistart Sep 14, 2026
8be33d1
test(sqlserver): route direct formatter calls through expression nodes
vistart Sep 14, 2026
5458750
fix(sqlserver): compose CollationMixin for format_collate_expression
vistart Sep 14, 2026
bf368be
refactor(sqlserver): emit bind placeholders via dialect accessor
vistart Sep 14, 2026
c6f92eb
fix: replace hardcoded ? placeholder with dialect.p() in async_backend
vistart Sep 14, 2026
bf3d2fc
refactor: normalize format_* return types to Tuple[str, tuple] and el…
vistart Sep 14, 2026
89989cb
chore: fix .gitignore for .claude/plan*/
vistart Sep 14, 2026
8a49050
fix: update tests and dialect for signature normalization
vistart Sep 14, 2026
172374f
fix: update tests to unpack (sql, params) tuple from format_tablesamp…
vistart Sep 14, 2026
db1de28
fix: handle BaseExpression default values in format_column_definition
vistart Sep 14, 2026
f16a5d1
refactor(ddl)!: SQL Server LIKE unsupported via CreateTableLikeExpres…
vistart Sep 15, 2026
4d9a4de
feat(ddl): prefer typed table_options for memory_optimized/durability
vistart Sep 15, 2026
ef40500
fix(ddl): fix DROP TABLE CASCADE/RESTRICT and remove override
vistart Sep 15, 2026
3eff57e
feat(ddl): add supports_multi_action_alter_table(False)
vistart Sep 15, 2026
d8c6b7e
refactor(ddl): rename table.py → ddl_table.py in mixins
vistart Sep 15, 2026
ce245a1
feat: add SQL Server DDL improvements with capability gating
vistart Sep 15, 2026
3c69370
refactor: remove ReturningMixin import (merged into DMLMixin in core)
vistart Sep 15, 2026
e252dda
feat: improve OUTPUT clause (per-expression prefix, INTO, validation)
vistart Sep 15, 2026
d51d576
test: classify DatabaseSupport in SQL Server protocol conformance
vistart Sep 15, 2026
078ff12
test: pass SQL Server version in DDL capability tests to satisfy vers…
vistart Sep 15, 2026
24dd06e
feat(dql): WITH TIES + typed lock strength + set-op FOR UPDATE gate
vistart Sep 15, 2026
b9e09d5
refactor: remove core DQL/Expression mixin references (merged into DQ…
vistart Sep 16, 2026
aaee15a
feat(ddl): render generated columns via core GeneratedColumnExpression
vistart Sep 16, 2026
88964d0
chore: add scripts/preflight.sh and optional git hook installer
vistart Sep 16, 2026
813e3f7
feat(graph): add SQL Server SQL Graph support (node/edge tables + MAT…
vistart Sep 16, 2026
245a45d
fix(graph): align SQLServerShortestPathExpression attrs with ctor params
vistart Sep 16, 2026
72ecc80
chore(ci): update rhosocial-activerecord install source to release/v1…
vistart Sep 16, 2026
9931729
fix(sqlserver): correct pagination, database ownership and temporal f…
vistart Sep 17, 2026
9f7823d
fix(graph): correct shortest path grouping and edge constraint gating
vistart Sep 17, 2026
d46f46c
test(graph): reuse scenario fixtures and protocol capability checks
vistart Sep 17, 2026
de8850a
feat(sqlserver): add statement-level NOSCAN execution option
vistart Sep 17, 2026
b03d009
chore: add changelog fragment for graph support and NOSCAN option
vistart Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 54 additions & 45 deletions .github/workflows/test.yml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ marimo/_lsp/
__marimo__/

# Project specific
.claude/plan/
.claude/plan*/
.claude/tmp/
tests/config/
test_connection.py
1 change: 1 addition & 0 deletions changelog.d/3.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add SQL Server SQL Graph support (node/edge tables, MATCH predicate, SHORTEST_PATH) and statement-level NOSCAN execution option
25 changes: 25 additions & 0 deletions scripts/install-git-hooks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# scripts/install-git-hooks.sh
#
# Installs an optional pre-commit hook that runs scripts/preflight.sh so a
# broken import/lint is caught before the commit (and before a CI round-trip).
# Bypass a single commit with: git commit --no-verify
set -euo pipefail

cd "$(dirname "$0")/.."

HOOK=".git/hooks/pre-commit"
if [ ! -d .git/hooks ]; then
echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2
exit 2
fi

cat > "$HOOK" <<'HOOK_BODY'
#!/usr/bin/env bash
# Installed by scripts/install-git-hooks.sh — runs the local pre-flight.
# Skip with: git commit --no-verify
exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh"
HOOK_BODY
chmod +x "$HOOK"

echo "install-git-hooks: installed $HOOK -> scripts/preflight.sh"
86 changes: 86 additions & 0 deletions scripts/preflight.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# scripts/preflight.sh
#
# Fast local pre-flight so a trivial ImportError/lint break is caught in
# seconds-to-a-minute instead of after a full CI round-trip.
#
# Steps:
# 1. ruff check <RUFF_TARGET> report-only by default (STRICT_LINT=1 to gate)
# 2. import smoke FATAL: catches broken src imports
# 3. pytest --collect-only FATAL: imports every test module -> ImportError
#
# `--collect-only` does NOT execute tests, but it does import conftest.py, so a
# missing local DB may print connection warnings. Collection still succeeds.
#
# Overridable env vars:
# VENV=<path> venv dir (default: .venv3.14-ubuntu26.04)
# IMPORT_MODULE=<module> package to import (repo-specific default below)
# PYTEST_TARGET=<path> collection target (default: tests)
# RUFF_TARGET=<path> lint target (default: src)
# STRICT_LINT=1 make ruff failures fatal (default: report-only)
# SKIP_LINT=1 / SKIP_IMPORT=1 / SKIP_COLLECT=1
set -uo pipefail

cd "$(dirname "$0")/.."

VENV="${VENV:-.venv3.14-ubuntu26.04}"
PY="$VENV/bin/python"
RUFF="$VENV/bin/ruff"
IMPORT_MODULE="${IMPORT_MODULE:-rhosocial.activerecord.backend.impl.sqlserver}"
PYTEST_TARGET="${PYTEST_TARGET:-tests}"
RUFF_TARGET="${RUFF_TARGET:-src}"

if [ ! -x "$PY" ]; then

Check failure on line 33 in scripts/preflight.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-sqlserver&issues=AaCvH-Gi4N1w55Snlyn0&open=AaCvH-Gi4N1w55Snlyn0&pullRequest=3
echo "preflight: venv python not found at $VENV (set VENV=...)" >&2
exit 2
fi

fail=0

if [ "${SKIP_LINT:-0}" != "1" ]; then
echo "==> [1/3] ruff check $RUFF_TARGET"
if [ -x "$RUFF" ]; then lint_cmd=("$RUFF"); else lint_cmd=("$PY" -m ruff); fi

Check failure on line 42 in scripts/preflight.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-sqlserver&issues=AaCvH-Gi4N1w55Snlyn2&open=AaCvH-Gi4N1w55Snlyn2&pullRequest=3
if ! "${lint_cmd[@]}" check "$RUFF_TARGET"; then
if [ "${STRICT_LINT:-0}" = "1" ]; then
echo "preflight: lint failed (STRICT_LINT=1)" >&2
fail=1
else
echo "preflight: lint issues found (non-fatal; set STRICT_LINT=1 to gate)"
fi
fi
else
echo "==> [1/3] ruff check (skipped)"
fi

if [ "${SKIP_IMPORT:-0}" != "1" ]; then
echo "==> [2/3] import smoke: $IMPORT_MODULE"
if ! "$PY" -c "import $IMPORT_MODULE"; then
echo "preflight: import of '$IMPORT_MODULE' failed" >&2
fail=1
fi
else
echo "==> [2/3] import smoke (skipped)"
fi

if [ "${SKIP_COLLECT:-0}" != "1" ]; then

Check failure on line 65 in scripts/preflight.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=rhosocial_python-activerecord-sqlserver&issues=AaCvH-Gi4N1w55Snlyn5&open=AaCvH-Gi4N1w55Snlyn5&pullRequest=3
echo "==> [3/3] pytest --collect-only $PYTEST_TARGET (no tests executed)"
log="$(mktemp)"
if ! PYTHONPATH=tests "$PY" -m pytest "$PYTEST_TARGET" \
--collect-only -q -p no:cacheprovider > "$log" 2>&1; then
echo "preflight: test collection failed (ImportError?):" >&2
grep -E "ImportError|ModuleNotFoundError|NameError|ERROR collecting" "$log" | head -30 >&2
fail=1
fi
rm -f "$log"
else
echo "==> [3/3] pytest --collect-only (skipped)"
fi

if [ "$fail" -ne 0 ]; then
echo ""
echo "preflight: FAILED — fix the above before committing/pushing."
exit 1
fi

echo ""
echo "preflight: OK"
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .config import SQLServerConnectionConfig
from .collation import SQLServerCollation
from .dialect import SQLServerDialect
from .options import SQLServerExecutionOptions
from .transaction import SQLServerTransactionManager
from .adapters import (
SQLServerUUIDAdapter,
Expand Down Expand Up @@ -66,6 +67,7 @@
__all__ = [
"SQLServerBackend",
"SQLServerConnectionConfig",
"SQLServerExecutionOptions",
"SQLServerDialect",
"SQLServerCollation",
"SQLServerTransactionManager",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
(``mixins/backend_mixin`` -> ``dialect``).
"""

from typing import Any, Tuple

from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError


Expand Down Expand Up @@ -35,7 +37,7 @@ def supports_drop_constraint_if_exists(self) -> bool:
"""``DROP CONSTRAINT IF EXISTS`` is supported since SQL Server 2016."""
return True

def format_add_column_action(self, action):
def format_add_column_action(self, action: Any) -> Tuple[str, tuple]:
if getattr(action, "if_not_exists", None) is True:
raise UnsupportedFeatureError(
self.name,
Expand All @@ -47,13 +49,13 @@ def format_add_column_action(self, action):
# SQL Server ALTER TABLE ... ADD takes the column without a COLUMN literal.
return f"ADD {column_sql}", column_params

def format_drop_column_action(self, action):
def format_drop_column_action(self, action: Any) -> Tuple[str, tuple]:
name = self.format_identifier(action.column_name)
if getattr(action, "if_exists", None) is True:
return f"DROP COLUMN IF EXISTS {name}", ()
return f"DROP COLUMN {name}", ()

def format_drop_table_constraint_action(self, action):
def format_drop_table_constraint_action(self, action: Any) -> Tuple[str, tuple]:
name = self.format_identifier(action.constraint_name)
if getattr(action, "if_exists", None) is True:
result = f"DROP CONSTRAINT IF EXISTS {name}"
Expand All @@ -63,7 +65,7 @@ def format_drop_table_constraint_action(self, action):
result += " CASCADE"
return result, ()

def format_alter_column_action(self, action):
def format_alter_column_action(self, action: Any) -> Tuple[str, tuple]:
"""Dispatch an ALTER COLUMN action to a SQL Server-specific formatter.

SQL Server re-specifies the full column rather than applying the
Expand Down Expand Up @@ -96,7 +98,7 @@ def format_alter_column_action(self, action):
"NULL/NOT NULL, COLLATE, SPARSE, or MASKED.",
)

def format_alter_column_type_action(self, action):
def format_alter_column_type_action(self, action: Any) -> Tuple[str, tuple]:
"""Format a T-SQL ALTER COLUMN type change.

SQL Syntax:
Expand Down Expand Up @@ -137,7 +139,7 @@ def format_alter_column_type_action(self, action):

return " ".join(parts), ()

def format_add_masked_action(self, action):
def format_add_masked_action(self, action: Any) -> Tuple[str, tuple]:
"""Format ALTER COLUMN ... ADD MASKED (dynamic data masking, 2016+).

SQL Syntax:
Expand Down Expand Up @@ -165,7 +167,7 @@ def format_add_masked_action(self, action):
(),
)

def format_drop_masked_action(self, action):
def format_drop_masked_action(self, action: Any) -> Tuple[str, tuple]:
"""Format ALTER COLUMN ... DROP MASKED (dynamic data masking, 2016+).

SQL Syntax:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional, Set, Tuple
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Dict, List, Optional, Set, Tuple

from rhosocial.activerecord.backend.base import AsyncStorageBackend
from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin
Expand Down Expand Up @@ -261,7 +262,7 @@ async def _get_identity_columns_async(self, table_name: str) -> Set[str]:
await cursor.execute(
"SELECT c.name FROM sys.identity_columns c "
"JOIN sys.tables t ON c.object_id = t.object_id "
"WHERE t.name = ? AND SCHEMA_NAME(t.schema_id) = 'dbo'",
f"WHERE t.name = {self.dialect.p()} AND SCHEMA_NAME(t.schema_id) = 'dbo'",
(table_name,),
)
for row in await cursor.fetchall():
Expand Down Expand Up @@ -329,6 +330,21 @@ async def execute(

return await self._execute_statement(sql, params, options)

@asynccontextmanager
async def _cursor_noscan(self, cursor: Any, noscan: Optional[bool]) -> AsyncIterator[None]:
if noscan is None:
yield
return

impl = cursor._impl
run_operation = cursor._run_operation
previous = await run_operation(getattr, impl, "noscan")
try:
await run_operation(setattr, impl, "noscan", noscan)
yield
finally:
await run_operation(setattr, impl, "noscan", previous)

async def _execute_statement(
self, sql: str, params: Optional[Tuple], options, start_time: Optional[float] = None
) -> QueryResult:
Expand Down Expand Up @@ -366,7 +382,8 @@ async def _execute_statement(
if identity_table is not None:
await self.set_identity_insert(identity_table, True)
try:
await cursor.execute(final_sql, final_params or ())
async with self._cursor_noscan(cursor, getattr(options, "noscan", None)):
await cursor.execute(final_sql, final_params or ())
finally:
if identity_table is not None:
await self.set_identity_insert(identity_table, False)
Expand Down
30 changes: 28 additions & 2 deletions src/rhosocial/activerecord/backend/impl/sqlserver/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging
import re
import time
from contextvars import ContextVar
from typing import Any, Dict, List, Optional, Tuple, Type

import pyodbc
Expand All @@ -24,6 +25,7 @@
OperationalError,
QueryError,
)
from rhosocial.activerecord.backend.options import ExecutionOptions
from rhosocial.activerecord.backend.result import QueryResult
from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin
from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin
Expand Down Expand Up @@ -161,6 +163,9 @@ def __init__(self, **kwargs):
self._connection = None
self._transaction_manager = None
self._default_suggestions_cache = None
self._execution_noscan: ContextVar[Optional[bool]] = ContextVar(
"sqlserver_execution_noscan", default=None
)

self._register_sqlserver_adapters()

Expand Down Expand Up @@ -358,7 +363,7 @@ def execute(
cursor.execute(f"SET {set_clause} ON")
cursor.close()
try:
return super().execute(statement, params, options=options)
return self._execute_with_options(statement, params, options)
finally:
cursor = self._get_cursor()
cursor.execute(f"SET {set_clause} OFF")
Expand All @@ -368,7 +373,7 @@ def execute(
if identity_table is not None:
self.set_identity_insert(identity_table, True)
try:
result = super().execute(sql, params, options=options)
result = self._execute_with_options(sql, params, options)
finally:
if identity_table is not None:
self.set_identity_insert(identity_table, False)
Expand All @@ -380,6 +385,27 @@ def execute(

return result

def _execute_with_options(
self, sql: str, params: Optional[Tuple], options: ExecutionOptions
) -> QueryResult:
token = self._execution_noscan.set(getattr(options, "noscan", None))
try:
return super().execute(sql, params, options=options)
finally:
self._execution_noscan.reset(token)

def _execute_query(self, cursor: Any, sql: str, params: Optional[Tuple]) -> Any:
noscan = self._execution_noscan.get()
if noscan is None:
return super()._execute_query(cursor, sql, params)

previous = cursor.noscan
try:
cursor.noscan = noscan
return super()._execute_query(cursor, sql, params)
finally:
cursor.noscan = previous

def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult:
"""Execute the same SQL statement multiple times with different parameters.

Expand Down
Loading
Loading