Skip to content

Commit ca38b52

Browse files
RostiMelkjudofyr
andauthored
feat: add unparse() function (#310)
Co-authored-by: Magnus Holm <[email protected]>
1 parent 7bdd653 commit ca38b52

File tree

5 files changed

+493
-0
lines changed

5 files changed

+493
-0
lines changed

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
"require": "./dist/1.js",
2929
"default": "./dist/1.js"
3030
},
31+
"./experimental": {
32+
"source": "./src/experimental.ts",
33+
"import": "./dist/experimental.mjs",
34+
"require": "./dist/experimental.js",
35+
"default": "./dist/experimental.js"
36+
},
3137
"./package.json": "./package.json"
3238
},
3339
"main": "./dist/index.js",
@@ -37,6 +43,9 @@
3743
"*": {
3844
"1": [
3945
"./dist/1.d.ts"
46+
],
47+
"experimental": [
48+
"./dist/experimental.d.ts"
4049
]
4150
}
4251
},

src/experimental.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {unparse} from './unparse'

src/unparse.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import type {ExprNode} from './nodeTypes'
2+
3+
const IDENT_RE = /^[a-zA-Z_][a-zA-Z_0-9]*/
4+
const isIdent = (s: string) => IDENT_RE.test(s)
5+
const json = (v: unknown) => JSON.stringify(v)
6+
7+
/** Property accessor: `.name` if identifier-like, else `["name"]` */
8+
const prop = (name: string) => (isIdent(name) ? `.${name}` : `[${json(name)}]`)
9+
/** Property accessor with a custom prefix (e.g. `->`) */
10+
const propWith = (prefix: string, name: string) =>
11+
`${prefix}${isIdent(name) ? `.${name}` : `[${json(name)}]`}`
12+
13+
/** Join args with `, ` after unparsing */
14+
const joinArgs = (args: ExprNode[]) => args.map(unparse).join(', ')
15+
16+
/**
17+
* Converts a GROQ AST node back into a GROQ query string.
18+
*
19+
* **Limitation**: This function cannot preserve parameter references. When a query
20+
* is parsed with parameters (e.g., `parse(query, {params: {name: "value"}})`),
21+
* the parameters are resolved to their values in the AST. Unparsing such a tree
22+
* will produce literals instead of parameter references (e.g., `"value"` instead
23+
* of `$name`). This means `parse(unparse(tree))` will produce a different AST
24+
* when the original tree contained resolved parameters.
25+
*/
26+
// eslint-disable-next-line complexity
27+
export function unparse(node: ExprNode): string {
28+
switch (node.type) {
29+
case 'AccessAttribute': {
30+
// Prefer dotted access for identifiers, else bracket access.
31+
if (isIdent(node.name)) {
32+
return node.base ? `${unparse(node.base)}.${node.name}` : node.name
33+
}
34+
// If there's no base, treat it like `This` for bracketed access.
35+
const base = node.base || {type: 'This'}
36+
return `${unparse(base)}[${json(node.name)}]`
37+
}
38+
39+
case 'AccessElement':
40+
return `${unparse(node.base)}[${node.index}]`
41+
42+
case 'Array':
43+
return `[${node.elements
44+
.map(({value, isSplat}) => (isSplat ? `...${unparse(value)}` : unparse(value)))
45+
.join(', ')}]`
46+
47+
case 'ArrayCoerce':
48+
return `${unparse(node.base)}[]`
49+
50+
case 'Asc':
51+
return `${unparse(node.base)} asc`
52+
53+
case 'Desc':
54+
return `${unparse(node.base)} desc`
55+
56+
case 'And':
57+
return `${unparse(node.left)} && ${unparse(node.right)}`
58+
59+
case 'Or':
60+
return `${unparse(node.left)} || ${unparse(node.right)}`
61+
62+
case 'OpCall':
63+
return `${unparse(node.left)} ${node.op} ${unparse(node.right)}`
64+
65+
case 'Filter':
66+
return `${unparse(node.base)}[${unparse(node.expr)}]`
67+
68+
case 'Everything':
69+
return '*'
70+
71+
case 'This':
72+
return '@'
73+
74+
case 'Value':
75+
return json(node.value)
76+
77+
case 'PipeFuncCall':
78+
return `${unparse(node.base)}|${node.name}(${joinArgs(node.args)})`
79+
80+
case 'FuncCall':
81+
return `${node.namespace}::${node.name}(${joinArgs(node.args)})`
82+
83+
case 'Deref':
84+
return `${unparse(node.base)}->`
85+
86+
case 'Map':
87+
case 'Projection':
88+
// Both serialize as base + map-expression
89+
return `${unparse(node.base)}${unparseMapExpr(node.expr)}`
90+
91+
case 'FlatMap':
92+
return `${unparse(node.base)}${unparseFlatMapExpr(node.expr)}`
93+
94+
case 'Object':
95+
return `{${node.attributes
96+
.map((attr) => {
97+
switch (attr.type) {
98+
case 'ObjectAttributeValue':
99+
return `${json(attr.name)}: ${unparse(attr.value)}`
100+
case 'ObjectConditionalSplat':
101+
return `${unparse(attr.condition)} => ${unparse(attr.value)}`
102+
case 'ObjectSplat':
103+
return `...${unparse(attr.value)}`
104+
default:
105+
throw new Error(`Unknown object attribute type: ${attr['type'] as string}`)
106+
}
107+
})
108+
.join(', ')}}`
109+
110+
case 'Pos':
111+
return `+${unparse(node.base)}`
112+
113+
case 'Neg':
114+
return `-${unparse(node.base)}`
115+
116+
case 'Group':
117+
return `(${unparse(node.base)})`
118+
119+
case 'Not':
120+
return `!${unparse(node.base)}`
121+
122+
case 'InRange':
123+
return `${unparse(node.base)} in ${unparse(node.left)}${
124+
node.isInclusive ? '..' : '...'
125+
}${unparse(node.right)}`
126+
127+
case 'Parent':
128+
return Array.from({length: node.n}, () => '^').join('.')
129+
130+
case 'Parameter':
131+
return `$${node.name}`
132+
133+
case 'Slice':
134+
return `${unparse(node.base)}[${node.left}${node.isInclusive ? '..' : '...'}${node.right}]`
135+
136+
case 'Select': {
137+
const alts = node.alternatives.map(
138+
({condition, value}) => `${unparse(condition)} => ${unparse(value)}`,
139+
)
140+
if (node.fallback) alts.push(unparse(node.fallback))
141+
return `select(${alts.join(', ')})`
142+
}
143+
144+
case 'Tuple':
145+
return `(${node.members.map(unparse).join(', ')})`
146+
147+
case 'SelectorFuncCall':
148+
return `${node.name}(${unparse(node.arg)})`
149+
150+
case 'SelectorNested':
151+
return `${unparseSelector(node.base)}.(${unparseSelector(node.nested)})`
152+
153+
default:
154+
throw new Error(`TODO: ${node['type'] as string}`)
155+
}
156+
}
157+
158+
function unparseSelector(node: ExprNode): string {
159+
switch (node.type) {
160+
case 'AccessAttribute':
161+
return node.base ? `${unparseSelector(node.base)}.${node.name}` : node.name
162+
163+
case 'Group':
164+
return `(${unparseSelector(node.base)})`
165+
166+
case 'Tuple':
167+
return `(${node.members.map(unparseSelector).join(', ')})`
168+
169+
case 'ArrayCoerce':
170+
return `${unparseSelector(node.base)}[]`
171+
172+
case 'Filter':
173+
return `${unparseSelector(node.base)}[${unparse(node.expr)}]`
174+
175+
case 'SelectorFuncCall':
176+
return `${node.name}(${unparse(node.arg)})`
177+
178+
case 'SelectorNested':
179+
return `${unparseSelector(node.base)}.(${unparseSelector(node.nested)})`
180+
181+
default:
182+
// Fall back to the general unparser when selector-specific cases don’t apply.
183+
return unparse(node)
184+
}
185+
}
186+
187+
function unparseMapExpr(node: ExprNode): string {
188+
// AccessAttribute chains with special handling for This/Deref
189+
if (node.type === 'AccessAttribute') {
190+
// this.<name> / this["name"]
191+
if (node.base?.type === 'This') return prop(node.name)
192+
193+
// this->.<name> / this->["name"]
194+
if (node.base?.type === 'Deref' && node.base.base?.type === 'This') {
195+
return propWith('->', node.name)
196+
}
197+
198+
// (this.attr)->.<name> / ...->["name"]
199+
if (node.base?.type === 'Deref' && node.base.base?.type === 'AccessAttribute') {
200+
const derefBase = unparseMapExpr(node.base.base)
201+
return isIdent(node.name)
202+
? `${derefBase}->.${node.name}`
203+
: `${derefBase}->[${json(node.name)}]`
204+
}
205+
206+
// Generic attribute or element bases: append property
207+
if (node.base?.type === 'AccessAttribute' || node.base?.type === 'AccessElement') {
208+
const base = unparseMapExpr(node.base)
209+
return `${base}${prop(node.name)}`
210+
}
211+
}
212+
213+
if (node.type === 'AccessElement') {
214+
const base = unparseMapExpr(node.base)
215+
return `${base}[${node.index}]`
216+
}
217+
218+
if (node.type === 'Deref' && node.base?.type === 'This') {
219+
return '->'
220+
}
221+
222+
if (node.type === 'ArrayCoerce') {
223+
return `${unparseMapExpr(node.base)}[]`
224+
}
225+
226+
if (node.type === 'Filter') {
227+
return `${unparseMapExpr(node.base)}[${unparse(node.expr)}]`
228+
}
229+
230+
if (node.type === 'Projection') {
231+
if (node.base?.type === 'This') return unparseMapExpr(node.expr)
232+
233+
if (node.base?.type === 'Deref') {
234+
if (node.base.base?.type === 'This') return `->${unparse(node.expr)}`
235+
if (node.base.base?.type === 'AccessAttribute') {
236+
const derefBase = unparseMapExpr(node.base.base)
237+
return `${derefBase}->${unparse(node.expr)}`
238+
}
239+
}
240+
241+
if (node.base?.type === 'Projection') {
242+
return unparseMapExpr(node.base) + unparse(node.expr)
243+
}
244+
}
245+
246+
if (node.type === 'Map') return unparseMapExpr(node.expr)
247+
if (node.type === 'Object') return unparse(node)
248+
249+
// Fallback to general unparse for anything else
250+
return unparse(node)
251+
}
252+
253+
function unparseFlatMapExpr(node: ExprNode): string {
254+
if (node.type === 'AccessAttribute') {
255+
// this.<name> / this["name"]
256+
if (node.base?.type === 'This') return prop(node.name)
257+
258+
if (node.base?.type === 'Deref') {
259+
// this->.<name> / this->["name"]
260+
if (node.base.base?.type === 'This') return propWith('->', node.name)
261+
262+
// Deref with any base expression: <base>->.<name> / <base>->["name"]
263+
const derefBase = unparseFlatMapExpr(node.base.base)
264+
return isIdent(node.name)
265+
? `${derefBase}->.${node.name}`
266+
: `${derefBase}->[${json(node.name)}]`
267+
}
268+
269+
// Generic attribute/element bases
270+
if (node.base?.type === 'AccessAttribute' || node.base?.type === 'AccessElement') {
271+
const base = unparseFlatMapExpr(node.base)
272+
return `${base}${prop(node.name)}`
273+
}
274+
}
275+
276+
if (node.type === 'AccessElement') {
277+
const base = unparseFlatMapExpr(node.base)
278+
return `${base}[${node.index}]`
279+
}
280+
281+
if (node.type === 'ArrayCoerce') {
282+
return `${unparseFlatMapExpr(node.base)}[]`
283+
}
284+
285+
if (node.type === 'Map') {
286+
const base = unparseFlatMapExpr(node.base)
287+
const expr = unparseMapExpr(node.expr)
288+
return `${base}${expr}`
289+
}
290+
291+
if (node.type === 'FlatMap') {
292+
const base = unparseFlatMapExpr(node.base)
293+
const expr = unparseFlatMapExpr(node.expr)
294+
return `${base}${expr}`
295+
}
296+
297+
if (node.type === 'Projection') {
298+
if (node.base?.type === 'This') return unparse(node.expr)
299+
if (node.base?.type === 'Deref' && node.base.base?.type === 'This') {
300+
return `->${unparse(node.expr)}`
301+
}
302+
}
303+
304+
if (node.type === 'Deref' && node.base?.type === 'This') {
305+
return '->'
306+
}
307+
308+
if (node.type === 'Filter') {
309+
const base = unparseFlatMapExpr(node.base)
310+
return `${base}[${unparse(node.expr)}]`
311+
}
312+
313+
// Fallback to general unparse for anything else
314+
return unparse(node)
315+
}

test/generate.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ write(`const fs = require('fs')`)
5151
write(`const ndjson = require('ndjson')`)
5252
write(`const tap = require('tap')`)
5353
write(`const {evaluate, parse, evaluateSync, toJS} = require('../src/1')`)
54+
write(`const {unparse} = require('../src/experimental')`)
5455
space()
5556

5657
write(`tap.setTimeout(0)`)
@@ -242,6 +243,17 @@ process.stdin
242243
write(`replaceScoreWithPos(syncData)`)
243244
write(`tt.match(syncData, result)`)
244245
}
246+
write('let unparsed = unparse(tree)')
247+
write('let parsed = parse(unparsed)')
248+
const hasParams = entry.params && Object.keys(entry.params).length > 0
249+
if (hasParams) {
250+
write('// Skip tree comparison when params are used - parameter resolution is lossy')
251+
write('// Once parameters are resolved to Values, unparsing produces literals which')
252+
write('// parse to different node types (Array/Object/Value instead of Parameter)')
253+
write('// tt.match(parsed, tree)')
254+
} else {
255+
write('tt.match(parsed, tree)')
256+
}
245257
} else {
246258
write(`tt.throws(() => parse(query))`)
247259
}

0 commit comments

Comments
 (0)