Skip to content

feat(sqlserver): support client certificates on the ODBC connection string - #6

Merged
vistart merged 15 commits into
release/v1.0.0.dev3from
fix/lazy-backend-init
Sep 29, 2026
Merged

vistart merged 15 commits into
release/v1.0.0.dev3from
fix/lazy-backend-init

Conversation

@vistart

@vistart vistart commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes # (issue)

SQLServerConnectionConfig could only express server-side encryption, via
encrypt and trust_server_certificate. There was no way to present a client
certificate: no ClientCertificate, ClientKey or HostNameInCertificate ODBC
keyword was ever emitted, and no config field existed to drive them.

This is a capability gap rather than a silently-dropped setting, but it sits in the
same area as the coordinated TLS fixes in the other backends on this branch.

Worth contrasting with the other four: encrypt defaults to True, so SQL Server
connections were already encrypted and the server certificate was already checked
(TrustServerCertificate=no). Nothing was silently downgraded here — mutual TLS was
simply not expressible.

Reproduction Steps

  1. Build a SQLServerConnectionConfig and call build_connection_string().
  2. Look for a client certificate keyword.

Expected: some way to supply one.
Actual: none exists — the four new fields are the only route, and any
options={"ClientCertificate": ...} workaround bypasses validation entirely.

Type of change

  • fix: A bug fix
  • test: Adding tests for the fix

Breaking Change

  • Yes
  • No

Applicable rhosocial-activerecord Version Range:
>=1.0.0,<2.0.0

All four fields default to unset, so existing connection strings are unchanged.
validate_config() gains one new check: a client_key supplied without a
client_certificate now raises ValueError instead of reaching the driver, where it
would surface as an opaque connect-time failure.

Related Repositories/Packages

This is one of five coordinated fixes. The same class of defect — SSL parameters
collected on the config but never handed to the driver — was found and fixed
alongside in python-activerecord-postgres, python-activerecord-mysql,
python-activerecord-mariadb and python-activerecord-oracle. No shared
python-activerecord change is required; each backend translates the generic
SSLMixin names to its own driver's keywords.

Testing

  • I have added new tests to cover my changes.
  • I have updated existing tests to reflect my changes.
  • All existing tests pass.
  • This change has been tested on [specify backend/DB versions].

Test Plan:

New tests/rhosocial/activerecord_sqlserver_test/feature/backend/test_ssl_connection_params.py
asserts the new fields are emitted as the correct ODBC keywords, that no client-TLS
keyword appears when they are unset, that encrypt/trust_server_certificate
behaviour is unchanged, and that the cert-without-key case raises.

Note: the SQL Server test suite's session-scoped conftest fixture probes every
configured scenario, so running it locally without a reachable server blocks on
connection timeouts. The tests here are offline and pass in 1.8s when the scenario
file is pointed elsewhere.

Checklist

  • My code follows the project's code style guidelines.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation (Docstrings, README.md, etc.).
  • My changes generate no new warnings.
  • I have added a Changelog fragment (changelog.d/<issue_number>.<type>.md). — will be added as changelog.d/<pr_number>.added.md once CI passes; this is new configuration surface rather than a fix, so added is the appropriate type.
  • I have verified that my changes do not introduce SQL injection vulnerabilities.
  • I have checked for potential performance regressions.

Additional Notes

This branch also carries accumulated work that predates this change: the
impl.sqlserver.backend package reorganisation, removal of two legacy re-export
shims, lazy backend exports, and the README/.claude documentation rewrite
(12 commits ahead of release/v1.0.0.dev3 in total).

The commit for this change is titled feat(sqlserver): rather than fix(...),
which is why the changelog type is added rather than fixed.

… without a driver

Every backend __init__ imported all of its submodules eagerly, and
.backend imports the DB driver. Python executes a parent package's
__init__ before any submodule, so `impl.sqlserver.expression` could not
be imported without the driver present - even though no expression class
touches it. Auditing the expression namespace needed a working driver;
pyodbc additionally needs the OS libodbc.so, so a plain pip install was
not enough and sqlserver dropped out of the audit entirely.

Public names are now resolved on first access through a PEP 562 module
__getattr__, with the name -> (module, attribute) map derived from the
existing import statements.

