diff --git a/src/__tests__/full-repository.ts b/src/__tests__/full-repository.ts new file mode 100644 index 0000000..bf9702a --- /dev/null +++ b/src/__tests__/full-repository.ts @@ -0,0 +1,354 @@ +import * as semanticLayer from "../index.js"; + +const customersModel = semanticLayer + .model() + .withName("customers") + .fromTable("Customer") + .withDimension("customer_id", { + type: "number", + primaryKey: true, + sql: ({ model, sql }) => sql`${model.column("CustomerId")}`, + }) + .withDimension("first_name", { + type: "string", + sql: ({ model }) => model.column("FirstName"), + }) + .withDimension("last_name", { + type: "string", + sql: ({ model }) => model.column("LastName"), + }) + .withDimension("full_name", { + type: "string", + sql: ({ model, sql }) => + sql`${model.dimension("first_name")} || ' ' || ${model.dimension( + "last_name", + )}`, + }) + .withDimension("company", { + type: "string", + sql: ({ model }) => model.column("Company"), + }) + .withDimension("address", { + type: "string", + sql: ({ model }) => model.column("Address"), + }) + .withDimension("city", { + type: "string", + sql: ({ model }) => model.column("City"), + }) + .withDimension("state", { + type: "string", + sql: ({ model }) => model.column("State"), + }) + .withDimension("country", { + type: "string", + sql: ({ model }) => model.column("Country"), + }) + .withDimension("postal_code", { + type: "string", + sql: ({ model }) => model.column("PostalCode"), + }) + .withDimension("phone", { + type: "string", + sql: ({ model }) => model.column("Phone"), + }) + .withDimension("fax", { + type: "string", + sql: ({ model }) => model.column("Fax"), + }) + .withDimension("email", { + type: "string", + sql: ({ model }) => model.column("Email"), + }); + +const invoicesModel = semanticLayer + .model() + .withName("invoices") + .fromTable("Invoice") + .withDimension("invoice_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("InvoiceId"), + }) + .withDimension("customer_id", { + type: "number", + sql: ({ model }) => model.column("CustomerId"), + }) + .withDimension("invoice_date", { + type: "date", + sql: ({ model }) => model.column("InvoiceDate"), + }) + .withDimension("billing_address", { + type: "string", + sql: ({ model }) => model.column("BillingAddress"), + }) + .withDimension("billing_city", { + type: "string", + sql: ({ model }) => model.column("BillingCity"), + }) + .withDimension("billing_state", { + type: "string", + sql: ({ model }) => model.column("BillingState"), + }) + .withDimension("billing_country", { + type: "string", + sql: ({ model }) => model.column("BillingCountry"), + }) + .withDimension("billing_postal_code", { + type: "string", + sql: ({ model }) => model.column("BillingPostalCode"), + }) + .withMetric("total", { + type: "number", + description: "Invoice total.", + sql: ({ model, sql }) => sql`SUM(COALESCE(${model.column("Total")}, 0))`, + }); + +const invoiceLinesModel = semanticLayer + .model() + .withName("invoice_lines") + .fromTable("InvoiceLine") + .withDimension("invoice_line_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("InvoiceLineId"), + }) + .withDimension("invoice_id", { + type: "number", + sql: ({ model }) => model.column("InvoiceId"), + }) + .withDimension("track_id", { + type: "number", + sql: ({ model }) => model.column("TrackId"), + }) + .withMetric("quantity", { + type: "number", + description: "Sum of the track quantities across models.", + sql: ({ model, sql }) => sql`SUM(COALESCE(${model.column("Quantity")}, 0))`, + }) + .withMetric("unit_price", { + type: "number", + description: "Sum of the track unit prices across models.", + sql: ({ model, sql }) => + sql`SUM(COALESCE(${model.column("UnitPrice")}, 0))`, + }); + +const tracksModel = semanticLayer + .model() + .withName("tracks") + .fromTable("Track") + .withDimension("track_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("TrackId"), + }) + .withDimension("album_id", { + type: "number", + sql: ({ model }) => model.column("AlbumId"), + }) + .withDimension("media_type_id", { + type: "number", + sql: ({ model }) => model.column("MediaTypeId"), + }) + .withDimension("genre_id", { + type: "number", + sql: ({ model }) => model.column("GenreId"), + }) + .withDimension("name", { + type: "string", + sql: ({ model }) => model.column("Name"), + }) + .withDimension("composer", { + type: "string", + sql: ({ model }) => model.column("Composer"), + }) + .withDimension("milliseconds", { + type: "number", + sql: ({ model }) => model.column("Milliseconds"), + }) + .withDimension("bytes", { + type: "number", + sql: ({ model }) => model.column("Bytes"), + }) + .withMetric("unit_price", { + type: "number", + description: "Sum of the track unit prices across models.", + sql: ({ model, sql }) => + sql`SUM(COALESCE(${model.column("UnitPrice")}, 0))`, + format: (value) => `Price: $${value}`, + }); + +const albumsModel = semanticLayer + .model() + .withName("albums") + .fromTable("Album") + .withDimension("album_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("AlbumId"), + }) + .withDimension("artist_id", { + type: "number", + sql: ({ model }) => model.column("ArtistId"), + }) + .withDimension("title", { + type: "string", + sql: ({ model }) => model.column("Title"), + }); + +const artistModel = semanticLayer + .model() + .withName("artists") + .fromTable("Artist") + .withDimension("artist_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("ArtistId"), + }) + .withDimension("name", { + type: "string", + sql: ({ model }) => model.column("Name"), + format: (value) => `Artist: ${value}`, + }); + +const mediaTypeModel = semanticLayer + .model() + .withName("media_types") + .fromTable("MediaType") + .withDimension("media_type_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("MediaTypeId"), + }) + .withDimension("name", { + type: "string", + sql: ({ model }) => model.column("Name"), + }); + +const genreModel = semanticLayer + .model() + .withName("genres") + .fromTable("Genre") + .withDimension("name", { + type: "string", + sql: ({ model }) => model.column("Name"), + }) + .withDimension("genre_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("GenreId"), + }); + +const playlistModel = semanticLayer + .model() + .withName("playlists") + .fromTable("Playlist") + .withDimension("playlist_id", { + type: "number", + primaryKey: true, + sql: ({ model }) => model.column("PlaylistId"), + }) + .withDimension("name", { + type: "string", + sql: ({ model }) => model.column("Name"), + }); + +const playlistTrackModel = semanticLayer + .model() + .withName("playlist_tracks") + .fromTable("PlaylistTrack") + .withDimension("playlist_id", { + type: "number", + sql: ({ model }) => model.column("PlaylistId"), + }) + .withDimension("track_id", { + type: "number", + sql: ({ model }) => model.column("TrackId"), + }); + +export const repository = semanticLayer + .repository() + .withModel(customersModel) + .withModel(invoicesModel) + .withModel(invoiceLinesModel) + .withModel(tracksModel) + .withModel(albumsModel) + .withModel(artistModel) + .withModel(mediaTypeModel) + .withModel(genreModel) + .withModel(playlistModel) + .withModel(playlistTrackModel) + .joinOneToMany( + "customers", + "invoices", + ({ sql, models }) => + sql`${models.customers.dimension( + "customer_id", + )} = ${models.invoices.dimension("customer_id")}`, + ) + .joinOneToMany( + "invoices", + "invoice_lines", + ({ sql, models }) => + sql`${models.invoices.dimension( + "invoice_id", + )} = ${models.invoice_lines.dimension("invoice_id")}`, + ) + .joinManyToOne( + "invoice_lines", + "tracks", + ({ sql, models }) => + sql`${models.invoice_lines.dimension( + "track_id", + )} = ${models.tracks.dimension("track_id")}`, + ) + .joinOneToMany( + "albums", + "tracks", + ({ sql, models }) => + sql`${models.tracks.dimension("album_id")} = ${models.albums.dimension( + "album_id", + )}`, + ) + .joinManyToOne( + "albums", + "artists", + ({ sql, models }) => + sql`${models.albums.dimension("artist_id")} = ${models.artists.dimension( + "artist_id", + )}`, + ) + .joinOneToOne( + "tracks", + "media_types", + ({ sql, models }) => + sql`${models.tracks.dimension( + "media_type_id", + )} = ${models.media_types.dimension("media_type_id")}`, + ) + .joinOneToOne( + "tracks", + "genres", + ({ sql, models }) => + sql`${models.tracks.dimension("genre_id")} = ${models.genres.dimension( + "genre_id", + )}`, + ) + .joinManyToMany( + "playlists", + "playlist_tracks", + ({ sql, models }) => + sql`${models.playlists.dimension( + "playlist_id", + )} = ${models.playlist_tracks.dimension("playlist_id")}`, + ) + .joinManyToMany( + "playlist_tracks", + "tracks", + ({ sql, models }) => + sql`${models.playlist_tracks.dimension( + "track_id", + )} = ${models.tracks.dimension("track_id")}`, + ); + +export const queryBuilder = repository.build("postgresql"); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index be9bc14..2514666 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert/strict"; import * as semanticLayer from "../index.js"; +import * as fullRepository from "./full-repository.js"; import { beforeAll, describe, it } from "vitest"; import { InferSqlQueryResultType, QueryBuilderQuery } from "../index.js"; @@ -1690,360 +1691,136 @@ describe("semantic layer", async () => { }); describe("full repository", async () => { - const customersModel = semanticLayer - .model() - .withName("customers") - .fromTable("Customer") - .withDimension("customer_id", { - type: "number", - primaryKey: true, - sql: ({ model, sql }) => sql`${model.column("CustomerId")}`, - }) - .withDimension("first_name", { - type: "string", - sql: ({ model }) => model.column("FirstName"), - }) - .withDimension("last_name", { - type: "string", - sql: ({ model }) => model.column("LastName"), - }) - .withDimension("full_name", { - type: "string", - sql: ({ model, sql }) => - sql`${model.dimension("first_name")} || ' ' || ${model.dimension( - "last_name", - )}`, - }) - .withDimension("company", { - type: "string", - sql: ({ model }) => model.column("Company"), - }) - .withDimension("address", { - type: "string", - sql: ({ model }) => model.column("Address"), - }) - .withDimension("city", { - type: "string", - sql: ({ model }) => model.column("City"), - }) - .withDimension("state", { - type: "string", - sql: ({ model }) => model.column("State"), - }) - .withDimension("country", { - type: "string", - sql: ({ model }) => model.column("Country"), - }) - .withDimension("postal_code", { - type: "string", - sql: ({ model }) => model.column("PostalCode"), - }) - .withDimension("phone", { - type: "string", - sql: ({ model }) => model.column("Phone"), - }) - .withDimension("fax", { - type: "string", - sql: ({ model }) => model.column("Fax"), - }) - .withDimension("email", { - type: "string", - sql: ({ model }) => model.column("Email"), - }); - - const invoicesModel = semanticLayer - .model() - .withName("invoices") - .fromTable("Invoice") - .withDimension("invoice_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("InvoiceId"), - }) - .withDimension("customer_id", { - type: "number", - sql: ({ model }) => model.column("CustomerId"), - }) - .withDimension("invoice_date", { - type: "date", - sql: ({ model }) => model.column("InvoiceDate"), - }) - .withDimension("billing_address", { - type: "string", - sql: ({ model }) => model.column("BillingAddress"), - }) - .withDimension("billing_city", { - type: "string", - sql: ({ model }) => model.column("BillingCity"), - }) - .withDimension("billing_state", { - type: "string", - sql: ({ model }) => model.column("BillingState"), - }) - .withDimension("billing_country", { - type: "string", - sql: ({ model }) => model.column("BillingCountry"), - }) - .withDimension("billing_postal_code", { - type: "string", - sql: ({ model }) => model.column("BillingPostalCode"), - }) - .withMetric("total", { - type: "number", - description: "Invoice total.", - sql: ({ model, sql }) => - sql`SUM(COALESCE, ${model.column("Total")}, 0))`, - }); + const { queryBuilder } = fullRepository; - const invoiceLinesModel = semanticLayer - .model() - .withName("invoice_lines") - .fromTable("InvoiceLine") - .withDimension("invoice_line_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("InvoiceLineId"), - }) - .withDimension("invoice_id", { - type: "number", - sql: ({ model }) => model.column("InvoiceId"), - }) - .withDimension("track_id", { - type: "number", - sql: ({ model }) => model.column("TrackId"), - }) - .withMetric("quantity", { - type: "number", - description: "Sum of the track quantities across models.", - sql: ({ model, sql }) => - sql`SUM(COALESCE(${model.column("Quantity")}, 0))`, - }) - .withMetric("unit_price", { - type: "number", - description: "Sum of the track unit prices across models.", - sql: ({ model, sql }) => - sql`SUM(COALESCE(${model.column("UnitPrice")}, 0))`, - }); - - const tracksModel = semanticLayer - .model() - .withName("tracks") - .fromTable("Track") - .withDimension("track_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("TrackId"), - }) - .withDimension("album_id", { - type: "number", - sql: ({ model }) => model.column("AlbumId"), - }) - .withDimension("media_type_id", { - type: "number", - sql: ({ model }) => model.column("MediaTypeId"), - }) - .withDimension("genre_id", { - type: "number", - sql: ({ model }) => model.column("GenreId"), - }) - .withDimension("name", { - type: "string", - sql: ({ model }) => model.column("Name"), - }) - .withDimension("composer", { - type: "string", - sql: ({ model }) => model.column("Composer"), - }) - .withDimension("milliseconds", { - type: "number", - sql: ({ model }) => model.column("Milliseconds"), - }) - .withDimension("bytes", { - type: "number", - sql: ({ model }) => model.column("Bytes"), - }) - .withMetric("unit_price", { - type: "number", - description: "Sum of the track unit prices across models.", - sql: ({ model, sql }) => - sql`SUM(COALESCE(${model.column("UnitPrice")}, 0))`, - format: (value) => `Price: $${value}`, - }); - - const albumsModel = semanticLayer - .model() - .withName("albums") - .fromTable("Album") - .withDimension("album_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("AlbumId"), - }) - .withDimension("artist_id", { - type: "number", - sql: ({ model }) => model.column("ArtistId"), - }) - .withDimension("title", { - type: "string", - sql: ({ model }) => model.column("Title"), - }); - - const artistModel = semanticLayer - .model() - .withName("artists") - .fromTable("Artist") - .withDimension("artist_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("ArtistId"), - }) - .withDimension("name", { - type: "string", - sql: ({ model }) => model.column("Name"), - format: (value) => `Artist: ${value}`, + it("should validate filters so or/and connectives include filters with members of the same type", async () => { + const result1 = queryBuilder.querySchema.safeParse({ + members: ["invoice_lines.unit_price"], + filters: [ + { + operator: "or", + filters: [ + { + operator: "equals", + member: "customers.customer_id", + value: [1], + }, + { + operator: "lt", + member: "invoice_lines.unit_price", + value: [1], + }, + ], + }, + ], }); - const mediaTypeModel = semanticLayer - .model() - .withName("media_types") - .fromTable("MediaType") - .withDimension("media_type_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("MediaTypeId"), - }) - .withDimension("name", { - type: "string", - sql: ({ model }) => model.column("Name"), - }); + assert.ok(!result1.success); + assert.deepEqual(result1.error.issues, [ + { + code: "custom", + message: + "All and/or connectives must include filters with members of the same type (dimension or metric)", + path: ["filters", 0, "filters"], + }, + ]); - const genreModel = semanticLayer - .model() - .withName("genres") - .fromTable("Genre") - .withDimension("name", { - type: "string", - sql: ({ model }) => model.column("Name"), - }) - .withDimension("genre_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("GenreId"), + const result2 = queryBuilder.querySchema.safeParse({ + members: ["invoice_lines.unit_price"], + filters: [ + { + operator: "or", + filters: [ + { + operator: "equals", + member: "customers.customer_id", + value: [1], + }, + { + operator: "and", + filters: [ + { + operator: "or", + filters: [ + { + operator: "lt", + member: "invoice_lines.unit_price", + value: [2], + }, + ], + }, + ], + }, + ], + }, + ], }); - const playlistModel = semanticLayer - .model() - .withName("playlists") - .fromTable("Playlist") - .withDimension("playlist_id", { - type: "number", - primaryKey: true, - sql: ({ model }) => model.column("PlaylistId"), - }) - .withDimension("name", { - type: "string", - sql: ({ model }) => model.column("Name"), - }); + assert.ok(!result2.success); + assert.deepEqual(result2.error.issues, [ + { + code: "custom", + message: + "All and/or connectives must include filters with members of the same type (dimension or metric)", + path: ["filters", 0, "filters"], + }, + ]); - const playlistTrackModel = semanticLayer - .model() - .withName("playlist_tracks") - .fromTable("PlaylistTrack") - .withDimension("playlist_id", { - type: "number", - sql: ({ model }) => model.column("PlaylistId"), - }) - .withDimension("track_id", { - type: "number", - sql: ({ model }) => model.column("TrackId"), + const result3 = queryBuilder.querySchema.safeParse({ + members: ["invoice_lines.unit_price"], + filters: [ + { + operator: "or", + filters: [ + { + operator: "and", + filters: [ + { + operator: "or", + filters: [ + { + operator: "equals", + member: "customers.customer_id", + value: [1], + }, + { + operator: "lt", + member: "invoice_lines.unit_price", + value: [2], + }, + ], + }, + ], + }, + ], + }, + ], }); - const repository = semanticLayer - .repository() - .withModel(customersModel) - .withModel(invoicesModel) - .withModel(invoiceLinesModel) - .withModel(tracksModel) - .withModel(albumsModel) - .withModel(artistModel) - .withModel(mediaTypeModel) - .withModel(genreModel) - .withModel(playlistModel) - .withModel(playlistTrackModel) - .joinOneToMany( - "customers", - "invoices", - ({ sql, models }) => - sql`${models.customers.dimension( - "customer_id", - )} = ${models.invoices.dimension("customer_id")}`, - ) - .joinOneToMany( - "invoices", - "invoice_lines", - ({ sql, models }) => - sql`${models.invoices.dimension( - "invoice_id", - )} = ${models.invoice_lines.dimension("invoice_id")}`, - ) - .joinManyToOne( - "invoice_lines", - "tracks", - ({ sql, models }) => - sql`${models.invoice_lines.dimension( - "track_id", - )} = ${models.tracks.dimension("track_id")}`, - ) - .joinOneToMany( - "albums", - "tracks", - ({ sql, models }) => - sql`${models.tracks.dimension( - "album_id", - )} = ${models.albums.dimension("album_id")}`, - ) - .joinManyToOne( - "albums", - "artists", - ({ sql, models }) => - sql`${models.albums.dimension( - "artist_id", - )} = ${models.artists.dimension("artist_id")}`, - ) - .joinOneToOne( - "tracks", - "media_types", - ({ sql, models }) => - sql`${models.tracks.dimension( - "media_type_id", - )} = ${models.media_types.dimension("media_type_id")}`, - ) - .joinOneToOne( - "tracks", - "genres", - ({ sql, models }) => - sql`${models.tracks.dimension( - "genre_id", - )} = ${models.genres.dimension("genre_id")}`, - ) - .joinManyToMany( - "playlists", - "playlist_tracks", - ({ sql, models }) => - sql`${models.playlists.dimension( - "playlist_id", - )} = ${models.playlist_tracks.dimension("playlist_id")}`, - ) - .joinManyToMany( - "playlist_tracks", - "tracks", - ({ sql, models }) => - sql`${models.playlist_tracks.dimension( - "track_id", - )} = ${models.tracks.dimension("track_id")}`, - ); + assert.ok(!result3.success); - const queryBuilder = repository.build("postgresql"); + // In this case all parent connective filters are automatically invalid because a child connective filter is invalid + assert.deepEqual(result3.error.issues, [ + { + code: "custom", + message: + "All and/or connectives must include filters with members of the same type (dimension or metric)", + path: ["filters", 0, "filters", 0, "filters", 0, "filters"], + }, + { + code: "custom", + message: + "All and/or connectives must include filters with members of the same type (dimension or metric)", + path: ["filters", 0, "filters", 0, "filters"], + }, + { + code: "custom", + message: + "All and/or connectives must include filters with members of the same type (dimension or metric)", + path: ["filters", 0, "filters"], + }, + ]); + }); it("should return distinct results for dimension only query", async () => { const query = queryBuilder.buildQuery({ diff --git a/src/__tests__/query-builder/process-query-and-expand-to-segments.test.ts b/src/__tests__/query-builder/process-query-and-expand-to-segments.test.ts new file mode 100644 index 0000000..fdcb1c9 --- /dev/null +++ b/src/__tests__/query-builder/process-query-and-expand-to-segments.test.ts @@ -0,0 +1,135 @@ +import * as semanticLayer from "../../index.js"; +import * as fullRepository from "../full-repository.js"; + +import { assert, it } from "vitest"; + +import { processQueryAndExpandToSegments } from "../../lib/query-builder/process-query-and-expand-to-segments.js"; + +it("can process query and expand to segments", () => { + const { queryBuilder } = fullRepository; + const query: semanticLayer.Query = { + dimensions: ["artists.name"], + metrics: ["tracks.unit_price", "invoices.total"], + filters: [ + { + operator: "equals", + member: "genres.name", + value: ["Rock"], + }, + { operator: "gt", member: "invoices.total", value: [100] }, + ], + order: [{ member: "artists.name", direction: "asc" }], + }; + + const processed = processQueryAndExpandToSegments( + queryBuilder.repository, + query, + ); + + assert.deepEqual(processed, { + query: { + dimensions: ["artists.name"], + metrics: ["tracks.unit_price", "invoices.total"], + filters: [ + { operator: "equals", member: "genres.name", value: ["Rock"] }, + { + member: "invoices.total", + operator: "gt", + value: [100], + }, + ], + order: [{ member: "artists.name", direction: "asc" }], + }, + referencedModels: { + all: ["artists", "tracks", "invoices", "genres"], + dimensions: ["artists"], + metrics: ["tracks", "invoices"], + }, + segments: [ + { + query: { + dimensions: ["artists.name"], + metrics: ["tracks.unit_price"], + filters: [ + { operator: "equals", member: "genres.name", value: ["Rock"] }, + { + member: "invoices.total", + operator: "gt", + value: [100], + }, + ], + }, + projectedQuery: { + dimensions: ["artists.name"], + metrics: ["tracks.unit_price"], + filters: [ + { operator: "equals", member: "genres.name", value: ["Rock"] }, + { + member: "invoices.total", + operator: "gt", + value: [100], + }, + ], + }, + referencedModels: { + all: ["artists", "tracks", "invoices", "genres"], + dimensions: ["artists"], + metrics: ["tracks"], + }, + modelQueries: { + artists: { + dimensions: new Set(["artists.name"]), + metrics: new Set(), + }, + tracks: { + dimensions: new Set(), + metrics: new Set(["tracks.unit_price"]), + }, + }, + metricModel: "tracks", + }, + { + query: { + dimensions: ["artists.name"], + metrics: ["invoices.total"], + filters: [ + { operator: "equals", member: "genres.name", value: ["Rock"] }, + { + member: "invoices.total", + operator: "gt", + value: [100], + }, + ], + }, + projectedQuery: { + dimensions: ["artists.name"], + metrics: ["invoices.total"], + filters: [ + { operator: "equals", member: "genres.name", value: ["Rock"] }, + { + member: "invoices.total", + operator: "gt", + value: [100], + }, + ], + }, + referencedModels: { + all: ["artists", "tracks", "invoices", "genres"], + dimensions: ["artists"], + metrics: ["invoices"], + }, + modelQueries: { + artists: { + dimensions: new Set(["artists.name"]), + metrics: new Set(), + }, + invoices: { + dimensions: new Set(), + metrics: new Set(["invoices.total"]), + }, + }, + metricModel: "invoices", + }, + ], + }); +}); diff --git a/src/lib/query-schema.ts b/src/lib/query-schema.ts index 9c07121..c3141c9 100644 --- a/src/lib/query-schema.ts +++ b/src/lib/query-schema.ts @@ -3,6 +3,10 @@ import { AnyZodObject, z } from "zod"; import { AnyQueryBuilder } from "./query-builder.js"; import { AnyQueryFilter } from "./types.js"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function buildQuerySchema(queryBuilder: AnyQueryBuilder) { const dimensionPaths = queryBuilder.repository .getDimensions() @@ -10,6 +14,18 @@ export function buildQuerySchema(queryBuilder: AnyQueryBuilder) { const metricPaths = queryBuilder.repository .getMetrics() .map((m) => m.getPath()); + + const memberToTypeIndex = { + ...dimensionPaths.reduce>((acc, path) => { + acc[path] = "dimension"; + return acc; + }, {}), + ...metricPaths.reduce>((acc, path) => { + acc[path] = "metric"; + return acc; + }, {}), + }; + const memberPaths = [...dimensionPaths, ...metricPaths]; const registeredFilterFragmentBuildersSchemas = queryBuilder.repository @@ -33,19 +49,69 @@ export function buildQuerySchema(queryBuilder: AnyQueryBuilder) { : mergedFilter; }); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Essential complexity for validating filters. We need to validate that all and/or connectives have filters with members of the same type (dimension or metric), and we need to do this recursively. Using a queue to avoid stack overflows (although it's not a big chance that we'll have more than a few levels of nesting). + const getConnectiveFilterMemberTypes = (filters: AnyQueryFilter[]) => { + const connectiveFiltersMemberTypes: Set<"metric" | "dimension"> = new Set(); + const filtersQueue = [...filters]; + + while (filtersQueue.length > 0) { + const filter = filtersQueue.shift(); + // We don't have any type safety here because at this moment we don't know which filters are registered (although we should in practice have two types of structures - filters which should all have format of {operator: string, member: string, ...} or connectives which should have a format of {operator: "and" | "or", filters: [...]}), so we do some extra checks // + if (isRecord(filter)) { + if ( + filter.operator === "and" || + (filter.operator === "or" && Array.isArray(filter.filters)) + ) { + const subFilters = filter.filters as AnyQueryFilter[]; + filtersQueue.push(...subFilters); + } else { + const member = + typeof filter.member === "string" ? filter.member : null; + if (member) { + const memberType = memberToTypeIndex[member]; + memberType && connectiveFiltersMemberTypes.add(memberType); + } + } + } + } + + return connectiveFiltersMemberTypes; + }; + + function validateConnectiveFiltersMemberTypes(filters: AnyQueryFilter[]) { + return getConnectiveFilterMemberTypes(filters).size < 2; + } + + const invalidConnectedFiltersMessage = + "All and/or connectives must include filters with members of the same type (dimension or metric)"; + const filters: z.ZodType = z.array( z .discriminatedUnion("operator", [ z .object({ operator: z.literal("and"), - filters: z.lazy(() => filters), + filters: z.lazy(() => + filters.refine( + (filters) => validateConnectiveFiltersMemberTypes(filters), + { + message: invalidConnectedFiltersMessage, + }, + ), + ), }) .describe("AND connective for filters"), z .object({ operator: z.literal("or"), - filters: z.lazy(() => filters), + filters: z.lazy(() => + filters.refine( + (filters) => validateConnectiveFiltersMemberTypes(filters), + { + message: invalidConnectedFiltersMessage, + }, + ), + ), }) .describe("OR connective for filters"), ...(registeredFilterFragmentBuildersSchemas as z.ZodDiscriminatedUnionOption<"operator">[]),