Skip to content

Commit

Permalink
fix: better types for query builder schema
Browse files Browse the repository at this point in the history
  • Loading branch information
retro committed Mar 21, 2024
1 parent 96e7c0e commit 9d1dbc1
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 58 deletions.
25 changes: 22 additions & 3 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import * as assert from "node:assert/strict";
import * as semanticLayer from "../index.js";

import { InferSqlQueryResultType, QueryBuilderQuery } from "../index.js";
import { after, before, describe, it } from "node:test";
import {
PostgreSqlContainer,
StartedPostgreSqlContainer,
} from "@testcontainers/postgresql";
import { after, before, describe, it } from "node:test";
import { InferSqlQueryResultType, QueryBuilderQuery } from "../index.js";

import fs from "node:fs/promises";
import path from "node:path";
Expand Down Expand Up @@ -1212,7 +1212,7 @@ await describe("semantic layer", async () => {
});
});

await describe("fill repository", async () => {
await describe("full repository", async () => {
const customersModel = semanticLayer
.model("customers")
.fromTable("Customer")
Expand Down Expand Up @@ -1658,5 +1658,24 @@ await describe("semantic layer", async () => {
},
]);
});

await it("should return same query after it's parsed by schema", async () => {
const query = {
dimensions: ["artists.name"],
metrics: ["tracks.sum_unit_price"],
filters: [
{
operator: "equals",
member: "genres.name",
value: ["Rock"],
},
],
order: { "artists.name": "asc" },
limit: 10,
};

const parsedQuery = queryBuilder.querySchema.parse(query);
assert.deepEqual(query, parsedQuery);
});
});
});
102 changes: 50 additions & 52 deletions src/lib/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "./types.js";

import knex from "knex";
import { ZodSchema, z } from "zod";
import { z } from "zod";

import { Simplify } from "type-fest";
import { BaseDialect } from "./dialect/base.js";
Expand All @@ -29,69 +29,67 @@ function getMemberNamesSchema(memberPaths: string[]) {
const [first, ...rest] = memberPaths;
return z.array(z.enum([first, ...rest])).optional();
}
return z.array(z.never()).optional();
return z.array(z.string()).max(0).optional();
}

export function buildQuerySchema(repository: AnyRepository) {
const dimensionPaths = repository.getDimensions().map((d) => d.getPath());
const metricPaths = repository.getMetrics().map((m) => m.getPath());
const memberPaths = [...dimensionPaths, ...metricPaths];

const registeredFilterFragmentBuildersSchemas = repository
.getFilterFragmentBuilderRegistry()
.getFilterFragmentBuilders()
.map((builder) => builder.fragmentBuilderSchema);

const filters: z.ZodType<AnyQueryFilter[]> = z.array(
z.union([
z.object({
operator: z.literal("and"),
filters: z.lazy(() => filters),
}),
z.object({
operator: z.literal("or"),
filters: z.lazy(() => filters),
}),
...registeredFilterFragmentBuildersSchemas.map((schema) =>
schema.refine((arg) => memberPaths.includes(arg.member), {
path: ["member"],
message: "Member not found",
}),
),
]),
);

const schema = z
.object({
dimensions: getMemberNamesSchema(dimensionPaths),
metrics: getMemberNamesSchema(metricPaths),
filters: filters.optional(),
limit: z.number().optional(),
offset: z.number().optional(),
order: z.record(z.string(), z.enum(["asc", "desc"])).optional(),
})
.refine(
(arg) => (arg.dimensions?.length ?? 0) + (arg.metrics?.length ?? 0) > 0,
"At least one dimension or metric must be selected",
);

return schema;
}

export class QueryBuilder<
D extends MemberNameToType,
M extends MemberNameToType,
F,
> {
public readonly querySchema: ZodSchema;
public readonly querySchema: ReturnType<typeof buildQuerySchema>;
constructor(
private readonly repository: AnyRepository,
private readonly Dialect: typeof BaseDialect,
private readonly client: knex.Knex,
) {
this.querySchema = this.buildQuerySchema();
}

private buildQuerySchema() {
const dimensionPaths = this.repository
.getDimensions()
.map((d) => d.getPath());
const metricPaths = this.repository.getMetrics().map((m) => m.getPath());
const memberPaths = [...dimensionPaths, ...metricPaths];

const registeredFilterFragmentBuildersSchemas = this.repository
.getFilterFragmentBuilderRegistry()
.getFilterFragmentBuilders()
.map((builder) => builder.fragmentBuilderSchema);

const filters: z.ZodType<AnyQueryFilter[]> = z.array(
z.union([
z.object({
operator: z.literal("and"),
filters: z.lazy(() => filters),
}),
z.object({
operator: z.literal("or"),
filters: z.lazy(() => filters),
}),
...registeredFilterFragmentBuildersSchemas.map((schema) =>
schema.refine((arg) => memberPaths.includes(arg.member), {
path: ["member"],
message: "Member not found",
}),
),
]),
);

const schema = z
.object({
dimensions: getMemberNamesSchema(dimensionPaths),
metrics: getMemberNamesSchema(metricPaths),
filters: filters.optional(),
limit: z.number().optional(),
offset: z.number().optional(),
order: z.record(z.enum(["asc", "desc"])).optional(),
})
.refine(
(arg) => (arg.dimensions?.length ?? 0) + (arg.metrics?.length ?? 0) > 0,
"At least one dimension or metric must be selected",
);

return schema;
this.querySchema = buildQuerySchema(repository);
}

unsafeBuildQuery(payload: unknown) {
Expand Down
6 changes: 3 additions & 3 deletions src/lib/query-builder/build-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import * as graphlib from "@dagrejs/graphlib";

import { AnyQuery, ModelQuery, QuerySegment } from "../types.js";

import type { AnyRepository } from "../repository.js";
import knex from "knex";
import invariant from "tiny-invariant";
import { BaseDialect } from "../dialect/base.js";
import type { Join } from "../join.js";
import invariant from "tiny-invariant";
import knex from "knex";
import type { AnyRepository } from "../repository.js";

interface ReferencedModels {
all: string[];
Expand Down

0 comments on commit 9d1dbc1

Please sign in to comment.