fix(context): merge overlay columns onto manifest columns by name (#94)

* fix(context): merge overlay columns onto manifest columns by name

composeOverlay was appending overlay columns to the manifest column list,
producing duplicate entries when dbt/metabase overlays declared a column
just to attach descriptions. The duplicates carried no `type`, so the
pydantic SourceDefinition rejected them at semantic-query time and broke
`ktx sl query` for every overlay-backed measure. Now overlay columns
match base columns by name (case-insensitive): same-name entries merge
onto the manifest (overlay fields win, type/role fall back to the base,
descriptions merge per source key) and only new names append.

* refactor(sl): split overlay columns from column_overrides and enforce TS/Python wire contract

Overlay sources now have two distinct collections: `columns:` for computed
columns (requiring `expr` + `type`) and `column_overrides:` for metadata
patches to inherited manifest columns. Composing or loading an overlay that
mixes the two — or references an unknown column — fails with a typed error.

Introduce `ResolvedSemanticLayerSource` / `resolvedSourceSchema` /
`toResolvedWire` as the strict shape sent to the Python engine, and add a
schema contract test that diffs Zod against the Pydantic JSON schema dumped
by `python -m semantic_layer dump-schema`. `SourceDefinition` is now
`extra="forbid"` on the Python side.

`loadAllSources` surfaces per-file load errors instead of swallowing them,
so validation/query paths can report manifest shard parse failures.

* fix(context): make scan description generation resilient and quiet

A transient sampleTable failure during ingest used to take out every
table in a connection: generateTableDescription returned a hardcoded
'Table not found' string into descriptions.ai, and KtxDescriptionGenerator
was constructed without a logger, so the failure left no trail anywhere.

- sampleTable / sampleColumn calls retry 3x with 200/400/800ms backoff,
  honouring KtxScanContext.signal via a new KtxAbortedError.
- On retry exhaustion or missing capability, table generation falls back
  to a metadata-only prompt built from column name / native type / comment
  / rawDescriptions. The column path follows the same rule -- call the
  LLM when any of samples or rawDescriptions are available; skip only
  when both are absent.
- Logger is now threaded from KtxScanContext into the generator. Failures
  emit structured KtxScanWarning entries (new description_fallback_used
  code, plus existing sampling_failed / enrichment_failed /
  connector_capability_missing). ktx scan groups warnings by code so a
  batch of identical failures collapses to one summary line plus sample.
- Returns null on failure instead of the 'Table not found' sentinel; the
  manifest writer's existing guard already skips empty descriptions, so
  schema YAML no longer carries misleading text. SCAN_MANAGED_DESCRIPTION_KEYS
  already strips stale 'ai' on merge, so existing YAML clears on next run.

Also suppress AI SDK v6 'system in messages' warning: pull system messages
out of KtxMessageBuilder.wrapSimple's output via a new splitKtxSystemMessages
helper and pass them top-level to generateText (preserves cacheControl
providerOptions on the SystemModelMessage). Agent-runner's local
splitSystemPromptMessages dedupes onto the shared helper.

* test(docs): align examples-docs assertions with revamped docs

PR #103 (setup/guide doc revamp) reworded several CLI examples and
connection labels; the assertions in scripts/examples-docs.test.mjs
still referenced the pre-revamp wording and were failing in CI on main.
Update the regexes to match the post-revamp content:

- drop the `--json` flag from the sl-query example expectation
- move the `Driver:` / `Status: ok` probe to the connection reference,
  which is where that output now lives (driver id is lowercase
  `postgres`, not the display name `PostgreSQL`)
- drop the obsolete `Install \`uv\`...` troubleshooting line
- accept `<connectionId>` everywhere; the docs no longer use the
  hyphenated `<connection-id>` form
- match the `warehouse` connection id used in the quickstart instead of
  the `postgres-warehouse` id only used in the README and setup ref

* fix(sl): skip TS/Python schema contract test when uv is unavailable

The TypeScript checks CI job does not install uv or Python, so the
module-level `execFileSync('uv', ...)` in schemas.contract.test.ts threw
ENOENT and failed the suite. Wrap the schema dump in a try/catch and
guard the describe block with `describe.skipIf` so the test skips in
environments without uv. Local dev and any CI job that has uv on PATH
still runs the cross-language contract assertion.
This commit is contained in:
Andrey Avtomonov 2026-05-15 02:11:04 +02:00 committed by GitHub
parent 6bc8d200ea
commit cb8902f1e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1650 additions and 237 deletions

View file

@ -2,13 +2,17 @@ import type { Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ColumnNameCollisionError,
composeOverlay,
ConflictingExcludeAndOverrideError,
enrichColumnsFromManifest,
findDanglingSegmentRefs,
projectManifestEntry,
SemanticLayerService,
toResolvedWire,
UnknownColumnOverrideError,
} from './semantic-layer.service.js';
import { sourceDefinitionSchema } from './schemas.js';
import { resolvedSourceSchema, sourceDefinitionSchema, sourceOverlaySchema } from './schemas.js';
import type { SemanticLayerSource } from './types.js';
const pythonPort = {
@ -139,6 +143,69 @@ describe('composeOverlay', () => {
expect(composed.measures).toHaveLength(1);
});
it('applies column_overrides to same-named manifest columns', () => {
const overlay = {
name: 'fct_labs',
column_overrides: [
{ name: 'lab_order_id', descriptions: { user: 'Primary key' } },
{ name: 'admin_user_id', descriptions: { user: 'FK to admin_users' } },
],
};
const composed = composeOverlay(baseTable, overlay);
// No duplicate columns appended — same-named overlay entries merged onto the base.
expect(composed.columns).toHaveLength(3);
const labOrder = composed.columns.find((c) => c.name === 'lab_order_id');
expect(labOrder?.type).toBe('string');
expect(labOrder?.descriptions).toEqual({ user: 'Primary key' });
const adminUser = composed.columns.find((c) => c.name === 'admin_user_id');
expect(adminUser?.type).toBe('string');
expect(adminUser?.descriptions).toEqual({ user: 'FK to admin_users' });
});
it('appends computed columns alongside column overrides', () => {
const overlay = {
name: 'fct_labs',
column_overrides: [
{ name: 'lab_order_id', descriptions: { user: 'PK doc' } },
],
columns: [
{ name: 'is_byol', type: 'boolean', expr: "lab_type = 'byol'" },
],
};
const composed = composeOverlay(baseTable, overlay);
expect(composed.columns).toHaveLength(4);
expect(composed.columns.find((c) => c.name === 'is_byol')?.expr).toBe("lab_type = 'byol'");
expect(composed.columns.find((c) => c.name === 'lab_order_id')?.type).toBe('string');
});
it('rejects column_overrides that target unknown manifest columns', () => {
expect(() =>
composeOverlay(baseTable, {
name: 'fct_labs',
column_overrides: [{ name: 'missing', descriptions: { user: 'Nope' } }],
}),
).toThrow(UnknownColumnOverrideError);
});
it('rejects computed columns whose names collide with manifest columns', () => {
expect(() =>
composeOverlay(baseTable, {
name: 'fct_labs',
columns: [{ name: 'lab_order_id', type: 'string', expr: 'lab_order_id' }],
}),
).toThrow(ColumnNameCollisionError);
});
it('rejects exclude/override conflicts before applying exclusions', () => {
expect(() =>
composeOverlay(baseTable, {
name: 'fct_labs',
exclude_columns: ['lab_order_id'],
column_overrides: [{ name: 'lab_order_id', descriptions: { user: 'Hidden PK' } }],
}),
).toThrow(ConflictingExcludeAndOverrideError);
});
it('merges overlay descriptions (plural) with base descriptions keyed by source', () => {
const baseWithDescriptions: SemanticLayerSource = {
...baseTable,
@ -418,6 +485,62 @@ describe('sourceDefinitionSchema', () => {
});
});
describe('sourceOverlaySchema', () => {
it('accepts column_overrides and keeps columns computed-only', () => {
const result = sourceOverlaySchema.safeParse({
name: 'orders',
column_overrides: [{ name: 'status', descriptions: { user: 'Lifecycle status' } }],
columns: [{ name: 'is_paid', type: 'boolean', expr: "status = 'paid'" }],
});
expect(result.success).toBe(true);
});
it('rejects typeless overlay columns and singular description on overrides', () => {
const result = sourceOverlaySchema.safeParse({
name: 'orders',
column_overrides: [{ name: 'status', description: 'Lifecycle status' }],
columns: [{ name: 'status', descriptions: { user: 'Lifecycle status' } }],
});
expect(result.success).toBe(false);
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'));
expect(paths).toContain('column_overrides.0');
expect(paths).toContain('columns.0.type');
expect(paths).toContain('columns.0.expr');
}
});
});
describe('toResolvedWire', () => {
it('strips TS-only authoring and provenance fields before the Python boundary', () => {
const wire = toResolvedWire({
name: 'orders',
table: 'public.orders',
inherits_columns_from: 'orders',
grain: ['id'],
columns: [{ name: 'id', type: 'string' }],
joins: [{ to: 'customers', on: 'orders.customer_id = customers.id', relationship: 'many_to_one', source: 'formal' }],
measures: [],
usage: {
narrative: 'Frequently queried orders.',
frequencyTier: 'high',
commonFilters: ['status'],
commonJoins: [],
},
});
expect(wire).toEqual({
name: 'orders',
table: 'public.orders',
grain: ['id'],
columns: [{ name: 'id', type: 'string' }],
joins: [{ to: 'customers', on: 'orders.customer_id = customers.id', relationship: 'many_to_one' }],
measures: [],
});
expect(resolvedSourceSchema.parse(wire)).toEqual(wire);
});
});
describe('projectManifestEntry', () => {
it('projects manifest usage onto the semantic-layer source', () => {
const source = projectManifestEntry('orders', {
@ -537,7 +660,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
].join('\n'),
});
const sources = await service.loadAllSources('conn-1');
const { sources, loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors).toEqual([]);
expect(sources[0]).toMatchObject({
name: 'orders',
@ -601,7 +725,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
return Promise.reject(new Error(`Unexpected readFile: ${path}`));
});
const sources = await service.loadAllSources('conn-1');
const { sources, loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors).toEqual([]);
const aav = sources.find((s) => s.name === 'aav_consignments');
expect(aav).toBeDefined();
expect(aav?.columns).toEqual([
@ -646,7 +771,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
});
});
const sources = await service.loadAllSources('conn-1');
const { sources, loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors).toEqual([]);
const aav = sources.find((s) => s.name === 'aav_consignments');
expect(aav?.columns[0].type).toBe('string');
});
@ -670,7 +796,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
].join('\n'),
});
const sources = await service.loadAllSources('conn-1');
const { sources, loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors).toEqual([]);
const aav = sources.find((s) => s.name === 'aav_consignments');
expect(aav?.columns).toEqual([{ name: 'FOO', type: 'string' }]);
});
@ -693,7 +820,8 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
].join('\n'),
});
const sources = await service.loadAllSources('conn-1');
const { sources, loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors).toEqual([]);
expect(sources[0]).toMatchObject({
name: 'orders',
@ -701,6 +829,33 @@ describe('loadAllSources — standalone enrichment via inherits_columns_from', (
columns: [{ name: 'id', type: 'string', descriptions: { user: 'Stable order identifier.' } }],
});
});
it('reports file-attributed errors for legacy overlay column patches', async () => {
const schemaPath = 'semantic-layer/conn-1/_schema/marts.yaml';
const overlayPath = 'semantic-layer/conn-1/orders.yaml';
configService.listFiles.mockResolvedValue({ files: [schemaPath, overlayPath] });
configService.readFile.mockImplementation((path: string) => {
if (path === schemaPath) {
return Promise.resolve({
content: [
'tables:',
' orders:',
' table: public.orders',
' columns:',
' - { name: id, type: string, pk: true }',
].join('\n'),
});
}
return Promise.resolve({
content: ['name: orders', 'columns:', ' - name: id', ' descriptions: { user: "Stable id." }'].join('\n'),
});
});
const { loadErrors } = await service.loadAllSources('conn-1');
expect(loadErrors.join('\n')).toContain(overlayPath);
expect(loadErrors.join('\n')).toContain("move it to 'column_overrides:'");
});
});
describe('validateWithProposedSource', () => {