diff --git a/.github/workflows/extract.yml b/.github/workflows/extract.yml new file mode 100644 index 0000000..e480297 --- /dev/null +++ b/.github/workflows/extract.yml @@ -0,0 +1,27 @@ +name: Extract + +on: + push: + branches: [main] + pull_request: + +jobs: + extract-spec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + + - run: bun run bin/cli.js extract-spec --file /tmp/extracted-spec.md + - run: bun run bin/cli.js extract-schema --folder /tmp/extracted-schemas + + - name: Compare extracted spec with core-spec/v1/spec.md + run: diff core-spec/v1/spec.md /tmp/extracted-spec.md + + - name: Compare extracted schemas with core-spec/v1/schemas + run: diff -r core-spec/v1/schemas /tmp/extracted-schemas diff --git a/.github/workflows/schema.yml b/.github/workflows/validate.yml similarity index 89% rename from .github/workflows/schema.yml rename to .github/workflows/validate.yml index aa4d3cf..06e60ab 100644 --- a/.github/workflows/schema.yml +++ b/.github/workflows/validate.yml @@ -1,4 +1,4 @@ -name: Schema +name: Validate on: push: @@ -6,7 +6,7 @@ on: pull_request: jobs: - schema-test: + validate-schema: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index a065001..836dc26 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,26 @@ npx @metabase/representations validate-schema --folder ./my-export Omit `--folder` to validate the current directory. +### Extracting the spec + +Copy the bundled `spec.md` to a target file: + +```sh +npx @metabase/representations extract-spec --file ./spec.md +``` + +Omit `--file` to write `spec.md` into the current directory. + +### Extracting schemas + +Copy the bundled schemas (preserving folder structure) into a target directory: + +```sh +npx @metabase/representations extract-schema --folder ./schemas +``` + +Omit `--folder` to extract into the current directory. + ### Programmatic ```js diff --git a/bin/cli.js b/bin/cli.js index d2e2722..07f7f48 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -2,54 +2,78 @@ import { parseArgs } from "node:util"; import { relative } from "path"; + +import { extractSchema } from "../src/extract-schema.js"; +import { extractSpec } from "../src/extract-spec.js"; import { validateSchema } from "../src/validate-schema.js"; const { values, positionals } = parseArgs({ allowPositionals: true, options: { folder: { type: "string" }, + file: { type: "string" }, help: { type: "boolean", short: "h", default: false }, }, }); const command = positionals[0]; -if (values.help || !command) { - console.log(`Usage: representations [options] +const HELP = `Usage: representations [options] Commands: validate-schema Validate YAML files against Metabase representation schemas + --folder Folder to validate (default: cwd) + + extract-spec Copy the bundled spec.md into a target file + --file Destination file (default: ./spec.md) + + extract-schema Copy bundled schemas into a target folder + --folder Destination folder (default: cwd) Options: - --folder Folder to validate (default: current directory) - -h, --help Show this help message`); - process.exit(command ? 0 : 1); -} + -h, --help Show this help message`; -if (command !== "validate-schema") { - console.error(`Unknown command: ${command}`); - process.exit(1); +if (values.help || !command) { + console.log(HELP); + process.exit(command ? 0 : 1); } -const folder = values.folder ?? process.cwd(); -const { results, passed, failed } = validateSchema({ folder }); +if (command === "validate-schema") { + const folder = values.folder ?? process.cwd(); + const { results, passed, failed } = validateSchema({ folder }); -if (results.length === 0) { - console.error(`No YAML files found in ${folder}`); - process.exit(1); -} + if (results.length === 0) { + console.error(`No YAML files found in ${folder}`); + process.exit(1); + } -for (const result of results) { - const path = relative(process.cwd(), `${folder}/${result.file}`); - if (result.status === "ok") { - console.log(`OK ${path} (${result.model})`); - } else { - console.error(`FAIL ${path}${result.model ? ` (${result.model})` : ""}`); - for (const error of result.errors) { - console.error(` ${error.path} ${error.message}`); + for (const result of results) { + const path = relative(process.cwd(), `${folder}/${result.file}`); + if (result.status === "ok") { + console.log(`OK ${path} (${result.model})`); + } else { + console.error(`FAIL ${path}${result.model ? ` (${result.model})` : ""}`); + for (const error of result.errors) { + console.error(` ${error.path} ${error.message}`); + } } } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +if (command === "extract-spec") { + const { target } = extractSpec({ file: values.file ?? "spec.md" }); + console.log(`Spec extracted to ${target}`); + process.exit(0); +} + +if (command === "extract-schema") { + const { target } = extractSchema({ folder: values.folder ?? process.cwd() }); + console.log(`Schemas extracted to ${target}`); + process.exit(0); } -console.log(`\n${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); +console.error(`Unknown command: ${command}`); +process.exit(1); diff --git a/core-spec/v1/schemas/common/parameter.yaml b/core-spec/v1/schemas/common/parameter.yaml index e1ec46a..e9bac9c 100644 --- a/core-spec/v1/schemas/common/parameter.yaml +++ b/core-spec/v1/schemas/common/parameter.yaml @@ -13,8 +13,10 @@ required: properties: id: type: string - description: UUID identifier - format: uuid + minLength: 1 + description: > + Unique identifier within the dashboard or card. UUIDs are recommended, + but Metabase accepts any unique non-empty string. name: type: string description: Display name @@ -135,3 +137,10 @@ $defs: maxItems: 2 minItems: 2 maxItems: 2 + - if: { prefixItems: [{ const: text-tag }] } + then: + prefixItems: + - const: text-tag + - type: string + minItems: 2 + maxItems: 2 diff --git a/core-spec/v1/schemas/common/query.yaml b/core-spec/v1/schemas/common/query.yaml index d180de2..c42666f 100644 --- a/core-spec/v1/schemas/common/query.yaml +++ b/core-spec/v1/schemas/common/query.yaml @@ -55,14 +55,6 @@ $defs: properties: include-current: { type: boolean } - case_if_options: - description: Options for case/if operators. - allOf: - - $ref: "#/$defs/options" - - type: object - properties: - default: { $ref: "#/$defs/expression" } - datetime_parse_options: description: Options for the datetime parsing operator. allOf: @@ -474,15 +466,16 @@ $defs: then: prefixItems: - enum: [case, if] - - $ref: "#/$defs/case_if_options" + - $ref: "#/$defs/options" - type: array items: type: array prefixItems: [{ $ref: "#/$defs/expression" }, { $ref: "#/$defs/expression" }] minItems: 2 maxItems: 2 + - $ref: "#/$defs/expression" minItems: 3 - maxItems: 3 + maxItems: 4 - if: { prefixItems: [{ const: offset }] } then: prefixItems: diff --git a/core-spec/v1/schemas/dashboard.yaml b/core-spec/v1/schemas/dashboard.yaml index ee4f6ad..b40a8ca 100644 --- a/core-spec/v1/schemas/dashboard.yaml +++ b/core-spec/v1/schemas/dashboard.yaml @@ -257,7 +257,6 @@ $defs: description: > Connects a dashboard parameter to a specific card column or variable. required: - - card_id - parameter_id - target properties: diff --git a/core-spec/v1/spec.md b/core-spec/v1/spec.md index 1218e36..64d0a6f 100644 --- a/core-spec/v1/spec.md +++ b/core-spec/v1/spec.md @@ -1223,7 +1223,7 @@ Extraction units for `temporal-extract`: `year-of-era`, `quarter-of-year`, `mont | Operator | Arguments | Returns | Description | |----------|-----------|---------|-------------| -| `case` | pairs of [condition, value] (default in options) | value type | Conditional expression (if/then/else) | +| `case` | pairs of [condition, value], optional default expression | value type | Conditional expression (if/then/else). The default value is supplied as a 4th positional argument. | | `if` | same as `case` | value type | Alias for `case` | | `coalesce` | 2+ expressions | first non-null type | First non-null value | @@ -1232,7 +1232,6 @@ Extraction units for `temporal-extract`: `year-of-era`, `quarter-of-year`, `mont expressions: - - case - "lib/expression-name": Price Tier - default: Standard - - - - ">" - {} - - field @@ -1247,6 +1246,7 @@ expressions: - [Sample Database, PUBLIC, PRODUCTS, PRICE] - 20 - Budget + - Standard # default (4th positional arg) # Coalesce - coalesce @@ -1857,6 +1857,15 @@ visualization_settings: display: text text: "**Bold** and _italic_ markdown content" +# Text with parameter placeholders. Each `{{name}}` is wired to a dashboard +# parameter through `parameter_mappings` on the dashcard, with target +# `[text-tag, name]`. At render time the placeholder is replaced with the +# parameter's current value. +visualization_settings: + virtual_card: + display: text + text: "Showing results for {{product_category}}" + # Link (URL) visualization_settings: virtual_card: @@ -2013,7 +2022,7 @@ On **cards**, parameters are typically empty `[]` for MBQL queries. For native q | Field | Type | Required | Description | |-------|------|----------|-------------| -| `id` | string | Yes | UUID identifier | +| `id` | string | Yes | Unique identifier within the dashboard or card. UUIDs are recommended, but any unique non-empty string is accepted (e.g., `9d9cddd4`). | | `name` | string | Yes | Display name | | `slug` | string | Yes | URL-friendly identifier | | `type` | string | Yes | Filter widget type (see below) | @@ -2109,10 +2118,11 @@ Use `sectionId: id` to make a `number/=` or `string/=` parameter map only to pri ### Parameter Targets -Parameter targets specify which column or variable a parameter maps to. The outer wrapper is either `dimension` or `variable`: +Parameter targets specify which column or variable a parameter maps to. The outer wrapper is `dimension`, `variable`, or `text-tag`: - **`dimension`** — for MBQL column references (`field`, `expression`) and for native template tags of type `dimension` or `temporal-unit` - **`variable`** — for native template tags of type `text`, `number`, `date`, or `boolean` +- **`text-tag`** — for placeholders inside text/heading virtual cards (see [Virtual Card Settings](#virtual-card-settings)) An optional third element `{stage-number: N}` or `null` can specify which query stage the target belongs to (0 = first stage). @@ -2169,6 +2179,14 @@ target: - min_price ``` +**Text card placeholder:** + +```yaml +target: +- text-tag +- product_category +``` + --- ## Collection @@ -2400,8 +2418,8 @@ Connects a dashboard parameter to a specific card column or variable. Each mappi | Field | Type | Required | Description | |-------|------|----------|-------------| -| `card_id` | string | Yes | Card FK (entity_id) | -| `parameter_id` | string | Yes | UUID matching a dashboard parameter's `id` | +| `card_id` | string | No | Card FK (entity_id). Omit for mappings on virtual cards (e.g., text-tag placeholders). | +| `parameter_id` | string | Yes | Matches a dashboard parameter's `id` | | `target` | array | Yes | Parameter target | ### DashboardCardSeries diff --git a/examples/v1/collections/main/dashboards/virtual_cards.yaml b/examples/v1/collections/main/dashboards/virtual_cards.yaml index fa81dc8..5b504e7 100644 --- a/examples/v1/collections/main/dashboards/virtual_cards.yaml +++ b/examples/v1/collections/main/dashboards/virtual_cards.yaml @@ -2,7 +2,12 @@ name: Virtual Cards Dashboard entity_id: Ylbpm_iPeEYav_o0kpUVD creator_id: admin@example.com collection_id: cOlDaShBoArDs0ExAmPlx -parameters: [] +parameters: +- id: 9d9cddd4 + name: Product Category + slug: product_category + type: string/= + sectionId: string tabs: [] dashcards: - entity_id: WcNpxqVEO_rIeWZwjYv7Z @@ -28,13 +33,17 @@ dashcards: col: 0 size_x: 12 size_y: 3 - parameter_mappings: [] + parameter_mappings: + - parameter_id: 9d9cddd4 + target: + - text-tag + - product_category series: [] visualization_settings: virtual_card: display: text text: |- - This dashboard provides an overview of sales metrics. + This dashboard provides an overview of sales metrics for {{product_category}}. **Key metrics** include revenue, order count, and average order value. Use the filters above to narrow down the data. serdes/meta: diff --git a/examples/v1/collections/main/queries/conditional_and_type.yaml b/examples/v1/collections/main/queries/conditional_and_type.yaml index 03cc48f..bb68690 100644 --- a/examples/v1/collections/main/queries/conditional_and_type.yaml +++ b/examples/v1/collections/main/queries/conditional_and_type.yaml @@ -15,8 +15,7 @@ dataset_query: - PRODUCTS expressions: - - case - - default: Budget - "lib/expression-name": Price Tier + - "lib/expression-name": Price Tier - - - - ">" - {} - - field @@ -37,9 +36,9 @@ dataset_query: - PRICE - 30 - Standard + - Budget - - if - - default: false - "lib/expression-name": Is Premium + - "lib/expression-name": Is Premium - - - - ">" - {} - - field @@ -50,6 +49,7 @@ dataset_query: - PRICE - 100 - true + - false - - case - "lib/expression-name": Simple Category - - - - ">" diff --git a/package.json b/package.json index 2541f66..19c2c08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/representations", - "version": "1.1.3", + "version": "1.1.4", "description": "Metabase representation format specification and schema validator", "license": "SEE LICENSE IN LICENSE.txt", "repository": { @@ -16,7 +16,8 @@ "files": [ "src", "bin", - "core-spec/v1/schemas" + "core-spec/v1/schemas", + "core-spec/v1/spec.md" ], "main": "src/index.js", "exports": { diff --git a/src/extract-schema.js b/src/extract-schema.js new file mode 100644 index 0000000..70c7ecf --- /dev/null +++ b/src/extract-schema.js @@ -0,0 +1,12 @@ +import { cpSync, mkdirSync } from "fs"; +import { resolve } from "path"; + +const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); + +export function extractSchema({ folder }) { + const schemasDir = resolve(PACKAGE_ROOT, "core-spec/v1/schemas"); + const target = resolve(folder); + mkdirSync(target, { recursive: true }); + cpSync(schemasDir, target, { recursive: true }); + return { source: schemasDir, target }; +} diff --git a/src/extract-spec.js b/src/extract-spec.js new file mode 100644 index 0000000..da1e36e --- /dev/null +++ b/src/extract-spec.js @@ -0,0 +1,12 @@ +import { copyFileSync, mkdirSync } from "fs"; +import { dirname, resolve } from "path"; + +const PACKAGE_ROOT = resolve(import.meta.dirname, ".."); + +export function extractSpec({ file }) { + const source = resolve(PACKAGE_ROOT, "core-spec/v1/spec.md"); + const target = resolve(file); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(source, target); + return { source, target }; +}