Skip to content

feat(sql): PIVOT and UNPIVOT - #219

Merged
techgopal merged 3 commits into
mainfrom
feat/sql-pivot-unpivot
Oct 25, 2026
Merged

techgopal merged 3 commits into
mainfrom
feat/sql-pivot-unpivot

Conversation

@techgopal

Copy link
Copy Markdown
Contributor

PIVOT and UNPIVOT parsed but lowering rejected them as unsupported. They
now lower to two unresolved IR nodes, PlanNode::Pivot and
PlanNode::Unpivot, built over the relation they follow (per relation and at
the FROM-clause level, as the AST places them); an alias becomes a
SubqueryAlias. A FROM clause carrying both is
NOT_ALLOWED_IN_FROM.UNPIVOT_WITH_PIVOT. The encoding version is bumped to 4
for the new nodes, and the inventory, generator, printers, walks and
mutation sites cover them. Nothing changes in the executor, the physical
planner or the plan codec.

The analyzer resolves them as Spark's ResolvePivot and ResolveUnpivot do:

  • PIVOT becomes an Aggregate grouping every input column that neither the
    pivot column nor the aggregates reference, with one aggregate per
    (value, aggregate) pair of the form agg(if(pivot_col <=> value, expr,
    NULL)) (Spark's non-PivotFirst path, which gives the same results),
    bound with the existing aggregate machinery. Output names follow Spark:
    the value for one aggregate, value_aggalias or value_
    otherwise; count(*) counts matching rows; first/last ignore the nulls the
    if introduces. Pivot values must be foldable and castable to the pivot
    column type (NON_LITERAL_PIVOT_VALUES, PIVOT_VALUE_DATA_TYPE_MISMATCH);
    an aggregate expression that is not an aggregate is rejected as Spark
    rejects it.
  • UNPIVOT becomes the existing Expand (one projection per column set: the
    kept columns, the name literal, the values) and, unless INCLUDE NULLS, a
    filter on coalesce(values) IS NOT NULL. Value columns take the wider
    common type of their sources, through the coercion rules
    (UNPIVOT_REQUIRES_VALUE_COLUMNS, UNPIVOT_VALUE_DATA_TYPE_MISMATCH,
    UNPIVOT_VALUE_SIZE_MISMATCH, UNPIVOT_REQUIRES_ATTRIBUTES).

Multi-column pivots need struct values, which the engine cannot value yet:
once the values are validated they fail with KRISHIV_UNSUPPORTED_FEATURE
("multi-column PIVOT"), never with a wrong result.

Tests: unit tests for lowering and for each analyzer rule and error, and an
end-to-end file replaying the Spark 4.2.0 pivot.sql and unpivot.sql goldens
over registered tables (schema with generated names, rows, error class and
message parameters). Statements that read array, map or struct columns
cannot run in the engine and are skipped by the replay with their reason;
the multi-column pivot statements are asserted to fail as unsupported.

Not verified: Spark Connect's DataFrame pivot is untouched and still
rejected. Diff coverage was not measured locally.

PIVOT and UNPIVOT parsed but lowering rejected them as unsupported. They
now lower to two unresolved IR nodes, PlanNode::Pivot and
PlanNode::Unpivot, built over the relation they follow (per relation and at
the FROM-clause level, as the AST places them); an alias becomes a
SubqueryAlias. A FROM clause carrying both is
NOT_ALLOWED_IN_FROM.UNPIVOT_WITH_PIVOT. The encoding version is bumped to 4
for the new nodes, and the inventory, generator, printers, walks and
mutation sites cover them. Nothing changes in the executor, the physical
planner or the plan codec.

The analyzer resolves them as Spark's ResolvePivot and ResolveUnpivot do:

* PIVOT becomes an Aggregate grouping every input column that neither the
  pivot column nor the aggregates reference, with one aggregate per
  (value, aggregate) pair of the form agg(if(pivot_col <=> value, expr,
  NULL)) (Spark's non-PivotFirst path, which gives the same results),
  bound with the existing aggregate machinery. Output names follow Spark:
  the value for one aggregate, value_aggalias or value_<aggregate sql>
  otherwise; count(*) counts matching rows; first/last ignore the nulls the
  if introduces. Pivot values must be foldable and castable to the pivot
  column type (NON_LITERAL_PIVOT_VALUES, PIVOT_VALUE_DATA_TYPE_MISMATCH);
  an aggregate expression that is not an aggregate is rejected as Spark
  rejects it.
* UNPIVOT becomes the existing Expand (one projection per column set: the
  kept columns, the name literal, the values) and, unless INCLUDE NULLS, a
  filter on coalesce(values) IS NOT NULL. Value columns take the wider
  common type of their sources, through the coercion rules
  (UNPIVOT_REQUIRES_VALUE_COLUMNS, UNPIVOT_VALUE_DATA_TYPE_MISMATCH,
  UNPIVOT_VALUE_SIZE_MISMATCH, UNPIVOT_REQUIRES_ATTRIBUTES).

Multi-column pivots need struct values, which the engine cannot value yet:
once the values are validated they fail with KRISHIV_UNSUPPORTED_FEATURE
("multi-column PIVOT"), never with a wrong result.

Tests: unit tests for lowering and for each analyzer rule and error, and an
end-to-end file replaying the Spark 4.2.0 pivot.sql and unpivot.sql goldens
over registered tables (schema with generated names, rows, error class and
message parameters). Statements that read array, map or struct columns
cannot run in the engine and are skipped by the replay with their reason;
the multi-column pivot statements are asserted to fail as unsupported.

Not verified: Spark Connect's DataFrame pivot is untouched and still
rejected. Diff coverage was not measured locally.

Signed-off-by: techysneha14 <343708768+shehatathe@users.noreply.github.com>
…ck, chained PIVOT/UNPIVOT rejection

count(*) and first/last rewrites gain session row tests over courseSales
so a wrong rewrite fails instead of only changing plan shape; the
pivot_counts_star plan walk now matches the bound when form. Multi-column
values with the right arity but an uncastable part fail with
PIVOT_VALUE_DATA_TYPE_MISMATCH before the unsupported path. A relation
carrying both PIVOT and UNPIVOT lowers to
NOT_ALLOWED_IN_FROM.UNPIVOT_WITH_PIVOT, as Spark reports for the
pipe-operator chain in the goldens; the lowering snapshot records the
rejection. Softened the PivotFirst equivalence comments.

Signed-off-by: techysneha14 <343708768+shehatathe@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 9311aa2 and 35ca238.

Files ignored due to path filters: 2
  • crates/krishiv-sql/tests/snapshots/lower__constructs_lower_or_are_named_as_unsupported.snap is excluded by !**/*.snap
  • crates/krishiv-sql/tests/snapshots/lower__pivot_and_unpivot_lower_over_the_relation_they_follow.snap is excluded by !**/*.snap
Files selected for processing: 19
  • crates/krishiv-plan/src/analyzer/exprs.rs
  • crates/krishiv-plan/src/analyzer/mod.rs
  • crates/krishiv-plan/src/analyzer/pivot.rs
  • crates/krishiv-plan/src/analyzer/resolve.rs
  • crates/krishiv-plan/src/analyzer/unpivot.rs
  • crates/krishiv-plan/src/display.rs
  • crates/krishiv-plan/src/encode.rs
  • crates/krishiv-plan/src/lib.rs
  • crates/krishiv-plan/src/plan.rs
  • crates/krishiv-plan/src/spark_fmt.rs
  • crates/krishiv-plan/src/tests/encode.rs
  • crates/krishiv-plan/src/tests/inventory.rs
  • crates/krishiv-plan/src/tests/mod.rs
  • crates/krishiv-plan/src/tests/pivot.rs
  • crates/krishiv-plan/src/tests/props.rs
  • crates/krishiv-plan/src/walk.rs
  • crates/krishiv-session/tests/pivot_unpivot.rs
  • crates/krishiv-sql/src/lower.rs
  • crates/krishiv-sql/tests/lower.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Walkthrough

Adds unresolved PIVOT and UNPIVOT plan nodes, SQL lowering, and analyzer resolution. PIVOT resolution creates filtered aggregates and rejects unsupported multi-column forms. UNPIVOT resolution widens column types and builds an Expand, with optional filtering of null rows. Traversal, display, encoding, and tests cover both nodes.

Changes: PIVOT and UNPIVOT

Layer / File(s) Summary
Plan node contracts and integration
crates/krishiv-plan/src/plan.rs, lib.rs, walk.rs, display.rs, encode.rs, tests/*
Adds unresolved PIVOT and UNPIVOT nodes and public data types. Adds child and expression traversal, unresolved-state handling, display and encoding support, and corresponding test coverage.
SQL lowering
crates/krishiv-sql/src/lower.rs, crates/krishiv-sql/tests/lower.rs
Lowers relation-level and FROM-clause PIVOT and UNPIVOT into plan nodes. Tests cover aliases, joins, and the error when both extensions appear together.
Analyzer resolution and validation
crates/krishiv-plan/src/analyzer/*, spark_fmt.rs, tests/pivot.rs, crates/krishiv-session/tests/pivot_unpivot.rs
Resolves PIVOT into filtered aggregates and UNPIVOT into an Expand. Adds validation for values, aggregates, columns, and types, plus unit and Spark golden tests.

Priority: Normal

Estimated code review effort: 4 (Complex) | ~50 minutes

Possibly related PRs

Merge Risk: Minimal · up to 35ca2

This change adds SQL PIVOT and UNPIVOT support through SQL lowering and analyzer resolution, with unit tests and Spark golden-file tests. No actionable merge-blocking risk was found. Multi-column pivots are rejected explicitly as unsupported rather than silently mishandled.

Security architecture risk: Low · up to 35ca2

The new SQL operations reuse existing authorization checks and execution operations. No introduced authorization bypass was established in the inspected paths. Remaining uncertainty concerns external compatibility and deployment exposure, rather than a demonstrated security defect.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Attacker-supplied SQL gains new transformation paths over its input relations, not an independently evidenced grant of data access. In the inspected flow, those relations still pass through binding and access-policy decisions before resolution.

Trust Boundaries and Controls

  • observed — The existing access check uses the current principal and SELECT privilege, rejects denials and policy errors, and rejects row-filter or column-mask decisions as unsupported. The implicit-column bookkeeping difference therefore does not demonstrate a bypass of currently effective column controls.

Resilience and Maintainability Implications

  • observed — Resolution creates fresh per-call identity, memoization, column-use, and folding state. AnalyzedPlan is returned only after complete resolution and final plan validation, so a failure after UNPIVOT expansion does not return a partially analyzed plan or publish its local resolver state.

Hardening Proposals

  • proposed — Before relying on SecurableUse.columns for column-level enforcement or complete auditing, explicitly account for implicit PIVOT grouping and UNPIVOT kept columns rather than depending on a later SELECT to record them. This is future control-drift prevention, not an observed authorization vulnerability.
Pre-merge checks: Passed 3 · Failed 2

Failed Failed checks: 2 warnings

Check name Status Explanation Resolution
Description check Warning The description gives a detailed summary and test scope, but it omits the required task/spec/ADR links, exact test commands with result lines, and completed checklist statuses. Add the task/spec/ADR references, list each test command with its result, and complete the applicable checklist items. Mark skipped or not-applicable items explicitly.
Docstring Coverage Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 126 functions across 19 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 3 passed
Check name Status Explanation
Title check Passed The title clearly identifies the main change: adding SQL PIVOT and UNPIVOT support.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Finishing Touches: 💡 1
⚔️ Resolve merge conflicts: 💡
  • Resolve merge conflict in branch feat/sql-pivot-unpivot
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Signed-off-by: techysneha14 <343708768+shehatathe@users.noreply.github.com>

# Conflicts:
#	crates/krishiv-plan/src/analyzer/mod.rs
#	crates/krishiv-plan/src/display.rs
#	crates/krishiv-plan/src/encode.rs
#	crates/krishiv-plan/src/plan.rs
#	crates/krishiv-plan/src/tests/inventory.rs
#	crates/krishiv-plan/src/tests/props.rs
#	crates/krishiv-plan/src/walk.rs
#	crates/krishiv-sql/src/lower.rs
@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.65675% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/krishiv-plan/src/analyzer/pivot.rs 99.2% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 9 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 121 untouched benchmarks
⏩ 4 skipped benchmarks1


Comparing feat/sql-pivot-unpivot (c460b58) with main (37df819)

Open in CodSpeed

Footnotes

  1. 4 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@techgopal
techgopal added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit ba99eef Oct 25, 2026
47 checks passed
@techgopal
techgopal deleted the feat/sql-pivot-unpivot branch October 25, 2026 07:38
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