Skip to content

Releases: pagebrooks/maxdop

maxdop 0.1.3

Choose a tag to compare

@github-actions github-actions released this 25 Sep 22:23
d3ecd56

No formatting changes. A file formatted by 0.1.2 comes out of 0.1.3 byte for byte the same.
What changed is what happens around the format: how --write puts the result on disk, what the VS
Code extension tells you when it declines a file, and how much of the safety net is actually tested.

--write can no longer destroy the file it was formatting

Until now --write used File.WriteAllBytes, which truncates the file and then writes it. A write
that did not finish — a full volume, a killed process, a container out of memory — left the file
truncated. Every gate upstream exists so maxdop never writes output it cannot prove safe, and then
the write itself could lose the file for a reason none of those gates can see. Disk-full is the
realistic trigger, and it is likeliest exactly when --write is running across a large repository.

Output now goes to a temporary file beside the original, which is renamed over it only once the new
bytes are complete. If anything fails first, the original is untouched and the temporary file is
removed.

  • Permissions carry over. On Linux and macOS the file mode is copied before the rename, so a
    0640 file does not come back 0644. On Windows the replace goes through ReplaceFile, which keeps
    the file's ACLs and attributes.
  • Symlinks are followed, not replaced. A symlink named on the command line has its target
    rewritten, and stays a symlink.
  • Hard links are the trade. An atomic replace necessarily gives the file a new inode, so another
    hard link to it keeps the old content. Writing in place instead would risk a half-old, half-new file,
    and for SQL that is worse than truncation: a hybrid can still parse and mean something different.
    Git makes the same call.

The temporary file is named .<file>.maxdop-<random>.tmp, so a concurrent *.sql walk never picks
it up.

VS Code: a declined file says so

A file that does not parse is handed back untouched, and until now the only trace of that was a line
in the maxdop output channel — so pressing Format appeared to do nothing. The status bar now shows
⚠ maxdop: parse error while that file is the active one. Its tooltip carries the parser's message,
and clicking it opens the output channel.

It is passive on purpose: no popup and no coloured background, since format-on-save runs on every
save while a statement is half typed. It clears as soon as the file formats, and it does not follow
you to other files.

The output channel is also available as maxdop: Show Output in the command palette.

Testing the safety net itself

The refusal path — hand back the input rather than output that failed a gate — had quietly become
the one behaviour no test executed. A working printer cannot produce output that trips the gates, and
every construct that used to has been fixed, so the refusal machinery lost its coverage as the bugs
went away. A refusal that returned the rejected text instead of the input would have kept every
suite green.

0.1.3 tests it directly:

  • Refusal paths. The gates are handed deliberately damaged output, and the tests assert the
    input comes back.
  • Batch seams. The same for the check that runs after GO-separated batches are joined back
    together — for example, a batch whose trailing newline went missing and welded END onto the next
    GO.
  • Width sweep. The committed corpus is formatted at every width in a range, not only the
    120-column default, and each result must pass the gates and be a fixed point.
  • Mutation testing. A weekly job deliberately breaks the five gate files — inverting a
    comparison, dropping a negation — and fails if the tests stop noticing. It is scoped to the gates on
    purpose: a surviving mutant there means a gate could stop working with the suite still green.

Distribution

  • WinGet. winget install --id pagebrooks.maxdop -e. The package is in microsoft/winget-pkgs
    now. WinGet updates go through Microsoft's review, so a new version reaches it a little after the
    release rather than with it.
  • Open VSX. The extension is published to Open VSX
    alongside the Visual Studio Marketplace, for VSCodium, Cursor and other editors that use it.
  • Homebrew. The formula is now generated from each release's SHA256SUMS and pushed to the tap
    by the release workflow, rather than edited by hand.

maxdop 0.1.2

Choose a tag to compare

@github-actions github-actions released this 31 Aug 23:19
e523869

Casing on Built-in Functions and Globals

Built-in function names and global variables now take keyword casing. Until now CAST, COALESCE, NULLIF, LEFT, RIGHT and IIF were recased while len and
row_number beside them were not, so a single expression could come out in two cases.

New Configuration

maxdop now recases from a list of built-in names, and that makes it the only casing decision in
the formatter not derived from the parse tree, which is controllable with a new configuration value:

{
  ...
  "recaseBuiltInFunctions": true
}

