Skip to content

Commit 04fc86f

Browse files
authored
feat: polish raw query diff output (#734)
Co-authored-by: Kevin Longe <273747162+kvlonge@users.noreply.github.com>
1 parent c2f9a05 commit 04fc86f

28 files changed

Lines changed: 1614 additions & 187 deletions

File tree

‎docs/query-diffs.md‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ from a UTF-8 file, and choose keyed or unkeyed comparison explicitly:
3737
sqb diff \
3838
--left-query 'SELECT order_id, amount_cents FROM analytics.orders_before' \
3939
--right-query 'SELECT order_id, amount_cents FROM analytics.orders_after' \
40+
--left-label before \
41+
--right-label after \
4042
--key order_id
4143
```
4244

@@ -56,6 +58,9 @@ Query mode performs a full comparison when no comparison mode flag is present. `
5658
also available. `--bounded` is model-specific; filter both raw queries directly when a bounded
5759
query comparison is needed.
5860

61+
Raw-query discovery loads only project configuration, target configuration, and the selected
62+
adapter. It does not import unrelated project sinks, hooks, loaders, or providers.
63+
5964
### Sampling, tolerances, and exclusions
6065

6166
Keyed query comparisons support deterministic union-key sampling and the same bounded mismatch
@@ -85,6 +90,31 @@ sqb diff \
8590
`--exhaustive` disables inherited or requested sampling. Unkeyed comparison is always exhaustive
8691
and exact, so it does not accept sampling or numeric tolerances.
8792

93+
### Bounded example values
94+
95+
Comparison remains exact regardless of how examples are rendered. By default, long values are
96+
limited to 160 characters and show context around the first differing character. Every shortened
97+
value is marked as truncated and includes its original length.
98+
99+
Control evidence independently from the number of collected examples:
100+
101+
```bash
102+
# Increase the displayed context without changing comparison scope.
103+
sqb diff --left-query-file before.sql --right-query-file after.sql \
104+
--key order_id --max-value-length 500
105+
106+
# Retain keys and aggregate counts but suppress example values.
107+
sqb diff --left-query-file before.sql --right-query-file after.sql \
108+
--key order_id --no-example-values
109+
110+
# Explicitly opt in to complete values. --verbose alone remains bounded.
111+
sqb diff --left-query-file before.sql --right-query-file after.sql \
112+
--key order_id --full-example-values
113+
```
114+
115+
Ordinary keys are kept complete. Exceptionally long keys are shortened with an explicit length and
116+
stable digest so an operator can still identify them without flooding terminal output.
117+
88118
## Stable query results and cleanup
89119

90120
SQLBuild materializes each raw query once in the selected target's default schema before comparing
@@ -115,6 +145,20 @@ error.
115145

116146
## Structured output and exit behavior
117147

118-
Use `--json-output PATH` in either mode. Query results include `input_kind = "query"`; model results
119-
use `input_kind = "model"`. Exit code `0` means no differences were found. Exit code `1` means
120-
differences were found or the comparison could not execute successfully.
148+
Use `--json-output PATH` in either mode, or `--json` to emit one JSON document to stdout while all
149+
lifecycle progress remains on stderr. Query results include `input_kind = "query"`, named left and
150+
right inputs, `query_comparisons`, sparse nonzero `column_mismatches`, lifecycle timings, and
151+
explicit example truncation metadata. Existing `models` output remains available for compatibility.
152+
153+
Raw-query automation distinguishes complete findings from incomplete evidence and execution
154+
failure:
155+
156+
| Exit | Outcome |
157+
|---:|---|
158+
| `0` | Complete comparison with no differences |
159+
| `1` | Complete comparison with findings |
160+
| `2` | Safety, schema, or key preflight prevented complete value evidence |
161+
| `3` | Setup, connection, materialization, comparison execution, ownership, or cleanup failed |
162+
163+
Human output and JSON use the same outcome. Lifecycle messages include elapsed connection,
164+
reconciliation, inspection, materialization, comparison, cleanup, and total durations.

‎src/sqlbuild/.agents/skills/sqlbuild/SKILL.md‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7780,6 +7780,8 @@ SQLBuild templates such as `ref()` are not expanded.
77807780
sqb diff \
77817781
--left-query 'SELECT order_id, amount_cents FROM analytics.orders_before' \
77827782
--right-query 'SELECT order_id, amount_cents FROM analytics.orders_after' \
7783+
--left-label before \
7784+
--right-label after \
77837785
--key order_id
77847786

77857787
sqb diff \
@@ -7793,6 +7795,9 @@ sqb diff \
77937795
Query mode defaults to a full comparison and also supports `--schema-only`. It does not accept
77947796
`--bounded`; put the desired filter in both SQL inputs instead.
77957797

7798+
Raw-query mode loads configuration and the selected adapter without importing unrelated project
7799+
sinks, hooks, loaders, or providers.
7800+
77967801
### Comparison modes
77977802

77987803
Model mode requires exactly one mode. Raw-query mode defaults to full when no mode is supplied:
@@ -7979,19 +7984,47 @@ sqb diff prod:dev --full --select customer_status_snapshot --max-column-examples
79797984
Example limits only control diagnostic values printed after comparison. They are separate from
79807985
`row_diff_sample_rows`, which controls how many unique keys receive the wide value comparison.
79817986

7987+
Long values default to 160 rendered characters. SQLBuild shows context around the first difference,
7988+
marks truncation, and reports the original length without changing exact comparison counts. Control
7989+
value evidence independently from example counts:
7990+
7991+
```bash
7992+
sqb diff \
7993+
--left-query-file queries/orders_before.sql \
7994+
--right-query-file queries/orders_after.sql \
7995+
--key order_id \
7996+
--max-value-length 500
7997+
7998+
sqb diff \
7999+
--left-query-file queries/orders_before.sql \
8000+
--right-query-file queries/orders_after.sql \
8001+
--key order_id \
8002+
--no-example-values
8003+
8004+
sqb diff \
8005+
--left-query-file queries/orders_before.sql \
8006+
--right-query-file queries/orders_after.sql \
8007+
--key order_id \
8008+
--full-example-values
8009+
```
8010+
8011+
`--verbose` increases the example count but does not remove the value-length bound.
8012+
79828013
### Structured output
79838014

79848015
Use `--json-output PATH` to write a stable structured result while retaining the normal terminal
7985-
summary:
8016+
summary, or `--json` to write one JSON document to stdout with progress on stderr:
79868017

79878018
```bash
79888019
sqb diff prod:dev --bounded 14d --sample-rows 50000 --select order_lines --json-output diff.json
8020+
sqb diff --left-query-file before.sql --right-query-file after.sql --key order_id --json
79898021
```
79908022

79918023
Each model records `schema_only`, `exhaustive`, or `sampled` comparison scope; requested and observed
79928024
cursor coverage; bounded population and compared key counts; seed and configured limit; row result
7993-
counts; and changed-column counts. The top-level status distinguishes `no_differences_found` from
7994-
`differences_found`.
8025+
counts; and changed-column counts. Query output additionally includes named inputs,
8026+
`query_comparisons`, sparse nonzero `column_mismatches`, lifecycle timings, and explicit evidence
8027+
truncation metadata. Existing `models` output remains for compatibility.
79958028

79968029
### Invocation safety limits
79978030

@@ -8022,9 +8055,10 @@ sqb diff prod:dev --full --select tag:acceptance
80228055

80238056
### Exit codes
80248057

8025-
`sqb diff` returns exit code `0` when no differences are found, and `1` when any input has schema or
8026-
row differences or the comparison cannot execute successfully. This makes it usable in CI pipelines
8027-
as a validation gate.
8058+
Complete comparisons return `0` when no differences are found and `1` for findings. Raw-query mode
8059+
returns `2` when safety, schema, or key preflight prevents complete value evidence, and `3` for
8060+
setup, connection, materialization, comparison execution, ownership publication, or cleanup
8061+
failure. Human and JSON output carry the same outcome.
80288062

80298063
## Execution Observability
80308064

@@ -14541,17 +14575,23 @@ and falls back to a full row comparison when no cursor is configured.
1454114575
| `--sample-seed` | Override the deterministic sample seed |
1454214576
| `--exhaustive` | Disable inherited sampling for this invocation |
1454314577
| `--json-output` | Write structured comparison scope, coverage, and results to a JSON file |
14578+
| `--json` | Write one structured JSON document to stdout; progress remains on stderr |
1454414579
| `--max-models` | Fail when the selected scope contains more models than this limit |
1454514580
| `--max-columns` | Fail when either side of a model has more columns than this limit |
1454614581
| `--left-query` | Literal read-only SQL for the left side |
1454714582
| `--left-query-file` | UTF-8 SQL file for the left side |
1454814583
| `--right-query` | Literal read-only SQL for the right side |
1454914584
| `--right-query-file` | UTF-8 SQL file for the right side |
14585+
| `--left-label` | Display and structured-output label for the left query |
14586+
| `--right-label` | Display and structured-output label for the right query |
1455014587
| `--target` | Target connection for raw-query mode; defaults to the active target |
1455114588
| `--key` | Row identity column; repeat for a composite key or to override model configuration |
1455214589
| `--unkeyed` | Compare exact full-row multiplicities without a key |
1455314590
| `--exclude-column` | Column to omit from row comparison; repeat as needed |
1455414591
| `--tolerance` | Per-column numeric rule such as `amount:absolute=0.01`; repeat as needed |
14592+
| `--max-value-length` | Maximum rendered characters per example value (default: 160) |
14593+
| `--no-example-values` | Retain example keys and counts while suppressing values |
14594+
| `--full-example-values` | Explicitly opt in to complete example values |
1455514595
| `--no-sql-analysis` | Disable compile-time SQL analysis (`--no-sql-validation` is an alias) |
1455614596
| `--select`, `-s` | Select specific models to diff (required in v1) |
1455714597
| `--exclude` | Exclude specific models from diffing |
@@ -14578,6 +14618,8 @@ sqb diff prod:dev --bounded 14d --exhaustive --select order_lines
1457814618
sqb diff \
1457914619
--left-query-file queries/orders_before.sql \
1458014620
--right-query-file queries/orders_after.sql \
14621+
--left-label before \
14622+
--right-label after \
1458114623
--key account_id \
1458214624
--key order_id
1458314625
```
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Bounded, diff-aware rendering for row-diff evidence."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
7+
from sqlbuild.cli.commands.models import DiffExampleRenderOptions, RenderedDiffExampleValue
8+
9+
_MAX_RENDERED_KEY_LENGTH: int = 256
10+
11+
12+
def render_example_pair(
13+
*,
14+
left_value: object,
15+
right_value: object,
16+
options: DiffExampleRenderOptions,
17+
) -> tuple[RenderedDiffExampleValue, RenderedDiffExampleValue]:
18+
"""Render two unequal values around their first differing character."""
19+
20+
left_text: str = str(left_value)
21+
right_text: str = str(right_value)
22+
first_difference: int | None = _first_difference(left=left_text, right=right_text)
23+
return (
24+
_render_value(text=left_text, first_difference=first_difference, options=options),
25+
_render_value(text=right_text, first_difference=first_difference, options=options),
26+
)
27+
28+
29+
def display_example_value(value: RenderedDiffExampleValue) -> str:
30+
"""Render one bounded value for terminal output."""
31+
32+
if value.suppressed:
33+
return f"<value suppressed; {value.original_length:,} chars>"
34+
rendered: str = value.text or ""
35+
if value.truncated:
36+
return f"{rendered} [truncated; {value.original_length:,} chars]"
37+
return rendered
38+
39+
40+
def render_key_value(value: object) -> str:
41+
"""Keep ordinary keys complete and identify exceptionally long shortened keys."""
42+
43+
text: str = str(value)
44+
if len(text) <= _MAX_RENDERED_KEY_LENGTH:
45+
return text
46+
digest: str = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
47+
return f"{text[:_MAX_RENDERED_KEY_LENGTH]}… [truncated; {len(text):,} chars; sha256:{digest}]"
48+
49+
50+
def example_value_payload(value: RenderedDiffExampleValue) -> dict[str, object]:
51+
"""Build stable structured evidence for one rendered value."""
52+
53+
return {
54+
"value": value.text,
55+
"original_length": value.original_length,
56+
"truncated": value.truncated,
57+
"suppressed": value.suppressed,
58+
"first_difference": value.first_difference,
59+
}
60+
61+
62+
def _render_value(
63+
*,
64+
text: str,
65+
first_difference: int | None,
66+
options: DiffExampleRenderOptions,
67+
) -> RenderedDiffExampleValue:
68+
original_length: int = len(text)
69+
if options.suppress_values:
70+
return RenderedDiffExampleValue(
71+
text=None,
72+
original_length=original_length,
73+
truncated=False,
74+
suppressed=True,
75+
first_difference=first_difference,
76+
)
77+
limit: int | None = options.max_value_length
78+
if limit is None or original_length <= limit:
79+
return RenderedDiffExampleValue(
80+
text=text,
81+
original_length=original_length,
82+
truncated=False,
83+
suppressed=False,
84+
first_difference=first_difference,
85+
)
86+
difference: int = first_difference or 0
87+
context_before: int = min(limit // 3, max(limit - 1, 0))
88+
start: int = min(max(difference - context_before, 0), original_length - limit)
89+
end: int = start + limit
90+
fragment: str = text[start:end]
91+
rendered: str = f"{'…' if start else ''}{fragment}{'…' if end < original_length else ''}"
92+
return RenderedDiffExampleValue(
93+
text=rendered,
94+
original_length=original_length,
95+
truncated=True,
96+
suppressed=False,
97+
first_difference=first_difference,
98+
)
99+
100+
101+
def _first_difference(*, left: str, right: str) -> int | None:
102+
index: int
103+
for index, (left_char, right_char) in enumerate(zip(left, right, strict=False)):
104+
if left_char != right_char:
105+
return index
106+
if len(left) != len(right):
107+
return min(len(left), len(right))
108+
return None

0 commit comments

Comments
 (0)