Skip to content

Experiment: Sqlcmd - #402

Draft
Shiwani Gupta (shiwanigupta0809) wants to merge 16 commits into
mainfrom
dev/shiwanigupta/rust-sqlcmd
Draft

Experiment: Sqlcmd #402
Shiwani Gupta (shiwanigupta0809) wants to merge 16 commits into
mainfrom
dev/shiwanigupta/rust-sqlcmd

Conversation

@shiwanigupta0809

Copy link
Copy Markdown
Contributor

Description

Related Issues

Checklist

  • cargo bfmt passes
  • cargo bclippy passes
  • cargo btest passes
  • New/changed functionality has tests
  • Public API changes are documented

@shiwanigupta0809 Shiwani Gupta (shiwanigupta0809) changed the title Dev/shiwanigupta/rust sqlcmd Experiment: Sqlcmd Aug 27, 2026
@shiwanigupta0809

Copy link
Copy Markdown
Contributor Author

Copilot resolve the merge conflicts in this pull request

Copilot AI commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Copilot resolve the merge conflicts in this pull request

Resolved the merge conflicts with main and pushed commit 0e12bd7.

Shiwani Gupta added 13 commits August 29, 2026 16:48
A batch that fails part-way still has result sets to render, but the client
stopped iterating at the first ERROR token, so everything after the failing
statement was lost. Add an opt-in defer_batch_errors mode that collects errors
and keeps reading, tracking COLMETADATA as it goes so the rows of later result
sets can still be parsed.

Also expose the row count from each DONE token in arrival order. A statement
under SET NOCOUNT ON sends no count and is recorded as None, so a caller can
tell "affected zero rows" apart from "reported no count" — the distinction
sqlcmd needs to place its "(N rows affected)" lines correctly.

Both are additive and off by default, so existing callers are unaffected.
A single binary that stands in for either existing client, built on mssql-tds
rather than the ODBC driver, so it carries no native install footprint and
builds the same way on Windows and Linux.

ODBC sqlcmd behaviour is the default, so existing scripts keep working
unchanged. --compat go (or SQLCMDCOMPAT=go) selects go-sqlcmd's rendering and
messages where the two disagree, and go-sqlcmd's subcommand CLI -- config,
query, and the container lifecycle -- is available directly.

Correctness is established by differential testing against the real binaries
rather than hand-written expectations, so the suites cannot drift from what the
references actually do:

  ODBC sqlcmd          116 cases on Windows, 111 on Linux
  go-sqlcmd legacy      62 cases on both
  go-sqlcmd subcommands 49 cases on both, 53 with the container lifecycle
  SQLCMDCOLORSCHEME     35 cases, captured through a PTY

Where an option is not supported it is refused with a message rather than
accepted and ignored, so a caller is never silently given behaviour it did not
ask for.

sqlcmd.md records the plan, the measured divergences between the two
references, and the reasoning behind each decision.
ClientContext::login_server_name separates where to connect from what name to
present at login. A connection through a tunnel, proxy or port-forward dials
localhost while the login must still name the real server, so server-side
routing and any name-based policy see the intended target. None keeps the
previous behaviour of writing the dialled address.

LOGIN7 stores ServerName as an offset/length pair separate from its payload, so
a length taken from the dialled address with bytes taken from the override
would corrupt the packet while looking correct from the client side. Both now
read one accessor. The tests read the name back off the wire from the mock
server rather than asserting on client state, and cover an override both longer
and shorter than the address dialled; reintroducing the mismatch fails three of
them.

Also add the five federated methods the enum was missing -- AzCli,
AzureDeveloperCli, AzurePipelines, Environment and ClientAssertion -- and map
them to the same workflow byte as the other non-password credentials, since
each resolves to a bearer token out of band. ActiveDirectoryMSI now maps
alongside ActiveDirectoryManagedIdentity instead of falling through to the
error arm, which it should have done all along as a documented alias.
The Entra methods parsed but never acquired a token: no factory was registered
on the client context, so a connection that negotiated FedAuth reached the
handshake with nothing to send. Every federated method now has one, covering
the full set both references name -- default, password, service principal,
managed identity, device code, workload identity, Azure CLI, Azure Developer
CLI, Azure Pipelines, environment and client assertion -- plus the
Application/MSI aliases. Most map onto an azure_identity credential; password
and device code talk to the token endpoint directly because the Rust SDK has
no equivalent. A unit test asserts every method registers a factory, since the
failure is otherwise invisible until a real Entra server refuses the login.