It follows keywordCase, so "lower" brings GETDATE() and @@ROWCOUNT down rather than leaving
casing half-applied.

Setting it to false keeps the casing you wrote for everything the list covers — built-in function
names, global variables, the parser-matched table functions such as STRING_SPLIT, and the aggregate
a PIVOT names — so off means off rather than mostly off.

It does not reach casing that the parse tree proves. CAST, NVARCHAR, WITHIN GROUP and
DBCC CHECKDB … WITH NO_INFOMSGS stay cased under keywordCase with the switch off, because this
switch exists for the one decision made from a list instead of from the grammar.

Caveats:

  • The call must have no qualifier. SQL Server requires at least a two-part name to invoke a
    user-defined function, so an unqualified len(x) can only ever bind to the built-in. If you have a
    function of your own called Len, you reach it as dbo.Len(x) — which has a call target and is
    left exactly as you wrote it.
  • The name must be undelimited. [len](x) is untouched. Brackets are how you say "this
    identifier is spelled exactly like this".
  • The list is consulted in function-name position only. A column named len, a table named
    replace, an alias named getdate, a variable named @len. All plain names are untouched.

Global variables

IF @@ERROR <> 0 PRINT @@SERVERNAME;   -- @@rowcount, @@trancount, @@fetch_status, …

DECLARE @@MyVar INT is legal T-SQL, and ScriptDom resolves a later reference by
spelling rather than by scope, so in an expression position a
local @@MyVar arrives as the very same GlobalVariableExpression that @@ROWCOUNT does. Recasing
on the prefix quietly renames that variable under a case-sensitive collation. So the
documented globals are listed by name, and a local variable spelled like a system one keeps every
character you wrote.

WITHIN GROUP is one clause

-- 0.1.1                                          -- 0.1.2
STRING_AGG(a, ',') within GROUP (ORDER BY b)      STRING_AGG(a, ',') WITHIN GROUP (ORDER BY b)

GROUP is reserved and lexed as its own token; WITHIN is not reserved and lexed as an identifier.
The clause reached the output through a slice that recases neither names nor identifiers — because a
COLLATE Latin1_General_BIN can sit in that same region, and a collation name is a name. So half the
clause came up and half did not.

Fixed structurally rather than by spelling: WithinGroupClause is a node, so its head is a region the
grammar guarantees holds no name. The collation that shares the old region is not a node in the same
way, and keeps exactly the treatment it had. The clause also gained a layout, breaking at its own
parenthesis when the line is long, the same as OVER.

PIVOT aggregates

-- 0.1.1                                          -- 0.1.2
SELECT SUM(a) FROM t;                             SELECT SUM(a) FROM t;
… PIVOT (sum(amount) FOR m IN ([Jan])) p          … PIVOT (SUM(amount) FOR m IN ([Jan])) p

The same split casing as WITHIN GROUP, in a different place. PIVOT names its aggregate through
AggregateFunctionIdentifier, which is a MultiPartIdentifier rather than a FunctionCall, so the
built-in list never saw it — a SUM in the select list came up while a sum two lines below did not.

DBCC

-- 0.1.1                                     -- 0.1.2
dbcc checkdb('MyDb') with no_infomsgs;       DBCC CHECKDB('MyDb') WITH NO_INFOMSGS;
dbcc shrinkfile (MyFileName, 100);           DBCC SHRINKFILE (MyFileName, 100);

Administrative statements reach the output through a generic fallback that normalises spacing and
descends into children but deliberately recases nothing, because it cannot tell a keyword from a name
in a construct nobody wrote a handler for. That is still true of GRANT, BACKUP and the rest, and
it is the right default.

DBCC is different because the parser resolves it to an enum. checkdb and no_infomsgs are
non-reserved and lex as Identifier, so the tokens prove nothing; but ScriptDom reports
DbccCommand.CheckDB and DbccOptionKind.NoInfoMessages, which is the parser saying it matched fixed
vocabulary. Only those positions are claimed (the file name in the second line above is an ordinary
identifier and survives verbatim).

One case is excluded on purpose. DBCC mydll (FREE) names an extended-procedure library, and
ScriptDom still reports Command = Free, so the enum alone would have recased somebody's DLL name.
Those statements are handed back untouched.

Added 7 missing function families

