diff --git a/src/__tests__/helpers.test.ts b/src/__tests__/helpers.test.ts index 352e26c..cc4894e 100644 --- a/src/__tests__/helpers.test.ts +++ b/src/__tests__/helpers.test.ts @@ -22,14 +22,17 @@ const userModel = semanticLayer }) .withMetric("count", { type: "string", + sql: ({ model, sql }) => sql`COUNT(DISTINCT ${model.column("CustomerId")})`, format: (value) => `Count: ${value}`, }) .withMetric("percentage", { type: "string", + sql: ({ model, sql }) => sql`COUNT(DISTINCT ${model.column("CustomerId")})`, format: "percentage", }) .withMetric("currency", { type: "string", + sql: ({ model, sql }) => sql`COUNT(DISTINCT ${model.column("CustomerId")})`, format: "currency", }); diff --git a/src/__tests__/query-builder/query-plan.test.ts b/src/__tests__/query-builder/query-plan.test.ts index d5a2a73..6ab3995 100644 --- a/src/__tests__/query-builder/query-plan.test.ts +++ b/src/__tests__/query-builder/query-plan.test.ts @@ -7,7 +7,7 @@ import { getQueryPlan } from "../../lib/query-builder/query-plan.js"; it("can crate a query plan", () => { const { queryBuilder } = fullRepository; - const queryPlan = getQueryPlan(queryBuilder.repository, { + const queryPlan = getQueryPlan(queryBuilder, undefined, { members: [ "artists.name", "tracks.name", diff --git a/src/lib/join.ts b/src/lib/join.ts index 84991d6..768c558 100644 --- a/src/lib/join.ts +++ b/src/lib/join.ts @@ -1,6 +1,5 @@ import { ColumnRef, DimensionRef, IdentifierRef, SqlFn } from "./sql-fn.js"; -import invariant from "tiny-invariant"; import { AnyModel } from "./model.js"; import { ModelMemberWithoutModelPrefix } from "./types.js"; @@ -42,10 +41,6 @@ export function makeModelJoinPayload(model: AnyModel, context: unknown) { return { dimension: (dimensionName: string) => { const dimension = model.getDimension(dimensionName); - invariant( - dimension, - `Dimension ${dimensionName} not found in model ${model.name}`, - ); return new DimensionRef(dimension, context); }, column: (columnName: string) => new ColumnRef(model, columnName, context), diff --git a/src/lib/member.ts b/src/lib/member.ts new file mode 100644 index 0000000..f104eac --- /dev/null +++ b/src/lib/member.ts @@ -0,0 +1,63 @@ +import { + QueryMember, + QueryMemberCache, +} from "./query-builder/query-plan/query-member.js"; + +import { AnyBaseDialect } from "./dialect/base.js"; +import { pathToAlias } from "./helpers.js"; +import { AnyModel } from "./model.js"; +import { AnyBasicDimensionProps } from "./model/basic-dimension.js"; +import { AnyBasicMetricProps } from "./model/basic-metric.js"; +import { GranularityDimension } from "./model/granularity-dimension.js"; +import { AnyRepository } from "./repository.js"; + +export abstract class Member { + public abstract readonly name: string; + public abstract readonly model: AnyModel; + public abstract props: AnyBasicDimensionProps | AnyBasicMetricProps; + + abstract isMetric(): this is Metric; + abstract isDimension(): this is Dimension; + + getAlias() { + return `${this.model.name}___${pathToAlias(this.name)}`; + } + getPath() { + return `${this.model.name}.${this.name}`; + } + getDescription() { + return this.props.description; + } + getType() { + return this.props.type; + } + getFormat() { + return this.props.format; + } + unsafeFormatValue(value: unknown) { + const format = this.getFormat(); + if (typeof format === "function") { + return (format as (value: unknown) => string)(value); + } + if (format === "currency") { + return `$${value}`; + } + if (format === "percentage") { + return `${value}%`; + } + return String(value); + } + abstract clone(model: AnyModel): Member; + abstract getQueryMember( + queryMembers: QueryMemberCache, + repository: AnyRepository, + dialect: AnyBaseDialect, + context: unknown, + ): QueryMember; +} + +export abstract class Dimension extends Member { + abstract isPrimaryKey(): boolean; + abstract isGranularity(): this is GranularityDimension; +} +export abstract class Metric extends Member {} diff --git a/src/lib/model.ts b/src/lib/model.ts index 654be40..ac87729 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -13,14 +13,16 @@ import { import invariant from "tiny-invariant"; import { AnyBaseDialect } from "./dialect/base.js"; + import { BasicDimension, BasicDimensionProps, - BasicMetric, - BasicMetricProps, DimensionHasTemporalGranularity, WithTemporalGranularityDimensions, -} from "./model/member.js"; +} from "./model/basic-dimension.js"; +import { BasicMetric, BasicMetricProps } from "./model/basic-metric.js"; +import { GranularityDimension } from "./model/granularity-dimension.js"; +import { QueryMemberCache } from "./query-builder/query-plan/query-member.js"; import { AnyRepository } from "./repository.js"; import { SqlFragment } from "./sql-builder.js"; import { IdentifierRef, SqlFn } from "./sql-fn.js"; @@ -77,7 +79,7 @@ export class Model< DG extends boolean = DimensionHasTemporalGranularity, >( name: Exclude, - dimension: DP, + dimensionProps: DP, ): Model< C, N, @@ -91,20 +93,23 @@ export class Model< !(this.dimensions[name] || this.metrics[name]), `Member "${name}" already exists`, ); + const dimension = new BasicDimension(this, name, dimensionProps); - this.dimensions[name] = new BasicDimension(this, name, dimension); + this.dimensions[name] = dimension; if ( // TODO: figure out why typeHasGranularity is not working anymore - dimension.type === "datetime" || - dimension.type === "date" || - (dimension.type === "time" && dimension.omitGranularity !== true) + dimensionProps.type === "datetime" || + dimensionProps.type === "date" || + (dimensionProps.type === "time" && + dimensionProps.omitGranularity !== true) ) { const granularityDimensions = - TemporalGranularityByDimensionType[dimension.type]; + TemporalGranularityByDimensionType[dimensionProps.type]; for (const g of granularityDimensions) { - const { format: _format, ...dimensionWithoutFormat } = dimension; - this.dimensions[`${name}.${g}`] = new BasicDimension( + const { format: _format, ...dimensionWithoutFormat } = dimensionProps; + this.dimensions[`${name}.${g}`] = new GranularityDimension( this, + dimension, `${name}.${g}`, { ...dimensionWithoutFormat, @@ -117,7 +122,7 @@ export class Model< } this.unsafeWithHierarchy( name, - makeTemporalHierarchyElementsForDimension(name, dimension.type), + makeTemporalHierarchyElementsForDimension(name, dimensionProps.type), "temporal", ); } @@ -201,7 +206,12 @@ export class Model< getMetrics() { return Object.values(this.metrics); } - getTableName(repository: AnyRepository, dialect: AnyBaseDialect, context: C) { + getTableName( + repository: AnyRepository, + queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + context: C, + ) { invariant(this.config.type === "table", "Model is not a table"); if (typeof this.config.name === "string") { @@ -221,9 +231,14 @@ export class Model< getContext: () => context, }); - return result.render(repository, dialect); + return result.render(repository, queryMembers, dialect); } - getSql(repository: AnyRepository, dialect: AnyBaseDialect, context: C) { + getSql( + repository: AnyRepository, + queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + context: C, + ) { invariant(this.config.type === "sqlQuery", "Model is not an SQL query"); const result = this.config.sql({ @@ -232,26 +247,37 @@ export class Model< new SqlFn([...strings], values), getContext: () => context, }); - return result.render(repository, dialect); + return result.render(repository, queryMembers, dialect); } getTableNameOrSql( repository: AnyRepository, + queryMembers: QueryMemberCache, dialect: AnyBaseDialect, context: C, ) { if (this.config.type === "table") { - const { sql, bindings } = this.getTableName(repository, dialect, context); + const { sql, bindings } = this.getTableName( + repository, + queryMembers, + dialect, + context, + ); return dialect.fragment(sql, bindings); } - const modelSql = this.getSql(repository, dialect, context); + const modelSql = this.getSql(repository, queryMembers, dialect, context); return dialect.fragment( `(${modelSql.sql}) as ${dialect.asIdentifier(this.config.alias)}`, modelSql.bindings, ); } - getAs(repository: AnyRepository, dialect: AnyBaseDialect, context: C) { + getAs( + repository: AnyRepository, + queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + context: C, + ) { if (this.config.type === "sqlQuery") { return SqlFragment.make({ sql: dialect.asIdentifier(this.config.alias), @@ -259,7 +285,7 @@ export class Model< }); } - return this.getTableName(repository, dialect, context); + return this.getTableName(repository, queryMembers, dialect, context); } clone(name: N) { diff --git a/src/lib/model/basic-dimension.ts b/src/lib/model/basic-dimension.ts new file mode 100644 index 0000000..89abdaf --- /dev/null +++ b/src/lib/model/basic-dimension.ts @@ -0,0 +1,210 @@ +import { Get, Simplify } from "type-fest"; +import { Dimension, Metric } from "../member.js"; +import { + QueryMember, + QueryMemberCache, +} from "../query-builder/query-plan/query-member.js"; +import { + ColumnRef, + DimensionRef, + IdentifierRef, + Ref, + SqlFn, +} from "../sql-fn.js"; +import { + DimensionWithTemporalGranularity, + MemberFormat, + TemporalGranularityByDimensionType, +} from "../types.js"; + +import { AnyBaseDialect } from "../dialect/base.js"; +import { AnyModel } from "../model.js"; +import { AnyRepository } from "../repository.js"; +import { SqlFragment } from "../sql-builder.js"; + +export interface DimensionSqlFnArgs { + identifier: (name: string) => IdentifierRef; + model: { + column: (name: string) => ColumnRef; + dimension: (name: DN) => DimensionRef; + }; + sql: (strings: TemplateStringsArray, ...values: unknown[]) => SqlFn; + getContext: () => C; +} + +export type DimensionSqlFn = ( + args: DimensionSqlFnArgs, +) => Ref; + +export type AnyDimensionSqlFn = DimensionSqlFn; + +export type WithTemporalGranularityDimensions< + N extends string, + T extends string, +> = T extends keyof TemporalGranularityByDimensionType + ? { [k in N]: T } & DimensionWithTemporalGranularity + : { [k in N]: T }; + +// TODO: Figure out how to ensure that DimensionProps and MetricProps have support for all valid member types +export type BasicDimensionProps = Simplify< + { + sql?: DimensionSqlFn; + primaryKey?: boolean; + description?: string; + } & ( + | { type: "string"; format?: MemberFormat<"string"> } + | { type: "number"; format?: MemberFormat<"number"> } + | { type: "date"; format?: MemberFormat<"date">; omitGranularity?: boolean } + | { + type: "datetime"; + format?: MemberFormat<"datetime">; + omitGranularity?: boolean; + } + | { type: "time"; format?: MemberFormat<"time">; omitGranularity?: boolean } + | { type: "boolean"; format?: MemberFormat<"boolean"> } + ) +>; + +export type AnyBasicDimensionProps = BasicDimensionProps; + +export type DimensionHasTemporalGranularity = + Get extends "datetime" | "date" | "time" + ? Get extends true + ? false + : true + : false; + +export class BasicDimension extends Dimension { + constructor( + public readonly model: AnyModel, + public readonly name: string, + public readonly props: AnyBasicDimensionProps, + ) { + super(); + } + clone(model: AnyModel) { + return new BasicDimension(model, this.name, { ...this.props }); + } + + isPrimaryKey() { + return !!this.props.primaryKey; + } + isGranularity() { + return false; + } + isDimension(): this is Dimension { + return true; + } + isMetric(): this is Metric { + return false; + } + + getQueryMember( + queryMembers: QueryMemberCache, + repository: AnyRepository, + dialect: AnyBaseDialect, + context: unknown, + ): BasicDimensionQueryMember { + return new BasicDimensionQueryMember( + queryMembers, + repository, + dialect, + context, + this, + ); + } +} + +export class BasicDimensionQueryMember extends QueryMember { + private sqlFnRenderResult: SqlFragment | undefined; + constructor( + readonly queryMembers: QueryMemberCache, + readonly repository: AnyRepository, + readonly dialect: AnyBaseDialect, + readonly context: unknown, + readonly member: BasicDimension, + ) { + super(); + const sqlFnResult = this.callSqlFn(); + if (sqlFnResult) { + this.sqlFnRenderResult = sqlFnResult.render( + this.repository, + this.queryMembers, + this.dialect, + ); + } + } + private callSqlFn(): Ref | undefined { + if (this.member.props.sql) { + return this.member.props.sql({ + identifier: (name: string) => new IdentifierRef(name), + model: { + column: (name: string) => { + return new ColumnRef(this.member.model, name, this.context); + }, + dimension: (name: string) => { + return new DimensionRef( + this.member.model.getDimension(name), + this.context, + ); + }, + }, + sql: (strings, ...values) => new SqlFn([...strings], values), + getContext: () => this.context, + }); + } + } + getAlias() { + return this.member.getAlias(); + } + getSql() { + if (this.sqlFnRenderResult) { + return this.sqlFnRenderResult; + } + + const { sql: asSql, bindings } = this.member.model.getAs( + this.repository, + this.queryMembers, + this.dialect, + this.context, + ); + const sql = `${asSql}.${this.dialect.asIdentifier(this.member.name)}`; + + return SqlFragment.make({ sql, bindings }); + } + getFilterSql() { + return this.getSql(); + } + getModelQueryProjection() { + const { sql, bindings } = this.getSql(); + const fragment = this.dialect.fragment( + `${sql} as ${this.dialect.asIdentifier(this.member.getAlias())}`, + bindings, + ); + return [fragment]; + } + getSegmentQueryProjection(modelQueryAlias: string) { + const fragment = this.dialect.fragment( + `${this.dialect.asIdentifier(modelQueryAlias)}.${this.dialect.asIdentifier( + this.member.getAlias(), + )} as ${this.dialect.asIdentifier(this.member.getAlias())}`, + ); + return [fragment]; + } + getSegmentQueryGroupBy(modelQueryAlias: string) { + const fragment = this.dialect.fragment( + `${this.dialect.asIdentifier(modelQueryAlias)}.${this.dialect.asIdentifier( + this.member.getAlias(), + )}`, + ); + return [fragment]; + } + getRootQueryProjection(segmentQueryAlias: string) { + const fragment = this.dialect.fragment( + `${this.dialect.asIdentifier(segmentQueryAlias)}.${this.dialect.asIdentifier( + this.member.getAlias(), + )} as ${this.dialect.asIdentifier(this.member.getAlias())}`, + ); + return [fragment]; + } +} diff --git a/src/lib/model/basic-metric.ts b/src/lib/model/basic-metric.ts new file mode 100644 index 0000000..961dc82 --- /dev/null +++ b/src/lib/model/basic-metric.ts @@ -0,0 +1,247 @@ +import { Dimension, Metric } from "../member.js"; +import { + QueryMember, + QueryMemberCache, +} from "../query-builder/query-plan/query-member.js"; +import { + AliasRef, + ColumnRef, + DimensionRef, + IdentifierRef, + MetricRef, + SqlFn, +} from "../sql-fn.js"; + +import { Simplify } from "type-fest"; +import { AnyBaseDialect } from "../dialect/base.js"; +import { AnyModel } from "../model.js"; +import { AnyRepository } from "../repository.js"; +import { SqlFragment } from "../sql-builder.js"; +import { MemberFormat } from "../types.js"; + +export interface MetricSqlFnArgs< + C, + DN extends string = string, + MN extends string = string, +> { + identifier: (name: string) => IdentifierRef; + model: { + column: (name: string) => AliasRef; + dimension: (name: DN) => AliasRef; + metric: (name: MN) => AliasRef; + }; + sql: (strings: TemplateStringsArray, ...values: unknown[]) => SqlFn; + getContext: () => C; +} + +export type AnyMetricSqlFn = MetricSqlFn; + +export type MetricSqlFn< + C, + DN extends string = string, + MN extends string = string, +> = (args: MetricSqlFnArgs) => SqlFn; + +// TODO: Figure out how to ensure that DimensionProps and MetricProps have support for all valid member types +export type BasicMetricProps< + C, + DN extends string = string, + MN extends string = string, +> = Simplify< + { + sql: MetricSqlFn; + description?: string; + } & ( + | { type: "string"; format?: MemberFormat<"string"> } + | { type: "number"; format?: MemberFormat<"number"> } + | { type: "date"; format?: MemberFormat<"date"> } + | { type: "datetime"; format?: MemberFormat<"datetime"> } + | { type: "time"; format?: MemberFormat<"time"> } + | { type: "boolean"; format?: MemberFormat<"boolean"> } + ) +>; +export type AnyBasicMetricProps = BasicMetricProps; + +export class BasicMetric extends Metric { + constructor( + public readonly model: AnyModel, + public readonly name: string, + public readonly props: AnyBasicMetricProps, + ) { + super(); + } + clone(model: AnyModel) { + return new BasicMetric(model, this.name, { ...this.props }); + } + + getQueryMember( + queryMembers: QueryMemberCache, + repository: AnyRepository, + dialect: AnyBaseDialect, + context: unknown, + ): BasicMetricQueryMember { + return new BasicMetricQueryMember( + queryMembers, + repository, + dialect, + context, + this, + ); + } + + isDimension(): this is Dimension { + return false; + } + isMetric(): this is Metric { + return true; + } +} + +export class BasicMetricQueryMember extends QueryMember { + private sqlFnResult: SqlFn; + private sqlFnRenderResult: SqlFragment; + constructor( + readonly queryMembers: QueryMemberCache, + readonly repository: AnyRepository, + readonly dialect: AnyBaseDialect, + readonly context: unknown, + readonly member: BasicMetric, + ) { + super(); + this.sqlFnResult = this.callSqlFn(); + this.sqlFnRenderResult = this.sqlFnResult.render( + this.repository, + this.queryMembers, + this.dialect, + ); + } + private callSqlFn(): SqlFn { + let refAliasCounter = 0; + const getNextRefAlias = () => + `${this.member.name}___metric_ref_${refAliasCounter++}`; + + return this.member.props.sql({ + identifier: (name: string) => new IdentifierRef(name), + model: { + column: (name: string) => { + const columnRef = new ColumnRef( + this.member.model, + name, + this.context, + ); + return new AliasRef(getNextRefAlias(), columnRef); + }, + dimension: (name: string) => { + const dimensionRef = new DimensionRef( + this.member.model.getDimension(name), + this.context, + ); + return new AliasRef(getNextRefAlias(), dimensionRef); + }, + metric: (name: string) => { + const metricRef = new MetricRef( + this.member, + this.member.model.getMetric(name), + this.context, + ); + return new AliasRef(getNextRefAlias(), metricRef); + }, + }, + sql: (strings, ...values) => new SqlFn([...strings], values), + getContext: () => this.context, + }); + } + + getAlias() { + return this.member.getAlias(); + } + getSql() { + const result = this.sqlFnRenderResult; + + if (result) { + return result; + } + + const { sql: asSql, bindings } = this.member.model.getAs( + this.repository, + this.queryMembers, + this.dialect, + this.context, + ); + const sql = `${asSql}.${this.dialect.asIdentifier(this.member.name)}`; + + return SqlFragment.make({ + sql, + bindings, + }); + } + getFilterSql() { + return SqlFragment.fromSql(this.dialect.asIdentifier(this.getAlias())); + } + getModelQueryProjection() { + const sqlFnResult = this.sqlFnResult; + + const refs: SqlFragment[] = []; + const valuesQueue = [...sqlFnResult.values]; + + while (valuesQueue.length > 0) { + const value = valuesQueue.shift()!; + if (value instanceof AliasRef) { + const alias = value.alias; + const { sql, bindings } = value.aliasOf.render( + this.repository, + this.queryMembers, + this.dialect, + ); + + refs.push( + SqlFragment.make({ + sql: `${sql} as ${this.dialect.asIdentifier(alias)}`, + bindings, + }), + ); + } else if (value instanceof SqlFn) { + valuesQueue.push(...value.values); + } + } + return refs; + } + getSegmentQueryProjection(_modelQueryAlias: string) { + const { sql, bindings } = this.getSql(); + const fragment = this.dialect.fragment( + `${sql} as ${this.dialect.asIdentifier(this.member.getAlias())}`, + bindings, + ); + return [fragment]; + } + getSegmentQueryGroupBy(_modelQueryAlias: string) { + return []; + } + getRootQueryProjection(segmentQueryAlias: string) { + const fragment = this.dialect.fragment( + `${this.dialect.asIdentifier(segmentQueryAlias)}.${this.dialect.asIdentifier( + this.member.getAlias(), + )} as ${this.dialect.asIdentifier(this.member.getAlias())}`, + ); + return [fragment]; + } + getMetricRefs() { + const sqlFnResult = this.sqlFnResult; + if (sqlFnResult) { + const valuesToProcess: unknown[] = [sqlFnResult]; + const refs: MetricRef[] = []; + + while (valuesToProcess.length > 0) { + const ref = valuesToProcess.pop()!; + if (ref instanceof AliasRef && ref.aliasOf instanceof MetricRef) { + refs.push(ref.aliasOf); + } + if (ref instanceof SqlFn) { + valuesToProcess.push(...ref.values); + } + } + return refs; + } + return []; + } +} diff --git a/src/lib/model/granularity-dimension.ts b/src/lib/model/granularity-dimension.ts new file mode 100644 index 0000000..abee820 --- /dev/null +++ b/src/lib/model/granularity-dimension.ts @@ -0,0 +1,61 @@ +import { + AnyBasicDimensionProps, + BasicDimension, + BasicDimensionQueryMember, +} from "./basic-dimension.js"; + +import { AnyBaseDialect } from "../dialect/base.js"; +import { AnyModel } from "../model.js"; +import { QueryMemberCache } from "../query-builder/query-plan/query-member.js"; +import { AnyRepository } from "../repository.js"; +import { SqlFragment } from "../sql-builder.js"; +import { TemporalGranularity } from "../types.js"; + +export class GranularityDimension extends BasicDimension { + constructor( + model: AnyModel, + public readonly parent: BasicDimension, + name: string, + props: AnyBasicDimensionProps, + public readonly granularity: TemporalGranularity, + ) { + super(model, name, props); + } + isGranularity() { + return true; + } + getQueryMember( + queryMembers: QueryMemberCache, + repository: AnyRepository, + dialect: AnyBaseDialect, + context: unknown, + ): GranularityDimensionQueryMember { + return new GranularityDimensionQueryMember( + queryMembers, + repository, + dialect, + context, + this, + ); + } +} + +export class GranularityDimensionQueryMember extends BasicDimensionQueryMember { + constructor( + queryMembers: QueryMemberCache, + repository: AnyRepository, + dialect: AnyBaseDialect, + context: unknown, + readonly member: GranularityDimension, + ) { + super(queryMembers, repository, dialect, context, member); + } + getSql() { + const parent = this.queryMembers.getByPath(this.member.parent.getPath()); + const result = parent.getSql(); + return SqlFragment.make({ + sql: this.dialect.withGranularity(this.member.granularity, result.sql), + bindings: result.bindings, + }); + } +} diff --git a/src/lib/model/member.ts b/src/lib/model/member.ts index 6d896fc..24dccc2 100644 --- a/src/lib/model/member.ts +++ b/src/lib/model/member.ts @@ -1,527 +1,3 @@ -import { Get, Simplify } from "type-fest"; -import { - AliasRef, - ColumnRef, - DimensionRef, - IdentifierRef, - MetricRef, - Ref, - SqlFn, -} from "../sql-fn.js"; -import { - DimensionWithTemporalGranularity, - MemberFormat, - NextColumnRefOrDimensionRefAlias, - TemporalGranularity, - TemporalGranularityByDimensionType, -} from "../types.js"; - -import { AnyBaseDialect } from "../dialect/base.js"; -import { pathToAlias } from "../helpers.js"; -import { AnyModel } from "../model.js"; -import { AnyRepository } from "../repository.js"; -import { SqlFragment } from "../sql-builder.js"; - -export interface DimensionSqlFnArgs { - identifier: (name: string) => IdentifierRef; - model: { - column: (name: string) => ColumnRef | AliasRef; - dimension: (name: DN) => DimensionRef | AliasRef; - }; - sql: (strings: TemplateStringsArray, ...values: unknown[]) => SqlFn; - getContext: () => C; -} - -export interface MetricSqlFnArgs< - C, - DN extends string = string, - MN extends string = string, -> { - identifier: (name: string) => IdentifierRef; - model: { - column: (name: string) => ColumnRef | AliasRef; - dimension: (name: DN) => DimensionRef | AliasRef; - metric: (name: MN) => MetricRef | AliasRef; - }; - sql: (strings: TemplateStringsArray, ...values: unknown[]) => SqlFn; - getContext: () => C; -} - -export type AnyMetricSqlFn = MetricSqlFn; - -export type DimensionSqlFn = ( - args: DimensionSqlFnArgs, -) => Ref; - -export type MetricSqlFn< - C, - DN extends string = string, - MN extends string = string, -> = (args: MetricSqlFnArgs) => SqlFn; - -export type AnyDimensionSqlFn = DimensionSqlFn; - -export type WithTemporalGranularityDimensions< - N extends string, - T extends string, -> = T extends keyof TemporalGranularityByDimensionType - ? { [k in N]: T } & DimensionWithTemporalGranularity - : { [k in N]: T }; - -// TODO: Figure out how to ensure that DimensionProps and MetricProps have support for all valid member types -export type BasicDimensionProps = Simplify< - { - sql?: DimensionSqlFn; - primaryKey?: boolean; - description?: string; - } & ( - | { type: "string"; format?: MemberFormat<"string"> } - | { type: "number"; format?: MemberFormat<"number"> } - | { type: "date"; format?: MemberFormat<"date">; omitGranularity?: boolean } - | { - type: "datetime"; - format?: MemberFormat<"datetime">; - omitGranularity?: boolean; - } - | { type: "time"; format?: MemberFormat<"time">; omitGranularity?: boolean } - | { type: "boolean"; format?: MemberFormat<"boolean"> } - ) ->; - -export type AnyBasicDimensionProps = BasicDimensionProps; - -export type DimensionHasTemporalGranularity = - Get extends "datetime" | "date" | "time" - ? Get extends true - ? false - : true - : false; - -// TODO: Figure out how to ensure that DimensionProps and MetricProps have support for all valid member types -export type BasicMetricProps< - C, - DN extends string = string, - MN extends string = string, -> = Simplify< - { - sql?: MetricSqlFn; - description?: string; - } & ( - | { type: "string"; format?: MemberFormat<"string"> } - | { type: "number"; format?: MemberFormat<"number"> } - | { type: "date"; format?: MemberFormat<"date"> } - | { type: "datetime"; format?: MemberFormat<"datetime"> } - | { type: "time"; format?: MemberFormat<"time"> } - | { type: "boolean"; format?: MemberFormat<"boolean"> } - ) ->; -export type AnyBasicMetricProps = BasicMetricProps; - -function callSqlFn( - member: Member, - context: unknown, - nextColumnRefOrDimensionRefAlias?: NextColumnRefOrDimensionRefAlias, -): Ref | undefined { - if (member.props.sql) { - const dimensionMemberModelProp = { - column: (name: string) => { - const columnRef = new ColumnRef(member.model, name, context); - if (nextColumnRefOrDimensionRefAlias) { - return new AliasRef(nextColumnRefOrDimensionRefAlias(), columnRef); - } - return columnRef; - }, - dimension: (name: string) => { - const dimensionRef = new DimensionRef( - member.model.getDimension(name), - context, - ); - if (nextColumnRefOrDimensionRefAlias) { - return new AliasRef(nextColumnRefOrDimensionRefAlias(), dimensionRef); - } - return dimensionRef; - }, - }; - if (member.isDimension()) { - return member.props.sql({ - identifier: (name: string) => new IdentifierRef(name), - model: dimensionMemberModelProp, - sql: (strings, ...values) => new SqlFn([...strings], values), - getContext: () => context, - }); - } - if (member.isMetric()) { - return member.props.sql({ - identifier: (name: string) => new IdentifierRef(name), - model: { - ...dimensionMemberModelProp, - metric: (name: string) => { - const metricRef = new MetricRef( - member, - member.model.getMetric(name), - context, - ); - if (nextColumnRefOrDimensionRefAlias) { - return new AliasRef( - nextColumnRefOrDimensionRefAlias(), - metricRef, - ); - } - return metricRef; - }, - }, - sql: (strings, ...values) => new SqlFn([...strings], values), - getContext: () => context, - }); - } - } -} - -function callAndRenderSqlFn( - repository: AnyRepository, - dialect: AnyBaseDialect, - member: Member, - context: unknown, - nextColumnRefOrDimensionRefAlias?: NextColumnRefOrDimensionRefAlias, -) { - const result = callSqlFn(member, context, nextColumnRefOrDimensionRefAlias); - if (result) { - return result.render(repository, dialect); - } -} - -export abstract class Member { - public abstract readonly name: string; - public abstract readonly model: AnyModel; - public abstract props: AnyBasicDimensionProps | AnyBasicMetricProps; - - abstract getSql( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - ): SqlFragment; - abstract isMetric(): this is BasicMetric; - abstract isDimension(): this is BasicDimension; - - abstract getModelQueryProjection( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - ): SqlFragment[]; - abstract getSegmentQueryProjection( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - modelQueryAlias: string, - ): SqlFragment[]; - abstract getSegmentQueryGroupBy( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - modelQueryAlias: string, - ): SqlFragment[]; - abstract getRootQueryProjection( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - segmentQueryAlias: string, - ): SqlFragment[]; - - getAlias() { - return `${this.model.name}___${pathToAlias(this.name)}`; - } - getPath() { - return `${this.model.name}.${this.name}`; - } - getDescription() { - return this.props.description; - } - getType() { - return this.props.type; - } - getFormat() { - return this.props.format; - } - unsafeFormatValue(value: unknown) { - const format = this.getFormat(); - if (typeof format === "function") { - return (format as (value: unknown) => string)(value); - } - if (format === "currency") { - return `$${value}`; - } - if (format === "percentage") { - return `${value}%`; - } - return String(value); - } - abstract clone(model: AnyModel): Member; -} - -export class BasicDimension extends Member { - constructor( - public readonly model: AnyModel, - public readonly name: string, - public readonly props: AnyBasicDimensionProps, - public readonly granularity?: TemporalGranularity, - ) { - super(); - } - clone(model: AnyModel) { - return new BasicDimension( - model, - this.name, - { ...this.props }, - this.granularity, - ); - } - getSql(repository: AnyRepository, dialect: AnyBaseDialect, context: unknown) { - const result = this.getSqlWithoutGranularity(repository, dialect, context); - - if (this.granularity) { - return SqlFragment.make({ - sql: dialect.withGranularity(this.granularity, result.sql), - bindings: result.bindings, - }); - } - return result; - } - getSqlWithoutGranularity( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - ) { - const result = callAndRenderSqlFn(repository, dialect, this, context); - - if (result) { - return result; - } - - const { sql: asSql, bindings } = this.model.getAs( - repository, - dialect, - context, - ); - const sql = `${asSql}.${dialect.asIdentifier(this.name)}`; - - return SqlFragment.make({ sql, bindings }); - } - getGranularity() { - return this.granularity; - } - isGranularity() { - return !!this.granularity; - } - isPrimaryKey() { - return !!this.props.primaryKey; - } - isDimension(): this is BasicDimension { - return true; - } - isMetric(): this is BasicMetric { - return false; - } - getModelQueryProjection( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - ) { - const { sql, bindings } = this.getSql(repository, dialect, context); - const fragment = dialect.fragment( - `${sql} as ${dialect.asIdentifier(this.getAlias())}`, - bindings, - ); - return [fragment]; - } - - getSegmentQueryProjection( - _repository: AnyRepository, - dialect: AnyBaseDialect, - _context: unknown, - modelQueryAlias: string, - ) { - const fragment = dialect.fragment( - `${dialect.asIdentifier(modelQueryAlias)}.${dialect.asIdentifier( - this.getAlias(), - )} as ${dialect.asIdentifier(this.getAlias())}`, - ); - return [fragment]; - } - getSegmentQueryGroupBy( - _repository: AnyRepository, - dialect: AnyBaseDialect, - _context: unknown, - modelQueryAlias: string, - ): SqlFragment[] { - const fragment = dialect.fragment( - `${dialect.asIdentifier(modelQueryAlias)}.${dialect.asIdentifier( - this.getAlias(), - )}`, - ); - return [fragment]; - } - getRootQueryProjection( - _repository: AnyRepository, - dialect: AnyBaseDialect, - _context: unknown, - segmentQueryAlias: string, - ) { - const fragment = dialect.fragment( - `${dialect.asIdentifier(segmentQueryAlias)}.${dialect.asIdentifier( - this.getAlias(), - )} as ${dialect.asIdentifier(this.getAlias())}`, - ); - return [fragment]; - } -} - -export class BasicMetric extends Member { - constructor( - public readonly model: AnyModel, - public readonly name: string, - public readonly props: AnyBasicMetricProps, - ) { - super(); - } - clone(model: AnyModel) { - return new BasicMetric(model, this.name, { ...this.props }); - } - getNextColumnRefOrDimensionRefAlias() { - let columnRefOrDimensionRefAliasCounter = 0; - return () => - `${this.name}___metric_ref_${columnRefOrDimensionRefAliasCounter++}`; - } - - getSql(repository: AnyRepository, dialect: AnyBaseDialect, context: unknown) { - const result = callAndRenderSqlFn( - repository, - dialect, - this, - context, - this.getNextColumnRefOrDimensionRefAlias(), - ); - - if (result) { - return result; - } - - const { sql: asSql, bindings } = this.model.getAs( - repository, - dialect, - context, - ); - const sql = `${asSql}.${dialect.asIdentifier(this.name)}`; - - return SqlFragment.make({ - sql, - bindings, - }); - } - - getModelQueryProjection( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - ) { - const sqlFnResult = callSqlFn(this, context); - - if (sqlFnResult instanceof SqlFn) { - const nextColumnRefOrDimensionRefAlias = - this.getNextColumnRefOrDimensionRefAlias(); - - const columnOrDimensionRefs: SqlFragment[] = []; - const valuesQueue = [...sqlFnResult.values]; - - while (valuesQueue.length > 0) { - const value = valuesQueue.shift()!; - if (value instanceof DimensionRef || value instanceof ColumnRef) { - const alias = nextColumnRefOrDimensionRefAlias(); - const { sql, bindings } = value.render(repository, dialect); - - columnOrDimensionRefs.push( - SqlFragment.make({ - sql: `${sql} as ${dialect.asIdentifier(alias)}`, - bindings, - }), - ); - } else if (value instanceof MetricRef) { - const alias = nextColumnRefOrDimensionRefAlias(); - const { sql, bindings } = value.render(repository, dialect); - columnOrDimensionRefs.push( - SqlFragment.make({ - sql: `${sql} as ${dialect.asIdentifier(alias)}`, - bindings, - }), - ); - } else if (value instanceof SqlFn) { - valuesQueue.push(...value.values); - } - } - return columnOrDimensionRefs; - } - return []; - } - - getSegmentQueryProjection( - repository: AnyRepository, - dialect: AnyBaseDialect, - context: unknown, - _modelQueryAlias: string, - ): SqlFragment[] { - const { sql, bindings } = this.getSql(repository, dialect, context); - const fragment = dialect.fragment( - `${sql} as ${dialect.asIdentifier(this.getAlias())}`, - bindings, - ); - return [fragment]; - } - getSegmentQueryGroupBy( - _repository: AnyRepository, - _dialect: AnyBaseDialect, - _context: unknown, - _modelQueryAlias: string, - ): SqlFragment[] { - return []; - } - - getRootQueryProjection( - _repository: AnyRepository, - dialect: AnyBaseDialect, - _context: unknown, - segmentQueryAlias: string, - ) { - const fragment = dialect.fragment( - `${dialect.asIdentifier(segmentQueryAlias)}.${dialect.asIdentifier( - this.getAlias(), - )} as ${dialect.asIdentifier(this.getAlias())}`, - ); - return [fragment]; - } - - getMetricRefs(context: unknown) { - const sqlFnResult = callSqlFn(this, context); - if (sqlFnResult) { - const valuesToProcess: unknown[] = [sqlFnResult]; - const refs: MetricRef[] = []; - - while (valuesToProcess.length > 0) { - const ref = valuesToProcess.pop()!; - if (ref instanceof MetricRef) { - refs.push(ref); - } - if (ref instanceof SqlFn) { - valuesToProcess.push(...ref.values); - } - } - return refs; - } - return []; - } - - isDimension(): this is BasicDimension { - return false; - } - isMetric(): this is BasicMetric { - return true; - } -} +export * from "./basic-dimension.js"; +export * from "./basic-metric.js"; +export * from "./granularity-dimension.js"; diff --git a/src/lib/query-builder.ts b/src/lib/query-builder.ts index 3dc8584..ea1ce89 100644 --- a/src/lib/query-builder.ts +++ b/src/lib/query-builder.ts @@ -17,6 +17,7 @@ import { HierarchyElementConfig } from "./hierarchy.js"; import { buildQuery } from "./query-builder/build-query.js"; import { FilterBuilder } from "./query-builder/filter-builder.js"; import { getQueryPlan } from "./query-builder/query-plan.js"; +import { QueryMemberCache } from "./query-builder/query-plan/query-member.js"; import { QuerySchema, buildQuerySchema } from "./query-schema.js"; import type { AnyRepository } from "./repository.js"; import { SqlQuery } from "./sql-builder/to-sql.js"; @@ -129,7 +130,7 @@ export class QueryBuilder< parsedQuery: AnyInputQuery, context: unknown, ): SqlQuery { - const queryPlan = getQueryPlan(this.repository, parsedQuery); + const queryPlan = getQueryPlan(this, context, parsedQuery); const sqlQuery = buildQuery(this, context, queryPlan); return sqlQuery.toSQL(); @@ -175,10 +176,10 @@ export class QueryBuilder< return result; } - getFilterBuilder(): FilterBuilder { + getFilterBuilder(queryMembers: QueryMemberCache): FilterBuilder { return this.repository .getFilterFragmentBuilderRegistry() - .getFilterBuilder(this); + .getFilterBuilder(this, queryMembers); } introspect(query: AnyInputQuery): IntrospectionResult { diff --git a/src/lib/query-builder/build-query.ts b/src/lib/query-builder/build-query.ts index 59084b8..0d7e8f3 100644 --- a/src/lib/query-builder/build-query.ts +++ b/src/lib/query-builder/build-query.ts @@ -1,12 +1,12 @@ import { SqlFragment, SqlQueryBuilder } from "../sql-builder.js"; -import { AnyInputQuery, Order } from "../types.js"; -import { QueryPlan, getQueryPlan } from "./query-plan.js"; import invariant from "tiny-invariant"; import type { AnyJoin } from "../join.js"; import { AnyQueryBuilder } from "../query-builder.js"; import type { AnyRepository } from "../repository.js"; +import { Order } from "../types.js"; import { METRIC_REF_SUBQUERY_ALIAS } from "../util.js"; +import { QueryPlan } from "./query-plan.js"; function getDefaultOrderBy( repository: AnyRepository, @@ -17,7 +17,7 @@ function getDefaultOrderBy( for (const dimensionName of query.projectedDimensions ?? []) { const dimension = repository.getDimension(dimensionName); - if (dimension.getGranularity()) { + if (dimension.isGranularity()) { return [{ member: dimensionName, direction: "asc" }]; } } @@ -52,18 +52,15 @@ function buildModelQuery( const sqlQuery = queryBuilder.dialect.from( model.getTableNameOrSql( queryBuilder.repository, + segment.queryMembers, queryBuilder.dialect, context, ), ); - for (const memberName of segment.modelQuery.members) { - const member = queryBuilder.repository.getMember(memberName); - const modelQueryProjection = member.getModelQueryProjection( - queryBuilder.repository, - queryBuilder.dialect, - context, - ); + for (const memberPath of segment.modelQuery.members) { + const queryMember = segment.queryMembers.getByPath(memberPath); + const modelQueryProjection = queryMember.getModelQueryProjection(); for (const fragment of modelQueryProjection) { sqlQuery.select(fragment); @@ -90,10 +87,15 @@ function buildModelQuery( const joinType = join.reversed ? "rightJoin" : "leftJoin"; const joinOn = join .joinOnDef(context) - .render(queryBuilder.repository, queryBuilder.dialect); + .render( + queryBuilder.repository, + segment.queryMembers, + queryBuilder.dialect, + ); const rightModel = queryBuilder.repository.getModel(join.right); const joinSubject = rightModel.getTableNameOrSql( queryBuilder.repository, + segment.queryMembers, queryBuilder.dialect, context, ); @@ -115,72 +117,37 @@ function buildModelQuery( join: queryBuilder.repository.getJoin(modelName, unvisitedModelName), })), ); + } - const metricRefs = Array.from( - new Set( - segment.modelQuery.metrics.flatMap((metricName) => { - const metric = queryBuilder.repository.getMetric(metricName); - return metric - .getMetricRefs(context) - .map((metricRef) => metricRef.metric.getPath()); - }), - ), + if (segment.metricsRefsSubQueryPlan) { + const { sql, bindings } = buildQuery( + queryBuilder, + context, + segment.metricsRefsSubQueryPlan.queryPlan, + ).toSQL(); + + const joinOn = segment.metricsRefsSubQueryPlan.joinOnDimensions.reduce<{ + sqls: string[]; + bindings: unknown[]; + }>( + (acc, dimensionPath) => { + const dimensionQueryMember = + segment.queryMembers.getByPath(dimensionPath); + + const { sql, bindings } = dimensionQueryMember.getSql(); + acc.sqls.push( + `${sql} = ${queryBuilder.dialect.asIdentifier(METRIC_REF_SUBQUERY_ALIAS)}.${queryBuilder.dialect.asIdentifier(dimensionQueryMember.getAlias())}`, + ); + acc.bindings.push(...bindings); + return acc; + }, + { sqls: [], bindings: [] }, ); - /*if (modelQueryMetricRefsSubQuery) { - const alias = `${pathToAlias(memberName)}___metric_refs_subquery`; - const { sql, bindings } = modelQueryMetricRefsSubQuery.toSQL(); - console.log(sql); - console.log("---------------------------------"); sqlQuery.leftJoin( - new SqlFragment(`(${sql}) as ${alias}`, bindings), - filteredDimensions - .map((dimensionPath) => { - const dimension = queryBuilder.repository.getDimension(dimensionPath); - - return `${dimension.getSql(queryBuilder.repository, queryBuilder.dialect, context).sql} = ${alias}.${dimension.getAlias()}`; - }) - .join(" and "), + new SqlFragment(`(${sql}) as ${METRIC_REF_SUBQUERY_ALIAS}`, bindings), + new SqlFragment(joinOn.sqls.join(" and "), joinOn.bindings), ); - }*/ - - if (metricRefs.length > 0) { - const query: AnyInputQuery = { - members: [...segment.modelQuery.dimensions, ...metricRefs], - filters: segment.filters, - }; - const queryPlan = getQueryPlan(queryBuilder.repository, query); - const { sql, bindings } = buildQuery( - queryBuilder, - context, - queryPlan, - ).toSQL(); - - const joinOn = segment.modelQuery.dimensions.reduce<{ - sqls: string[]; - bindings: unknown[]; - }>( - (acc, dimensionPath) => { - const dimension = queryBuilder.repository.getDimension(dimensionPath); - const { sql, bindings } = dimension.getSql( - queryBuilder.repository, - queryBuilder.dialect, - context, - ); - acc.sqls.push( - `${sql} = ${queryBuilder.dialect.asIdentifier(METRIC_REF_SUBQUERY_ALIAS)}.${queryBuilder.dialect.asIdentifier(dimension.getAlias())}`, - ); - acc.bindings.push(...bindings); - return acc; - }, - { sqls: [], bindings: [] }, - ); - - sqlQuery.leftJoin( - new SqlFragment(`(${sql}) as ${METRIC_REF_SUBQUERY_ALIAS}`, bindings), - new SqlFragment(joinOn.sqls.join(" and "), joinOn.bindings), - ); - } } return sqlQuery; @@ -198,7 +165,7 @@ function buildSegmentQuery( if (segment.filters) { const filter = queryBuilder - .getFilterBuilder() + .getFilterBuilder(segment.queryMembers) .buildFilters(segment.filters, "and", context); if (filter) { @@ -215,14 +182,10 @@ function buildSegmentQuery( initialSqlQuery.as(modelQueryAlias), ); - for (const memberName of segment.segmentQuery.members) { - const member = queryBuilder.repository.getMember(memberName); - const segmentQueryProjection = member.getSegmentQueryProjection( - queryBuilder.repository, - queryBuilder.dialect, - context, - modelQueryAlias, - ); + for (const memberPath of segment.segmentQuery.members) { + const queryMember = segment.queryMembers.getByPath(memberPath); + const segmentQueryProjection = + queryMember.getSegmentQueryProjection(modelQueryAlias); for (const fragment of segmentQueryProjection) { sqlQuery.select(fragment); @@ -230,12 +193,8 @@ function buildSegmentQuery( // We always GROUP BY the dimensions, if there are no metrics, it will behave as DISTINCT // For metrics, this is currently NOOP because Metric returns an empty array - const segmentQueryGroupBy = member.getSegmentQueryGroupBy( - queryBuilder.repository, - queryBuilder.dialect, - context, - modelQueryAlias, - ); + const segmentQueryGroupBy = + queryMember.getSegmentQueryGroupBy(modelQueryAlias); for (const fragment of segmentQueryGroupBy) { sqlQuery.groupBy(fragment); @@ -289,15 +248,11 @@ function buildRootQuery( initialSegmentWithSqlQuery.sqlQuery.as(rootQueryAlias), ); - for (const memberName of initialSegmentWithSqlQuery.segment.rootQuery + for (const memberPath of initialSegmentWithSqlQuery.segment.rootQuery .members) { - const member = queryBuilder.repository.getMember(memberName); - const rootQueryProjection = member.getRootQueryProjection( - queryBuilder.repository, - queryBuilder.dialect, - context, - rootQueryAlias, - ); + const queryMember = queryPlan.queryMembers.getByPath(memberPath); + const rootQueryProjection = + queryMember.getRootQueryProjection(rootQueryAlias); for (const fragment of rootQueryProjection) { rootSqlQuery.select(fragment); @@ -329,14 +284,10 @@ function buildRootQuery( queryBuilder.dialect.fragment(joinOn), ); - for (const metricName of segmentWithSqlQuery.segment.rootQuery.metrics) { - const metric = queryBuilder.repository.getMetric(metricName); - const rootQueryProjection = metric.getRootQueryProjection( - queryBuilder.repository, - queryBuilder.dialect, - context, - segmentQueryAlias, - ); + for (const metricPath of segmentWithSqlQuery.segment.rootQuery.metrics) { + const queryMember = queryPlan.queryMembers.getByPath(metricPath); + const rootQueryProjection = + queryMember.getRootQueryProjection(segmentQueryAlias); for (const fragment of rootQueryProjection) { rootSqlQuery.select(fragment); @@ -355,7 +306,7 @@ export function buildQuery( if (queryPlan.filters) { const filter = queryBuilder - .getFilterBuilder() + .getFilterBuilder(queryPlan.queryMembers) .buildFilters(queryPlan.filters, "and", context); if (filter) { diff --git a/src/lib/query-builder/filter-builder.ts b/src/lib/query-builder/filter-builder.ts index e2f7292..ea2d922 100644 --- a/src/lib/query-builder/filter-builder.ts +++ b/src/lib/query-builder/filter-builder.ts @@ -41,6 +41,7 @@ import { import { AnyQueryBuilder } from "../query-builder.js"; import { SqlFragment } from "../sql-builder.js"; +import { QueryMemberCache } from "./query-plan/query-member.js"; export class FilterBuilder { constructor( @@ -48,22 +49,12 @@ export class FilterBuilder { string, AnyFilterFragmentBuilder >, - public readonly queryBuilder: AnyQueryBuilder, + readonly queryBuilder: AnyQueryBuilder, + private readonly queryMembers: QueryMemberCache, ) {} - getMemberSql(memberName: string, context: unknown): SqlFragment | undefined { - const member = this.queryBuilder.repository.getMember(memberName); - - if (member.isDimension()) { - return member.getSql( - this.queryBuilder.repository, - this.queryBuilder.dialect, - context, - ); - } - if (member.isMetric()) { - const sql = this.queryBuilder.dialect.asIdentifier(member.getAlias()); - return SqlFragment.fromSql(sql); - } + getMemberSql(memberPath: string): SqlFragment | undefined { + const queryMember = this.queryMembers.getByPath(memberPath); + return queryMember.getFilterSql(); } buildOr(filter: OrConnective, context: unknown): SqlFragment | undefined { @@ -82,7 +73,7 @@ export class FilterBuilder { if (filter.operator === "or") { return this.buildOr(filter, context); } - const memberSql = this.getMemberSql(filter.member, context); + const memberSql = this.getMemberSql(filter.member); if (memberSql) { const builder = this.filterFragmentBuilders[filter.operator]; if (builder) { @@ -141,8 +132,15 @@ export class FilterFragmentBuilderRegistry { getFilterFragmentBuilders() { return Object.values(this.filterFragmentBuilders); } - getFilterBuilder(queryBuilder: AnyQueryBuilder): FilterBuilder { - return new FilterBuilder(this.filterFragmentBuilders, queryBuilder); + getFilterBuilder( + queryBuilder: AnyQueryBuilder, + queryMembers: QueryMemberCache, + ): FilterBuilder { + return new FilterBuilder( + this.filterFragmentBuilders, + queryBuilder, + queryMembers, + ); } } diff --git a/src/lib/query-builder/query-plan.ts b/src/lib/query-builder/query-plan.ts index c5a25b5..fa50ace 100644 --- a/src/lib/query-builder/query-plan.ts +++ b/src/lib/query-builder/query-plan.ts @@ -1,14 +1,12 @@ -import { - AnyModel, - BasicDimension, - BasicMetric, - Member, -} from "../semantic-layer.js"; +import { Dimension, Member, Metric } from "../member.js"; +import { AnyModel, AnyQueryBuilder } from "../semantic-layer.js"; import { AnyInputQuery, Order } from "../types.js"; import invariant from "tiny-invariant"; +import { BasicMetricQueryMember } from "../model/basic-metric.js"; import { AnyRepository } from "../repository.js"; import { findOptimalJoinGraph } from "./optimal-join-graph.js"; +import { QueryMemberCache } from "./query-plan/query-member.js"; export type QueryFilterConnective = { operator: "and" | "or"; @@ -78,8 +76,8 @@ function getMembersDimensionsAndMetrics( members: string[], ) { return members.reduce<{ - dimensions: BasicDimension[]; - metrics: BasicMetric[]; + dimensions: Dimension[]; + metrics: Metric[]; }>( (acc, memberName) => { const member = repository.getMember(memberName); @@ -102,20 +100,20 @@ function getSegmentQueryModelsAndMembers({ dimensions, metrics, }: { - dimensions: { projected: BasicDimension[]; filter: BasicDimension[] }; + dimensions: { projected: Dimension[]; filter: Dimension[] }; metrics?: { - projected: BasicMetric[]; - filter: BasicMetric[]; + projected: Metric[]; + filter: Metric[]; model: string; }; }) { const models = new Set(); - const modelQueryDimensions = new Set(); - const modelQueryMetrics = new Set(); - const segmentQueryDimensions = new Set(); - const segmentQueryMetrics = new Set(); - const rootQueryDimensions = new Set(); - const rootQueryMetrics = new Set(); + const modelQueryDimensions = new Set(); + const modelQueryMetrics = new Set(); + const segmentQueryDimensions = new Set(); + const segmentQueryMetrics = new Set(); + const rootQueryDimensions = new Set(); + const rootQueryMetrics = new Set(); for (const dimension of dimensions.projected) { modelQueryDimensions.add(dimension); @@ -193,22 +191,59 @@ function getSegmentQueryModelsAndMembers({ }; } +function getSegmentQueryMetricsRefsSubQueryPlan( + queryBuilder: AnyQueryBuilder, + queryMembers: QueryMemberCache, + context: unknown, + dimensions: string[], + metrics: string[], + filters: QueryFilter[], +): { joinOnDimensions: string[]; queryPlan: QueryPlan } | undefined { + const metricRefs = Array.from( + new Set( + metrics.flatMap((metricPath) => { + const metricQueryMember = queryMembers.getByPath(metricPath); + if (metricQueryMember instanceof BasicMetricQueryMember) { + return metricQueryMember + .getMetricRefs() + .map((metricRef) => metricRef.metric.getPath()); + } + return []; + }), + ), + ); + if (metricRefs.length > 0) { + const query: AnyInputQuery = { + members: [...dimensions, ...metricRefs], + filters, + }; + const queryPlan = getQueryPlan(queryBuilder, context, query); + const joinOnDimensions = [...dimensions]; + return { + queryPlan, + joinOnDimensions, + }; + } +} + function getSegmentQuery( - repository: AnyRepository, + queryBuilder: AnyQueryBuilder, + queryMembers: QueryMemberCache, + context: unknown, + alias: string, { dimensions, metrics, filters, }: { - dimensions: { projected: BasicDimension[]; filter: BasicDimension[] }; + dimensions: { projected: Dimension[]; filter: Dimension[] }; metrics?: { - projected: BasicMetric[]; - filter: BasicMetric[]; + projected: Metric[]; + filter: Metric[]; model: string; }; filters: QueryFilter[]; }, - alias: string, ) { const initialModel = metrics?.model ?? @@ -223,12 +258,21 @@ function getSegmentQuery( }); const joinGraph = findOptimalJoinGraph( - repository.graph, + queryBuilder.repository.graph, segmentModelsAndMembers.models, ); return { ...segmentModelsAndMembers, + metricsRefsSubQueryPlan: getSegmentQueryMetricsRefsSubQueryPlan( + queryBuilder, + queryMembers, + context, + segmentModelsAndMembers.modelQuery.dimensions, + segmentModelsAndMembers.modelQuery.metrics, + filters, + ), + queryMembers: queryMembers, alias, joinGraph, initialModel, @@ -252,12 +296,12 @@ function orderWithOnlyProjectedMembers( } function getMetricsByModel( - projectedMetrics: BasicMetric[], - filtersMetrics: BasicMetric[], + projectedMetrics: Metric[], + filtersMetrics: Metric[], ) { const metricsByModel: Record< string, - { projected: BasicMetric[]; filter: BasicMetric[] } + { projected: Metric[]; filter: Metric[] } > = {}; for (const m of projectedMetrics) { @@ -273,7 +317,17 @@ function getMetricsByModel( return Object.entries(metricsByModel); } -export function getQueryPlan(repository: AnyRepository, query: AnyInputQuery) { +export function getQueryPlan( + queryBuilder: AnyQueryBuilder, + context: unknown, + query: AnyInputQuery, +) { + const repository = queryBuilder.repository; + const queryMembers = new QueryMemberCache( + repository, + queryBuilder.dialect, + context, + ); const { dimensions: projectedDimensions, metrics: projectedMetrics } = getMembersDimensionsAndMetrics(repository, query.members); const { dimensionFilters, metricFilters } = getDimensionAndMetricFilters( @@ -281,10 +335,10 @@ export function getQueryPlan(repository: AnyRepository, query: AnyInputQuery) { query.filters, ); const filtersDimensions = ( - getFiltersMembers(repository, dimensionFilters) as BasicDimension[] + getFiltersMembers(repository, dimensionFilters) as Dimension[] ).filter((dimension) => !projectedDimensions.includes(dimension)); const filtersMetrics = ( - getFiltersMembers(repository, metricFilters) as BasicMetric[] + getFiltersMembers(repository, metricFilters) as Metric[] ).filter((metric) => !projectedMetrics.includes(metric)); const metricsByModel = getMetricsByModel(projectedMetrics, filtersMetrics); @@ -293,7 +347,10 @@ export function getQueryPlan(repository: AnyRepository, query: AnyInputQuery) { metricsByModel.length > 0 ? metricsByModel.map(([modelName, metrics], index) => getSegmentQuery( - repository, + queryBuilder, + queryMembers, + context, + getSegmentAlias(index), { dimensions: { projected: projectedDimensions, @@ -306,12 +363,14 @@ export function getQueryPlan(repository: AnyRepository, query: AnyInputQuery) { }, filters: dimensionFilters, }, - getSegmentAlias(index), ), ) : [ getSegmentQuery( - repository, + queryBuilder, + queryMembers, + context, + getSegmentAlias(0), { dimensions: { projected: projectedDimensions, @@ -319,7 +378,6 @@ export function getQueryPlan(repository: AnyRepository, query: AnyInputQuery) { }, filters: dimensionFilters, }, - getSegmentAlias(0), ), ]; @@ -328,6 +386,7 @@ export function getQueryPlan(repository: AnyRepository, query: AnyInputQuery) { return { segments, + queryMembers, filters: metricFilters, projectedDimensions: projectedDimensionPaths, projectedMetrics: projectedMetricPaths, diff --git a/src/lib/query-builder/query-plan/query-member.ts b/src/lib/query-builder/query-plan/query-member.ts new file mode 100644 index 0000000..1cdf165 --- /dev/null +++ b/src/lib/query-builder/query-plan/query-member.ts @@ -0,0 +1,40 @@ +import { AnyBaseDialect } from "../../dialect/base.js"; +import { Member } from "../../member.js"; +import { AnyRepository } from "../../repository.js"; +import { SqlFragment } from "../../sql-builder.js"; + +export class QueryMemberCache { + private cache: Record = {}; + constructor( + private readonly repository: AnyRepository, + private readonly dialect: AnyBaseDialect, + private readonly context: unknown, + ) {} + getByPath(memberPath: string) { + const cached = this.cache[memberPath]; + if (cached) { + return cached; + } + const member = this.repository + .getMember(memberPath) + .getQueryMember(this, this.repository, this.dialect, this.context); + this.cache[memberPath] = member; + return member; + } +} + +export abstract class QueryMember { + abstract readonly queryMembers: QueryMemberCache; + abstract readonly repository: AnyRepository; + abstract readonly dialect: AnyBaseDialect; + abstract readonly context: unknown; + abstract readonly member: Member; + + abstract getSql(): SqlFragment; + abstract getFilterSql(): SqlFragment; + abstract getAlias(): string; + abstract getModelQueryProjection(): SqlFragment[]; + abstract getSegmentQueryProjection(modelQueryAlias: string): SqlFragment[]; + abstract getSegmentQueryGroupBy(modelQueryAlias: string): SqlFragment[]; + abstract getRootQueryProjection(segmentQueryAlias: string): SqlFragment[]; +} diff --git a/src/lib/repository.ts b/src/lib/repository.ts index f686542..3dde09a 100644 --- a/src/lib/repository.ts +++ b/src/lib/repository.ts @@ -16,7 +16,7 @@ import { makeModelJoinPayload, } from "./join.js"; import { AnyModel, Model } from "./model.js"; -import type { BasicDimension, BasicMetric } from "./model/member.js"; + import { AnyFilterFragmentBuilderRegistry, GetFilterFragmentBuilderRegistryPayload, @@ -26,6 +26,7 @@ import { HierarchyType, MemberNameToType } from "./types.js"; import graphlib from "@dagrejs/graphlib"; import invariant from "tiny-invariant"; +import { Dimension, Metric } from "./member.js"; import { QueryBuilder } from "./query-builder.js"; import { IdentifierRef, SqlFn } from "./sql-fn.js"; @@ -260,7 +261,7 @@ export class Repository< return this.join("manyToMany", model1, model2, joinSqlDefFn); } - getDimension(dimensionName: string): BasicDimension { + getDimension(dimensionName: string): Dimension { invariant( this.dimensionsIndex[dimensionName], `Dimension ${dimensionName} not found`, @@ -274,7 +275,7 @@ export class Repository< return model.getDimension(dimension); } - getMetric(metricName: string): BasicMetric { + getMetric(metricName: string): Metric { invariant(this.metricsIndex[metricName], `Metric ${metricName} not found`); const { model: modelName, metric } = this.metricsIndex[metricName]!; const model = this.models[modelName]; @@ -284,7 +285,7 @@ export class Repository< return model.getMetric(metric); } - getMember(memberName: string): BasicMetric | BasicDimension { + getMember(memberName: string): Metric | Dimension { if (this.dimensionsIndex[memberName]) { return this.getDimension(memberName); } @@ -294,11 +295,11 @@ export class Repository< throw new Error(`Member ${memberName} not found`); } - getDimensions(): BasicDimension[] { + getDimensions(): Dimension[] { return Object.values(this.models).flatMap((m) => m.getDimensions()); } - getMetrics(): BasicMetric[] { + getMetrics(): Metric[] { return Object.values(this.models).flatMap((m) => m.getMetrics()); } diff --git a/src/lib/semantic-layer.ts b/src/lib/semantic-layer.ts index 248341a..c90c7d5 100644 --- a/src/lib/semantic-layer.ts +++ b/src/lib/semantic-layer.ts @@ -1,7 +1,6 @@ export type * from "./repository.js"; export type * from "./model.js"; export type * from "./sql-fn.js"; -export type * from "./model/member.js"; export type * from "./join.js"; export type * from "./query-schema.js"; export type * from "./query-builder.js"; @@ -12,6 +11,9 @@ export type * from "./sql-builder/to-sql.js"; export type * from "./query-builder/filter-builder/filter-fragment-builder.js"; export type * from "./dialect.js"; export type * from "./hierarchy.js"; +export type * from "./member.js"; +export type * from "./model/member.js"; +export type * from "./query-builder/query-plan/query-member.js"; import * as helpers from "./helpers.js"; import { model } from "./model.js"; diff --git a/src/lib/sql-fn.ts b/src/lib/sql-fn.ts index 86b3894..be440cc 100644 --- a/src/lib/sql-fn.ts +++ b/src/lib/sql-fn.ts @@ -1,7 +1,8 @@ -import { BasicDimension, BasicMetric, Member } from "./model/member.js"; +import { Dimension, Member, Metric } from "./member.js"; import { AnyBaseDialect } from "./dialect/base.js"; import { AnyModel } from "./model.js"; +import { QueryMemberCache } from "./query-builder/query-plan/query-member.js"; import { AnyRepository } from "./repository.js"; import { SqlFragment } from "./sql-builder.js"; import { METRIC_REF_SUBQUERY_ALIAS } from "./util.js"; @@ -9,31 +10,43 @@ import { METRIC_REF_SUBQUERY_ALIAS } from "./util.js"; export abstract class Ref { public abstract render( repository: AnyRepository, + queryMembers: QueryMemberCache, dialect: AnyBaseDialect, ): SqlFragment; } export class DimensionRef extends Ref { constructor( - private readonly dimension: BasicDimension, + private readonly dimension: Dimension, private readonly context: unknown, ) { super(); } - render(repository: AnyRepository, dialect: AnyBaseDialect) { - return this.dimension.getSql(repository, dialect, this.context); + render( + _repository: AnyRepository, + queryMembers: QueryMemberCache, + _dialect: AnyBaseDialect, + ) { + const dimensionQueryMember = queryMembers.getByPath( + this.dimension.getPath(), + ); + return dimensionQueryMember.getSql(); } } export class MetricRef extends Ref { constructor( readonly owner: Member, - readonly metric: BasicMetric, + readonly metric: Metric, private readonly context: unknown, ) { super(); } - render(_repository: AnyRepository, dialect: AnyBaseDialect) { + render( + _repository: AnyRepository, + _queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + ) { return SqlFragment.fromSql( `${dialect.asIdentifier(METRIC_REF_SUBQUERY_ALIAS)}.${dialect.asIdentifier( this.metric.getAlias(), @@ -50,9 +63,14 @@ export class ColumnRef extends Ref { ) { super(); } - render(repository: AnyRepository, dialect: AnyBaseDialect) { + render( + repository: AnyRepository, + queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + ) { const { sql: asSql, bindings } = this.model.getAs( repository, + queryMembers, dialect, this.context, ); @@ -63,14 +81,20 @@ export class ColumnRef extends Ref { } } -export class AliasRef extends Ref { +export class AliasRef< + T extends DimensionRef | ColumnRef | MetricRef, +> extends Ref { constructor( - private readonly alias: string, - readonly aliasOf: DimensionRef | ColumnRef | MetricRef, + readonly alias: string, + readonly aliasOf: T, ) { super(); } - render(_repository: AnyRepository, dialect: AnyBaseDialect) { + render( + _repository: AnyRepository, + _queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + ) { return SqlFragment.fromSql(dialect.asIdentifier(this.alias)); } } @@ -79,7 +103,11 @@ export class IdentifierRef extends Ref { constructor(private readonly identifier: string) { super(); } - render(_repository: AnyRepository, dialect: AnyBaseDialect) { + render( + _repository: AnyRepository, + _queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + ) { return SqlFragment.make({ sql: dialect.asIdentifier(this.identifier), bindings: [], @@ -95,7 +123,12 @@ export class SqlFn extends Ref { super(); } - render(repository: AnyRepository, dialect: AnyBaseDialect) { + render( + repository: AnyRepository, + + queryMembers: QueryMemberCache, + dialect: AnyBaseDialect, + ) { const sql: string[] = []; const bindings: unknown[] = []; for (let i = 0; i < this.strings.length; i++) { @@ -103,7 +136,7 @@ export class SqlFn extends Ref { if (this.values[i]) { const value = this.values[i]; if (value instanceof Ref) { - const result = value.render(repository, dialect); + const result = value.render(repository, queryMembers, dialect); sql.push(result.sql); bindings.push(...result.bindings); } else { diff --git a/src/lib/types.ts b/src/lib/types.ts index fc8c8d7..4b52896 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -364,5 +364,3 @@ export type ModelMemberWithoutModelPrefix< TModelName extends string, TDimensionName extends string, > = TDimensionName extends `${TModelName}.${infer D}` ? D : never; - -export type NextColumnRefOrDimensionRefAlias = () => string;