Skip to content

M2: execution core (SqlSession, SqlParameters)#2

Merged
lgamorim merged 7 commits into
masterfrom
feature/M2-execution-core
Jul 11, 2026
Merged

M2: execution core (SqlSession, SqlParameters)#2
lgamorim merged 7 commits into
masterfrom
feature/M2-execution-core

Conversation

@lgamorim

@lgamorim lgamorim commented Jul 11, 2026

Copy link
Copy Markdown
Owner

What

Milestone 2 (Phase 1 — Bedrock) of the SqlBound roadmap: the hand-written runtime primitives that both manual callers and the future source generator (M4+) will call into.

  • SqlParameters: an immutable, public, named parameter set. Normalizes null values to DBNull.Value at construction so downstream ADO.NET binding never needs a separate null check; rejects empty/whitespace/duplicate names.
  • SqlSession: the coexistence-safe, instance-based entry point for the "dynamic surface" (deliberately not named Query*/Execute*/ExecuteScalar* on DbConnection, per the Dapper-coexistence design constraint). Never opens, closes, or owns the connection — requires an already-open one, fails fast with InvalidOperationException otherwise.
    • RunAsync: non-query execution, returns affected row count.
    • FetchScalarAsync<T>: scalar execution with DBNull/null-aware conversion to T (throws for non-nullable value types, default for nullable ones), falling back to Convert.ChangeType for same-family type mismatches (e.g. a provider returning long where int was requested).
    • Cancellation is checked before any ADO.NET call is made, and threaded through to the underlying async calls for mid-execution cancellation too.
  • Hand-rolled ADO.NET test doubles (FakeDbConnection/FakeDbCommand/FakeDbParameter(Collection)) in the test project, chosen over a real provider to keep unit tests hermetic — the real-provider question is deferred to M3, which needs one regardless for the Dapper-coexistence sample.

Two infrastructure fixes surfaced along the way and landed as their own commits, ahead of the feature work that exposed them:

  • .gitattributes: .editorconfig already declared end_of_line = lf, but nothing enforced it at the git layer — on Windows with core.autocrlf=true (a common default), every fresh checkout converted committed LF to CRLF, failing dotnet format on every file. Now normalized at the git level.
  • Async-suffix naming rule exemption for tests: the M1 .editorconfig naming rule requiring an Async suffix on async methods didn't account for testing.md's Should_ExpectedOutcome_When_Scenario convention, which async test methods must also follow. Scoped off for test/**/*.cs.

Also upgraded the test project from the legacy xunit 2.x package to xunit.v3, keeping VSTest execution (Microsoft.NET.Test.Sdk + xunit.runner.visualstudio) so dotnet test and CI are unaffected.

Why

These are the primitives every later milestone builds on — M4's source generator will emit calls into SqlSession, and SqlParameters is public specifically because generated code compiles into the consumer's assembly.

Test plan

  • Full TDD cycle (red → green → refactor) for every unit: SqlParameters, SqlSession.RunAsync, SqlSession.FetchScalarAsync<T>
  • 31/31 tests passing locally
  • dotnet build — 0 warnings, 0 errors (TreatWarningsAsErrors + EnforceCodeStyleInBuild)
  • dotnet format --verify-no-changes — clean
  • dotnet pack — succeeds
  • CI green on this PR (first real execution of the xunit.v3 + .gitattributes changes on a Linux runner)

Reviewer notes

  • SqlSession requiring an already-open connection (no auto-open/close) is deliberate, not an oversight — see the Dapper-coexistence constraint in CLAUDE.md.
  • ConvertScalar<T>'s default(T) is not null check is the standard idiom for "is T a non-nullable value type" at runtime; used to decide throw-vs-return-default for a null/DBNull scalar result.

🤖 Generated with Claude Code

lgamorim added 7 commits July 11, 2026 01:28
Codifies the practice established while wrapping M1: each roadmap
milestone gets a matching GitHub milestone, created per phase, with
its PR associated on creation and the milestone closed on merge.
.editorconfig already declares end_of_line = lf for every file, but
nothing enforced it at the git layer. On Windows with core.autocrlf=true
(the common default), a fresh checkout converts every committed LF file
to CRLF in the working tree, which then fails dotnet format's ENDOFLINE
check on every file for any Windows contributor. Normalizing at the git
level makes checkouts match the editorconfig policy regardless of the
contributor's local autocrlf setting.
xunit 2.x is the legacy package line. Swaps it for xunit.v3 (3.2.2)
while keeping Microsoft.NET.Test.Sdk and xunit.runner.visualstudio
(bumped to 3.1.5) so execution stays on VSTest via `dotnet test` —
no change to the CI workflow or how tests are run. The existing smoke
test needed no code changes; namespace and Fact/Assert APIs are
unchanged in v3.
The first runtime primitive of the execution core, and the first
production code in SqlBound. Public (not internal) since generated
code from M4 onward will construct these directly in the consumer's
assembly. Normalizes null values to DBNull.Value at construction so
downstream ADO.NET binding never needs a separate null check, and
rejects empty/whitespace/duplicate names to fail fast on caller bugs
rather than silently misbinding a query parameter.
The Async-suffix naming rule added in M1 didn't account for testing.md's
Should_ExpectedOutcome_When_Scenario convention, which async test methods
must also follow. This is the first commit with async test methods, so
it's the first time the two rules conflicted. Scopes the async-suffix
rule off for test/**/*.cs, where the TDD naming convention takes
precedence.
SqlSession is the instance-based entry point for the coexistence-safe
dynamic surface (never named Query*/Execute*/ExecuteScalar* on
DbConnection, per the Dapper-coexistence constraint). It never opens,
closes, or owns the connection it's given — it requires an already-open
one and fails fast with InvalidOperationException otherwise, so behavior
can't surprise a caller sharing the connection with Dapper.

RunAsync executes a non-query statement and returns the affected row
count. Cancellation is checked before any ADO.NET call is made (a
pre-canceled token throws before a command is even created) and the
token is also threaded through to ExecuteNonQueryAsync so cancellation
during execution propagates too. Provider exceptions are left unwrapped.

FakeDbConnection/FakeDbCommand/FakeDbParameter(Collection) are minimal
hand-rolled subclasses of the abstract System.Data.Common types, chosen
over a real provider (e.g. SQLite) to keep unit tests hermetic and defer
the real-provider question to M3, which needs one regardless for the
Dapper-coexistence sample.
Executes a scalar-returning statement and converts the boxed ADO.NET
result to T. A database null becomes default(T) when T can represent
it (a reference type or Nullable<T>); for a non-nullable value type it
throws InvalidOperationException instead, since there's no way to
represent "no value" there. Falls back to Convert.ChangeType for
same-family conversions (e.g. a provider returning long for a query
the caller expects as int) after a fast exact-type-match path.

Extracts the sql/cancellation/connection-state validation shared with
RunAsync into ValidateAndPrepare, now that two methods need it.
@lgamorim lgamorim added this to the M2 — Execution core milestone Jul 11, 2026
@lgamorim lgamorim merged commit d5dfa78 into master Jul 11, 2026
2 checks passed
@lgamorim lgamorim deleted the feature/M2-execution-core branch July 11, 2026 01:28
@lgamorim lgamorim modified the milestones: M2 — Execution core, Phase 1 — Bedrock (0.1.x) Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant