Adding Custom Resolvers #
Generated schemas cover standard CRUD patterns, but you'll often need custom queries, computed fields, or business-logic-specific endpoints. LakeQL makes this straightforward — create a new query schema file and it will be picked up automatically.
Creating a Custom Query #
Create a new file in your schemas directory:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44import { builder } from "@lakeql/api/builder"
const AnalyticsResult = builder.objectRef<{
period: string
totalOrders: number
revenue: number
}>("AnalyticsResult")
AnalyticsResult.implement({
fields: (t) => ({
period: t.exposeString("period"),
totalOrders: t.exposeInt("totalOrders"),
revenue: t.exposeFloat("revenue"),
}),
})
builder.queryField("orderAnalytics", (t) =>
t.field({
type: [AnalyticsResult],
args: {
schema: t.arg.string({ required: true }),
startDate: t.arg.string({ required: true }),
endDate: t.arg.string({ required: true }),
},
resolve: async (_root, args, context) => {
// Your custom Trino query logic here
const sql = `
SELECT
date_format(created_at, '%Y-%m') AS period,
COUNT(*) AS total_orders,
SUM(total) AS revenue
FROM hive.${args.schema}.orders
WHERE created_at BETWEEN TIMESTAMP '${args.startDate}' AND TIMESTAMP '${args.endDate}'
GROUP BY date_format(created_at, '%Y-%m')
ORDER BY period DESC
`
// Execute via trino-client
// Return mapped results
return []
},
})
)
File Discovery #
The API server loads all schema files from the configured schemaPath directory relative to baseDir. Any file ending in -schema.ts that imports and uses builder will be included in the final GraphQL schema.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { defineConfig } from "@lakeql/api/config"
import { allConfigs } from "./config-registry"
const baseDir = import.meta.dirname
export const config = defineConfig({
allConfigs,
baseDir,
schemaPath: "./schemas",
graphqlPath: "/graphql",
healthCheckEndpoint: "/live",
port: 4000,
})
Custom schema files are loaded alongside generated ones. Make sure your custom
types and field names don't conflict with generated names.
Custom Types #
Define custom GraphQL types that aren't tied to a Trino table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27import { builder } from "@lakeql/api/builder"
const HealthStatus = builder.objectRef<{
status: string
version: string
uptime: number
}>("HealthStatus")
HealthStatus.implement({
fields: (t) => ({
status: t.exposeString("status"),
version: t.exposeString("version"),
uptime: t.exposeFloat("uptime"),
}),
})
builder.queryField("health", (t) =>
t.field({
type: HealthStatus,
resolve: () => ({
status: "healthy",
version: process.env.npm_package_version ?? "unknown",
uptime: process.uptime(),
}),
})
)
Accessing Context #
Custom resolvers have full access to the GraphQL context, including the authenticated user:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { builder } from "@lakeql/api/builder"
builder.queryField("myProfile", (t) =>
t.field({
type: UserProfile,
resolve: async (_root, _args, context) => {
if (!context.currentUser) {
throw new Error("Not authenticated")
}
// Use context.currentUser.userName
return fetchProfile(context.currentUser.userName)
},
})
)
Best Practices #
-
Separate files
— Keep custom resolvers in
schemas/custom/solakeql-cli pullwon't overwrite them -
Name carefully
— Prefix custom types to avoid conflicts (e.g.
CustomAnalyticsResultvs generatedOrdersResult) -
Use the builder
— Always import
builderfrom@lakeql/api/builderto ensure types are registered in the same schema - Check permissions — Use auth scopes or implement your own auth checks in custom resolvers