Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 63 additions & 6 deletions src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,30 @@

Online: https://sqlbuild.com/docs/cli/diff/

## Contents

- Usage
- Flags
- Size guard
- Examples
- Exit codes

Compares schemas and optionally row-level data between two targets (e.g. `prod:dev`), or between two read-only SQL queries. See [Data Diffs](../concepts/diff.md) for detailed usage.

## Usage

```bash
# Model mode
sqb diff <FROM>:<TO> <mode> --select <models> [flags]
sqb diff <FROM>[:<TO>] [<mode>] --select <models> [flags]

# Query mode
sqb diff --left-query <sql> --right-query <sql> (--key <column> | --unkeyed) [flags]
sqb diff --left-query <sql> --right-query <sql> (--key <column>... | --unkeyed) [flags]
sqb diff --left-query <sql> --right-query <sql> --schema-only [flags]
```

In model mode, the first argument is a positional `FROM:TO` range, `--select` is required, and exactly one mode is required: `--full`, `--schema-only`, or `--bounded <duration>`.
In model mode, the first argument is a positional `FROM:TO` range, `--select` is required, and at most one mode can be given: `--full`, `--schema-only`, or `--bounded <duration>`. Without a mode, the diff is a full comparison guarded by table size (see [Size guard](#size-guard)).

`FROM` alone compares `FROM` with the active target, so with `dev` active, `sqb diff prod -s orders` runs as `sqb diff prod:dev -s orders`. This is the same default `sqb clone --from` uses. It fails when no target is active or `FROM` is the active target; pass `FROM:TO` in those cases.

`FROM` and `TO` are configured target names. Their database/schema namespaces remain
authoritative, while the `TO` target's named connection executes the complete comparison and must
Expand All @@ -46,9 +56,9 @@ In query mode, omit `FROM:TO` and model selectors; each query identifies its own
| `--max-models` | Fail when the selected scope contains more models than this limit |
| `--max-columns` | Fail when either side of a model has more columns than this limit |
| `--no-sql-analysis` | Disable compile-time SQL analysis (`--no-sql-validation` is an alias) |
| `--key` | Row key column; repeat for a composite key. Overrides the model's `unique_key` |
| `--key` | Row key columns, for example `--key order_id line_number`. Repeating the flag also works. Overrides the model's `unique_key` |
| `--unkeyed` | Compare exact full-row multisets when there is no stable row key |
| `--exclude-column` | Leave a column out of the comparison; repeatable |
| `--exclude-column` | Leave columns out of the comparison; takes several values, and repeating the flag also works |
| `--tolerance` | Numeric tolerance such as `amount:absolute=1` or `rate:relative=0.001`; repeatable |
| `--left-query`, `--right-query` | Compare two read-only SQL queries instead of models |
| `--left-query-file`, `--right-query-file` | Read each query from a UTF-8 file |
Expand All @@ -62,9 +72,53 @@ In query mode, omit `FROM:TO` and model selectors; each query identifies its own
| `--exclude` | Exclude specific models from diffing |
| `--warehouse <name>` | Snowflake warehouse for this invocation; overrides the target's `query` [warehouse group](../concepts/project-configuration.md#command-group-warehouses) and the connection warehouse |

## Size guard

Without `--full`, `--bounded` or `--schema-only`, model mode first checks table sizes. Before it reads
any table data, SQLBuild reads each selected table's row count from warehouse metadata on both
sides and compares it with that target's
[`max_full_rows`](../concepts/project-configuration.md#diff-limits) limit. The limit is 10,000,000
rows unless configured, and `"unlimited"` turns the check off for that target. If every table is
within its limits, the full comparison runs as usual.

Otherwise, the diff stops before reading any rows and exits with `2`. The error lists each blocked model
with its row count and limit on both sides, then gives the commands to run instead:

```text
error[C270]: full diff stopped before reading data: 1 of 1 selected models exceed the full-comparison row limit or have an unknown size
orders: prod 12,400,000 rows, limit 10,000,000; dev 12,400,000 rows, limit 10,000,000
Run one of these instead:
sqb diff prod:dev --full --select orders
sqb diff prod:dev --bounded <window> --select orders
sqb diff prod:dev --schema-only --select orders
```

The `--bounded` command appears only when every blocked model has a cursor. With `--json` or
`--json-output`, the error document has `status` `incomplete` and a `size_guard` object holding the
same models, sizes, limits and commands.

A size counts as over the limit when the warehouse cannot report it, for example for views or for
tables without statistics. Sizes come from metadata only:

| Adapter | Source |
|---------|--------|
| DuckDB, MotherDuck | `duckdb_tables()` `estimated_size` |
| PostgreSQL | `pg_class.reltuples`; unknown until the table has been analyzed |
| Snowflake | `INFORMATION_SCHEMA.TABLES` `ROW_COUNT` |
| BigQuery | Tables API `numRows` |
| Databricks | Statistics from `DESCRIBE TABLE EXTENDED`; unknown until `ANALYZE TABLE ... COMPUTE STATISTICS` has run |
| SQL Server | `sys.partitions` row counts |
| Custom adapters | Unknown unless the adapter implements `estimate_relation_row_count` |

Metadata counts can be estimates. The guard only decides whether to start a full comparison; the
comparison itself always counts rows exactly.

## Examples

```bash
# Full diff against the active target, if the tables are within the size limit
sqb diff prod --select customer_status_snapshot

# Full diff of a specific model
sqb diff prod:dev --full --select customer_status_snapshot

Expand All @@ -82,8 +136,11 @@ sqb diff prod:dev --bounded 14d --exhaustive --select order_lines

# Compare two queries on a key
sqb diff --left-query-file queries/orders_before.sql --right-query-file queries/orders_after.sql --key order_id

# Composite key and several excluded columns
sqb diff prod:dev --full --select order_lines --key order_id line_number --exclude-column updated_at loaded_at
```

## Exit codes

Returns `0` when all selected models have no differences, `1` when any model has schema or row differences. Query mode also returns `2` when a safety, schema, or key check prevented a complete comparison, and `3` when setup, execution, or cleanup failed; see [Query output and exit codes](../concepts/diff.md#query-output-and-exit-codes).
Returns `0` when all selected models have no differences, `1` when any model has schema or row differences, and `2` when the size guard stopped a default full diff before reading data. Query mode also returns `2` when a safety, schema, or key check prevented a complete comparison, and `3` when setup, execution, or cleanup failed; see [Query output and exit codes](../concepts/diff.md#query-output-and-exit-codes).
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ Online: https://sqlbuild.com/docs/concepts/diff/

SQLBuild can compare schemas and row-level data between two targets. This lets you validate that changes produce the expected results before they reach production.

`sqb diff FROM:TO` compares two targets, for example `prod:dev`.
`sqb diff FROM:TO` compares two targets, for example `prod:dev`. `sqb diff FROM` compares `FROM`
with the active target, the same default `sqb clone --from` uses.

`FROM` and `TO` resolve the authoritative database/schema namespaces to compare.
SQLBuild uses the `TO` target's named connection for the complete comparison and accesses both
Expand All @@ -37,7 +38,7 @@ sqb diff prod:dev --full --select customer_status_snapshot

## Comparison modes

Every diff requires exactly one mode:
A diff takes at most one mode flag. Without one, it runs a full diff guarded by table size.

### Full diff

Expand All @@ -47,6 +48,13 @@ Compares both schema and row-level data for the selected models:
sqb diff prod:dev --full --select fact_orders
```

Leaving out the mode flag also runs a full diff, but only after a size check. Before reading any
table data, SQLBuild reads each selected table's row count from warehouse metadata on both sides.
If a table is larger than that side's
[`max_full_rows`](project-configuration.md#diff-limits) limit (10,000,000 rows unless
configured), or its size is unknown, the diff stops without scanning. See
[size guard](../cli/diff.md#size-guard) for details. `--full` skips the check.

Rows are joined on the model's `unique_key` and compared column by column. The output shows:
- Row counts for each side
- How many rows are equal, unequal, or only in one side
Expand Down Expand Up @@ -387,4 +395,4 @@ reconciliation, inspection, materialization, comparison, cleanup, and total dura

## Exit codes

In model mode, `sqb diff` returns exit code `0` when all selected models have no differences, and `1` when any model has schema or row differences. Raw-query mode uses the exit codes in [Query output and exit codes](#query-output-and-exit-codes). This makes it usable in CI pipelines as a validation gate.
In model mode, `sqb diff` returns exit code `0` when all selected models have no differences, `1` when any model has schema or row differences, and `2` when the size guard stopped a default full diff before reading data. Raw-query mode uses the exit codes in [Query output and exit codes](#query-output-and-exit-codes). This makes it usable in CI pipelines as a validation gate.
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,33 @@ This permits an intentional developer override while keeping the shared target p
`sqlbuild_project.toml`. Other target fields and any execution-limit fields omitted locally continue
to inherit their project values.

### Diff limits

`sqb diff` without `--full`, `--bounded` or `--schema-only` runs a full comparison only when every
selected table is small enough. Each target sets the largest table it will compare in full this
way:

```toml
[targets.prod.diff]
max_full_rows = 50_000_000

[targets.dev.diff]
max_full_rows = "unlimited"
```

`max_full_rows` is a positive integer or `"unlimited"`. When it is not set, the limit is
10,000,000 rows. Each side of a comparison uses its own target's limit. See
[`sqb diff`](../cli/diff.md#size-guard) for how sizes are read and what happens when a table is too
large.

`sqlbuild_local.toml` accepts the same section, and a local value replaces the project value for
that target:

```toml
[targets.dev.diff]
max_full_rows = 100_000_000
```

## Defaults

Project-wide defaults for models, seeds, and functions:
Expand Down
16 changes: 16 additions & 0 deletions src/sqlbuild/adapter/contract/classes/base_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
RelationGrant,
RelationInfo,
RelationReadProbe,
RelationRowCountEstimate,
RowDiffCoverage,
RowDiffPreparedRelations,
RowDiffResult,
Expand Down Expand Up @@ -1570,6 +1571,21 @@ def inspect_row_diff_coverage(
end_cursor=end_cursor,
)

def estimate_relation_row_count(
self,
*,
connection: Any,
database: str | None,
schema: str | None,
name: str,
) -> RelationRowCountEstimate:
"""Report an unknown row count for custom adapters without metadata support."""

return RelationRowCountEstimate(
row_count=None,
detail=f"adapter '{self.adapter_name}' does not report row counts from metadata",
)

def _build_row_diff_relation_ctes(
self,
*,
Expand Down
13 changes: 13 additions & 0 deletions src/sqlbuild/adapter/contract/classes/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from sqlbuild.adapter.contract.models import (
CursorValue,
RelationRowCountEstimate,
RowDiffCoverage,
RowDiffResult,
RowDiffSampleRow,
Expand Down Expand Up @@ -75,6 +76,18 @@ def inspect_row_diff_coverage(
"""Return exact bounded row count and cursor extent for one relation."""
...

@abstractmethod
def estimate_relation_row_count(
self,
*,
connection: Any,
database: str | None,
schema: str | None,
name: str,
) -> RelationRowCountEstimate:
"""Return the metadata row count for one relation without scanning its data."""
...

@abstractmethod
def sample_unequal_rows(
self,
Expand Down
35 changes: 35 additions & 0 deletions src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
RelationGrant,
RelationInfo,
RelationReadProbe,
RelationRowCountEstimate,
RowDiffColumnResult,
RowDiffCoverage,
RowDiffPreparedRelations,
Expand Down Expand Up @@ -2034,6 +2035,40 @@ def inspect_row_diff_coverage(
end_cursor=end_cursor,
)

def estimate_relation_row_count(
self,
*,
connection: Any,
database: str | None,
schema: str | None,
name: str,
) -> RelationRowCountEstimate:
"""Read DuckDB's estimated table size from the catalog."""

database_filter: str = (
"database_name = current_database()"
if database is None
else f"lower(database_name) = lower({_duckdb_string_literal(database)})"
)
schema_filter: str = (
"schema_name = current_schema()"
if schema is None
else f"lower(schema_name) = lower({_duckdb_string_literal(schema)})"
)
row: tuple[Any, ...] | None = self.execute(
connection=connection,
sql=(
"SELECT estimated_size FROM duckdb_tables() WHERE "
f"{database_filter} AND {schema_filter} "
f"AND lower(table_name) = lower({_duckdb_string_literal(name)})"
),
).fetchone()
if row is None or row[0] is None:
return RelationRowCountEstimate(
row_count=None, detail="no table size metadata (view or missing table)"
)
return RelationRowCountEstimate(row_count=int(row[0]))

def diff_schema(
self,
*,
Expand Down
8 changes: 8 additions & 0 deletions src/sqlbuild/adapter/contract/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ class RowDiffCoverage:
maximum_cursor: object | None = None


@dataclass(frozen=True)
class RelationRowCountEstimate:
"""Metadata row count read without scanning data; ``row_count`` is None when unknown."""

row_count: int | None
detail: str | None = None


@dataclass(frozen=True)
class RowDiffSampling:
"""Deterministic key-sampling policy for a row comparison."""
Expand Down
25 changes: 25 additions & 0 deletions src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
RelationGrant,
RelationInfo,
RelationReadProbe,
RelationRowCountEstimate,
RenderedRetentionChange,
RetentionRequest,
RetentionState,
Expand Down Expand Up @@ -127,6 +128,7 @@
INTEGER_METADATA_TYPE_NAME,
INTEGER_TYPE_TOKEN,
INTEGER_WIRE_TYPE_NAME,
NATIVE_TABLE_TYPE,
NOT_FOUND_ERROR_CLASS_NAME,
SELECT_STATEMENT_TYPE,
TABLE_NAME_WILDCARD,
Expand Down Expand Up @@ -2518,6 +2520,29 @@ def normalize_row_diff_numeric_type(self, column_type: str) -> str | None:
def format_row_diff_decimal_sql(self, value: Decimal) -> str:
return format(value, "f")

def estimate_relation_row_count(
self,
*,
connection: _BigQueryConnection,
database: str | None,
schema: str | None,
name: str,
) -> RelationRowCountEstimate:
"""Read ``numRows`` from the tables API; only native tables report a count."""

if schema is None:
return RelationRowCountEstimate(row_count=None, detail="relation has no dataset")
table: Any = connection.client.get_table(
self._build_table_id(database=database, schema=schema, name=name)
)
table_type: str = str(getattr(table, "table_type", NATIVE_TABLE_TYPE)).upper()
num_rows: object | None = getattr(table, "num_rows", None)
if table_type != NATIVE_TABLE_TYPE or not isinstance(num_rows, int):
return RelationRowCountEstimate(
row_count=None, detail=f"no row count ({table_type.lower()})"
)
return RelationRowCountEstimate(row_count=num_rows)

def inspect_row_diff_coverage(
self,
*,
Expand Down
1 change: 1 addition & 0 deletions src/sqlbuild/adapters/bigquery/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@
"clone or snapshot",
)
MAX_CLONE_REFUSAL_ERROR_CHAIN: int = 8
NATIVE_TABLE_TYPE: str = "TABLE"
Loading
Loading