docId: query-builder

source: query-builder

slug: query-builder

title: Query Builder

documentType: unknown

contentOrigin: static-doc

content:
---
title: Query Builder
navTitle: Query Builder
description: Translates GraphQL queries into Trino SQL.
entrypoint: /docs/query-builder/overview/introduction
---

navTitle: Query Builder

keywords: query builder translates graphql queries


slug: filtering/combining-filters

title: Combining Filters

text: WhereOperator Enum

text: normalizeUserQuery

text: normalizeFilter

text: Processing Pipeline

text: Nested Example

content:
---
title: Combining Filters
description: How AND/OR logic works and how filters are normalized before query compilation.
---

## WhereOperator Enum

The `WhereOperator` enum defines the two logical combinators available for grouping filter conditions:

```ts
import { WhereOperator } from "@lakeql/query-builder"

enum WhereOperator {
  AND = "and",
  OR = "or",
}
```

Every `Where` object must have either `and` or `or` as its root key. Conditions within an `and` array are combined with SQL `AND`; conditions within an `or` array are combined with SQL `OR`.

## normalizeUserQuery

User-supplied filter objects might not always follow the strict `{ and: [...] }` or `{ or: [...] }` root format. The `normalizeUserQuery` function ensures a consistent structure by wrapping bare field objects into an `and` root.

```ts
import { normalizeUserQuery } from "@lakeql/query-builder"

// Input: bare fields without a root operator
const input = { status: { eq: "active" }, region: { eq: "us-east" } }

// Output: wrapped in an AND root
const normalized = normalizeUserQuery(input)
// { and: [{ status: { eq: "active" } }, { region: { eq: "us-east" } }] }
```

## normalizeFilter

The `normalizeFilter` function handles another edge case: filter objects where multiple fields are combined in a single array element. It splits multi-field objects into separate entries so each condition is processed independently.

```ts
import { normalizeFilter } from "@lakeql/query-builder"

// Input: multiple fields in one object
const input = {
  and: [{ status: { eq: "active" }, region: { eq: "us-east" } }],
}

// Output: each field in its own object
const normalized = normalizeFilter(input)
// { and: [{ status: { eq: "active" } }, { region: { eq: "us-east" } }] }
```

## Processing Pipeline

Inside `generateQuery`, both normalization functions are applied before building the WHERE clause:

1. `normalizeUserQuery(userQuery)` — ensures there's a root `and`/`or` key
2. `normalizeFilter(...)` — splits multi-field objects into single-field entries
3. `conditionBuilder(...)` — recursively walks the tree and builds Kysely expressions

## Nested Example

```ts
import type { Where } from "@lakeql/query-builder"

const complexFilter: Where = {
  and: [
    { status: { eq: "active" } },
    {
      or: [
        { region: { eq: "us-east" } },
        {
          and: [{ region: { eq: "eu-west" } }, { tier: { eq: "premium" } }],
        },
      ],
    },
  ],
}
```

This translates to:

```sql
WHERE "status" = 'active'
  AND (
    "region" = 'us-east'
    OR ("region" = 'eu-west' AND "tier" = 'premium')
  )
```

keywords: filters combining andor logic works


docId: query-builder/filtering/operators

slug: filtering/operators

title: Operators

text: Supported Operators

text: Pattern Matching Examples

text: Comparison Examples

content:
---
title: Operators
description: All supported filter operators and their SQL translations.
---

## Supported Operators

The query builder supports a comprehensive set of comparison and pattern-matching operators. Each operator in `FieldOptions` maps to a specific SQL expression.

| Operator        | SQL Output           | Description                               |
| --------------- | -------------------- | ----------------------------------------- |
| `eq`            | `= value`            | Equals                                    |
| `neq`           | `!= value`           | Not equals                                |
| `in`            | `IN (values)`        | Matches any value in the list             |
| `notIn`         | `NOT IN (values)`    | Does not match any value in the list      |
| `lt`            | `< value`            | Less than                                 |
| `lte`           | `<= value`           | Less than or equal                        |
| `gt`            | `> value`            | Greater than                              |
| `gte`           | `>= value`           | Greater than or equal                     |
| `like`          | `LIKE '%value%'`     | Contains (wraps value with wildcards)     |
| `notLike`       | `NOT LIKE '%value%'` | Does not contain                          |
| `startsWith`    | `LIKE 'value%'`      | Starts with the given prefix              |
| `notStartsWith` | `NOT LIKE 'value%'`  | Does not start with the given prefix      |
| `endsWith`      | `LIKE '%value'`      | Ends with the given suffix                |
| `notEndsWith`   | `NOT LIKE '%value'`  | Does not end with the given suffix        |
| `is`            | `= value`            | Equality check (typically for booleans)   |
| `isNot`         | `!= value`           | Inequality check (typically for booleans) |

