LakeQL
Introduction
  • Overview
  • Key Concepts
  • Package Map
Getting Started
  • Prerequisites
  • Quickstart
  • Environment Configuration
  • First Run
Architecture
  • System Overview
  • Data Flow
  • Request Lifecycle
Configuration
  • Environment Variables
  • Authentication
  • Trino Connection
create-app
  • Usage
  • Template Structure
  • Post Creation
Contributing
  • Local Development
  • Contribution Guide
Guides
  • Custom Resolvers
  • Extending Schema
  • Deploying
  • Mutations
  • Load Strategies
GitHub
LakeQL
  1. LakeQL
  2. Guides
  3. Custom Resolvers

On this page

  1. Adding Custom Resolvers
  2. Creating a Custom Query
  3. File Discovery
  4. Custom Types
  5. Accessing Context
  6. Best Practices

Custom Resolvers

Add custom queries and mutations beyond the generated schema.

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/ so lakeql-cli pull won't overwrite them
  • Name carefully — Prefix custom types to avoid conflicts (e.g. CustomAnalyticsResult vs generated OrdersResult )
  • Use the builder — Always import builder from @lakeql/api/builder to ensure types are registered in the same schema
  • Check permissions — Use auth scopes or implement your own auth checks in custom resolvers

Previous page

Guides

Next page

Extending Schema

src/schemas/custom/hive/analytics/analytics-query-schema.ts
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 44
src/config.ts
src/schemas/custom/health-query-schema.ts
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