family
Graph SQL Server 2017 NODE_ID_FROM_PARTS, EDGE_ID_FROM_PARTS, OBJECT_ID_FROM_NODE_ID, …
Collation COLLATIONPROPERTY, TERTIARY_WEIGHTS
Regular expression SQL Server 2025 REGEXP_LIKE, REGEXP_REPLACE, REGEXP_SUBSTR, REGEXP_INSTR, REGEXP_COUNT
Fuzzy string SQL Server 2025 EDIT_DISTANCE, JARO_WINKLER_SIMILARITY, …
Vector SQL Server 2025 VECTOR_DISTANCE, VECTOR_NORM, VECTOR_NORMALIZE, VECTORPROPERTY
AI SQL Server 2025 AI_TRANSLATE, AI_SUMMARIZE, AI_CLASSIFY, …
External INVOKE_EXTERNAL_API

Names that already had a node of their own are still not on the list, and this is the rule the
list is kept to: REGEXP_MATCHES and REGEXP_SPLIT_TO_TABLE return tables and arrive as a
GlobalFunctionTableReference, exactly as STRING_SPLIT does, so the parser has already matched them.
AI_GENERATE_EMBEDDINGS, AI_GENERATE_CHUNKS and VECTOR_SEARCH have syntax rather than arguments —
USE MODEL, SOURCE = …, TABLE = … AS x — and get nodes of their own. Those three are still
passed through unchanged; casing them is not in this release.

maxdop 0.1.1

Choose a tag to compare

@github-actions github-actions released this 24 Aug 23:36
eb4095c

What's Changed

  • Bump actions/attest-build-provenance from 3.0.0 to 4.2.2 in the actions group by @dependabot[bot] in #1
  • Bump Microsoft.NET.Test.Sdk from 17.14.1 to 18.9.0 by @dependabot[bot] in #3
  • Bump xunit.runner.visualstudio from 3.1.4 to 4.0.0 by @dependabot[bot] in #4
  • Bump coverlet.collector from 6.0.4 to 10.0.1 by @dependabot[bot] in #2

New Contributors

Full Changelog: v0.1.0...v0.1.1

maxdop 0.1.0

Choose a tag to compare

@github-actions github-actions released this 22 Aug 05:12
8a8f19c

A T-SQL formatter that runs in CI, understands the whole language, and checks its own work.

First public release. One static binary, no runtime to install, MIT.

Why another SQL formatter

Most of them never parse your SQL — they split it into tokens and guess, which holds until the shape
of the code matters. maxdop is built on ScriptDom,
Microsoft's own T-SQL parser, the one behind DacFx and SQL database projects. Twelve grammars, SQL
Server 2000 through 2025 plus Fabric DW. Stored procedures, GO batches, custom delimiters and
2000-era syntax all read the way the server reads them.

Microsoft ships two ScriptDom-based formatters of its own, and both live inside an editor. Neither
has a command line, so neither can fail a build. That is the gap this fills: the same binary runs in
your editor and your pipeline, and the style lives in a .maxdop.json committed next to the code.

It verifies its own output

Every format is re-parsed and compared against the input — token stream, tree, and comments. On any
mismatch you get your original file back untouched and a distinct exit code. Below that sits a
byte-level gate: encodings round-trip or the file is not written, so a UTF-16-with-BOM file stays
UTF-16 with its BOM, CRLF stays CRLF, and a Windows-1252 file is declined rather than mangled.

Measured against 2,215 real-world files — AdventureWorks, WideWorldImporters, the First Responder
Kit, Ola Hallengren's Maintenance Solution, sp_WhoIsActive, and ScriptDom's own parser test suite —
formatted once per configuration option, ten variants each:

Refused, crashed, or non-idempotent 0, in every variant
Comments lost 0 of 11,261
Token coverage, corpus-wide 98.6%
Per-file median coverage 100%

Those numbers are re-measured nightly in CI, not on a laptop. How it's measured →

Install

Download for your platform, or install maxdop from the VS Code Marketplace — the extension
bundles the binary and downloads nothing on first run.

# Linux x64
curl -fsSL -O https://github.com/pagebrooks/maxdop/releases/download/v0.1.0/maxdop-0.1.0-linux-x64.tar.gz
tar -xzf maxdop-0.1.0-linux-x64.tar.gz && sudo install maxdop-0.1.0-linux-x64/maxdop /usr/local/bin/

