Skip to content

Commit

Permalink
feat: improve date/datetime/time dimension granularities
Browse files Browse the repository at this point in the history
  • Loading branch information
retro committed Apr 14, 2024
1 parent bc8edc8 commit c1b23be
Show file tree
Hide file tree
Showing 5 changed files with 201 additions and 28 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"Galactica",
"gjuchault",
"Goyer",
"granularities",
"graphlib",
"hler",
"ilike",
Expand Down
58 changes: 57 additions & 1 deletion src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,16 @@ await describe("semantic layer", async () => {
user: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
options: "-c TimeZone=UTC",
});

await client.connect();
await client.query(bootstrapSql);

const timezoneResult = await client.query("SHOW TIMEZONE");
const timezone = timezoneResult.rows[0].TimeZone;

assert.equal(timezone, "UTC");
});

after(async () => {
Expand Down Expand Up @@ -94,7 +100,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", {
Expand Down Expand Up @@ -522,6 +528,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<InferSqlQueryResultType<typeof 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 () => {
Expand Down
33 changes: 22 additions & 11 deletions src/lib/dialect/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 8 additions & 4 deletions src/lib/model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
DimensionWithGranularity,
Granularity,
GranularityByDimensionType,
GranularityIndex,
MemberFormat,
MemberNameToType,
MemberType,
Expand Down Expand Up @@ -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<N, T>
: { [k in N]: T };

export interface DimensionProps<C, DN extends string = string> {
Expand Down Expand Up @@ -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,
);
}
Expand Down
125 changes: 113 additions & 12 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { description: string; type: MemberType }>;

export type GranularityIndex = typeof GranularityIndex;

export type GranularityToMemberType = {
[K in keyof GranularityIndex]: GranularityIndex[K]["type"];
};

function granularities<T extends (keyof GranularityIndex)[]>(
...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 };
Expand All @@ -86,14 +182,19 @@ export type QueryReturnType<
N extends keyof M,
S = Pick<M, N>,
> = {
[K in keyof S as Replace<string & K, ".", "___">]: 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"
Expand All @@ -102,7 +203,7 @@ export type QueryReturnType<
};

export type ProcessTOverridesNames<T extends Record<string, unknown>> = {
[K in keyof T as Replace<string & K, ".", "___">]: T[K];
[K in keyof T as Replace<string & K, ".", "___", { all: true }>]: 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
Expand Down

0 comments on commit c1b23be

Please sign in to comment.