--server-name is now accepted rather than refused, resting on the LOGIN7
override added to mssql-tds.

Three gaps found by comparing against the real binaries rather than reading
the plan:

  -f with an unusable code page fell back to UTF-8, writing bytes the caller
  did not ask for. It is now refused, with the reference's own wording.

  SQLCMDINI reported nothing when the script could not be opened, where both
  references name the variable and its value. More subtly, -X suppressed
  environment seeding entirely -- but ODBC still seeds under -X and still runs
  the startup script, and only go-sqlcmd suppresses. Measured with :listvar
  against both.

  `open ads` is implemented instead of refused. The reference only handles
  Windows: its macOS build writes no password (Azure Data Studio reads UTF-16
  from the Keychain, the Go library writes UTF-8) and its Linux build panics.
  This launches on all three, and hands the password over only where that can
  be done correctly -- elsewhere Azure Data Studio prompts, which beats storing
  something it cannot decode.

Interactive input now goes through rustyline, which was already a dependency
but unused, so the prompt has history and line editing. Piped input keeps the
plain reader: an editor on a non-terminal would strip the bytes the batch
parser needs.
A server message with state 127 ends the session whatever its severity, and
outranks both -b and -V. The exit code is the message's own number, so
RAISERROR(14599, 16, 127) exits 14599 and an ad-hoc RAISERROR('boom', 16, 127)
exits 50000. The rest of the batch is discarded.

Unix exit statuses are 8 bits and the two references disagree about that:
go-sqlcmd hands the full number to exit() and lets the OS truncate -- 50000
becomes 80, 14599 becomes 7 -- while msodbcsql clamps to a plain failure. On
Windows both return the number itself. All four combinations were measured
against the real binaries rather than inferred; the differential suites now
cover both compat modes.

Also back out the rustyline editor added in the previous commit. Captured
through a PTY, its line redraw erased output the results stream had already
written: the `a` column heading came back blank where both references show it.
The editor owns the line it is redrawing while the results stream is written
independently, and reconciling the two means routing every write through the
editor's external printer -- more than the history and line editing are worth
here. The prompt is plain text again, and now verified: `1> 2> 3> a` matches
go-sqlcmd byte for byte through the same PTY capture. The dependency is dropped
rather than left unused.
Two rows of the parity table were platform-specific and I had not checked
whether that was a test gap or a feature gap. Both were test gaps, and running
them found a real bug.

The container lifecycle works on Linux and now runs there: 53 cases, matching
Windows exactly. Doing so caught a regression from the `open ads` commit -- the
"Open in Azure Data Studio" hint was added to `create` unconditionally, but the
reference offers it only on Windows and macOS, where it implements the command.
This port runs `open ads` on Linux too, but the hint list is compared against
the reference, so it follows the same rule.

Colour could not be compared end to end on Windows because that needs a
pseudo-terminal and the harness has no equivalent of `script(1)` there. The
parts that need no PTY now run on both platforms: the suppression gate, which is
what a script capturing our output depends on, and `:list color`. Redirected
output was separately confirmed byte-identical to go-sqlcmd on Windows.

`:list color` needs a reachable server despite answering locally, because
sqlcmd connects before it reads any input -- the first version of that test
assumed otherwise and failed on Linux, where `-E` wants a Kerberos ticket.

PARITY.md records what each platform actually covers and why, rather than
leaving the difference unexplained.
The parity table claimed all three tools accepted -R and ignored it. That was
wrong, and only running the binary showed it: ODBC implements it, on Linux as
well as Windows. A script using -R would have got different output from this
build -- a genuine drop-in gap, and one that had been written down as a
non-issue.

The reference goes through the platform's own locale services, so this does
too rather than carrying a locale database: GetCurrencyFormatEx and friends on
Windows, localeconv and strftime elsewhere. Money, decimal/numeric and every
date/time type are formatted; int, bigint, float and real are left alone,
matching the reference. Ten differential cases cover it on both platforms,
including the types it must not touch.

The Linux behaviour needed measuring rather than guessing. POSIX reports
frac_digits as CHAR_MAX in the C locale, meaning "unspecified", and the
reference renders that as no decimal places at all -- which is why 1234.56
comes back as 1235 there rather than unchanged.

Three defects in the reference are deliberately not reproduced, because each
puts something in front of a user that cannot be meant:

  datetime2 renders an unsubstituted printf specifier, `1:45:06.%07lu PM`
  time fails with "Internal error at LocalizeTimestampData"        (Windows)
  negative money fails with "Internal error at ReadAndHandleColumnData" (Linux)