## Pattern Matching Examples

```ts
import type { Where } from "@lakeql/query-builder"

// Contains "smith" anywhere in the name
const containsFilter: Where = {
  and: [{ name: { like: "smith" } }],
}
// SQL: WHERE "name" LIKE '%smith%'

// Starts with "prod-"
const prefixFilter: Where = {
  and: [{ environment: { startsWith: "prod-" } }],
}
// SQL: WHERE "environment" LIKE 'prod-%'

// Ends with ".csv"
const suffixFilter: Where = {
  and: [{ filename: { endsWith: ".csv" } }],
}
// SQL: WHERE "filename" LIKE '%.csv'
```

## Comparison Examples

```ts
import type { Where } from "@lakeql/query-builder"

// Numeric range
const rangeFilter: Where = {
  and: [{ price: { gte: "10" } }, { price: { lte: "100" } }],
}
// SQL: WHERE "price" >= '10' AND "price" <= '100'

// Membership
const membershipFilter: Where = {
  and: [{ status: { in: ["active", "pending"] } }],
}
// SQL: WHERE "status" IN ('active', 'pending')
```

keywords: operators supported examples filter their


docId: query-builder/filtering/where-interface

slug: filtering/where-interface

title: Where Interface

text: Overview

text: Example Structure

content:
---
title: Where Interface
description: The recursive type structure that represents filter conditions.
---

## Overview

The query builder uses a recursive type system to represent arbitrarily nested filter conditions. The root `Where` type can contain AND/OR groups, each of which may hold field conditions or further nested groups.

## Example Structure

```ts
import type { Where } from "@lakeql/query-builder"

const filter: Where = {
  and: [
    { status: { eq: "active" } },
    {
      or: [{ region: { eq: "us-east" } }, { region: { eq: "eu-west" } }],
    },
    { amount: { gte: "100" } },
  ],
}
```

This produces SQL equivalent to:

```sql
WHERE "status" = 'active'
  AND ("region" = 'us-east' OR "region" = 'eu-west')
  AND "amount" >= '100'
```

keywords: structure where interface recursive represents


docId: query-builder/overview/introduction

slug: overview/introduction

title: Introduction

text: What is the Query Builder?

text: Core Responsibilities

text: DummyDriver Approach

content:
---
title: Introduction
description: Learn how the query builder uses Kysely to generate Trino-compatible SQL from GraphQL queries.
---

## What is the Query Builder?

