Skip to content

Commit e339552

Browse files
authoredMar 6, 2025
feat: d2ql with clause for CTEs (#38)
1 parent 1b8530c commit e339552

File tree

7 files changed

+434
-4
lines changed

7 files changed

+434
-4
lines changed
 

‎.changeset/neat-rice-heal.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@electric-sql/d2ts': patch
3+
---
4+
5+
add support for CTEs via a `with` clause to D2QL

‎packages/d2ts/src/d2ql/README.md

+62
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,68 @@ The current implementation supports:
7171
- JSON_EXTRACT for working with JSON data
7272
- GROUP BY and HAVING clauses with aggregate functions:
7373
- SUM, COUNT, AVG, MIN, MAX, MEDIAN, MODE
74+
- Common Table Expressions (CTEs) using the WITH clause
75+
76+
## Common Table Expressions (CTEs)
77+
78+
D2QL supports SQL-like Common Table Expressions (CTEs) using the `WITH` clause. CTEs allow you to define temporary result sets that can be referenced in the main query or in other CTEs.
79+
80+
### Basic CTE Example
81+
82+
```typescript
83+
const query: Query = {
84+
with: [
85+
{
86+
select: ['@id', '@name', '@age'],
87+
from: 'users',
88+
where: ['@age', '>', 21],
89+
as: 'adult_users'
90+
}
91+
],
92+
select: ['@id', '@name'],
93+
from: 'adult_users',
94+
where: ['@name', 'like', 'A%']
95+
};
96+
```
97+
98+
### Multiple CTEs Example
99+
100+
You can define multiple CTEs and reference earlier CTEs in later ones:
101+
102+
```typescript
103+
const query: Query = {
104+
with: [
105+
{
106+
select: ['@id', '@name', '@age'],
107+
from: 'users',
108+
where: ['@age', '>', 21],
109+
as: 'adult_users'
110+
},
111+
{
112+
select: ['@id', '@name', { order_count: 'COUNT(@order_id)' }],
113+
from: 'adult_users',
114+
join: [
115+
{
116+
type: 'left',
117+
from: 'orders',
118+
on: ['@adult_users.id', '=', '@orders.user_id']
119+
}
120+
],
121+
groupBy: ['@id', '@name'],
122+
as: 'user_order_counts'
123+
}
124+
],
125+
select: ['@id', '@name', '@order_count'],
126+
from: 'user_order_counts',
127+
where: ['@order_count', '>', 0]
128+
};
129+
```
130+
131+
### CTE Requirements
132+
133+
- Each CTE must have an `as` property that defines its name
134+
- CTEs cannot have a `keyBy` property (they cannot be keyed)
135+
- CTEs can reference other CTEs defined earlier in the `with` array
74136

75137
## Planned Features
76138

‎packages/d2ts/src/d2ql/compiler.ts

+41-4
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,51 @@ export function compileQuery<T extends IStreamBuilder<unknown>>(
144144
query: Query,
145145
inputs: Record<string, IStreamBuilder<Record<string, unknown>>>,
146146
): T {
147+
// Create a copy of the inputs map to avoid modifying the original
148+
const allInputs = { ...inputs }
149+
150+
// Process WITH queries if they exist
151+
if (query.with && query.with.length > 0) {
152+
// Process each WITH query in order
153+
for (const withQuery of query.with) {
154+
// Ensure the WITH query has an alias
155+
if (!withQuery.as) {
156+
throw new Error('WITH query must have an "as" property')
157+
}
158+
159+
// Ensure the WITH query is not keyed
160+
if (withQuery.keyBy !== undefined) {
161+
throw new Error('WITH query cannot have a "keyBy" property')
162+
}
163+
164+
// Check if this CTE name already exists in the inputs
165+
if (allInputs[withQuery.as]) {
166+
throw new Error(`CTE with name "${withQuery.as}" already exists`)
167+
}
168+
169+
// Create a new query without the 'with' property to avoid circular references
170+
const withQueryWithoutWith = { ...withQuery, with: undefined }
171+
172+
// Compile the WITH query using the current set of inputs
173+
// (which includes previously compiled WITH queries)
174+
const compiledWithQuery = compileQuery(
175+
withQueryWithoutWith,
176+
allInputs,
177+
) as IStreamBuilder<Record<string, unknown>>
178+
179+
// Add the compiled query to the inputs map using its alias
180+
allInputs[withQuery.as] = compiledWithQuery
181+
}
182+
}
183+
147184
// Create a map of table aliases to inputs
148185
const tables: Record<string, IStreamBuilder<Record<string, unknown>>> = {}
149186

150187
// The main table is the one in the FROM clause
151188
const mainTableAlias = query.as || query.from
152189

153-
// Get the main input from the inputs map
154-
const input = inputs[query.from]
190+
// Get the main input from the inputs map (now including CTEs)
191+
const input = allInputs[query.from]
155192
if (!input) {
156193
throw new Error(`Input for table "${query.from}" not found in inputs map`)
157194
}
@@ -198,9 +235,9 @@ export function compileQuery<T extends IStreamBuilder<unknown>>(
198235
// Get the joined table input from the inputs map
199236
let joinedTableInput: IStreamBuilder<Record<string, unknown>>
200237

201-
if (inputs[joinClause.from]) {
238+
if (allInputs[joinClause.from]) {
202239
// Use the provided input if available
203-
joinedTableInput = inputs[joinClause.from]
240+
joinedTableInput = allInputs[joinClause.from]
204241
} else {
205242
// Create a new input if not provided
206243
joinedTableInput = input.graph.newInput<Record<string, unknown>>()

‎packages/d2ts/src/d2ql/examples.md

+52
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,55 @@ const query: Query = {
7777
offset: 10
7878
};
7979
```
80+
81+
## Common Table Expressions (CTEs) Example
82+
83+
```ts
84+
const query: Query = {
85+
with: [
86+
// First CTE: Get active customers
87+
{
88+
select: ["@id", "@name", "@email", "@region"],
89+
from: "customers",
90+
where: ["@status", "=", "active"],
91+
as: "active_customers"
92+
},
93+
// Second CTE: Get recent orders (references the first CTE)
94+
{
95+
select: [
96+
"@order_id",
97+
"@customer_id",
98+
"@amount",
99+
"@date",
100+
{ customer_name: "@active_customers.name" },
101+
{ customer_region: "@active_customers.region" }
102+
],
103+
from: "orders",
104+
join: [
105+
{
106+
type: "inner",
107+
from: "active_customers",
108+
on: ["@orders.customer_id", "=", "@active_customers.id"]
109+
}
110+
],
111+
where: ["@date", ">=", new Date("2023-01-01")],
112+
as: "recent_orders"
113+
}
114+
],
115+
// Main query uses the second CTE
116+
select: [
117+
"@customer_region",
118+
{ total_orders: { COUNT: "@order_id" } },
119+
{ total_amount: { SUM: "@amount" } },
120+
{ avg_order_value: { AVG: "@amount" } }
121+
],
122+
from: "recent_orders",
123+
groupBy: ["@customer_region"],
124+
orderBy: [{ total_amount: "desc" }]
125+
};
126+
```
127+
128+
This example demonstrates:
129+
1. Creating a CTE for active customers
130+
2. Creating a second CTE that joins with the first CTE
131+
3. Using the second CTE in the main query for aggregation

‎packages/d2ts/src/d2ql/schema.json

+17
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,23 @@
124124
{ "type": "string" },
125125
{ "type": "array", "items": { "type": "string" } }
126126
]
127+
},
128+
"with": {
129+
"description": "An array of Common Table Expressions (CTEs) that can be referenced in the main query or in other CTEs. Each CTE must have an 'as' property that defines its name and cannot have a 'keyBy' property.",
130+
"type": "array",
131+
"items": {
132+
"type": "object",
133+
"allOf": [
134+
{ "$ref": "#" },
135+
{
136+
"required": ["as"],
137+
"properties": {
138+
"keyBy": { "type": "null" },
139+
"with": { "type": "null" }
140+
}
141+
}
142+
]
143+
}
127144
}
128145
},
129146
"required": ["select", "from"],

‎packages/d2ts/src/d2ql/schema.ts

+10
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ export interface Query {
131131
limit?: number
132132
offset?: number
133133
keyBy?: string | string[]
134+
with?: WithQuery[]
135+
}
136+
137+
// A WithQuery is a query that is used as a Common Table Expression (CTE)
138+
// It cannot be keyed and must have an alias (as)
139+
// There is no support for recursive CTEs
140+
export interface WithQuery extends Query {
141+
keyBy: undefined
142+
as: string
143+
with: undefined
134144
}
135145

136146
// A keyed query is a query that has a keyBy clause, and so the result is always

0 commit comments

Comments
 (0)