Skip to content

feat(tsql): support FOR JSON clause [CLAUDE]#7649

Merged
VaggelisD merged 6 commits into
tobymao:mainfrom
lvanoverberghe:feat/tsql-for-json
May 18, 2026
Merged

feat(tsql): support FOR JSON clause [CLAUDE]#7649
VaggelisD merged 6 commits into
tobymao:mainfrom
lvanoverberghe:feat/tsql-for-json

Conversation

@lvanoverberghe
Copy link
Copy Markdown
Contributor

Summary

Adds parsing and generation for T-SQL's FOR JSON query modifier, mirroring the existing FOR XML handling.

Grammar covered:

FOR JSON { AUTO | PATH } [, ROOT [ ('name') ]]
[, INCLUDE_NULL_VALUES]
[, WITHOUT_ARRAY_WRAPPER]

Reference: https://learn.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server

Behavior in other dialects

Other dialects emit Unsupported query option. and drop the FOR JSON clause — same behavior as FOR XML today. Locked in via validate_all against postgres/duckdb.

Test plan

  • Identity round-trip for every documented option combination (incl. arbitrary modifier ordering)
  • Canonical correlated-subquery JSON-shaping pattern
  • FOR JSON inside a derived table
  • Pretty-print round-trip
  • Cross-dialect drop (validate_all → tsql/postgres/duckdb)
  • Full test suite: 1108 tests / 18,028 subtests passing
  • ruff check, ruff format, mypy clean

Noted follow-up (not in this PR)

The dynamic args for_ (XML) and for_json_ (JSON) could be consolidated into a single for_ slot with a kind discriminator (e.g. exp.ForClause(this="XML"|"JSON", expressions=[...])). Left as-is for parity with the current XML implementation; happy to do this in a separate PR if preferred.

Adds parsing and generation for T-SQL's FOR JSON query modifier, mirroring
the existing FOR XML handling.

  FOR JSON { AUTO | PATH } [, ROOT [ ('name') ]]
                           [, INCLUDE_NULL_VALUES]
                           [, WITHOUT_ARRAY_WRAPPER]

Other dialects emit "Unsupported query option." and drop the clause, matching
the existing FOR XML behavior.

Reference: https://learn.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server
Comment thread sqlglot/parsers/tsql.py Outdated
Per PR review: collapse the separate for_json_ arg back into for_ by
introducing a ForClause container node with a `kind` discriminant
("XML" or "JSON"). The QUERY_MODIFIER_PARSERS entry for FOR returns the
fixed `for_` key again, matching the pre-existing convention.

Also fixes a long-standing bug where `for_` was missing from
QUERY_MODIFIERS in core.py, so Select/SetOperation/Subquery did not
declare the arg they were already storing.
Copy link
Copy Markdown
Collaborator

@georgesittas georgesittas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Comment thread sqlglot/expressions/query.py
Comment thread sqlglot/parsers/tsql.py Outdated
Addresses PR review:
- BROWSE is the third alternative in `[ FOR { BROWSE | <XML> | <JSON> } ]`.
  Parsed as `ForClause(kind="BROWSE")` with no options; emitted only by the
  TSQL generator (other dialects silently drop, matching FOR XML/JSON).
- Rename JSON_OPTIONS -> FOR_JSON_OPTIONS (per reviewer request) and
  XML_OPTIONS -> FOR_XML_OPTIONS for symmetry.
Comment thread sqlglot/generator.py Outdated
Comment thread sqlglot/generators/tsql.py Outdated
…UDE]

Address review feedback:
- Drop the for_modifiers helper; call self.sql(expression, "for_")
  inline in select_sql, matching the other modifier emissions.
- Move the FOR BROWSE branch into the base forclause_sql so the T-SQL
  generator no longer needs an override. Drop the cross-dialect
  validate_all assertion that documented our (self-imposed) "silently
  dropped in postgres/duckdb" behavior, which no longer holds.
Copy link
Copy Markdown
Collaborator

@VaggelisD VaggelisD left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @lvanoverberghe, thanks for the PR! Leaving a few comments too:

Comment thread sqlglot/generator.py
Comment on lines +3115 to +3116
if not options:
return ""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that intentional/right? At the very least there'll be {AUTO | PATH} right, so we should always have options in FOR {JSON | XML}

Comment thread sqlglot/generator.py
Comment on lines +3112 to +3113
if kind == "BROWSE":
return f"{self.sep()}FOR BROWSE"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could remove this part and rewrite the next branch so that if we don't have options, we simply fall down to f"{self.sep()}FOR {kind}" which will return exactly what's highlighted here too, no need to separate it

Comment thread tests/dialects/test_tsql.py Outdated
Comment on lines +767 to +770
def test_for_browse(self):
# FOR BROWSE is a bare keyword — no options follow.
# https://learn.microsoft.com/en-us/sql/t-sql/queries/select-transact-sql
self.validate_identity("SELECT * FROM t FOR BROWSE")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can unify these two into one test_for_modifiers function for simplicity

@VaggelisD VaggelisD merged commit 10af777 into tobymao:main May 18, 2026
8 checks passed
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.

3 participants