go-sqlcmd accepts -R and ignores it, so the flag stays inert under --compat go.
Re-measuring the divergence table against all four binaries turned up eight
defects a green differential suite had not caught:

- SQLCMDCOMPAT was documented but never read; only --compat reached the parser.
- -Q/-q bypassed the batch pipeline, so $(var) went to the server unexpanded
  and -e echoed nothing. Both worked from -i files.
- -q and -Q ran both queries instead of -Q winning.
- -m hid PRINT, which neither reference does, and -m -1 dropped the Msg header.
- SQLCMDUSER was empty under -E; both references report the OS account.
- SQLCMDDBNAME was filled from the landed database; both leave it empty.

The go-only feature tests hard-coded a Windows integrated-auth connection, so
seven of them could never run on Linux. They now take the same connection
prefix as the differential suites.

PARITY.md is corrected: four rows of the divergence table were recorded
backwards, and the table now says which reference each mode actually matched.
The 57 long options invited the obvious question — what are the four Go does
not have? — and the doc could not answer it. Every count in section 1 is now
backed by a set diff rather than a total:

- Short options: Go is 46, not 45. The doc counted -? for ODBC but not for Go.
- 52 short = ODBC's 47 plus five it accepts but omits from its usage text
  (-D, -n, -O, -T) plus Go's -J. Almost no new surface.
- 57 long = Go's 53 plus --dsn and --print-statistics, which are long
  spellings for ODBC options Go never implemented, plus --compat and --format.
- "Superset" is now stated as a measured fact: diffing the three sets shows
  nothing either reference accepts is missing here.

The output-format row claimed CSV and JSON. Neither was ever designed — the
design doc has always said vert/vertical/ascii/horiz/horizontal, and all five
work. The claim was invented in this document. What is a real defect is that
an unrecognised name falls through silently instead of being refused, which
breaks the standing rule that no option is accepted then ignored; that is
recorded as a gap rather than fixed here.

Also records two gaps found while answering the same question: the banner
hardcodes NT on every platform where ODBC prints the platform name, and the
binary is not self-contained the way go-sqlcmd is — it needs system OpenSSL 3.
extract-styles.ps1 wrote to $env:TEMP and wrap-styles.ps1 read it back,
with an absolute path to one machine's working copy. Neither ran anywhere
else. generate-schemes.ps1 does both steps, locates chroma through the Go
module cache or an explicit -ChromaPath, and writes to a path derived from
its own location.

Also adds SUMMARY.md, a status overview for reviewers.
main replaced execute(sql, timeout, cancel) with ExecuteOptions and renamed
move_to_next to advance_to_rows. The rebase carried the old call sites across
without conflict, since the files themselves did not clash, so the breakage
was semantic rather than textual.
These two tests were dropped during an earlier merge with main rather than
adapted, taking their coverage of set_defer_batch_errors with them. Restored
and moved onto on_rows/next_row/advance_to_rows.
Shiwani Gupta added 3 commits August 29, 2026 17:38
The tool moves from main.rs into lib.rs, so the same implementation can be a
standalone binary or an archive the native ODBC sqlcmd links. `run` returns the
exit code rather than exiting, because when the caller is the native binary it
owns the process. main.rs becomes the four lines that call it.

ffi.rs is the whole boundary: `sqlcmd_modern_claims` answers whether this side
owns the command line and `sqlcmd_modern_main` runs it. No Rust type crosses
and no allocation changes hands, so the two halves need agree on nothing but
argv.

What is claimed is deliberately narrow: the go-sqlcmd subcommand verbs, and the
long options with no short form in the ODBC option string. Those are exactly
the options go-sqlcmd added, so nothing written against ODBC sqlcmd can be
using them, and a command line the ODBC tool could have parsed is never taken.

The crate gains rlib, which the binary and tests link as usual, and staticlib,
which the native build links so both CLIs end up in one executable.
The native tool's entry point is main(char**) on Unix but wmain(WCHAR**) on
Windows, so a narrow-only boundary cannot be called from the Windows build.
Mirror the pair as sqlcmd_modern_claims_w and sqlcmd_modern_main_w, taking
UTF-16 and narrowing to the same Vec<String> the existing pair produces. Every
decision after that is shared, and a test asserts the two agree on the same
command lines, so the halves cannot diverge by platform.

Both are cfg(windows): nothing changes for Unix.
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.

2 participants