diff --git a/README.md b/README.md index 82f9669..918fd87 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ This library allows you to define models and their respective fields, including **Defining a Model:** ```typescript -const customersModel = C.model("customers") +import * as semanticLayer from "@verybigthings/semantic-layer"; + +const customersModel = semanticLayer + .model("customers") .fromTable("Customer") .withDimension("customer_id", { type: "number", @@ -49,7 +52,8 @@ const customersModel = C.model("customers") sql: ({ model }) => model.column("LastName"), }); -const invoicesModel = C.model("invoices") +const invoicesModel = semanticLayer + .model("invoices") .fromTable("Invoice") .withDimension("invoice_id", { type: "number", @@ -57,11 +61,14 @@ const invoicesModel = C.model("invoices") sql: ({ model, sql }) => sql`${model.column("InvoiceId")}`, }) .withMetric("total", { - type: "sum", + // node-postgres returns string types for big integers + type: "string", + aggregateWith: "sum", sql: ({ model }) => model.column("Total"), }); -const invoiceLinesModel = C.model("invoice_lines") +const invoiceLinesModel = semanticLayer + .model("invoice_lines") .fromTable("InvoiceLine") .withDimension("invoice_line_id", { type: "number", @@ -77,19 +84,25 @@ const invoiceLinesModel = C.model("invoice_lines") sql: ({ model }) => model.column("TrackId"), }) .withMetric("quantity", { - type: "sum", + // node-postgres returns string types for big integers + type: "string", + aggregateWith: "sum", sql: ({ model }) => model.column("Quantity"), }) .withMetric("total_unit_price", { - type: "sum", + // node-postgres returns string types for big integers + + type: "string", + aggregateWith: "sum" sql: ({ model }) => model.column("UnitPrice"), }); ``` -**Defining a Database and joining models:** +**Defining a Repository and joining models:** ```typescript -const db = C.database() +const repository = semanticLayer + .repository() .withModel(customersModel) .withModel(invoicesModel) .withModel(invoiceLinesModel) @@ -113,7 +126,7 @@ Leverage the library's querying capabilities to fetch dimensions and metrics, ap ```typescript // Dimension and metric query -const query = db.query({ +const query = repository.query({ dimensions: ["customers.customer_id"], metrics: ["invoices.total"], order: { "customers.customer_id": "asc" }, @@ -121,7 +134,7 @@ const query = db.query({ }); // Metric query with filters -const query = db.query({ +const query = repository.query({ metrics: ["invoices.total", "invoice_lines.quantity"], filters: [ { operator: "equals", member: "customers.customer_id", value: [1] }, @@ -129,7 +142,7 @@ const query = db.query({ }); // Dimension query with filters -const query = db.query({ +const query = repository.query({ dimensions: ["customers.first_name", "customers.last_name"], filters: [ { operator: "equals", member: "customers.customer_id", value: [1] }, @@ -137,7 +150,7 @@ const query = db.query({ }); // Filtering and sorting -const query = db.query({ +const query = repository.query({ dimensions: ["customers.first_name"], metrics: ["invoices.total"], filters: [{ operator: "gt", member: "invoices.total", value: [100] }], diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 521cc85..e4663bc 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -10,12 +10,13 @@ import { import fs from "node:fs/promises"; import path from "node:path"; import pg from "pg"; +import { InferSqlQueryResultType } from "../index.js"; //import { format as sqlFormat } from "sql-formatter"; const __dirname = path.dirname(new URL(import.meta.url).pathname); -/*const query = db.query({ +/*const query = built.query({ dimensions: [ "customers.customer_id", //'invoice_lines.invoice_line_id', @@ -128,7 +129,8 @@ await describe("semantic layer", async () => { sql: ({ model }) => model.column("InvoiceDate"), }) .withMetric("total", { - type: "sum", + type: "string", + aggregateWith: "sum", sql: ({ model }) => model.column("Total"), }); @@ -148,11 +150,13 @@ await describe("semantic layer", async () => { sql: ({ model }) => model.column("TrackId"), }) .withMetric("quantity", { - type: "sum", + type: "string", + aggregateWith: "sum", sql: ({ model }) => model.column("Quantity"), }) .withMetric("total_unit_price", { - type: "sum", + type: "string", + aggregateWith: "sum", sql: ({ model }) => model.column("UnitPrice"), }); @@ -184,7 +188,7 @@ await describe("semantic layer", async () => { sql: ({ model }) => model.column("Title"), }); - const db = C.database() + const repository = C.repository() .withModel(customersModel) .withModel(invoicesModel) .withModel(invoiceLinesModel) @@ -215,15 +219,20 @@ await describe("semantic layer", async () => { sql`${dimensions.tracks.album_id} = ${dimensions.albums.album_id}`, ); + const queryBuilder = repository.build(); + await it("can query one dimension and one metric", async () => { - const query = db.query({ + const query = queryBuilder.build({ dimensions: ["customers.customer_id"], metrics: ["invoices.total"], order: { "customers.customer_id": "asc" }, limit: 10, }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [ { customers___customer_id: 1, invoices___total: "39.62" }, @@ -240,14 +249,17 @@ await describe("semantic layer", async () => { }); await it("can query one dimension and multiple metrics", async () => { - const query = db.query({ + const query = queryBuilder.build({ dimensions: ["customers.customer_id"], metrics: ["invoices.total", "invoice_lines.total_unit_price"], order: { "customers.customer_id": "asc" }, limit: 10, }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [ { @@ -304,7 +316,7 @@ await describe("semantic layer", async () => { }); await it("can query one dimension and metric and filter by a different metric", async () => { - const query = db.query({ + const query = queryBuilder.build({ dimensions: ["customers.customer_id"], metrics: ["invoices.total"], order: { "customers.customer_id": "asc" }, @@ -318,7 +330,10 @@ await describe("semantic layer", async () => { ], }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [ { customers___customer_id: 2, invoices___total: "37.62" }, @@ -335,27 +350,33 @@ await describe("semantic layer", async () => { }); await it("can query a metric and filter by a dimension", async () => { - const query = db.query({ + const query = queryBuilder.build({ metrics: ["invoices.total"], filters: [ { operator: "equals", member: "customers.customer_id", value: [1] }, ], }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [{ invoices___total: "39.62" }]); }); await it("can query multiple metrics and filter by a dimension", async () => { - const query = db.query({ + const query = queryBuilder.build({ metrics: ["invoices.total", "invoice_lines.quantity"], filters: [ { operator: "equals", member: "customers.customer_id", value: [1] }, ], }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [ { invoices___total: "39.62", invoice_lines___quantity: "38" }, @@ -363,14 +384,17 @@ await describe("semantic layer", async () => { }); await it("can query dimensions only", async () => { - const query = db.query({ + const query = queryBuilder.build({ dimensions: ["customers.customer_id", "albums.title"], filters: [ { operator: "equals", member: "customers.customer_id", value: [1] }, ], }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [ { customers___customer_id: 1, albums___title: "Ac�stico MTV" }, @@ -441,11 +465,12 @@ await describe("semantic layer", async () => { sql: ({ model }) => model.column("CustomerId"), }) .withMetric("total", { - type: "sum", + type: "string", + aggregateWith: "sum", sql: ({ model }) => model.column("Total"), }); - const db = C.database() + const repository = C.repository() .withModel(customersModel) .withModel(invoicesModel) .joinOneToMany( @@ -455,15 +480,20 @@ await describe("semantic layer", async () => { sql`${dimensions.customers.customer_id} = ${dimensions.invoices.customer_id}`, ); + const queryBuilder = repository.build(); + await it("can query one dimension and multiple metrics", async () => { - const query = db.query({ + const query = queryBuilder.build({ dimensions: ["customers.customer_id"], metrics: ["invoices.total"], order: { "customers.customer_id": "asc" }, limit: 10, }); - const result = await client.query(query.sql, query.bindings); + const result = await client.query>( + query.sql, + query.bindings, + ); assert.deepEqual(result.rows, [ { diff --git a/src/lib/builder/join.ts b/src/lib/builder/join.ts new file mode 100644 index 0000000..5d9a787 --- /dev/null +++ b/src/lib/builder/join.ts @@ -0,0 +1,93 @@ +import type { BaseDialect } from "../dialect/base.js"; +import type { Repository } from "./repository.js"; + +export class JoinDimensionRef { + constructor( + private readonly model: N, + private readonly dimension: DN, + ) {} + render(repository: Repository, dialect: BaseDialect) { + return repository + .getModel(this.model) + .getDimension(this.dimension) + .getSql(dialect); + } +} +export class JoinOnDef { + constructor( + private readonly strings: string[], + private readonly values: unknown[], + ) {} + render(repository: Repository, dialect: BaseDialect) { + const sql: string[] = []; + const bindings: unknown[] = []; + for (let i = 0; i < this.strings.length; i++) { + sql.push(this.strings[i]!); + if (this.values[i]) { + const value = this.values[i]; + if (value instanceof JoinDimensionRef) { + const result = value.render(repository, dialect); + sql.push(result.sql); + bindings.push(...result.bindings); + } else { + sql.push("?"); + bindings.push(value); + } + } + } + return { + sql: sql.join(""), + bindings, + }; + } +} + +export interface Join { + left: string; + right: string; + joinOnDef: JoinOnDef; + reversed: boolean; + type: "oneToOne" | "oneToMany" | "manyToOne" | "manyToMany"; +} + +export type JoinFn< + DN extends string, + N1 extends string, + N2 extends string, +> = (args: { + sql: (strings: TemplateStringsArray, ...values: unknown[]) => JoinOnDef; + dimensions: JoinDimensions; +}) => JoinOnDef; + +export type ModelDimensionsWithoutModelPrefix< + N extends string, + DN extends string, +> = DN extends `${N}.${infer D}` ? D : never; + +export type JoinDimensions< + DN extends string, + N1 extends string, + N2 extends string, +> = { + [TK in N1]: { + [DK in ModelDimensionsWithoutModelPrefix]: JoinDimensionRef; + }; +} & { + [TK in N2]: { + [DK in ModelDimensionsWithoutModelPrefix]: JoinDimensionRef; + }; +}; + +export const JOIN_WEIGHTS: Record = { + oneToOne: 1, + oneToMany: 3, + manyToOne: 2, + manyToMany: 4, +}; + +export const REVERSED_JOIN: Record = { + oneToOne: "oneToOne", + oneToMany: "manyToOne", + manyToOne: "oneToMany", + manyToMany: "manyToMany", +}; diff --git a/src/lib/builder/model.ts b/src/lib/builder/model.ts index b236f95..13bb87e 100644 --- a/src/lib/builder/model.ts +++ b/src/lib/builder/model.ts @@ -1,6 +1,8 @@ import { Granularity, GranularityByDimensionType, + MemberNameToType, + MemberType, SqlWithBindings, } from "../../types.js"; @@ -69,15 +71,13 @@ export class SqlWithRefs extends Ref { } } -export type SqlDef = ColumnRef | SqlWithRefs; - export type SqlFn = (args: { model: { column: (name: string) => ColumnRef; dimension: (name: DN) => DimensionRef; }; sql: (strings: TemplateStringsArray, ...values: unknown[]) => SqlWithRefs; -}) => SqlDef; +}) => Ref; function typeHasGranularity( type: string, @@ -85,30 +85,25 @@ function typeHasGranularity( return type in GranularityByDimensionType; } -export type WithGranularityDimensionNames< +export type WithGranularityDimensions< N extends string, T extends string, > = T extends keyof GranularityByDimensionType - ? N | `${N}.${GranularityByDimensionType[T][number]}` - : N; - -export type DimensionType = - | "string" - | "number" - | "date" - | "time" - | "datetime" - | "boolean"; + ? { [k in N]: T } & { + [k in `${N}.${GranularityByDimensionType[T][number]}`]: number; + } + : { [k in N]: T }; export interface DimensionProps { - type: DimensionType; + type: MemberType; sql?: SqlFn; primaryKey?: boolean; } export type MetricType = "count" | "sum" | "avg" | "min" | "max"; export interface MetricProps { - type: MetricType; + type: MemberType; + aggregateWith: MetricType; sql?: SqlFn; } @@ -208,7 +203,7 @@ export class Metric extends Member { getAggregateSql(dialect: BaseDialect, modelAlias?: string) { const { sql, bindings } = this.getSql(dialect, modelAlias); return { - sql: `${this.props.type.toUpperCase()}(${sql})`, + sql: `${this.props.aggregateWith.toUpperCase()}(${sql})`, bindings, }; } @@ -228,8 +223,8 @@ export type ModelConfig = export class Model< N extends string, - DN extends string = never, - MN extends string = never, + D extends MemberNameToType = MemberNameToType, + M extends MemberNameToType = MemberNameToType, > { public readonly dimensions: Record = {}; public readonly metrics: Record = {}; @@ -240,10 +235,13 @@ export class Model< ) { this.name = name; } - withDimension>( + withDimension< + DN1 extends string, + DP extends DimensionProps, + >( name: DN1, dimension: DP, - ): Model, MN> { + ): Model, M> { this.dimensions[name] = new Dimension(this, name, dimension); if (typeHasGranularity(dimension.type)) { const granularity = GranularityByDimensionType[dimension.type]; @@ -258,21 +256,21 @@ export class Model< } return this; } - withMetric( + withMetric>( name: MN1, - metric: MetricProps, - ): Model { + metric: MP, + ): Model { this.metrics[name] = new Metric(this, name, metric); return this; } - getMetric(name: MN) { + getMetric(name: string & keyof M) { const metric = this.metrics[name]; if (!metric) { throw new Error(`Metric ${name} not found in model ${this.name}`); } return metric; } - getDimension(name: DN) { + getDimension(name: string & keyof D) { const dimension = this.dimensions[name]; if (!dimension) { throw new Error(`Dimension ${name} not found in model ${this.name}`); @@ -282,7 +280,7 @@ export class Model< getPrimaryKeyDimensions() { return Object.values(this.dimensions).filter((d) => d.props.primaryKey); } - getMember(name: DN | MN) { + getMember(name: string & (keyof D | keyof M)) { const member = this.dimensions[name] || this.metrics[name]; if (!member) { throw new Error(`Member ${name} not found in model ${this.name}`); diff --git a/src/lib/builder/database.ts b/src/lib/builder/repository.ts similarity index 60% rename from src/lib/builder/database.ts rename to src/lib/builder/repository.ts index c393c6e..a0c1e42 100644 --- a/src/lib/builder/database.ts +++ b/src/lib/builder/repository.ts @@ -1,126 +1,43 @@ -import * as queryBuilder from "../query/builder.js"; - -import { FilterType, Query } from "../../types.js"; +import { FilterType, MemberNameToType } from "../../types.js"; import { AnyFilterFragmentBuilderRegistry, GetFilterFragmentBuilderRegistryPayload, defaultFilterFragmentBuilderRegistry, } from "../query/filter-builder.js"; +import { + JOIN_WEIGHTS, + Join, + JoinDimensionRef, + JoinDimensions, + JoinFn, + JoinOnDef, + REVERSED_JOIN, +} from "./join.js"; import { AnyModel, Model } from "./model.js"; import graphlib from "@dagrejs/graphlib"; import invariant from "tiny-invariant"; import { BaseDialect } from "../dialect/base.js"; +import { QueryBuilder } from "../query/builder.js"; -// biome-ignore lint/suspicious/noExplicitAny: +// biome-ignore lint/suspicious/noExplicitAny: Using any for inference export type ModelN = T extends Model ? N : never; -// biome-ignore lint/suspicious/noExplicitAny: -export type ModelDN = T extends Model - ? `${N}.${DN}` +// biome-ignore lint/suspicious/noExplicitAny: Using any for inference +export type ModelD = T extends Model + ? { [K in string & keyof D as `${N}.${K}`]: D[K] } : never; -// biome-ignore lint/suspicious/noExplicitAny: -export type ModelMN = T extends Model - ? `${N}.${MN}` +// biome-ignore lint/suspicious/noExplicitAny: Using any for inference +export type ModelM = T extends Model + ? { [K in string & keyof M as `${N}.${K}`]: M[K] } : never; -export class JoinDimensionRef { - constructor( - private readonly model: N, - private readonly dimension: DN, - ) {} - render(database: Database, dialect: BaseDialect) { - return database - .getModel(this.model) - .getDimension(this.dimension) - .getSql(dialect); - } -} -export class JoinOnDef { - constructor( - private readonly strings: string[], - private readonly values: unknown[], - ) {} - render(database: Database, dialect: BaseDialect) { - const sql: string[] = []; - const bindings: unknown[] = []; - for (let i = 0; i < this.strings.length; i++) { - sql.push(this.strings[i]!); - if (this.values[i]) { - const value = this.values[i]; - if (value instanceof JoinDimensionRef) { - const result = value.render(database, dialect); - sql.push(result.sql); - bindings.push(...result.bindings); - } else { - sql.push("?"); - bindings.push(value); - } - } - } - return { - sql: sql.join(""), - bindings, - }; - } -} - -export interface Join { - left: string; - right: string; - joinOnDef: JoinOnDef; - reversed: boolean; - type: "oneToOne" | "oneToMany" | "manyToOne" | "manyToMany"; -} +// biome-ignore lint/suspicious/noExplicitAny: Using any for inference +export type AnyRepository = Repository; -export type JoinFn< - DN extends string, - N1 extends string, - N2 extends string, -> = (args: { - sql: (strings: TemplateStringsArray, ...values: unknown[]) => JoinOnDef; - dimensions: JoinDimensions; -}) => JoinOnDef; - -export type ModelDimensionsWithoutModelPrefix< - N extends string, - DN extends string, -> = DN extends `${N}.${infer D}` ? D : never; - -export type JoinDimensions< - DN extends string, - N1 extends string, - N2 extends string, -> = { - [TK in N1]: { - [DK in ModelDimensionsWithoutModelPrefix]: JoinDimensionRef; - }; -} & { - [TK in N2]: { - [DK in ModelDimensionsWithoutModelPrefix]: JoinDimensionRef; - }; -}; - -const JOIN_WEIGHTS: Record = { - oneToOne: 1, - oneToMany: 3, - manyToOne: 2, - manyToMany: 4, -}; - -const REVERSED_JOIN: Record = { - oneToOne: "oneToOne", - oneToMany: "manyToOne", - manyToOne: "oneToMany", - manyToMany: "manyToMany", -}; - -// biome-ignore lint/suspicious/noExplicitAny: -export type AnyDatabase = Database; - -export class Database< +export class Repository< N extends string = never, - DN extends string = never, - MN extends string = never, + D extends MemberNameToType = MemberNameToType, + M extends MemberNameToType = MemberNameToType, F = GetFilterFragmentBuilderRegistryPayload< ReturnType >, @@ -151,30 +68,30 @@ export class Database< metric, }; } - return this as Database, DN | ModelDN, MN | ModelMN, F>; + return this as Repository, D & ModelD, M & ModelM, F>; } public withFilterFragmentBuilderRegistry< T extends AnyFilterFragmentBuilderRegistry, >(filterFragmentBuilderRegistry: T) { this.filterFragmentBuilderRegistry = filterFragmentBuilderRegistry; - return this as Database< + return this as Repository< N, - DN, - MN, + D, + M, GetFilterFragmentBuilderRegistryPayload >; } getFilterBuilder( - database: Database, + repository: Repository, dialect: BaseDialect, filterType: FilterType, referencedModels: string[], metricPrefixes?: Record, ) { return this.filterFragmentBuilderRegistry.getFilterBuilder( - database, + repository, dialect, filterType, referencedModels, @@ -186,12 +103,12 @@ export class Database< type: Join["type"], modelName1: N1, modelName2: N2, - joinSqlDefFn: JoinFn, + joinSqlDefFn: JoinFn, ) { const model1 = this.models[modelName1]; const model2 = this.models[modelName2]; - invariant(model1, `Model ${model1} not found in database`); - invariant(model2, `Model ${model2} not found in database`); + invariant(model1, `Model ${model1} not found in repository`); + invariant(model2, `Model ${model2} not found in repository`); const dimensions = { [model1.name]: Object.keys(model1.dimensions).reduce< Record> @@ -205,7 +122,7 @@ export class Database< acc[dimension] = new JoinDimensionRef(model2.name, dimension); return acc; }, {}), - } as JoinDimensions; + } as JoinDimensions; const joinSqlDef = joinSqlDefFn({ sql: (strings, ...values) => new JoinOnDef([...strings], values), @@ -239,7 +156,7 @@ export class Database< joinOneToOne( model1: N1, model2: N2, - joinSqlDefFn: JoinFn, + joinSqlDefFn: JoinFn, ) { return this.join("oneToOne", model1, model2, joinSqlDefFn); } @@ -247,7 +164,7 @@ export class Database< joinOneToMany( model1: N1, model2: N2, - joinSqlDefFn: JoinFn, + joinSqlDefFn: JoinFn, ) { return this.join("oneToMany", model1, model2, joinSqlDefFn); } @@ -255,7 +172,7 @@ export class Database< joinManyToOne( model1: N1, model2: N2, - joinSqlDefFn: JoinFn, + joinSqlDefFn: JoinFn, ) { return this.join("manyToOne", model1, model2, joinSqlDefFn); } @@ -263,7 +180,7 @@ export class Database< joinManyToMany( model1: N1, model2: N2, - joinSqlDefFn: JoinFn, + joinSqlDefFn: JoinFn, ) { return this.join("manyToMany", model1, model2, joinSqlDefFn); } @@ -318,18 +235,11 @@ export class Database< return this.joins[modelName]?.[joinModelName]; } - query(query: Query) { - const graphComponents = graphlib.alg.components(this.graph); - if (graphComponents.length > 1) { - throw new Error("Database graph must be a single connected component"); - } - - const DialectClass = BaseDialect; - - return queryBuilder.build(this, DialectClass, query); + build() { + return new QueryBuilder(this, BaseDialect); } } -export function database() { - return new Database(); +export function repository() { + return new Repository(); } diff --git a/src/lib/query/builder.ts b/src/lib/query/builder.ts index 3fbb358..2d6df92 100644 --- a/src/lib/query/builder.ts +++ b/src/lib/query/builder.ts @@ -1,11 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as graphlib from "@dagrejs/graphlib"; -import { AnyQuery, ModelQuery, QuerySegment } from "../../types.js"; -import type { AnyDatabase, Join } from "../builder/database.js"; +import { + AnyQuery, + MemberNameToType, + ModelQuery, + Query, + QueryMemberName, + QueryReturnType, + QuerySegment, + SqlQueryResult, +} from "../../types.js"; import knex from "knex"; import invariant from "tiny-invariant"; +import { Simplify } from "type-fest"; +import type { Join } from "../builder/join.js"; +import type { AnyRepository } from "../builder/repository.js"; import { BaseDialect } from "../dialect/base.js"; import { expandQueryToSegments } from "./expand-query.js"; import { findOptimalJoinGraph } from "./optimal-join-graph.js"; @@ -21,14 +32,14 @@ const client = knex({ client: "pg" }); // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: function buildQuerySegmentJoinQuery( knex: knex.Knex, - database: AnyDatabase, + repository: AnyRepository, Dialect: typeof BaseDialect, joinGraph: graphlib.Graph, modelQueries: Record, source: string, ) { const visitedModels = new Set(); - const model = database.getModel(source); + const model = repository.getModel(source); const sqlQuery = model.config.type === "table" ? knex(model.config.name) @@ -47,7 +58,7 @@ function buildQuerySegmentJoinQuery( visitedModels.add(modelName); const modelQuery = modelQueries[modelName]; - const model = database.getModel(modelName); + const model = repository.getModel(modelName); const hasMetrics = modelQuery?.metrics && modelQuery.metrics.size > 0; const unvisitedNeighbors = (joinGraph.neighbors(modelName) ?? []).filter( (modelName) => !visitedModels.has(modelName), @@ -62,8 +73,8 @@ function buildQuerySegmentJoinQuery( if (join) { const joinType = join.reversed ? "rightJoin" : "leftJoin"; - const joinOn = join.joinOnDef.render(database, dialect); - const rightModel = database.getModel(join.right); + const joinOn = join.joinOnDef.render(repository, dialect); + const rightModel = repository.getModel(join.right); const joinSubject = rightModel.config.type === "table" ? rightModel.config.name @@ -80,7 +91,7 @@ function buildQuerySegmentJoinQuery( } for (const metricName of modelQuery?.metrics || []) { - const metric = database.getMetric(metricName); + const metric = repository.getMetric(metricName); const { sql, bindings } = metric.getSql(dialect); sqlQuery.select( knex.raw(`${sql} as ${metric.getAlias(dialect)}`, bindings), @@ -88,7 +99,7 @@ function buildQuerySegmentJoinQuery( } for (const dimensionName of dimensionNames) { - const dimension = database.getDimension(dimensionName); + const dimension = repository.getDimension(dimensionName); const { sql, bindings } = dimension.getSql(dialect); sqlQuery.select( @@ -99,7 +110,7 @@ function buildQuerySegmentJoinQuery( modelStack.push( ...unvisitedNeighbors.map((unvisitedModelName) => ({ modelName: unvisitedModelName, - join: database.getJoin(modelName, unvisitedModelName), + join: repository.getJoin(modelName, unvisitedModelName), })), ); } @@ -109,7 +120,7 @@ function buildQuerySegmentJoinQuery( function buildQuerySegment( knex: knex.Knex, - database: AnyDatabase, + repository: AnyRepository, Dialect: typeof BaseDialect, joinGraph: graphlib.Graph, segment: QuerySegment, @@ -125,7 +136,7 @@ function buildQuerySegment( const initialSqlQuery = buildQuerySegmentJoinQuery( knex, - database, + repository, Dialect, joinGraph, segment.modelQueries, @@ -134,9 +145,9 @@ function buildQuerySegment( const dialect = new Dialect(initialSqlQuery); if (segment.query.filters) { - const filter = database + const filter = repository .getFilterBuilder( - database, + repository, dialect, "dimension", segment.referencedModels.all, @@ -153,7 +164,7 @@ function buildQuerySegment( const hasMetrics = segment.query.metrics && segment.query.metrics.length > 0; for (const dimensionName of segment.query.dimensions || []) { - const dimension = database.getDimension(dimensionName); + const dimension = repository.getDimension(dimensionName); sqlQuery.select( knex.raw( `${dialect.asIdentifier(alias)}.${dimension.getAlias( @@ -171,7 +182,7 @@ function buildQuerySegment( } for (const metricName of segment.query.metrics || []) { - const metric = database.getMetric(metricName); + const metric = repository.getMetric(metricName); const { sql, bindings } = metric.getAggregateSql(dialect, alias); sqlQuery.select( @@ -189,7 +200,7 @@ function getAlias(index: number) { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: function buildQuery( knex: knex.Knex, - database: AnyDatabase, + repository: AnyRepository, Dialect: typeof BaseDialect, query: AnyQuery, referencedModels: ReferencedModels, @@ -197,14 +208,14 @@ function buildQuery( segments: QuerySegment[], ) { const sqlQuerySegments = segments.map((segment) => - buildQuerySegment(client, database, Dialect, joinGraph, segment), + buildQuerySegment(client, repository, Dialect, joinGraph, segment), ); const [initialSqlQuerySegment, ...restSqlQuerySegments] = sqlQuerySegments; invariant(initialSqlQuerySegment, "No initial sql query segment found"); const joinOnDimensions = referencedModels.dimensions.flatMap((modelName) => - database.getModel(modelName).getPrimaryKeyDimensions(), + repository.getModel(modelName).getPrimaryKeyDimensions(), ); const rootAlias = getAlias(0); const rootSqlQuery = knex(initialSqlQuerySegment.sqlQuery.as(rootAlias)); @@ -212,7 +223,7 @@ function buildQuery( for (const dimensionName of initialSqlQuerySegment.projectedQuery .dimensions || []) { - const dimension = database.getDimension(dimensionName); + const dimension = repository.getDimension(dimensionName); rootSqlQuery.select( knex.raw( @@ -225,7 +236,7 @@ function buildQuery( for (const metricName of initialSqlQuerySegment.projectedQuery.metrics || []) { - const metric = database.getMetric(metricName); + const metric = repository.getMetric(metricName); rootSqlQuery.select( knex.raw( @@ -256,7 +267,7 @@ function buildQuery( for (const metricName of segment.projectedQuery.metrics || []) { if ((query.metrics ?? []).includes(metricName)) { - const metric = database.getMetric(metricName); + const metric = repository.getMetric(metricName); rootSqlQuery.select( knex.raw( `${dialect.asIdentifier(alias)}.${metric.getAlias( @@ -278,9 +289,9 @@ function buildQuery( }, {}, ); - const filter = database + const filter = repository .getFilterBuilder( - database, + repository, dialect, "metric", referencedModels.metrics, @@ -294,7 +305,7 @@ function buildQuery( const orderBy = Object.entries(query.order || {}).map( ([member, direction]) => { - const memberSql = database.getMember(member).getAlias(dialect); + const memberSql = repository.getMember(member).getAlias(dialect); return `${memberSql} ${direction}`; }, ); @@ -309,30 +320,59 @@ function buildQuery( return rootSqlQuery; } -export function build( - database: AnyDatabase, - Dialect: typeof BaseDialect, - query: AnyQuery, -) { - const { referencedModels, segments } = expandQueryToSegments(database, query); - - const joinGraph = findOptimalJoinGraph(database.graph, referencedModels.all); +export class QueryBuilder< + D extends MemberNameToType, + M extends MemberNameToType, + F, +> { + constructor( + private readonly repository: AnyRepository, + private readonly Dialect: typeof BaseDialect, + ) {} + + build( + query: Q & + Query< + string & keyof D, + string & keyof M, + F & { member: string & (keyof D | keyof M) } + >, + ) { + const { referencedModels, segments } = expandQueryToSegments( + this.repository, + query, + ); - const sqlQuery = buildQuery( - client, - database, - Dialect, - query, - referencedModels, - joinGraph, - segments, - ); + const joinGraph = findOptimalJoinGraph( + this.repository.graph, + referencedModels.all, + ); - const result = sqlQuery.toSQL().toNative(); - const bindings: unknown[] = [...result.bindings]; + const sqlQuery = buildQuery( + client, + this.repository, + this.Dialect, + query, + referencedModels, + joinGraph, + segments, + ); - return { - sql: result.sql, - bindings, - }; + const { sql, bindings } = sqlQuery.toSQL().toNative(); + + const result: SqlQueryResult< + Simplify< + QueryReturnType< + D & M, + | (QueryMemberName & keyof D) + | (QueryMemberName & keyof M) + > + > + > = { + sql, + bindings: bindings as unknown[], + }; + + return result; + } } diff --git a/src/lib/query/expand-query.ts b/src/lib/query/expand-query.ts index 7d5d214..e178a7c 100644 --- a/src/lib/query/expand-query.ts +++ b/src/lib/query/expand-query.ts @@ -5,9 +5,9 @@ import { QuerySegment, } from "../../types.js"; -import { Database } from "../builder/database.js"; +import { Repository } from "../builder/repository.js"; -function analyzeQuery(database: Database, query: AnyQuery) { +function analyzeQuery(repository: Repository, query: AnyQuery) { const allModels = new Set(); const dimensionModels = new Set(); const metricModels = new Set(); @@ -17,7 +17,7 @@ function analyzeQuery(database: Database, query: AnyQuery) { const metricsByModel: Record> = {}; for (const dimension of query.dimensions || []) { - const modelName = database.getDimension(dimension).model.name; + const modelName = repository.getDimension(dimension).model.name; allModels.add(modelName); dimensionModels.add(modelName); dimensionsByModel[modelName] ||= new Set(); @@ -27,7 +27,7 @@ function analyzeQuery(database: Database, query: AnyQuery) { } for (const metric of query.metrics || []) { - const modelName = database.getMetric(metric).model.name; + const modelName = repository.getMetric(metric).model.name; allModels.add(modelName); metricModels.add(modelName); metricsByModel[modelName] ||= new Set(); @@ -43,7 +43,7 @@ function analyzeQuery(database: Database, query: AnyQuery) { if (filter.operator === "and" || filter.operator === "or") { filterStack.push(...filter.filters); } else { - const member = database.getMember(filter.member); + const member = repository.getMember(filter.member); const modelName = member.model.name; allModels.add(modelName); @@ -84,7 +84,7 @@ interface PreparedQuery { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: function getQuerySegment( - database: Database, + repository: Repository, queryAnalysis: ReturnType, metricModel: string | null, index: number, @@ -119,7 +119,7 @@ function getQuerySegment( for (const [modelName, dimensions] of Object.entries( queryAnalysis.projectedDimensionsByModel, )) { - const model = database.getModel(modelName); + const model = repository.getModel(modelName); referencedModels.all.add(modelName); referencedModels.dimensions.add(modelName); @@ -209,20 +209,20 @@ function mergeQuerySegmentWithFilters( }; } -export function expandQueryToSegments(database: Database, query: AnyQuery) { - const queryAnalysis = analyzeQuery(database, query); +export function expandQueryToSegments(repository: Repository, query: AnyQuery) { + const queryAnalysis = analyzeQuery(repository, query); const metricModels = Object.keys(queryAnalysis.metricsByModel); const segments = metricModels.length === 0 ? [ mergeQuerySegmentWithFilters( - getQuerySegment(database, queryAnalysis, null, 0), + getQuerySegment(repository, queryAnalysis, null, 0), query.filters, ), ] : metricModels.map((model, idx) => mergeQuerySegmentWithFilters( - getQuerySegment(database, queryAnalysis, model, idx), + getQuerySegment(repository, queryAnalysis, model, idx), query.filters, ), ); diff --git a/src/lib/query/filter-builder.ts b/src/lib/query/filter-builder.ts index 651f98b..2ee6f81 100644 --- a/src/lib/query/filter-builder.ts +++ b/src/lib/query/filter-builder.ts @@ -36,7 +36,7 @@ import { lte as filterLte, } from "./filter-builder/number-comparison-filter-builder.js"; -import type { Database } from "../builder/database.js"; +import type { Repository } from "../builder/repository.js"; import { BaseDialect } from "../dialect/base.js"; import { equals as filterEquals } from "./filter-builder/equals.js"; import { notEquals as filterNotEquals } from "./filter-builder/not-equals.js"; @@ -51,7 +51,7 @@ export class FilterBuilder { AnyFilterFragmentBuilder >, private readonly dialect: BaseDialect, - private readonly database: Database, + private readonly repository: Repository, private readonly filterType: FilterType, referencedModels: string[], private readonly metricPrefixes?: Record, @@ -59,7 +59,7 @@ export class FilterBuilder { this.referencedModels = new Set(referencedModels); } getMemberSql(memberName: string): SqlWithBindings | undefined { - const member = this.database.getMember(memberName); + const member = this.repository.getMember(memberName); if (this.referencedModels.has(member.model.name)) { if (this.filterType === "dimension" && member.isDimension()) { return member.getSql(this.dialect); @@ -146,7 +146,7 @@ export class FilterFragmentBuilderRegistry { return this; } getFilterBuilder( - database: Database, + repository: Repository, dialect: BaseDialect, filterType: FilterType, referencedModels: string[], @@ -155,7 +155,7 @@ export class FilterFragmentBuilderRegistry { return new FilterBuilder( this.filterFragmentBuilders, dialect, - database, + repository, filterType, referencedModels, metricPrefixes, diff --git a/src/lib/semantic-layer.ts b/src/lib/semantic-layer.ts index 247c22e..c9ee101 100644 --- a/src/lib/semantic-layer.ts +++ b/src/lib/semantic-layer.ts @@ -1,5 +1,7 @@ -export * from "./builder/database.js"; +export * from "./builder/repository.js"; export * from "./builder/model.js"; +export * from "./builder/join.js"; +export * from "./query/builder.js"; export * from "./query/filter-builder.js"; export { BaseDialect } from "./dialect/base.js"; export * from "./query/filter-builder/filter-fragment-builder.js"; diff --git a/src/types.ts b/src/types.ts index 6773cb5..0a94722 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { Replace } from "type-fest"; + export interface AndConnective { operator: "and"; filters: QueryFilter[]; @@ -73,3 +75,45 @@ export const GranularityByDimensionType = { export type GranularityByDimensionType = typeof GranularityByDimensionType; export type Granularity = GranularityByDimensionType[keyof GranularityByDimensionType][number]; + +export type MemberType = + | "string" + | "number" + | "date" + | "time" + | "datetime" + | "boolean"; + +export type MemberNameToType = { [k in never]: MemberType }; + +export type QueryReturnType< + M extends MemberNameToType, + N extends keyof M, + S = Pick, +> = { + [K in keyof S as Replace]: S[K] extends "string" + ? string + : S[K] extends "number" + ? number + : S[K] extends "date" + ? Date + : S[K] extends "time" + ? Date + : S[K] extends "datetime" + ? Date + : S[K] extends "boolean" + ? boolean + : never; +}; + +// biome-ignore lint/correctness/noUnusedVariables: We need the RT generic param to be present so we can infer the return type later +export interface SqlQueryResult { + sql: string; + bindings: unknown[]; +} + +export type InferSqlQueryResultType = T extends SqlQueryResult + ? RT + : never; + +export type QueryMemberName = T extends string[] ? T[number] : never;