diff --git a/.cspell.json b/.cspell.json index a9f7279..f52f75d 100644 --- a/.cspell.json +++ b/.cspell.json @@ -26,6 +26,7 @@ "Galactica", "gjuchault", "Goyer", + "granularities", "graphlib", "hler", "ilike", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 1a9f7f4..5da0afc 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -94,7 +94,7 @@ await describe("semantic layer", async () => { sql: ({ model }) => model.column("CustomerId"), }) .withDimension("invoice_date", { - type: "date", + type: "datetime", sql: ({ model }) => model.column("InvoiceDate"), }) .withMetric("total", { @@ -522,6 +522,56 @@ await describe("semantic layer", async () => { { customers___customer_id: 1, albums___title: "Use Your Illusion I" }, ]); }); + + await it("can correctly query datetime granularities", async () => { + const query = queryBuilder.buildQuery({ + dimensions: [ + "invoices.invoice_id", + "invoices.invoice_date", + "invoices.invoice_date.time", + "invoices.invoice_date.date", + "invoices.invoice_date.year", + "invoices.invoice_date.quarter", + "invoices.invoice_date.quarter_of_year", + "invoices.invoice_date.month", + "invoices.invoice_date.month_num", + "invoices.invoice_date.week", + "invoices.invoice_date.week_num", + "invoices.invoice_date.day_of_month", + "invoices.invoice_date.hour", + "invoices.invoice_date.hour_of_day", + "invoices.invoice_date.minute", + ], + filters: [ + { operator: "equals", member: "invoices.invoice_id", value: [6] }, + ], + }); + + const result = await client.query>( + query.sql, + query.bindings, + ); + + assert.deepEqual(result.rows, [ + { + invoices___invoice_date: new Date("2009-01-18T23:00:00.000Z"), + invoices___invoice_date___date: new Date("2009-01-18T23:00:00.000Z"), + invoices___invoice_date___day_of_month: 19, + invoices___invoice_date___hour: "2009-01-19 0", + invoices___invoice_date___hour_of_day: 0, + invoices___invoice_date___minute: "2009-01-19 0:0", + invoices___invoice_date___month: "2009-1", + invoices___invoice_date___month_num: 1, + invoices___invoice_date___quarter: "2009-Q1", + invoices___invoice_date___quarter_of_year: 1, + invoices___invoice_date___time: "00:00:00", + invoices___invoice_date___week: "2009-W4", + invoices___invoice_date___week_num: 4, + invoices___invoice_date___year: 2009, + invoices___invoice_id: 6, + }, + ]); + }); }); await describe("models from sql queries", async () => { diff --git a/src/lib/dialect/base.ts b/src/lib/dialect/base.ts index c689ed1..8789f2c 100644 --- a/src/lib/dialect/base.ts +++ b/src/lib/dialect/base.ts @@ -3,22 +3,33 @@ import { Granularity } from "../types.js"; export class BaseDialect { withGranularity(granularity: Granularity, sql: string) { switch (granularity) { - case "day": - return `EXTRACT(DAY FROM ${sql})`; - case "week": - return `EXTRACT(WEEK FROM ${sql})`; - case "month": - return `EXTRACT(MONTH FROM ${sql})`; - case "quarter": - return `EXTRACT(QUARTER FROM ${sql})`; + case "time": + return `CAST(${sql} AS TIME)`; + case "date": + return `CAST(${sql} AS DATE)`; case "year": return `EXTRACT(YEAR FROM ${sql})`; + case "quarter": + return `EXTRACT(YEAR FROM ${sql}) || '-' || 'Q' || EXTRACT(QUARTER FROM ${sql})`; + case "quarter_of_year": + return `EXTRACT(QUARTER FROM ${sql})`; + case "month": + return `EXTRACT (YEAR FROM ${sql}) || '-' || EXTRACT(MONTH FROM ${sql})`; + case "month_num": + return `EXTRACT(MONTH FROM ${sql})`; + case "week": + return `EXTRACT (YEAR FROM ${sql}) || '-' || 'W' || EXTRACT(WEEK FROM ${sql})`; + case "week_num": + return `EXTRACT(WEEK FROM ${sql})`; + case "day_of_month": + return `EXTRACT(DAY FROM ${sql})`; case "hour": + return `CAST(${sql} AS DATE) || ' ' || EXTRACT(HOUR FROM ${sql})`; + case "hour_of_day": return `EXTRACT(HOUR FROM ${sql})`; case "minute": - return `EXTRACT(MINUTE FROM ${sql})`; - case "second": - return `EXTRACT(SECOND FROM ${sql})`; + return `CAST(${sql} AS DATE) || ' ' || EXTRACT(HOUR FROM ${sql}) || ':' || EXTRACT(MINUTE FROM ${sql})`; + default: // biome-ignore lint/correctness/noSwitchDeclarations: Exhaustiveness check const _exhaustiveCheck: never = granularity; diff --git a/src/lib/model.ts b/src/lib/model.ts index a088eb5..1e61436 100644 --- a/src/lib/model.ts +++ b/src/lib/model.ts @@ -1,6 +1,8 @@ import { + DimensionWithGranularity, Granularity, GranularityByDimensionType, + GranularityIndex, MemberFormat, MemberNameToType, MemberType, @@ -114,9 +116,7 @@ export type WithGranularityDimensions< N extends string, T extends string, > = T extends keyof GranularityByDimensionType - ? { [k in N]: T } & { - [k in `${N}.${GranularityByDimensionType[T][number]}`]: number; - } + ? { [k in N]: T } & DimensionWithGranularity : { [k in N]: T }; export interface DimensionProps { @@ -310,7 +310,11 @@ export class Model< this.dimensions[`${name}.${g}`] = new Dimension( this, `${name}.${g}`, - { ...dimension, type: "number" }, + { + ...dimension, + type: GranularityIndex[g].type, + description: GranularityIndex[g].description, + }, g, ); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 77930a8..b1c4267 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -57,26 +57,122 @@ export interface SqlWithBindings { bindings: unknown[]; } +export const GranularityIndex = { + time: { + description: "Time of underlying field. Example output: 00:00:00", + type: "time", + }, + date: { + description: "Date of underlying field. Example output: 2021-01-01", + type: "date", + }, + year: { + description: "Year of underlying field. Example output: 2021", + type: "number", + }, + quarter: { + description: "Quarter of underlying field. Example output: 2021-Q1", + type: "string", + }, + quarter_of_year: { + description: "Quarter of year of underlying field. Example output: 1", + type: "number", + }, + month: { + description: "Month of underlying field. Example output: 2021-01", + type: "string", + }, + month_num: { + description: "Month number of underlying field. Example output: 1", + type: "number", + }, + week: { + description: "Week of underlying field. Example output: 2021-W01", + type: "string", + }, + week_num: { + description: "Week number of underlying field. Example output: 1", + type: "number", + }, + day_of_month: { + description: "Day of month of underlying field. Example output: 1", + type: "number", + }, + hour: { + description: + "Datetime of the underlying field truncated to the hour. Example output: 2021-01-01 00", + type: "string", + }, + hour_of_day: { + description: "Hour of underlying field. Example output: 00", + type: "string", + }, + minute: { + description: + "Datetime of the underlying field truncated to the minute. Example output: 2021-01-01 00:00", + type: "string", + }, +} as const satisfies Record; + +export type GranularityIndex = typeof GranularityIndex; + +export type GranularityToMemberType = { + [K in keyof GranularityIndex]: GranularityIndex[K]["type"]; +}; + +function granularities( + ...granularities: T +): T[number][] { + return granularities; +} + export const GranularityByDimensionType = { - time: ["hour", "minute", "second"], - date: ["year", "quarter", "month", "week", "day"], - datetime: [ + time: granularities("hour", "hour_of_day", "minute"), + date: granularities( "year", "quarter", + "quarter_of_year", "month", + "month_num", "week", - "day", + "week_num", + "day_of_month", + ), + datetime: granularities( + "time", + "date", + "year", + "quarter", + "quarter_of_year", + "month", + "month_num", + "week", + "week_num", + "day_of_month", "hour", + "hour_of_day", "minute", - "second", - ], + ), } as const; export type GranularityByDimensionType = typeof GranularityByDimensionType; -export type Granularity = - GranularityByDimensionType[keyof GranularityByDimensionType][number]; +export type Granularity = keyof typeof GranularityIndex; + +export type DimensionWithGranularity< + D extends string, + T extends keyof GranularityByDimensionType, + GT extends keyof GranularityIndex = GranularityByDimensionType[T][number], +> = { + [K in GT as `${D}.${K}`]: GranularityToMemberType[K]; +}; -export type MemberType = "string" | "number" | "date" | "datetime" | "boolean"; +export type MemberType = + | "string" + | "number" + | "date" + | "datetime" + | "time" + | "boolean"; export type MemberFormat = "percentage" | "currency"; export type MemberNameToType = { [k in never]: MemberType }; @@ -86,14 +182,19 @@ export type QueryReturnType< N extends keyof M, S = Pick, > = { - [K in keyof S as Replace]: S[K] extends "string" + [K in keyof S as Replace< + string & K, + ".", + "___", + { all: true } + >]: S[K] extends "string" ? string : S[K] extends "number" ? number : S[K] extends "date" ? Date : S[K] extends "time" - ? Date + ? string : S[K] extends "datetime" ? Date : S[K] extends "boolean" @@ -102,7 +203,7 @@ export type QueryReturnType< }; export type ProcessTOverridesNames> = { - [K in keyof T as Replace]: T[K]; + [K in keyof T as Replace]: T[K]; }; // biome-ignore lint/correctness/noUnusedVariables: We need the RT generic param to be present so we can extract it to infer the return type later