Join Relationships
defineServe queries can join any table on the fly, but bigger projects benefit from pre-declaring relationship paths. This page shows both approaches so you can balance velocity with consistency.
Basic joins
Inline joins look just like SQL but stay type-safe.
import { defineServe } from '@hypequery/clickhouse';
export const api = defineServe({
queries: {
ordersWithUsers: {
query: ({ ctx }) =>
ctx.db
.table('orders')
.innerJoin('users', 'user_id', 'users.id')
.select(['orders.id', 'users.name', 'orders.amount'])
.orderBy({ column: 'orders.created_at', direction: 'DESC' }),
},
},
});
Join types
The fluent builder surfaces every ClickHouse join primitive:
ctx.db.table('orders')
.innerJoin('users', 'user_id', 'users.id')
.leftJoin('regions', 'region_id', 'regions.id')
.rightJoin('returns', 'id', 'returns.order_id')
.fullJoin('invoices', 'id', 'invoices.order_id');
Use the shorthand methods (innerJoin, leftJoin, rightJoin, fullJoin) whenever the default semantics match what you need.
Defining reusable relationships
When multiple queries share the same join graph, define it once with JoinRelationships and reference it by name.
import { defineServe, JoinRelationships, QueryBuilder } from '@hypequery/clickhouse';
import type { IntrospectedSchema } from '../../analytics/schema';
type Schema = IntrospectedSchema;
const relationships = new JoinRelationships<Schema>();
relationships.define('userOrders', {
from: 'orders',
to: 'users',
leftColumn: 'user_id',
rightColumn: 'id',
type: 'LEFT',
});
relationships.define('orderItems', {
from: 'orders',
to: 'order_items',
leftColumn: 'id',
rightColumn: 'order_id',
type: 'INNER',
});
QueryBuilder.setJoinRelationships(relationships);
export const api = defineServe({
queries: {
ordersWithRelations: {
query: ({ ctx }) =>
ctx.db
.table('orders')
.withRelation('userOrders')
.withRelation('orderItems')
.select(['orders.id', 'users.name', 'order_items.sku']),
},
},
});
Filtering with relations
Once the relationship is attached you can filter and aggregate across the joined columns.
export const api = defineServe({
queries: {
activeAdmins: {
query: ({ ctx }) =>
ctx.db
.table('accounts')
.withRelation('ownerUser')
.where('accounts.active', 'eq', 1)
.where('users.user_name', 'like', '%admin%')
.select(['accounts.id', 'accounts.name', 'users.user_name']),
},
},
});
Aggregations with relations
withRelation plays nicely with aggregations, groupings, and aliases.
export const api = defineServe({
queries: {
revenueByUser: {
query: ({ ctx }) =>
ctx.db
.table('orders')
.withRelation('userOrders')
.select(['users.user_name'])
.sum('orders.total', 'total_spent')
.count('orders.id', 'order_count')
.groupBy(['users.user_name'])
.orderBy({ column: 'total_spent', direction: 'DESC' }),
},
},
});
Overriding relationship options
You can override the join type or alias per query while still reusing the base definition.
export const api = defineServe({
queries: {
innerJoinedOrders: {
query: ({ ctx }) =>
ctx.db
.table('orders')
.withRelation('userOrders', { type: 'INNER', alias: 'user_orders' })
.select(['orders.id', 'user_orders.user_name']),
},
},
});
Tips
- Call
QueryBuilder.setJoinRelationships()exactly once during startup before you invokedefineServe. - Keep relationship names semantic (
userOrders,ordersWithItems) so.withRelation()calls read like feature requirements. - Mix and match inline joins with relationships when prototyping; promote them to named relationships once they stabilize.
Next Steps
Continue: Query Definitions - Build type-safe APIs with serve
Or explore: Caching - Optimize query performance