@vinctus/oql-typed
v0.1.0-beta.9
Published
Compile-time typed queries for OQL
Downloads
364
Readme
@vinctus/oql-typed
Compile-time typed queries for OQL. Define your data model in TypeScript and get fully inferred result types — no manual type parameters needed.
Install
npm install @vinctus/oql-typedRequires one of the OQL backends as a peer dependency:
@vinctus/oql-pg— PostgreSQL backend@vinctus/oql-petradb— In-memory backend (great for tests)
Quick Start
1. Define your schema
Wrap a single schema object with defineSchema(...). The entity name is the object key — relations reference other entities by that string literal.
import {
defineSchema, entity,
uuid, text, integer, boolean, timestamp, float,
manyToOne, oneToMany, manyToMany, oneToOne, enumType,
} from '@vinctus/oql-typed'
export type Role = 'ADMIN' | 'DISPATCHER' | 'DRIVER'
export const schema = defineSchema({
account: entity('accounts', {
id: uuid().primaryKey(),
name: text(),
enabled: boolean(),
plan: text(),
users: oneToMany('user'),
stores: oneToMany('store'),
}),
store: entity('stores', {
id: uuid().primaryKey(),
name: text(),
enabled: boolean(),
account: manyToOne('account', { column: 'account_id' }),
users: manyToMany('user', { junction: 'users_stores' }),
}),
user: entity('users', {
id: uuid().primaryKey(),
firstName: text().column('first_name'),
lastName: text().column('last_name'),
email: text(),
role: enumType<Role>('Role', ['ADMIN', 'DISPATCHER', 'DRIVER']),
enabled: boolean(),
lastLoginAt: timestamp().column('last_login_at').nullable(),
account: manyToOne('account', { column: 'account_id' }),
stores: manyToMany('store', { junction: 'users_stores' }),
}),
})2. Wrap your OQL instance
import { readFileSync } from 'node:fs'
import { typedOQL } from '@vinctus/oql-typed'
import { OQL_PG } from '@vinctus/oql-pg'
import { schema } from './schema.js'
// Keep your schema.dm alongside schema.ts. Use oql-typed-codegen to bootstrap
// schema.ts from schema.dm; see the codegen guide.
const dm = readFileSync(new URL('./schema.dm', import.meta.url), 'utf8')
const oql = new OQL_PG(dm, host, port, database, username, password)
export const db = typedOQL(oql, schema)3. Write typed queries
import { eq, and, inList, ilike, desc } from '@vinctus/oql-typed'
// Result type is inferred from .select() — no manual type parameter.
// db.user.select(...) is shorthand for query(db, 'user').select(...).
const result = await db.user
.select('id', 'firstName', 'lastName', { account: ['id', 'name'] })
.findOneById(userId)
// => { id: string, firstName: string, lastName: string, account: { id: string, name: string } } | undefined
// No selection — returns all scalar fields
const accounts = await db.account.many()
// => { id: string, name: string, enabled: boolean, plan: string }[]
// Naming: methods without "One" are chainable filters; methods with "One" auto-terminate
// .findBy() — chainable sugar for .where(eq(...))
// .findIn() — chainable sugar for .where(inList(...))
// .findOneBy() — terminal sugar for .where(eq(...)).one()
// .findOneById() — terminal PK lookup
const drivers = await db.user
.findBy(db.user.role, 'DRIVER')
.findIn(db.user.enabled, [true])
.many()
const alice = await db.user.findOneBy(db.user.email, '[email protected]')What the compiler catches
db.user.select('id', 'fistName') // ✗ misspelled field
eq(db.user.enabled, 'yes') // ✗ wrong type (boolean expected)
eq(db.user.role, 'SUPERADMIN') // ✗ invalid enum value
ilike(db.user.enabled, '%test%') // ✗ ilike requires string field
const u = await db.user.select('id').one()
u?.firstName // ✗ not in projectionSelection
Scalars as string args, relations as objects:
.select('id', 'name') // scalars
.select('id', { account: ['id', 'name'] }) // simple relation
.select('id', { stores: ['id', 'name', { place: ['lat', 'lng'] }] }) // nested
.select('id', { account: 'name' }) // single-field shorthandFiltered sub-collections
Add where, orderBy, limit, and offset to a nested relation. limit/offset paginate a to-many relation just like the top-level builder (emitted as |limit, offset| after orderBy); the result type stays T[]:
.select('id', 'name', {
trips: {
fields: ['id', 'state', 'seats'],
where: ne(db.trip.state, 'COMPLETED'),
orderBy: [desc(db.trip.createdAt)],
limit: 10, // the 10 most recent per row
offset: 0,
},
})Dotted paths on manyToOne
Access fields on related entities directly in filters:
.where(and(
eq(db.trip.store.account.id, accountId), // multi-level FK chain
inList(db.trip.store.id, storeIds),
))Operators
| Operator | Example |
|----------|---------|
| eq, ne, gt, gte, lt, lte | eq(db.user.enabled, true) |
| and, or, not | and(eq(...), or(...)) |
| inList, notInList | inList(db.user.role, ['ADMIN', 'DRIVER']) |
| arrayContains (scalar in array column) | arrayContains(db.zone.tags, 'vip') → :p = ANY(tags) |
| like, ilike | ilike(db.user.firstName, '%john%') |
| between | between(db.user.lastLoginAt, start, end) |
| isNull, isNotNull | isNull(db.trip.vehicle) |
| exists | exists(db.user.stores, eq(db.store.id, storeId)) |
| asc, desc (optional NULLS) | desc(db.user.lastLoginAt), asc(db.trip.scheduledAt, 'last') |
Expressions
For OQL features beyond plain field comparisons:
import { fn, ref, subquery, alias, aliasedRelation, caseWhen, currentTimestamp } from '@vinctus/oql-typed'
import { lower, upper, trim, length, concat, concatOp, coalesce, count, sum, avg, min, max } from '@vinctus/oql-typed'
// Function call in a filter — fn(name, ...args). Bare strings are parameterized.
ilike(fn('concat', db.vehicle.make, ' ', db.vehicle.model), '%toyota%')
// Indexable concat — use concatOp() for PG `||` (IMMUTABLE, indexable)
ilike(concatOp(db.user.firstName, ' ', db.user.lastName), '%john%')
// Reference operator (&) — the FK column value itself; type inferred from the relation
isNull(ref(db.trip.returnTripFor)) // → &returnTripFor IS NULL
// Database clock — compare a timestamp column against "now"
lte(db.account.trialEndAt, currentTimestamp()) // → trialEndAt <= CURRENT_TIMESTAMP
// Subquery as a value — projection is a typed expression; the scalar type T is inferred
eq(subquery(db.vehicle.drivers, count('*')), 0)
// Aliased projection — label: (expression)
db.trip.select('id', alias('returnTripId', db.trip.returnTripFor.id))
// Aliased sub-collection — pass the relation ref; row shape inferred, fields type-checked
db.user.select('id', aliasedRelation('shifts', db.user.trips, {
fields: ['id', 'state'],
where: eq(db.trip.state, 'CONFIRMED'),
}))
// CASE expression — caseWhen(branches, else?). With `else` → T; without → T | null.
db.trip.select('id', alias('priority', caseWhen(
[{ when: eq(db.trip.state, 'COMPLETED'), then: 2 }],
0,
)))Mutations
import { insert, update } from '@vinctus/oql-typed'
// insert(db, entityName, input) — typed input (required/optional fields), returns full row
const newUser = await insert(db, 'user', {
id: crypto.randomUUID(),
firstName: 'Alice',
lastName: 'Smith',
email: '[email protected]',
role: 'ADMIN',
enabled: true,
account: accountId, // manyToOne FK
// lastLoginAt omitted — it's nullable
})
// => { id: string, firstName: string, ..., lastLoginAt: Date | null }
// update(db, entityName, id, patch) — all patch fields optional
const updated = await update(db, 'user', userId, {
firstName: 'Alicia',
})
// => { id: string, firstName: string }Conditional QueryBuilder
For dynamic filtering (paginated lists with optional search/role/etc.):
import { queryBuilder } from '@vinctus/oql-typed'
const results = await queryBuilder(db, 'user')
.select('id', 'firstName', 'role')
.where(eq(db.user.enabled, true))
.cond(role, eq(db.user.role, role)) // applied if `role` is truthy
.cond(search, ilike(db.user.firstName, `%${search}%`))
.orderBy(desc(db.user.lastLoginAt))
.limit(size)
.offset(page * size)
.many()Query API
query(db, 'user') // or just: db.user
.select(...) // Optional — fields and relations
.where(filter) // Optional — single filter expression
.findBy(col, v) // Optional — sugar for .where(eq(col, v)); chains AND
.findIn(col, vs) // Optional — sugar for .where(inList(col, vs)); chains AND
.orderBy(asc(f), ...) // Optional — sort
.limit(n) // Optional
.offset(n) // Optional
.one() // → T | undefined
.many() // → T[]
.count() // → number
.findOneBy(col, v) // → T | undefined (terminal — auto-runs .one())
.findOneById(id) // → T | undefined (terminal — auto-runs .one())
.toOQL() // → { queryStr, params } — no executionSchema reference
| Function | Description |
|----------|-------------|
| defineSchema({ name: entity(...), ... }) | Top-level schema wrapper |
| entity(fields) / entity(tableName, fields) | Define an entity (entity name comes from the defineSchema key) |
| uuid(), text(), integer(), bigint(), float(), boolean(), timestamp(), date(), time(), interval(), json<T>() | Column types |
| textArray(), integerArray(), decimal(p?, s?) | Array & decimal columns |
| enumType<T>(name, values) | Typed enum column |
| manyToOne(target, { column }) | FK relation (supports dotted paths) |
| oneToMany(target) | Reverse FK (array) |
| manyToMany(target, { junction }) | Junction-table relation (array) |
| oneToOne(target, { reference? }) | One-to-one |
Column modifiers: .primaryKey(), .nullable(), .column('db_alias')
Bootstrapping from an existing .dm file
npx oql-typed-codegen schema.dm src/schema.generated.tsOr programmatically:
import { parseDMAndGenerate } from '@vinctus/oql-typed'
const tsSource = parseDMAndGenerate(dmString)The generated file uses defineSchema(...) and string-literal relation targets, identical to the hand-written form.
Note: The reverse direction — generating a
.dmstring from a TypeScript schema withgenerateDM(...)— is currently a stub that throws. It's being rebuilt for the schema-object API. Until then, treat the.dmfile as the source of truth and run codegen to keepschema.tsin sync.