The `@lakeql/query-builder` package is responsible for translating GraphQL query parameters — field selections, filters, sorting, and pagination — into Trino-compatible SQL statements. It uses [Kysely](https://kysely.dev/) as a type-safe SQL query builder under the hood.

The package never executes queries against a database. Instead, it uses Kysely's `DummyDriver` to compile SQL strings that are later sent to Trino via the `@lakeql/trino-client`. This separation keeps query construction fully testable and free of I/O.

## Core Responsibilities

- Extract requested fields from GraphQL resolve info
- Build parameterized SQL with CTEs for total count and paginated records
- Apply user-supplied filters (AND/OR logic with multiple operators)
- Apply sorting and Trino-specific paging syntax
- Map GraphQL field names to database column names via `transformFields`
- Wrap date/time columns in `to_unixtime()` for serialization

```ts
import { generateQuery, getSelectFields } from "@lakeql/query-builder"

const query = generateQuery({
  catalog: "hive",
  schema: "sales",
  table: "orders",
  selectFields: ["id", "customer_name", "created_at"],
  userQuery: { and: [{ status: { eq: "shipped" } }] },
  sorting: [{ field: "created_at", direction: "desc" }],
  paging: { limit: 25, offset: 0 },
  dateFields: ["created_at"],
})
```

## DummyDriver Approach

Kysely normally requires a database dialect with a real driver. The query builder configures Kysely with a `PostgresAdapter`, `PostgresQueryCompiler`, and a `DummyDriver` that never opens a connection. This gives you full SQL compilation (including parameter binding) without any runtime dependency on a database.

```ts
import {
  DummyDriver,
  Kysely,
  PostgresAdapter,
  PostgresIntrospector,
  PostgresQueryCompiler,
} from "kysely"

const db = new Kysely({
  dialect: {
    createAdapter: () => new PostgresAdapter(),
    createDriver: () => new DummyDriver(),
    createIntrospector: (db) => new PostgresIntrospector(db),
    createQueryCompiler: () => new PostgresQueryCompiler(),
  },
})
```

keywords: query builder introduction learn kysely


docId: query-builder/sorting-and-paging/paging

slug: sorting-and-paging/paging

title: Paging

text: PagingInput Interface

text: Trino Pagination Syntax

text: Usage Example

text: Default Behavior

content:
---
title: Paging
description: Trino-specific pagination using FETCH FIRST/NEXT and OFFSET.
---

## PagingInput Interface

<InterfaceReference file="query-builder/src/index" name="PagingInput" />

## Trino Pagination Syntax

Trino uses SQL standard `FETCH FIRST N ROWS ONLY` syntax rather than the MySQL-style `LIMIT` clause. The query builder generates two different patterns depending on whether an offset is provided.

### Without Offset

When `offset` is not set or is `0`, the query uses:

```sql
FETCH FIRST 25 ROWS ONLY
```

### With Offset

When `offset` is a positive number, the query uses:

```sql
OFFSET 50
FETCH NEXT 25 ROWS ONLY
```

Note the difference: `FETCH FIRST` becomes `FETCH NEXT` when an offset is present.

## Usage Example

```ts
import { generateQuery } from "@lakeql/query-builder"

// Example table type
interface MyTable {
  id: number
  event_type: string
  timestamp: string
  user_id: number
}

// First page: 25 rows, no offset
const page1 = generateQuery<MyTable>({
  catalog: "hive",
  schema: "analytics",
  table: "events",
  selectFields: ["id", "event_type", "timestamp"],
  userQuery: {},
  sorting: [{ field: "timestamp", direction: "desc" }],
  paging: { limit: 25 },
})

// Second page: next 25 rows
const page2 = generateQuery<MyTable>({
  catalog: "hive",
  schema: "analytics",
  table: "events",
  selectFields: ["id", "event_type", "timestamp"],
  userQuery: {},
  sorting: [{ field: "timestamp", direction: "desc" }],
  paging: { limit: 25, offset: 25 },
})
```

## Default Behavior

If no `paging` option is provided, the query builder defaults to `FETCH FIRST 100 ROWS ONLY` with no offset. This prevents unbounded result sets from being returned accidentally.

keywords: pagination paging trino-specific using fetch


slug: sorting-and-paging/sorting

title: Sorting

text: SortInput Interface

text: Multiple Sort Fields

text: transformFields Integration

text: Why Sorting Matters for Paging

content:
---
title: Sorting
description: Multi-field sorting with configurable direction for query results.
---

## SortInput Interface

<InterfaceReference file="query-builder/src/index" name="SortInput" />

Each `SortInput` specifies a column to order by and the sort direction. The `field` property uses Kysely's `SelectExpression` type for type safety, but in practice it's a string matching a column name.

## Multiple Sort Fields

The `sorting` parameter accepts an array. Fields are applied in order — the first entry is the primary sort key, subsequent entries are secondary, tertiary, etc.

```ts
import { generateQuery } from "@lakeql/query-builder"

interface OrderTable {
  id: number
  status: string
  created_at: string
  customerName: string
}

const compiled = generateQuery<OrderTable>({
  catalog: "hive",
  schema: "sales",
  table: "orders",
  selectFields: ["id", "status", "created_at"],
  userQuery: {},
  sorting: [
    { field: "status", direction: "asc" },
    { field: "created_at", direction: "desc" },
  ],
})
```

Produces:

```sql
ORDER BY "status" ASC, "created_at" DESC
```

## transformFields Integration

If a sorted field has a mapping in `transformFields`, the query builder uses the database column name in the ORDER BY clause:

```ts
import { generateQuery } from "@lakeql/query-builder"

interface OrderTable {
  id: number
  status: string
  created_at: string
  customerName: string
}

const compiled = generateQuery<OrderTable>({
  catalog: "hive",
  schema: "sales",
  table: "orders",
  selectFields: ["id", "status", "created_at"],
  userQuery: {},
  sorting: [{ field: "customerName", direction: "asc" }],
  transformFields: { customerName: "customer_name" },
})
```

Produces:

```sql
ORDER BY "customer_name" ASC
```

## Why Sorting Matters for Paging

Without at least one sort field, Trino does not guarantee row ordering between pages. This means a client paginating through results might see duplicate rows or miss rows entirely. Always provide at least one deterministic sort key (such as a primary key or timestamp) when using pagination.

keywords: sorting multi-field configurable direction query