The exported surface is unchanged: attribute access, __all__, dir() and
`from impl.sqlserver import *` all behave as before, and the previous
eager imports move into an `if TYPE_CHECKING:` block so mypy and IDEs
still resolve them. The existing __getattr__ is preserved as
_optional_dependency_getattr, so the async backends keep raising an
install hint for aioodbc instead of a bare ImportError.

Importing the package, or its .expression submodule, no longer loads the
driver; the driver error now surfaces at first use of the name that needs
it, where it is actionable.

55 public names verified to resolve.
Drop the top-level impl/<backend>/__init__.py so the directory becomes a
PEP 420 namespace portion and no longer re-exports every public name. Each
name is now imported from the sub-package that defines it, so a symbol has
one public path instead of two.

The facade also made "expression is importable on its own" unenforceable:
importing impl.<backend>.expression executed the parent __init__, which
pulled in the driver. With the file gone, the sub-packages stand alone by
construction rather than by a lazy __getattr__ that each backend had to
maintain separately.

This is a breaking change for anyone importing
`from rhosocial.activerecord.backend.impl.<backend> import X`; the
replacement is `...impl.<backend>.<module> import X`.
The provider gave the worker tasks a single module path and the task code
looked for the async counterpart one level up. That was enough while the sync
and async classes shared the impl.<backend> facade; with the facade gone they
live in different modules and the one-level lookup fell through to the sync
class, so an async worker would silently run the sync backend.

Walk up the chain first, then try the backend's own async_backend module. No
package scanning is involved: both candidates are named explicitly.
Move the sync and async classes into a backend/ package with the fixed layout:
backend/backend.py and backend/async_backend.py. Eight backends kept them as
two modules beside backend/, and sqlite and postgres used backend/sync.py, so
the class locations differed three ways between backends.

With the layout fixed the worker tasks derive the async class from the sync
module's own package instead of searching for it, so the
_find_async_backend helper is gone.
The move into backend/ shifted every relative import by one level, and the
function-local ones were missed, so impl.bigquery could not reach its adapters
module. Resolve each target against the filesystem instead of counting dots.
impl.<backend>.backend already re-exported the sync class, so leaving the async
one out made the two classes reachable by different rules. Export both.

The eight backends whose async driver is a hard requirement import it
eagerly. SQLite and SQL Server keep a PEP 562 __getattr__, because aiosqlite
and aioodbc ship as the [async] extra and importing either eagerly would make
the async class a requirement for sync-only users.
The CLI tests monkeypatched SQLServerBackend on impl.sqlserver, which no longer
carries the name now that the directory is a namespace. Patch
impl.sqlserver.backend instead, and the async class on
impl.sqlserver.backend.async_backend, which is where each is defined.
The remaining multi-line monkeypatch still targeted impl.sqlserver.backend for
AsyncSQLServerBackend, but the CLI imports it from
impl.sqlserver.backend.async_backend inside the handler, so the patch never
reached it and the real backend was constructed.
The two CLI demos embed runnable Python as a string literal, and the import
migration rewrote the module path inside it without keeping the statement
terminator, leaving the string unterminated:

    "from rhosocial.activerecord.backend.impl.oracle.backend.async_backend import OracleBackend

ast.parse rejects all four demo modules with "unterminated string literal", so
they could not even be imported, let alone run. The migration also dropped the
trailing OracleConnectionConfig from the original single-statement import; the
surrounding lines already import it from its own module, so restoring the ";"
recovers the intended statements.

The SQL Server demos additionally reached SQLServerConnectionConfig through the
impl.sqlserver facade, which no longer exists now that the package is open, so
they now name impl.sqlserver.config like every other backend.

Verified: ast.parse is clean across every src/ and tests/ module in both repos.
mixins.py and protocols.py each sat next to a package of the same name, holding
nothing but a re-export of that package's contents. A regular package takes
precedence over a same-named module, so both shims were unreachable: importing
impl.sqlserver.mixins or impl.sqlserver.protocols always resolved to the
package's __init__.py. Each re-export list is a strict subset of the package's,
so nothing that resolved through a shim can break.

Also corrects the path comment on mixins/ddl_table.py, which still named the
file before it was renamed from table.py.
Removed the __partition__ model attribute from the partitioning example: no
such attribute exists, and table partitioning is in fact unsupported by this
backend -- add_partition, attach_partition, detach_partition and the three
partitioning switches are all False at every probed version. The generated
version table is what surfaces that. Corrected the core dependency floor,
documented as >=1.0.0 and unresolvable against the 1.0.0.devN pre-release
line.

