diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1efeaa1..d4eb867 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -48,7 +48,7 @@ { "name": "sl-toolkit", "description": "Semantic layer toolkit for Keboola — inspect, validate, and build models via the metastore API. CRUD operations handled conversationally via the semantic-layer skill. (Renamed from sl-builder in v3.0.0.)", - "version": "3.0.0", + "version": "3.0.1", "source": "./plugins/sl-toolkit", "category": "development" } diff --git a/plugins/sl-toolkit/.claude-plugin/plugin.json b/plugins/sl-toolkit/.claude-plugin/plugin.json index f8381d0..461f0b6 100644 --- a/plugins/sl-toolkit/.claude-plugin/plugin.json +++ b/plugins/sl-toolkit/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sl-toolkit", - "version": "3.0.0", + "version": "3.0.1", "description": "Semantic layer tools for Keboola — inspect (/sl-show), validate (/sl-validate), and build (/sl-build) models via the metastore API. Add/edit/remove operations handled conversationally via the semantic-layer reference skill.", "author": { "name": "Keboola s.r.o.", diff --git a/plugins/sl-toolkit/commands/sl-build.md b/plugins/sl-toolkit/commands/sl-build.md index 7a558f7..c995676 100644 --- a/plugins/sl-toolkit/commands/sl-build.md +++ b/plugins/sl-toolkit/commands/sl-build.md @@ -320,7 +320,7 @@ if UPDATE_ID: else: uuid = api_post('/api/v1/repository/semantic-model', { 'name': model['name'], - 'data': {'name': model['name'], 'description': model['description'], 'sqlDialect': 'Snowflake'}, + 'data': {'name': model['name'], 'description': model['description'], 'sql_dialect': 'Snowflake'}, 'branch': 'main', 'schemaVersion': '1.0.0', 'scope': 'project' })['data']['id'] print(f'✓ model created {uuid}') diff --git a/plugins/sl-toolkit/skills/semantic-layer/SKILL.md b/plugins/sl-toolkit/skills/semantic-layer/SKILL.md index a7f834c..4b40259 100644 --- a/plugins/sl-toolkit/skills/semantic-layer/SKILL.md +++ b/plugins/sl-toolkit/skills/semantic-layer/SKILL.md @@ -135,7 +135,7 @@ on the returned list — the `?modelId` query param is unreliable. ```json { "name": "", - "data": { "name": "", "description": "...", "sqlDialect": "Snowflake" }, + "data": { "name": "", "description": "...", "sql_dialect": "Snowflake" }, "branch": "main", "schemaVersion": "1.0.0", "scope": "project" @@ -248,10 +248,19 @@ and a bare `KEBOOLA` reference will fail at Snowflake query time. "name": "net_margin_critical", "constraintType": "range", "metrics": ["Net Profit Margin"], - "ruleExpression": { "bounds": { "min": -2.0, "max": 0.05 } }, + "rule": "-2.0 <= Net Profit Margin <= 0.05", + "ruleExpression": { "operator": "between", "left": "Net Profit Margin", "bounds": { "min": -2.0, "max": 0.05 } }, "severity": "error" } ``` +- **`rule` is REQUIRED** by the metastore — a plain-string logical expression using metric names + (e.g. `"profit <= revenue"`, `"-2.0 <= Net Profit Margin <= 0.05"`, `maxLength` 1000). Omitting it + returns **422 missing property 'rule'**. +- **`ruleExpression` is OPTIONAL** and is a structured object `{operator, left, right, bounds}` + (`operator` ∈ `< <= = >= > != between in sum_equals ratio_between`). Send it **in addition to** + `rule` — downstream pipelines read `ruleExpression.bounds`; the API enforces `rule`. +- The metastore validates `semantic-constraint` with **`additionalProperties: false`** — unlike + `semantic-model`, a stray/misspelled key is hard-rejected, so keep the payload clean. - `severity` API accepts only: `error` / `warning` / `info` - Encode 4-level health bands in the **name suffix**: `_critical` / `_warning` / `_healthy` / `_review` - Downstream pipelines parse the suffix; `severity` is secondary diff --git a/plugins/sl-toolkit/tests/README.md b/plugins/sl-toolkit/tests/README.md index 984d141..f5bdc33 100644 --- a/plugins/sl-toolkit/tests/README.md +++ b/plugins/sl-toolkit/tests/README.md @@ -13,9 +13,9 @@ python -m pytest plugins/sl-toolkit/tests/ -v | File | Covers | |---|---| -| `test_smoke.py` | jsonschema round-trip for all 6 entity types (envelope + data shape), `sqlDialect` camelCase invariant, constraint severity suffix, FQN uses `KEBOOLA_` | +| `test_smoke.py` | jsonschema round-trip for all 6 entity types (envelope + data shape), `sql_dialect` snake_case invariant, constraint requires string `rule`, constraint severity suffix, FQN uses `KEBOOLA_` | | `test_fqn.py` | `db_name()` token-verify success / cache hit / HTTP error fallback / malformed response. `fqn()` three-part construction with dotted schemas | -| `test_skill_consistency.py` | Greps `SKILL.md` and command markdowns to assert: no hardcoded `KEBOOLA` fqn, `sqlDialect` not `sql_dialect`, no `allowed-tools` in reference-skill frontmatter, multi-cloud regex (`gcp\|aws\|azure`), no literal placeholder strings, push loop includes `semantic-constraint`, VERSION rule instructs probing | +| `test_skill_consistency.py` | Greps `SKILL.md` and command markdowns to assert: no hardcoded `KEBOOLA` fqn, `sql_dialect` not `sqlDialect`, no `allowed-tools` in reference-skill frontmatter, multi-cloud regex (`gcp\|aws\|azure`), no literal placeholder strings, push loop includes `semantic-constraint`, VERSION rule instructs probing | ## Fixtures and schemas diff --git a/plugins/sl-toolkit/tests/fixtures/semantic-constraint.json b/plugins/sl-toolkit/tests/fixtures/semantic-constraint.json index 6cb84e7..41031e6 100644 --- a/plugins/sl-toolkit/tests/fixtures/semantic-constraint.json +++ b/plugins/sl-toolkit/tests/fixtures/semantic-constraint.json @@ -4,7 +4,8 @@ "name": "net_margin_critical", "constraintType": "range", "metrics": ["Net Profit Margin"], - "ruleExpression": {"bounds": {"min": -2.0, "max": 0.05}}, + "rule": "-2.0 <= Net Profit Margin <= 0.05", + "ruleExpression": {"operator": "between", "left": "Net Profit Margin", "bounds": {"min": -2.0, "max": 0.05}}, "severity": "error", "modelUUID": "00000000-0000-0000-0000-000000000001" }, diff --git a/plugins/sl-toolkit/tests/fixtures/semantic-model.json b/plugins/sl-toolkit/tests/fixtures/semantic-model.json index 97128a2..5abb9f5 100644 --- a/plugins/sl-toolkit/tests/fixtures/semantic-model.json +++ b/plugins/sl-toolkit/tests/fixtures/semantic-model.json @@ -3,7 +3,7 @@ "data": { "name": "Revenue Analytics", "description": "Greenfield model for revenue tracking", - "sqlDialect": "Snowflake" + "sql_dialect": "Snowflake" }, "branch": "main", "schemaVersion": "1.0.0", diff --git a/plugins/sl-toolkit/tests/schemas/semantic-constraint.json b/plugins/sl-toolkit/tests/schemas/semantic-constraint.json index cbb4998..32a8097 100644 --- a/plugins/sl-toolkit/tests/schemas/semantic-constraint.json +++ b/plugins/sl-toolkit/tests/schemas/semantic-constraint.json @@ -2,15 +2,19 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "semantic-constraint.data", "type": "object", - "required": ["name", "constraintType", "metrics", "ruleExpression", "severity", "modelUUID"], + "required": ["name", "constraintType", "metrics", "rule", "ruleExpression", "severity", "modelUUID"], "additionalProperties": true, "properties": { "name": {"type": "string", "minLength": 1, "pattern": ".*_(critical|warning|healthy|review)$"}, "constraintType": {"type": "string", "enum": ["range", "threshold", "expression"]}, "metrics": {"type": "array", "minItems": 1, "items": {"type": "string"}}, + "rule": {"type": "string", "minLength": 1, "maxLength": 1000}, "ruleExpression": { "type": "object", "properties": { + "operator": {"type": "string"}, + "left": {"type": "string"}, + "right": {"type": "string"}, "bounds": { "type": "object", "properties": { diff --git a/plugins/sl-toolkit/tests/schemas/semantic-model.json b/plugins/sl-toolkit/tests/schemas/semantic-model.json index 68337cf..e23bc75 100644 --- a/plugins/sl-toolkit/tests/schemas/semantic-model.json +++ b/plugins/sl-toolkit/tests/schemas/semantic-model.json @@ -2,16 +2,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "semantic-model.data", "type": "object", - "required": ["name", "description", "sqlDialect"], + "required": ["name", "description", "sql_dialect"], "additionalProperties": true, "properties": { "name": {"type": "string", "minLength": 1}, "description": {"type": "string"}, - "sqlDialect": {"type": "string", "enum": ["Snowflake", "BigQuery", "Redshift", "Postgres"]} + "sql_dialect": {"type": "string", "enum": ["Snowflake", "BigQuery", "Redshift", "Postgres"]} }, "not": { "anyOf": [ - {"required": ["sql_dialect"]}, + {"required": ["sqlDialect"]}, {"required": ["sqldialect"]}, {"required": ["SqlDialect"]} ] diff --git a/plugins/sl-toolkit/tests/test_skill_consistency.py b/plugins/sl-toolkit/tests/test_skill_consistency.py index a85e6ab..e20d07e 100644 --- a/plugins/sl-toolkit/tests/test_skill_consistency.py +++ b/plugins/sl-toolkit/tests/test_skill_consistency.py @@ -1,7 +1,7 @@ """Verify SKILL.md and command markdowns don't drift from canonical invariants. -These tests grep the markdown so changes to SKILL.md that re-introduce the bugs -Jordan flagged (hardcoded KEBOOLA, sql_dialect, allowed-tools, etc.) fail in CI. +These tests grep the markdown so changes to SKILL.md that re-introduce known bugs +(hardcoded KEBOOLA, camelCase sqlDialect, allowed-tools, etc.) fail in CI. """ import re from pathlib import Path @@ -43,11 +43,28 @@ def test_no_hardcoded_keboola_in_fqn_construction(): ) -def test_sqldialect_is_camelcase(): - """Regression: PR #72 sql_dialect bug. SKILL.md must use sqlDialect, never snake_case.""" +def test_sqldialect_is_snakecase(): + """The metastore semantic-model schema requires snake_case `sql_dialect` + (see go-monorepo services/metastore/migrations/schema/semantic-model_schema_1.0.0.json: + required=["name","sql_dialect"]). camelCase `sqlDialect` is silently ignored, so the + required key is missing and the very first POST fails with 422. PR #72 pinned the wrong + spelling; this asserts the correct one.""" text = read(SKILL_MD) - assert "sqlDialect" in text, "SKILL.md must document sqlDialect" - assert "sql_dialect" not in text, "snake_case sql_dialect is the PR #72 regression" + assert "sql_dialect" in text, "SKILL.md must document snake_case sql_dialect (metastore contract)" + assert "sqlDialect" not in text, "camelCase sqlDialect is rejected by the metastore API" + + +def test_constraint_rule_is_documented(): + """The metastore semantic-constraint schema requires a string `rule`; documenting only + `ruleExpression` causes 422 missing property 'rule'. SKILL.md must show `rule` in the + semantic-constraint payload.""" + text = read(SKILL_MD) + section = text[text.find("### semantic-constraint"):] + section = section[: section.find("\n---")] + assert '"rule"' in section, ( + "SKILL.md semantic-constraint payload must include the required string `rule` — " + "ruleExpression alone is rejected by the metastore with 422 missing property 'rule'." + ) def test_no_allowed_tools_wildcard_in_skill_frontmatter(): diff --git a/plugins/sl-toolkit/tests/test_smoke.py b/plugins/sl-toolkit/tests/test_smoke.py index 78042e1..189efb5 100644 --- a/plugins/sl-toolkit/tests/test_smoke.py +++ b/plugins/sl-toolkit/tests/test_smoke.py @@ -1,7 +1,8 @@ """Round-trip jsonschema validation for every metastore entity type. -These tests would have caught the sql_dialect/sqlDialect divergence from PR #72: -the model schema explicitly forbids snake_case spellings via `not.anyOf`. +These tests guard against key-name drift from the metastore contract: the model schema +requires snake_case `sql_dialect` and forbids the camelCase spelling via `not.anyOf`, and +the constraint fixture must carry the required string `rule`. """ import json from pathlib import Path @@ -42,10 +43,20 @@ def test_data_shape(entity): def test_no_dialect_drift(): - """Regression test for PR #72: sqlDialect must be camelCase, never snake_case.""" + """The metastore requires snake_case `sql_dialect`; camelCase `sqlDialect` is ignored and + the required key ends up missing (422 on the first POST). Assert the correct spelling.""" fixture = load(FIXTURES / "semantic-model.json") - assert "sqlDialect" in fixture["data"], "semantic-model.data must use camelCase sqlDialect" - assert "sql_dialect" not in fixture["data"], "snake_case sql_dialect is the bug from #72" + assert "sql_dialect" in fixture["data"], "semantic-model.data must use snake_case sql_dialect" + assert "sqlDialect" not in fixture["data"], "camelCase sqlDialect is rejected by the metastore API" + + +def test_constraint_requires_rule(): + """The metastore semantic-constraint schema requires a string `rule` (required=[...,'rule',...]). + `ruleExpression` alone triggers 422 missing property 'rule'. Send both: `rule` for the API, + `ruleExpression` for downstream pipelines that read the bounds.""" + fixture = load(FIXTURES / "semantic-constraint.json")["data"] + assert isinstance(fixture.get("rule"), str) and fixture["rule"], "constraint must include a string `rule`" + assert "ruleExpression" in fixture, "constraint should retain ruleExpression for downstream pipelines" def test_constraint_severity_suffix():