Builds for linux-x64, linux-arm64, linux-musl-x64 (Alpine), win-x64, win-arm64, osx-x64
and osx-arm64. ~18 MB, ~3 ms cold start, no .NET runtime, no Node, no installer.

Verify what you downloaded

Every archive and VSIX carries build provenance,
signed by the workflow that produced it:

gh attestation verify maxdop-0.1.0-linux-x64.tar.gz --repo pagebrooks/maxdop

SHA256SUMS is attached as well. The attestation is the stronger check — whoever could replace the
binaries could rewrite the checksum file alongside them.

These binaries are not signed with an Apple Developer ID or an Authenticode certificate, so macOS
Gatekeeper and Windows SmartScreen will warn on a direct download. The Marketplace extension is not
affected.

Using it

maxdop query.sql              # formatted SQL to stdout
maxdop --write src/           # a file or a directory, searched to the bottom
maxdop --check src/           # exit 1 if anything would change
maxdop --parser-version 2016  # pin the grammar, so 2016 code is not reformatted under 2025 rules
cat query.sql | maxdop        # stdin to stdout, how editors call it

Point it at directories rather than at a src/**/*.sql glob: globstar is off in a default bash,
including the shell GitHub Actions runs, so ** collapses to one directory level and quietly checks
a fraction of your repository.

Exit codes are the contract with CI. 0 nothing to do · 1 the input's problem and the file is
untouched · 2 maxdop's problem, please report it.

Only the files that changed

maxdop has no dependency on git and does not want one — it would mean a git binary in every CI
image, and it breaks in shallow checkouts and in Perforce and Mercurial shops. Hand it the list:

git diff --name-only --diff-filter=ACM -z origin/main | maxdop --check --files-from -

Separators are detected, so -z and -print0 output work without a second flag. An empty list exits
0, because a pull request that touched no SQL is the commonest way to reach this command.

Adopting on a codebase nobody has formatted

--check on an established repository fails on every file at once, so nobody turns it on.

maxdop --write-baseline src/                       # record today's unformatted files
maxdop --check --baseline .maxdop-baseline src/    # green, and gets stricter on its own

A baseline entry is the hash of a file's current, unformatted bytes. Edit the file and the hash stops
matching, so it has to be formatted to pass. The count only goes down, as people touch code they were
already touching. It is sorted plain text in sha256sum format — reviewable as a diff, mergeable line
by line, and it needs no version control history to work.

Configuration

One .maxdop.json at the repo root; the nearest one at or above the file wins.

{
  "maxWidth": 100,
  "indentSize": 4,
  "useTabs": false,
  "keywordCase": "upper",
  "leadingCommas": false,
  "alwaysBreakSelectList": false,
  "alwaysBreakWhere": false,
  "maxBlankLines": 1,
  "parserVersion": "2022",
  "initialQuotedIdentifiers": false,
  "exclude": ["db/generated/**", "*.gen.sql"]
}

There are no editor-level formatting settings, on purpose. A repository formats the same way whoever
opens it.

Known limitations

  • --range is reserved and not implemented. It is rejected rather than ignored, because
    formatting the whole document when the caller asked for a selection is how "Format Selection ate my
    file" happens. The VS Code extension therefore offers no range formatting.
  • 15 comments across 8 files moved in the corpus run — almost all commented-out DDL inside
    statements that have no handler yet and are emitted verbatim. Nothing is lost; a comment can land on
    the wrong side of such a construct.
  • 1.4% of tokens corpus-wide are still passed through verbatim rather than laid out. They are
    reproduced byte-for-byte, so this is a completeness limit, not a correctness one.
  • Files that do not parse are left alone, with the reason on stderr. sqlcmd directives
    (:setvar, $(Var)) are the usual cause. Multi-batch scripts are split at GO and formatted
    batch by batch, so one bad batch no longer costs the file.
  • No Oracle, MySQL, PostgreSQL or Snowflake, and no plans for them.

Editors

The VS Code extension bundles the platform binary. Neovim works through
conform.nvim, and anything that can pipe a buffer through
a command works too — the whole interface is stdin in, stdout out, exit code back.

Thanks

Built on ScriptDom (MIT). Measured against public T-SQL
from Microsoft, Brent Ozar Unlimited,
Ola Hallengren and
Adam Machanic — fetched for measurement, never
vendored.

Bug reports and corrections welcome. Security issues: see SECURITY.md.