From e152e7c67ff8334feff479439ad14ea3952b4f64 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin Date: Sat, 10 Oct 2026 10:56:14 -0400 Subject: [PATCH 1/4] Add the TransformTest model and the library-dashboards collection type Metabase exports transform tests next to their transforms and the Library's Dashboards collection with type library-dashboards; validate-schema rejected both. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + core-spec/v1/schemas/collection.yaml | 1 + core-spec/v1/schemas/transform_test.yaml | 231 ++++++++++++++++++ core-spec/v1/spec.md | 105 +++++++- examples/v1/collections/main/library.yaml | 7 + .../collections/main/library/dashboards.yaml | 8 + ...oduct_revenue_report__category_totals.yaml | 57 +++++ src/validate-schema.test.ts | 109 +++++++++ 8 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 core-spec/v1/schemas/transform_test.yaml create mode 100644 examples/v1/collections/main/library.yaml create mode 100644 examples/v1/collections/main/library/dashboards.yaml create mode 100644 examples/v1/collections/transforms/native_transform/product_revenue_report__category_totals.yaml diff --git a/README.md b/README.md index bbff37d..7e08680 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This repository contains the specification and examples for the Metabase Represe | Transform | Materializes query or Python script results into a database table | | TransformTag | Label for categorizing transforms (built-in or custom) | | TransformJob | Scheduled job that executes tagged transforms | +| TransformTest | Test data for a transform's input tables and checks on its output | | PythonLibrary | Shared Python source file available to Python transforms | ## Schema Validation diff --git a/core-spec/v1/schemas/collection.yaml b/core-spec/v1/schemas/collection.yaml index 7de9135..daaf16f 100644 --- a/core-spec/v1/schemas/collection.yaml +++ b/core-spec/v1/schemas/collection.yaml @@ -42,6 +42,7 @@ properties: - library - library-data - library-metrics + - library-dashboards - trash - tenant-specific-root-collection namespace: diff --git a/core-spec/v1/schemas/transform_test.yaml b/core-spec/v1/schemas/transform_test.yaml new file mode 100644 index 0000000..ea95cfb --- /dev/null +++ b/core-spec/v1/schemas/transform_test.yaml @@ -0,0 +1,231 @@ +$schema: https://json-schema.org/draft/2020-12/schema +title: TransformTest +description: > + A test of a transform: inputs that stand in for the tables the transform + reads, and expectations checked against its output. Stored next to its + transform under collections/transforms/, in a folder named after the + transform. +type: object +required: + - name + - entity_id + - transform_id + - creator_id + - inputs + - expectations + - serdes/meta +properties: + name: + $ref: "#/$defs/non_blank_string" + description: Test name + entity_id: + $ref: "common/id.yaml#/$defs/entity_id" + transform_id: + description: Transform FK (entity_id of the transform under test) + $ref: "common/id.yaml#/$defs/entity_id" + creator_id: + $ref: "common/id.yaml#/$defs/user_id" + description: + type: + - string + - "null" + description: Human-readable description + inputs: + type: array + description: > + Test data for the tables the transform reads. Every table the transform + reads needs one, and an input for a table it does not read is refused. + items: + $ref: "#/$defs/input" + expectations: + type: array + description: > + Checks run against the transform's output. Names must be unique within + a test. + items: + $ref: "#/$defs/expectation" + created_at: + type: string + description: ISO 8601 timestamp + format: date-time + serdes/meta: + type: array + description: Identity path for serialization + items: + type: object + required: + - model + properties: + id: + type: string + description: Entity identifier (matches entity_id) + label: + type: string + description: Slugified name + model: + type: string + const: TransformTest + +$defs: + non_blank_string: + type: string + pattern: "\\S" + + table: + type: object + description: A table read by the transform, by schema and name + additionalProperties: false + required: + - name + properties: + schema: + description: Schema name (null or omitted for schemaless databases) + type: [string, "null"] + if: { type: string } + then: { $ref: "#/$defs/non_blank_string" } + name: + $ref: "#/$defs/non_blank_string" + description: Table name + + column: + type: object + description: A column of inline test data and the type its cells are cast to + additionalProperties: false + required: + - name + - cast_type + properties: + name: + $ref: "#/$defs/non_blank_string" + description: Column name + cast_type: + $ref: "#/$defs/non_blank_string" + description: > + Type the cells are cast to, as the warehouse spells it in a CAST + (e.g. "INTEGER", "TEXT", "NUMERIC(12,2)") + + columns: + type: array + minItems: 1 + items: + $ref: "#/$defs/column" + + rows: + type: array + description: > + Rows keyed by column name. Each row must carry exactly the declared + columns. + items: + type: object + additionalProperties: + type: [string, number, boolean, "null"] + + input: + type: object + description: > + An input table replaced with test data, either written out as rows + (format: rows) or returned by a SQL query against the transform's + source database (format: sql). + required: + - format + - table + properties: + format: + type: string + enum: + - rows + - sql + allOf: + - if: + properties: + format: { const: rows } + then: + additionalProperties: false + required: + - columns + - rows + properties: + format: true + table: { $ref: "#/$defs/table" } + columns: { $ref: "#/$defs/columns" } + rows: { $ref: "#/$defs/rows" } + - if: + properties: + format: { const: sql } + then: + additionalProperties: false + required: + - sql + properties: + format: true + table: { $ref: "#/$defs/table" } + sql: { $ref: "#/$defs/non_blank_string" } + + expectation: + type: object + description: > + A check on the transform's output. `equals` checks that the output, read + over the declared columns, holds exactly the declared rows; `empty` + checks that a SQL query over the output returns no rows. + required: + - type + - name + properties: + type: + type: string + enum: + - equals + - empty + name: + $ref: "#/$defs/non_blank_string" + allOf: + - if: + properties: + type: { const: equals } + then: + required: + - format + properties: + format: + type: string + enum: + - rows + - sql + allOf: + - if: + properties: + format: { const: rows } + then: + additionalProperties: false + required: + - columns + - rows + properties: + type: true + name: true + format: true + columns: { $ref: "#/$defs/columns" } + rows: { $ref: "#/$defs/rows" } + - if: + properties: + format: { const: sql } + then: + additionalProperties: false + required: + - sql + properties: + type: true + name: true + format: true + sql: { $ref: "#/$defs/non_blank_string" } + - if: + properties: + type: { const: empty } + then: + additionalProperties: false + required: + - sql + properties: + type: true + name: true + sql: { $ref: "#/$defs/non_blank_string" } diff --git a/core-spec/v1/spec.md b/core-spec/v1/spec.md index 0a7b106..1e694d4 100644 --- a/core-spec/v1/spec.md +++ b/core-spec/v1/spec.md @@ -127,7 +127,9 @@ export-root/ │ │ └── {collection_slug}/ # Snippet collection contents │ │ └── {snippet_slug}.yaml │ └── transforms/ # Transforms namespace -│ └── {transform_slug}.yaml +│ ├── {transform_slug}.yaml +│ └── {transform_slug}/ # The transform's tests +│ └── {test_slug}.yaml ├── databases/ │ └── {database_slug}/ │ ├── {database_slug}.yaml @@ -162,7 +164,7 @@ export-root/ - Collection hierarchy is reflected in directory nesting within a namespace. - A collection's definition file (`{slug}.yaml`) is placed **outside** its folder, as a sibling: e.g., `main/my_collection.yaml` defines the collection whose contents live in `main/my_collection/`. - All entity types within a collection (cards, dashboards, documents, etc.) are stored flat in the same folder — no type-specific subdirectories. -- Collections are partitioned by namespace: `main/` for regular content, `snippets/` for SQL snippets, `transforms/` for transforms. +- Collections are partitioned by namespace: `main/` for regular content, `snippets/` for SQL snippets, `transforms/` for transforms and their tests. ### Entity Ownership and Containers @@ -2215,7 +2217,7 @@ A collection is a folder-like container for organizing cards, dashboards, and ot | `slug` | string | No | URL-friendly name | | `archived` | boolean | No | Whether archived (default: `false`) | | `archived_directly` | boolean | No | Archived directly vs. inherited | -| `type` | string | No | `null` or `"instance-analytics"` | +| `type` | string | No | `null`, `"instance-analytics"`, `"trash"`, `"tenant-specific-root-collection"`, or a Library type: `"library"`, `"library-data"`, `"library-metrics"`, `"library-dashboards"` | | `namespace` | string | No | `null`, `"transforms"`, `"snippets"`, `"data-apps"`, `"shared-tenant-collection"`, or `"tenant-specific"` | | `authority_level` | string | No | `null` or `"official"` | | `parent_id` | string | No | Collection FK (entity_id of parent). **Must** be set for subcollections; `null`/omitted = root-level collection | @@ -2245,6 +2247,21 @@ serdes/meta: - model: Collection ``` +### Library + +The Library is a root collection of type `library` with fixed subcollections: Data (`library-data`, tables), Metrics (`library-metrics`, metrics) and Dashboards (`library-dashboards`, dashboards and their questions). Metabase creates them with fixed entity IDs, so every instance exports the same ones: + +```yaml +name: Dashboards +entity_id: librarylibrarydashbrd +slug: dashboards +type: library-dashboards +parent_id: librarylibrarylibrary +serdes/meta: +- model: Collection +is_remote_synced: true +``` + --- ## Card @@ -2886,7 +2903,7 @@ serdes/meta: ## Transform -A transform generates a table in the database by running a query or Python script. Transforms allow materializing results as persistent database tables. Transform entities are stored under `collections/transforms/`. Transform jobs and tags are stored separately under the top-level `transforms/` directory. +A transform generates a table in the database by running a query or Python script. Transforms allow materializing results as persistent database tables. Transform entities, and their tests, are stored under `collections/transforms/`. Transform jobs and tags are stored separately under the top-level `transforms/` directory. The `source` defines how data is produced — either an MBQL/native query (`type: query`) or a Python script (`type: python`). The `target` specifies where the resulting table is written. @@ -3023,6 +3040,84 @@ job_tags: A job can reference multiple tags. Transforms tagged with any of the job's tags will be executed when the job runs. +### TransformTest + +A transform test runs a transform against test data and checks its output. Its `inputs` stand in for the tables the transform reads, and its `expectations` are checked against the table it writes. A test is stored next to its transform, in a folder named after the transform's file: `collections/transforms/{transform_slug}/{test_slug}.yaml`. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Test name | +| `entity_id` | string | Yes | NanoID identifier | +| `transform_id` | string | Yes | Transform FK (entity_id of the transform under test) | +| `creator_id` | string | Yes | User FK (email) | +| `inputs` | array | Yes | Test data for the tables the transform reads (see below) | +| `expectations` | array | Yes | Checks on the transform's output (see below) | +| `serdes/meta` | array | Yes | Identity path with `model: TransformTest` | +| `description` | string | No | Description | +| `created_at` | string | No | ISO 8601 timestamp | + +Each entry in `inputs` replaces one table the transform reads. Every table the transform reads needs an input, and an input for a table it does not read is refused when the test runs. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `table` | object | Yes | The table replaced: `name`, and `schema` (omit or `null` for schemaless databases) | +| `format` | string | Yes | `"rows"` (data written out) or `"sql"` (data returned by a query) | +| `columns` | array | With `rows` | At least one column: `name` and `cast_type`, the type its cells are cast to as the warehouse spells it in a `CAST` (e.g. `"INTEGER"`, `"NUMERIC(12,2)"`) | +| `rows` | array | With `rows` | Rows keyed by column name; each row carries exactly the declared columns. Cells are strings, numbers, booleans or `null` | +| `sql` | string | With `sql` | Query run against the transform's source database | + +Each entry in `expectations` is a check named uniquely within the test: + +| `type` | Fields | Passes when | +|--------|--------|-------------| +| `equals` | `name`, `format`, and `columns` + `rows` (`format: rows`) or `sql` (`format: sql`) | The output, read over the given columns, holds exactly the given rows: order is ignored, duplicates count, and columns not given are not compared | +| `empty` | `name`, `sql` | The query over the output returns no rows | + +SQL in inputs and expectations names tables by their real names, including the transform's target table; the test run rewrites them to temporary tables. No other keys are accepted in an input, a column, a table or an expectation. + +```yaml +name: 'Product Revenue Report: category totals' +entity_id: aRvM7DLesF1fOV4kRHidq +transform_id: 7JwIkkAG-06-nsl4jzsaq # entity_id of the transform +creator_id: admin@example.com +inputs: +- table: + name: ORDERS + format: rows + columns: + - name: ID + cast_type: INTEGER + - name: PRODUCT_ID + cast_type: INTEGER + - name: TOTAL + cast_type: REAL + rows: + - ID: 1 + PRODUCT_ID: 1 + TOTAL: 10.0 +- table: + name: PRODUCTS + format: sql + sql: SELECT 1 AS ID, 'Gadget' AS CATEGORY +expectations: +- type: equals + name: revenue per category + format: rows + columns: + - name: CATEGORY + cast_type: TEXT + - name: total_revenue + cast_type: REAL + rows: + - CATEGORY: Gadget + total_revenue: 10.0 +- type: empty + name: one row per category + sql: SELECT CATEGORY FROM TRANSFORMS.product_revenue_report GROUP BY CATEGORY HAVING COUNT(*) > 1 +serdes/meta: +- model: TransformTest +``` + ### PythonLibrary A shared Python source file available to transforms. Stored in `python_libraries/`. @@ -3227,7 +3322,7 @@ serdes/meta: - **1.0.0**: Initial release - Entity key system (NanoID and foreign key references) - Folder structure specification with namespace-based collection layout - - Collection, Card, Dashboard, Document, Segment, Measure, Snippet, Transform, TransformJob, TransformTag, PythonLibrary + - Collection, Card, Dashboard, Document, Segment, Measure, Snippet, Transform, TransformJob, TransformTag, TransformTest, PythonLibrary --- diff --git a/examples/v1/collections/main/library.yaml b/examples/v1/collections/main/library.yaml new file mode 100644 index 0000000..bad5a2f --- /dev/null +++ b/examples/v1/collections/main/library.yaml @@ -0,0 +1,7 @@ +name: Library +entity_id: librarylibrarylibrary +slug: library +type: library +serdes/meta: +- model: Collection +is_remote_synced: true diff --git a/examples/v1/collections/main/library/dashboards.yaml b/examples/v1/collections/main/library/dashboards.yaml new file mode 100644 index 0000000..317a4a9 --- /dev/null +++ b/examples/v1/collections/main/library/dashboards.yaml @@ -0,0 +1,8 @@ +name: Dashboards +entity_id: librarylibrarydashbrd +slug: dashboards +type: library-dashboards +parent_id: librarylibrarylibrary +serdes/meta: +- model: Collection +is_remote_synced: true diff --git a/examples/v1/collections/transforms/native_transform/product_revenue_report__category_totals.yaml b/examples/v1/collections/transforms/native_transform/product_revenue_report__category_totals.yaml new file mode 100644 index 0000000..d4f37eb --- /dev/null +++ b/examples/v1/collections/transforms/native_transform/product_revenue_report__category_totals.yaml @@ -0,0 +1,57 @@ +name: 'Product Revenue Report: category totals' +entity_id: aRvM7DLesF1fOV4kRHidq +transform_id: 7JwIkkAG-06-nsl4jzsaq +creator_id: admin@example.com +description: 'Cases: two orders of one category are summed and averaged; a category with one order stands alone.' +inputs: +- table: + name: ORDERS + format: rows + columns: + - name: ID + cast_type: INTEGER + - name: PRODUCT_ID + cast_type: INTEGER + - name: TOTAL + cast_type: REAL + rows: + - ID: 1 + PRODUCT_ID: 1 + TOTAL: 10.0 + - ID: 2 + PRODUCT_ID: 1 + TOTAL: 30.0 + - ID: 3 + PRODUCT_ID: 2 + TOTAL: 25.0 +- table: + name: PRODUCTS + format: sql + sql: SELECT 1 AS ID, 'Gadget' AS CATEGORY UNION ALL SELECT 2, 'Widget' +expectations: +- type: equals + name: order count, revenue and average order value per category + format: rows + columns: + - name: CATEGORY + cast_type: TEXT + - name: order_count + cast_type: INTEGER + - name: total_revenue + cast_type: REAL + - name: avg_order_value + cast_type: REAL + rows: + - CATEGORY: Gadget + order_count: 2 + total_revenue: 40.0 + avg_order_value: 20.0 + - CATEGORY: Widget + order_count: 1 + total_revenue: 25.0 + avg_order_value: 25.0 +- type: empty + name: one row per category + sql: SELECT CATEGORY FROM TRANSFORMS.product_revenue_report GROUP BY CATEGORY HAVING COUNT(*) > 1 +serdes/meta: +- model: TransformTest diff --git a/src/validate-schema.test.ts b/src/validate-schema.test.ts index 65378f7..e10dd2d 100644 --- a/src/validate-schema.test.ts +++ b/src/validate-schema.test.ts @@ -136,6 +136,115 @@ describe("validateSchema", () => { }); }); + describe("transform tests", () => { + const example = yaml.load( + readFileSync( + join( + EXAMPLES, + "collections", + "transforms", + "native_transform", + "product_revenue_report__category_totals.yaml", + ), + "utf8", + ), + ) as Record; // The example transform test is a YAML map. + + const validateTransformTest = (transformTest: Record) => { + mkdirSync(join(workdir, "collections", "transforms"), { + recursive: true, + }); + writeFileSync( + join(workdir, "collections", "transforms", "test.yaml"), + yaml.dump(transformTest), + ); + return validateSchema({ folder: workdir }); + }; + + const [rowsInput, sqlInput] = example.inputs; + const [equals, empty] = example.expectations; + + it("accepts the example", () => { + expect(validateTransformTest(example).failed).toBe(0); + }); + + it.each([ + [ + "no transform", + (({ transform_id: _transformId, ...test }) => test)(example), + ], + [ + "an unknown input format", + { ...example, inputs: [{ ...sqlInput, format: "csv" }] }, + ], + [ + "a rows input without columns", + { + ...example, + inputs: [(({ columns: _columns, ...input }) => input)(rowsInput)], + }, + ], + [ + "a sql input with rows", + { ...example, inputs: [{ ...sqlInput, rows: [] }] }, + ], + [ + "a column without a cast type", + { + ...example, + inputs: [{ ...rowsInput, columns: [{ name: "ID" }] }], + }, + ], + [ + "a table with an unknown key", + { + ...example, + inputs: [{ ...sqlInput, table: { name: "ORDERS", db: "x" } }], + }, + ], + [ + "an unknown expectation type", + { ...example, expectations: [{ ...empty, type: "contains" }] }, + ], + [ + "an empty expectation with a format", + { ...example, expectations: [{ ...empty, format: "sql" }] }, + ], + [ + "an equals expectation without a format", + { + ...example, + expectations: [(({ format: _format, ...e }) => e)(equals)], + }, + ], + [ + "an expectation without a name", + { + ...example, + expectations: [(({ name: _name, ...e }) => e)(empty)], + }, + ], + ])("rejects a transform test with %s", (_, transformTest) => { + expect(validateTransformTest(transformTest).failed).toBe(1); + }); + + it("accepts an equals expectation over a SQL query", () => { + expect( + validateTransformTest({ + ...example, + expectations: [ + { + type: "equals", + name: "same as the query", + format: "sql", + sql: "SELECT 'Gadget' AS CATEGORY", + }, + ], + }).failed, + ).toBe(0); + }); + }); + describe("serdes/meta", () => { const collection = { name: "Reports", From 52a9f1fba5d8086023292a58d26a5f30f4852a0a Mon Sep 17 00:00:00 2001 From: Alexander Polyankin Date: Sat, 10 Oct 2026 10:56:14 -0400 Subject: [PATCH 2/4] Bump version to 1.6.0 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c3fbce4..4c684be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metabase/representations", - "version": "1.5.1", + "version": "1.6.0", "description": "Metabase representation format specification and schema validator", "license": "SEE LICENSE IN LICENSE.txt", "repository": { From 8f9b8e64c85849e9fe3ec761c5351076a8cd891d Mon Sep 17 00:00:00 2001 From: Alexander Polyankin Date: Sat, 10 Oct 2026 11:39:00 -0400 Subject: [PATCH 3/4] Build from a clean dist so packed schemas are current Co-Authored-By: Claude Opus 5.5 --- bin/cli.test.ts | 28 ++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/bin/cli.test.ts b/bin/cli.test.ts index 2d4ba2a..e4022d3 100644 --- a/bin/cli.test.ts +++ b/bin/cli.test.ts @@ -185,4 +185,32 @@ describe("cli", () => { } }); }); + + describe("build", () => { + it("bundles the current schemas when rebuilt over an existing dist", () => { + for (let i = 0; i < 2; i++) { + const build = Bun.spawnSync({ + cmd: ["bun", "run", "build"], + cwd: REPO_ROOT, + }); + expect(build.exitCode).toBe(0); + } + + const proc = Bun.spawnSync({ + cmd: [ + "node", + "dist/bin/cli.js", + "validate-schema", + "--folder", + "examples/v1", + ], + cwd: REPO_ROOT, + }); + expect(proc.stderr.toString()).toBe(""); + expect(proc.exitCode).toBe(0); + expect( + readdirSync(join(REPO_ROOT, "dist/core-spec/v1/schemas")).sort(), + ).toEqual(readdirSync(join(REPO_ROOT, "core-spec/v1/schemas")).sort()); + }, 120_000); + }); }); diff --git a/package.json b/package.json index 4c684be..4f179b8 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "representations": "dist/bin/cli.js" }, "scripts": { - "build": "tsc -p tsconfig.build.json && cp -r core-spec dist/core-spec", + "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r core-spec dist/core-spec", "type-check": "tsc --noEmit", "lint-eslint": "eslint --max-warnings 0 --report-unused-disable-directives bin src", "lint-format": "oxfmt --check bin src", From 336250506b4539a5aa73ee206dd137b76535c5ed Mon Sep 17 00:00:00 2001 From: Alexander Polyankin Date: Sat, 10 Oct 2026 14:18:33 -0400 Subject: [PATCH 4/4] Move the example query action to the data actions root, where Metabase now requires model-less actions Co-Authored-By: Claude Opus 5.5 --- core-spec/v1/spec.md | 7 +++---- .../{main/queries => data_actions}/apply_discount.yaml | 1 - src/validate-schema.test.ts | 4 +++- 3 files changed, 6 insertions(+), 6 deletions(-) rename examples/v1/collections/{main/queries => data_actions}/apply_discount.yaml (96%) diff --git a/core-spec/v1/spec.md b/core-spec/v1/spec.md index 1e694d4..1362eff 100644 --- a/core-spec/v1/spec.md +++ b/core-spec/v1/spec.md @@ -2338,7 +2338,7 @@ serdes/meta: ## Action -An action writes to a database. A **query** action runs parameterized native SQL; it lives in a collection like a card (`collection_id`), and its permissions follow that collection. An **implicit** action creates, updates, or deletes a row of a model card's table. Implicit actions, and attaching any action to a model (`model_id`), are **deprecated**: they remain only for actions made before actions had their own collection. Author query actions without a `model_id`. +An action writes to a database. A **query** action runs parameterized native SQL; without a model it lives in the data actions namespace (its root, exported under `collections/data_actions/`, or a data actions collection) or in a data app's collection, set by `collection_id`, and its permissions follow that collection. An **implicit** action creates, updates, or deletes a row of a model card's table. Implicit actions, and attaching any action to a model (`model_id`), are **deprecated**: they remain only for actions made before actions had their own collection. Author query actions without a `model_id`. Actions are exported with their collection, like cards: `collections/main/{collection path}/{slug}.yaml`, or `collections/main/{slug}.yaml` in the root collection. Older exports kept them under a top-level `actions/` directory, which import still reads. Exactly one of the nested `implicit` and `query` arrays holds the action's definition, the one its `type` names; the other is empty. @@ -2350,7 +2350,7 @@ Actions are exported with their collection, like cards: `collections/main/{colle | `entity_id` | string | Yes | NanoID identifier | | `type` | string | Yes | `"query"`, or `"implicit"` (deprecated) | | `serdes/meta` | array | Yes | Identity path with `model: Action` | -| `collection_id` | string | No | Collection FK (entity_id); `null` for the root collection. An action with a model is always in its model's collection | +| `collection_id` | string | No | Collection FK (entity_id); `null`/omitted for the data actions root. An action without a model goes only in the data actions root, a data actions collection, or a data app's collection; an action with a model is always in its model's collection | | `model_id` | string | No | **Deprecated.** Card FK (entity_id) of the model the action is attached to. Omit it, or leave it `null`, for a query action; an implicit action requires one | | `query` | array | No | `[{database_id, dataset_query}]` when `type` is `query`, otherwise `[]`. `database_id` is a Database FK; `dataset_query` is a [Native Query](#native-query) whose template tags the parameters target | | `implicit` | array | No | `[{kind}]` when `type` is `implicit`, otherwise `[]`. `kind`: `row/create`, `row/update`, `row/delete`, `bulk/create`, `bulk/update`, `bulk/delete` | @@ -2378,14 +2378,13 @@ The rest (`title`, `description`, `placeholder`, `order`, `required`, `inputType ### Example -**Query action** (`collections/main/queries/apply_discount.yaml`): +**Query action** (`collections/data_actions/apply_discount.yaml`, at the data actions root): ```yaml name: Apply discount entity_id: NJksetfXk2oh530o3RD_5 creator_id: admin@example.com type: query -collection_id: cOlQuErIeS0ExAmPlE2x1 implicit: [] query: - database_id: Sample Database diff --git a/examples/v1/collections/main/queries/apply_discount.yaml b/examples/v1/collections/data_actions/apply_discount.yaml similarity index 96% rename from examples/v1/collections/main/queries/apply_discount.yaml rename to examples/v1/collections/data_actions/apply_discount.yaml index c3da635..f04dfac 100644 --- a/examples/v1/collections/main/queries/apply_discount.yaml +++ b/examples/v1/collections/data_actions/apply_discount.yaml @@ -2,7 +2,6 @@ name: Apply discount entity_id: NJksetfXk2oh530o3RD_5 creator_id: admin@example.com type: query -collection_id: cOlQuErIeS0ExAmPlE2x1 implicit: [] query: - database_id: Sample Database diff --git a/src/validate-schema.test.ts b/src/validate-schema.test.ts index e10dd2d..0920561 100644 --- a/src/validate-schema.test.ts +++ b/src/validate-schema.test.ts @@ -77,7 +77,9 @@ describe("validateSchema", () => { const example = (file: string): Record => yaml.load( readFileSync( - join(EXAMPLES, "collections", "main", "queries", file), + file === "apply_discount.yaml" + ? join(EXAMPLES, "collections", "data_actions", file) + : join(EXAMPLES, "collections", "main", "queries", file), "utf8", ), ) as Record; // Example actions are YAML maps.