In the agent docs: replaced the invented DatabaseCapabilities /
_initialize_capabilities / requires_capability system with the actual
supports_* protocol switches, and corrected the PYTHONPATH guidance, which
omitted tests.
…tring

encrypt and trust_server_certificate only cover server-side encryption,
so a client certificate could not be presented at all. Add
client_certificate, client_key, client_key_password and
host_name_in_certificate to SQLServerConnectionConfig and emit them as the
ClientCertificate/ClientKey/ClientKeyPassword/HostNameInCertificate ODBC
keywords from build_connection_string(), with the same four names accepted
as backend kwargs.

validate_config() now rejects a client_key given without a
client_certificate, so the mismatch surfaces as a clear ValueError instead
of an opaque driver failure at connect time.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
12.3% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
12.3% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 79.59184% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.36%. Comparing base (61ab594) to head (e2e0339).
⚠️ Report is 1 commits behind head on release/v1.0.0.dev3.

Files with missing lines Patch % Lines
...erecord/backend/impl/sqlserver/backend/__init__.py 44.44% 4 Missing and 1 partial ⚠️
...iverecord/backend/impl/sqlserver/cli/connection.py 0.00% 1 Missing ⚠️
...ord/backend/impl/sqlserver/cli/named_expression.py 50.00% 1 Missing ⚠️
...cord/backend/impl/sqlserver/cli/named_procedure.py 50.00% 1 Missing ⚠️
...ackend/impl/sqlserver/cli/named_procedure_graph.py 50.00% 1 Missing ⚠️
...cial/activerecord/backend/impl/sqlserver/config.py 90.90% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                   Coverage Diff                   @@
##           release/v1.0.0.dev3       #6      +/-   ##
=======================================================
- Coverage                73.36%   73.36%   -0.01%     
=======================================================
  Files                      133      132       -1     
  Lines                     7332     7313      -19     
  Branches                  1171     1171              
=======================================================
- Hits                      5379     5365      -14     
+ Misses                    1548     1540       -8     
- Partials                   405      408       +3     
Flag Coverage Δ
python-3.14 73.36% <79.59%> (-0.01%) ⬇️
sqlserver-2025 73.36% <79.59%> (-0.01%) ⬇️
unittests 73.36% <79.59%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Replace the six divergent changelog-check variants with one implementation
that encodes the documented merge rules.

feature/*|fix/* -> release/*|maint/*|main:
  - the PR must add at least one changelog.d/<number>.<type>.md
  - every added fragment's <number> must equal the PR number
  - fragments already present from previously merged branches are ignored,
    since only files ADDED by this PR are considered
  - escape hatches ([trivial] title, skip-changelog label, docs: update
    CHANGELOG) waive only the "at least one" requirement; the
    number-must-match rule is always enforced on fragments that were added

release/* -> main:
  - CHANGELOG.md must have changed (content is not inspected)
  - changelog.d/ must hold no fragments at the merge commit
  - no escape hatches apply to this path

The number-must-match rule was the one documented requirement that none of
the previous variants implemented. Two latent bugs are also fixed: the
paths-ignore of "**.md" meant a CHANGELOG.md-only push never ran the check at
all, and three backends skipped release merges outright instead of applying
the release rules.
A feature/fix branch merges into a release or maintenance branch; only
release/* merges into main. Opening a second PR at main from the same
branch is a duplicate, and it also breaks the changelog fragment, which is
numbered for exactly one PR.

This pattern already cost a wasted PR in six repositories (a ->release PR
merged alongside a ->main PR that was then closed), and the fragment
numbering check only exposed it indirectly, by failing the leftover PR.

hotfix/* -> main stays allowed; the version control policy calls for
hotfixes to be merged back to main and the active release branch.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
10.8% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@vistart vistart self-assigned this Sep 29, 2026
@vistart vistart added the bug Something isn't working label Sep 29, 2026
@vistart
vistart merged commit 6fe9527 into release/v1.0.0.dev3 Sep 29, 2026
21 of 22 checks passed
@vistart
vistart deleted the fix/lazy-backend-init branch October 1, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant