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
2 changes: 1 addition & 1 deletion .claude/skills/slayer-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SLayer is a lightweight, agent-first semantic layer. Instead of writing raw SQL,
- **SQLGenerator** — takes an EnrichedQuery (not SlayerQuery) and converts it to SQL via sqlglot (dialect-aware: postgres, mysql, bigquery, etc.)
- **SlayerSQLClient** — executes SQL via SQLAlchemy with retry logic and statement timeouts
- **Storage** — YAML or SQLite backends for model and datasource configs
- **Ingestion** — auto-generates models from DB schema with rollup-style FK joins (denormalized LEFT JOINs). It can be triggered manually (`slayer ingest`, `ingest_datasource_models`, `POST /ingest`) or **on every server boot** via `slayer serve --ingest-on-startup` / `slayer mcp --ingest-on-startup` (also `SLAYER_INGEST_ON_STARTUP=1`, or `create_app/create_mcp_server(ingest_on_startup=True)` programmatically). It is idempotent and continues on per-datasource failures.
- **Ingestion** — auto-generates models from DB schema with rollup-style FK joins (denormalized LEFT JOINs), importing DB column/table comments into `description`s (fill-if-empty, never overwriting; BigQuery also fills the datasource description from the dataset description; SQLite has no comments). It can be triggered manually (`slayer ingest`, `ingest_datasource_models`, `POST /ingest`) or **on every server boot** via `slayer serve --ingest-on-startup` / `slayer mcp --ingest-on-startup` (also `SLAYER_INGEST_ON_STARTUP=1`, or `create_app/create_mcp_server(ingest_on_startup=True)` programmatically). It is idempotent and continues on per-datasource failures.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **Interfaces** — MCP server (stdio via `slayer mcp`, SSE via `slayer serve` at `/mcp/sse`), REST API (FastAPI on port 5143), Python SDK, and two read-only wire-protocol facades for BI tools: Arrow Flight SQL (`slayer flight-serve`, port 5144) and Postgres (`slayer pg-serve`, port 5145; the connection `database` selects the SLayer datasource)

## Key Models
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ jobs:
timeout-minutes: 5
run: poetry run python examples/bigquery/verify.py

- name: Run BigQuery integration tests
if: steps.gate.outputs.skip != 'true'
timeout-minutes: 10
env:
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
run: poetry run pytest tests/integration/test_integration_bigquery.py -m integration -v

- name: Dump server logs on failure
if: failure() && steps.gate.outputs.skip != 'true'
run: cat /tmp/slayer-bq.log
Expand Down
1 change: 1 addition & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ implementation detail. Include issue refs when known.
- 2026-08-12 — Dotted dimension join-path binding (DEV-1780): a dotted dimension/time-dimension path resolves only when every hop is a direct join. Previously a hop that was not a direct join fell through leniently — the enriched dim kept its `A__B` alias in SELECT/GROUP BY but `_resolve_joins` emitted no join, shipping invalid SQL (unbound table alias). Filters and cross-model measures already rejected such paths; only dimensions/time-dimensions had the hole (the shared `_resolve_dotted_dim_with_stage_fallback` lenient branch). Fix is an engine routing pre-pass (`SlayerQueryEngine._route_dotted_dimension_refs`, run in `_enrich` before `enrich_query`, gated on `enforce_join_binding and source_model_origin is None`): it normalizes root-prefixes via `strip_source_model_prefix`, then for each dotted ref tries the explicit direct-join walk and, on `_NoJoinError`, routes via a datasource-scoped `JoinGraph`. A SHORT FORM (one model segment, e.g. `Consumer.name`) with exactly ONE route to the target auto-resolves — the ref is rewritten to the full routed path, so the result key is the full path (`root.Subscription.Customer.Consumer.name`), consistent with "joined dims keep the full path". Ambiguous (≥2 routes), unreachable (0), and explicit multi-hop chains with a broken hop are REJECTED with `UnresolvableDimensionJoinError(SlayerError, ValueError)` (mirrors the DEV-1645 `UnresolvableOrderColumnError` reject-don't-emit-invalid-SQL doctrine); the message suggests the short form when the target is uniquely reachable, else the shortest deterministic full path (`JoinGraph.shortest_path`), else nothing. `JoinGraph.count_simple_paths(root, target, cap=2)` classifies routes — it counts ALL simple paths (a 2-hop + 3-hop route is genuinely ambiguous; auto-picking the shorter would silently change join semantics), reverse-reachability-pruned and cycle-guarded. The rewrite map is also applied to matching `OrderItem.column` refs and `main_time_dimension` so dependent references stay consistent. Deliberate limits (conservative, prefer reject over a wrong route): routing runs only within a single datasource (`model.data_source` truthy; the graph is datasource-scoped) and is deferred when named-query stages are in scope (their virtual models aren't in the stored graph) — those refs fall through to the guard. A post-`_resolve_joins` safety-net guard in `enrich_query` (same gate) raises `UnresolvableDimensionJoinError` for any dim/time-dim whose alias is absent from `resolved_joins`, guaranteeing the invariant even for direct `enrich_query` callers; the re-rooted cross-model CTE enrichment passes `enforce_join_binding=False` (it legitimately carries source-local shared dims like `orders.status` that never bind to a base-table join). Out of scope: the multi-stage lenient cross-stage fall-through (`test_unresolvable_dotted_ref_falls_through`, where distinguishing a genuine error from a re-rooting artifact is unsolved) and leaf-column-missing-on-a-valid-path (the alias IS bound there — a different failure class).
- 2026-08-16 — FK-derived joins name the MODEL, not the live object (DEV-1688 / DEV-1741 / #279). Model names strip `__` (reserved for join paths), so an FK to `reports__patient__drug` used to persist a join targeting a model that cannot exist; `_generate_joins` now takes the live→model map, and a target whose object was skipped on a sanitization collision drops its join rather than dangling. Stores written before the fix self-heal on the normal re-ingest path rather than via a schema migration — no version bump, and the repair demands the sanitized target AND identical `join_pairs` to match a freshly-generated join, so it can only rename the join the bug produced (name-only matching would collapse `a__b` and `a___b` onto one target and trip the duplicate-target guard, turning a merely-dangling store into a failed re-ingest). A store that never re-ingests keeps a join that was already broken.
- 2026-08-18 — Two BigQuery-only failures fixed. (1) The DEV-1444 outer wrap re-parses already-emitted SQL, and BigQuery parses a quoted dotted alias (`` `orders.created_at` ``) into one part per segment, so the old qualifier strip (`col.set("table", None)`) both emitted an empty backtick pair — `400 Syntax error: Invalid empty identifier` on any computed measure plus ORDER BY — and, once that was fixed by replacing the node, silently dropped the model prefix, turning a syntax error into an unresolved name. `SqlDialect._outer_order_column` therefore re-resolves the column by picking the LONGEST part-suffix the inner SELECT actually projects (checked as a quoted identifier against `inner_sql`, which at that point still carries canonical dotted aliases — `rewrite_emitted_sql` mangles them afterwards). That subsumes the `_base.`-qualified form `_assemble_combined_sql` emits and keeps Postgres/DuckDB/MySQL output byte-identical, including the untouched bare-column and no-match fallbacks; hidden ORDER-BY hoists resolve too, which a `public`-list match would have missed. (2) `_get_columns_fallback` queried the bare `information_schema.columns`, which BigQuery resolves as `<project>.information_schema.columns` — a project-level view a dataset-scoped service account (the normal least-privilege setup) cannot read, so every per-table introspection 403'd. It now qualifies by dataset (`` `<dataset>`.INFORMATION_SCHEMA.COLUMNS ``), taken from `schema` or from the dotted table name, and drops the now-redundant `table_schema` predicate. Related hardening: `_live_schema_for_datasource` raises `IntrospectionUnavailable` when EVERY table in a datasource failed rather than returning an empty map, because empty is indistinguishable from "every table was dropped" — drift then reported a `WholeModelDelete` for every model in the tenant off one credential error, and `--force-clean` would act on it. `_collect_sql_table_diffs` catches it and returns no verdict; type refinement catches it and keeps the persisted types.
- 2026-08-20 — DB comments imported at ingestion (DEV-1809 / #317): column comments → `Column.description`, table comments → `SlayerModel.description`, BigQuery dataset description → `DatasourceConfig.description` — strictly fill-if-empty (DEV-1356 additive-only; no provenance tracking, so a DB comment edit never propagates over an existing description — clear it and re-ingest). Inspector path covers Postgres/MySQL/SQL Server/Snowflake/ClickHouse/BigQuery; per-dialect fallback comment SQL for MySQL/Snowflake/ClickHouse/DuckDB/Postgres (DuckDB reachable only via the fallback; SQL Server/BigQuery fallbacks stay types-only — disproportionate SQL for a path their Inspector already covers). Schema-level comments have no generic API, hence BigQuery-only datasource descriptions; comment fetching is always best-effort, never a failed ingest; dbt/OSI curated descriptions win over DB comments at creation time; drift detection ignores comment text.
33 changes: 32 additions & 1 deletion docs/concepts/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Tables with no FK references use their plain table name with no joins.
SLayer introspects each table's column types and generates a model:

- **One `Column`** per non-joined column on the source table — name, `type` inferred from the database (`string` / `number` / `boolean` / `time` / `date`), `primary_key=True` for PKs. Whether each column is used as a group-by dimension or as an aggregation source is decided per query.
- **Column and table comments** stored in the database become `Column.description` and `SlayerModel.description` (see [Comments and descriptions](#comments-and-descriptions) below).
- **`unique=True`** for columns that alone form a `UNIQUE` constraint or unique index. PK columns are not stamped redundantly — `primary_key` already implies uniqueness. Composite uniqueness is evaluated per key-set during join-cardinality inference rather than being flattened onto individual columns.
- **A column literally named `count`** is renamed to `count_col` to avoid clashing with the always-available `*:count`.
- **No auto-generated `measures`** — `SlayerModel.measures` is the named-formula library and stays empty after ingestion. You can add named formulas later via the API/MCP if you want bare-name shortcuts (`{"formula": "aov"}`).
Expand All @@ -54,6 +55,36 @@ All models use `sql_table` (the source table) plus `joins` (direct FK joins only

Each FK join also gets a structural [`cardinality`](models.md#join-cardinality) guess from the key constraints alone (no data is read): `many_to_one` by default, upgrading to `one_to_one` when the source key is itself unique. A side counts as unique only when some PK/unique key-set is a subset of the join key — if `(a)` is unique then `(a, b)` is too, but a constraint on `(a, b)` does not make `(a)` unique. When the target key cannot be *verified* unique from its constraints, cardinality is left unset rather than guessed. To infer it from the data instead, run `slayer validate-models --cardinality`.

### Comments and descriptions

Textual metadata stored in the database is imported during ingestion, so
agents see the same documentation the DBA wrote:

- **Column comments** → `Column.description`
- **Table comments** → `SlayerModel.description`
- **BigQuery dataset description** → the datasource's `description`
(BigQuery only — no cross-database API exists for schema-level comments)

The import is strictly **fill-if-empty**: a comment lands only where the
existing `description` is empty. Hand-written descriptions are never
overwritten, on first ingest or any re-ingest — consistent with the
additive-only re-ingestion contract. To re-import a comment, clear the
description and re-run `slayer ingest`. Imported descriptions feed the
search corpus like any other description, so the next embedding refresh
picks them up.

Per-database support:

| Database | Column comments | Table comments |
|---|---|---|
| BigQuery | ✓ (field descriptions) | ✓ (table description) |
| Postgres, MySQL, SQL Server, Snowflake, ClickHouse | ✓ | ✓ |
| DuckDB | ✓ (`COMMENT ON`) | ✓ |
| SQLite | — (no such feature) | — |

Comment fetching is best-effort: a database or driver that cannot surface
comments simply yields models without descriptions, never a failed ingest.

### SQLite affinity probing

SQLite's declared column types are affinity hints, not strict constraints: a column declared `INTEGER` can store `INTEGER`, `REAL`, `TEXT`, or `BLOB` values per row. To prevent silent truncation downstream (a column declared `INTEGER` but actually storing `0.99` would cast to `0` and break `AVG`/`SUM` results), SLayer runs an additional value-level probe on SQLite ingestion for every column the inspector reports as `INTEGER`-affinity.
Expand Down Expand Up @@ -276,7 +307,7 @@ Ingest-on-startup: N/M datasources ingested (K failed: name1, name2)
`slayer ingest` (and the equivalent MCP / REST entry points) is idempotent by default — re-runs are safe. For each in-scope live table:

- **No persisted model with that name** → ingest from scratch via the path above.
- **Existing `sql_table`-mode model** → append new columns and joins from the live schema. Existing user metadata is **never** overwritten — `description`, `label`, `format`, `meta`, and `allowed_aggregations` are preserved verbatim. The only in-place updates are strictly additive gap-fills: a join's `cardinality` is set only when it is currently unset (a value you chose is never replaced), and a column's `unique` is only ever turned on, never off. Filling either one is enough to trigger a save, so a re-ingest that adds no columns or joins still persists newly-discovered constraint metadata.
- **Existing `sql_table`-mode model** → append new columns and joins from the live schema. Existing user metadata is **never** overwritten — `description`, `label`, `format`, `meta`, and `allowed_aggregations` are preserved verbatim. The only in-place updates are strictly additive gap-fills: a join's `cardinality` is set only when it is currently unset (a value you chose is never replaced), a column's `unique` is only ever turned on, never off, and an **empty** `description` (column and model level) is filled from the live DB comment. Filling any of these is enough to trigger a save, so a re-ingest that adds no columns or joins still persists newly-discovered metadata. Filled descriptions are reported per model (`ModelAddition.described_columns`, `model_described`; the CLI prints `+descriptions: …`).
- **Existing `sql`-mode or query-backed model with the matching name** → skipped silently; those are user-authored.

With the default YAML storage, two live tables whose quoted names differ only by letter case (`"Orders"` vs `orders`) cannot both be persisted — model names collide as filenames on macOS / Windows, so the save is rejected (`IdCollisionError`). The first table wins; the second surfaces as a per-model entry in `IdempotentIngestResult.errors` (or a per-model message on the CLI / MCP paths) without aborting the rest of the ingest. SQLite storage persists both.
Expand Down
28 changes: 20 additions & 8 deletions docs/database-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ against the live service in CI when they are.
| **ClickHouse** | `tests/integration/test_integration_clickhouse.py` (`testcontainers[clickhouse]`) | `examples/clickhouse/` |
| **SQL Server** | `tests/integration/test_integration_sqlserver.py` (`testcontainers`, `msodbcsql18` + `unixodbc-dev` on the runner) | `examples/sqlserver/` |
| **Snowflake** | `tests/integration/test_integration_snowflake.py` (skips without `~/.snowflake/connections.toml`; profile name overridable via `$SLAYER_SNOWFLAKE_CONNECTION`) | `examples/snowflake/` (no Docker) |
| **BigQuery** | `examples/bigquery/verify.py` driven by CI against `bigquery-public-data.thelook_ecommerce` (gated on `GCP_PROJECT_ID` / `GCP_SA_KEY_B64` repo secrets) | `examples/bigquery/` (no Docker — managed service) |

BigQuery does not yet have a pytest-style integration suite; its CI coverage
runs the example's `verify.py` directly via `.github/workflows/ci.yml`. That
exercises auto-ingestion, basic projection, joins, time-grain dimensions, and
the cardinality / sum-of-grouped-equals-total invariants — enough to catch
emitted-SQL regressions, but the verify-script tier is shallower than the
testcontainers suites.
| **BigQuery** | `tests/integration/test_integration_bigquery.py` (live ingestion against a temp dataset in the billing project; skips without `GCP_PROJECT_ID` + ADC) plus `examples/bigquery/verify.py` driven by CI against `bigquery-public-data.thelook_ecommerce` (gated on `GCP_PROJECT_ID` / `GCP_SA_KEY_B64` repo secrets) | `examples/bigquery/` (no Docker — managed service) |

BigQuery CI coverage has two layers, both in the `bigquery-example` job of
`.github/workflows/ci.yml`: the example's `verify.py` exercises query
execution against the public dataset (basic projection, joins, time-grain
dimensions, and the cardinality / sum-of-grouped-equals-total invariants),
and `test_integration_bigquery.py` exercises live schema ingestion — creating
a temporary dataset with table/column/dataset descriptions in the billing
project, asserting comment import and idempotent re-ingest semantics, then
deleting it. The service account therefore needs dataset create/delete rights
in the billing project (e.g. `roles/bigquery.user`), not just `jobUser`.

## Tier 2 — code-covered

Expand Down Expand Up @@ -87,6 +90,10 @@ mapping lives in `SqlDialect.build_approx_count_distinct`.

### SQLite caveats

SQLite has no table or column comments, so ingestion imports no descriptions
there (every other Tier 1 database's comments are imported — see
[Ingestion — Comments and descriptions](concepts/ingestion.md#comments-and-descriptions)).

SQLite has a much smaller built-in math/stat catalog than the other supported
engines. SLayer registers Python aggregate and scalar UDFs on every new SQLite
connection via SQLAlchemy's `connect` event (see
Expand Down Expand Up @@ -225,6 +232,11 @@ plus `$GCP_PROJECT_ID` for billing). The `bigquery://` driver requires the
- **No FK introspection.** BigQuery exposes no foreign-key metadata via
`INFORMATION_SCHEMA`, so auto-ingestion cannot discover joins. Hand-declare
`ModelJoin`s on the model.
- **Descriptions ARE imported.** Auto-ingestion maps BigQuery column
descriptions → `Column.description`, table descriptions →
`SlayerModel.description`, and the dataset description → the datasource's
`description` (fill-if-empty; see
[Ingestion — Comments and descriptions](concepts/ingestion.md#comments-and-descriptions)).
- **Dotted alias mangling.** BigQuery rejects column names containing `.`
(output schema names must match `[A-Za-z_][A-Za-z0-9_]*`), so SLayer
rewrites `<model>.<column>` aliases (`orders._count`,
Expand Down
9 changes: 9 additions & 0 deletions slayer/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2237,6 +2237,15 @@ def _run_datasources_create(args, storage):
sys.exit(1)

_persist_ingested_models(report.models, storage, assume_yes=args.yes)

if report.schema_description and not ds.description:
ds = ds.model_copy(update={"description": report.schema_description})
try:
run_sync(storage.save_datasource(ds))
print("Datasource description imported.")
except Exception as e:
print(f"Could not save datasource description: {e}")

# After persistence, so the sections comment on what was just written. Exit
# stays 0 — creating the datasource succeeded, whatever was skipped/hidden.
_print_ingest_drift_and_errors(report, data_source=ds.name)
Expand Down
Loading
Loading