Skip to content

Commit 265a232

Browse files
authored
feat(gqlorm): Prisma inspired GraphQL query builder (#1223)
```ts useLiveQuery((gqlorm) => gqlorm.user.findMany()) ``` From Greptile's summary below: - Implemented a complete query builder system with three core components: QueryBuilder (proxy-based query capture), QueryParser (ORM to AST conversion), and GraphQLGenerator (AST to GraphQL string generation) - Added `useLiveQuery` React hook that integrates with `@cedarjs/web` to provide real-time data fetching using the `@live` directive - Created comprehensive TypeScript type definitions with module augmentation support allowing Cedar to inject its db type for full type safety - Integrated gqlorm into Cedar's internal type generation system to automatically augment the `GqlormTypeMap` interface with the framework's database client type - Includes test coverage for core functionality including `findUnique`, `findMany`, `findFirst` operations and live query configuration
1 parent 26f9deb commit 265a232

28 files changed

+2780
-0
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export default [
290290
{
291291
files: [
292292
'**/.babelrc.js',
293+
'**/.babelrc.cjs',
293294
'**/babel.config.js',
294295
'**/jest.config.js',
295296
'**/jest.setup.js',

packages/gqlorm/.babelrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = { extends: '../../babel.config.js' }

packages/gqlorm/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# gqlorm
2+
3+
Prisma inspired GraphQL query builder

packages/gqlorm/attw.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { $ } from 'zx'
2+
3+
interface Problem {
4+
kind: string
5+
entrypoint?: string
6+
resolutionKind?: string
7+
}
8+
9+
await $({ nothrow: true })`yarn attw -P -f json > .attw.json`
10+
const output = await $`cat .attw.json`
11+
await $`rm .attw.json`
12+
13+
const json = JSON.parse(output.stdout)
14+
15+
if (!json.analysis.problems || json.analysis.problems.length === 0) {
16+
console.log('No errors found')
17+
process.exit(0)
18+
}
19+
20+
if (
21+
json.analysis.problems.every(
22+
(problem: Problem) => problem.resolutionKind === 'node10',
23+
)
24+
) {
25+
console.log("Only found node10 problems, which we don't care about")
26+
process.exit(0)
27+
}
28+
29+
console.log('Errors found')
30+
console.log(json.analysis.problems)
31+
process.exit(1)

packages/gqlorm/build.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { buildExternalCjs, buildExternalEsm } from '@cedarjs/framework-tools'
2+
import {
3+
generateTypesCjs,
4+
generateTypesEsm,
5+
insertCommonJsPackageJson,
6+
} from '@cedarjs/framework-tools/generateTypes'
7+
8+
await buildExternalEsm()
9+
await generateTypesEsm()
10+
11+
await buildExternalCjs()
12+
await generateTypesCjs()
13+
14+
await insertCommonJsPackageJson({ buildFileUrl: import.meta.url })

packages/gqlorm/package.json

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
{
2+
"name": "@cedarjs/gqlorm",
3+
"version": "2.6.0",
4+
"repository": {
5+
"type": "git",
6+
"url": "git+https://github.com/cedarjs/cedar.git",
7+
"directory": "packages/gqlorm"
8+
},
9+
"license": "MIT",
10+
"type": "module",
11+
"exports": {
12+
".": {
13+
"import": {
14+
"types": "./dist/queryBuilder.d.ts",
15+
"default": "./dist/queryBuilder.js"
16+
},
17+
"require": {
18+
"types": "./dist/cjs/queryBuilder.d.ts",
19+
"default": "./dist/cjs/queryBuilder.js"
20+
}
21+
},
22+
"./queryBuilder": {
23+
"import": {
24+
"types": "./dist/queryBuilder.d.ts",
25+
"default": "./dist/queryBuilder.js"
26+
},
27+
"require": {
28+
"types": "./dist/cjs/queryBuilder.d.ts",
29+
"default": "./dist/cjs/queryBuilder.js"
30+
}
31+
},
32+
"./react/useLiveQuery": {
33+
"import": {
34+
"types": "./dist/react/useLiveQuery.d.ts",
35+
"default": "./dist/react/useLiveQuery.js"
36+
},
37+
"require": {
38+
"types": "./dist/cjs/react/useLiveQuery.d.ts",
39+
"default": "./dist/cjs/react/useLiveQuery.js"
40+
}
41+
},
42+
"./parser/queryParser": {
43+
"import": {
44+
"types": "./dist/parser/queryParser.d.ts",
45+
"default": "./dist/parser/queryParser.js"
46+
},
47+
"require": {
48+
"types": "./dist/cjs/parser/queryParser.d.ts",
49+
"default": "./dist/cjs/parser/queryParser.js"
50+
}
51+
},
52+
"./generator/graphqlGenerator": {
53+
"import": {
54+
"types": "./dist/generator/graphqlGenerator.d.ts",
55+
"default": "./dist/generator/graphqlGenerator.js"
56+
},
57+
"require": {
58+
"types": "./dist/cjs/generator/graphqlGenerator.d.ts",
59+
"default": "./dist/cjs/generator/graphqlGenerator.js"
60+
}
61+
},
62+
"./live/types": {
63+
"import": {
64+
"types": "./dist/live/types.d.ts",
65+
"default": "./dist/live/types.js"
66+
},
67+
"require": {
68+
"types": "./dist/cjs/live/types.d.ts",
69+
"default": "./dist/cjs/live/types.js"
70+
}
71+
},
72+
"./types/ast": {
73+
"import": {
74+
"types": "./dist/types/ast.d.ts",
75+
"default": "./dist/types/ast.js"
76+
},
77+
"require": {
78+
"types": "./dist/cjs/types/ast.d.ts",
79+
"default": "./dist/cjs/types/ast.js"
80+
}
81+
},
82+
"./types/orm": {
83+
"import": {
84+
"types": "./dist/types/orm.d.ts",
85+
"default": "./dist/types/orm.js"
86+
},
87+
"require": {
88+
"types": "./dist/cjs/types/orm.d.ts",
89+
"default": "./dist/cjs/types/orm.js"
90+
}
91+
},
92+
"./types/schema": {
93+
"import": {
94+
"types": "./dist/types/schema.d.ts",
95+
"default": "./dist/types/schema.js"
96+
},
97+
"require": {
98+
"types": "./dist/cjs/types/schema.d.ts",
99+
"default": "./dist/cjs/types/schema.js"
100+
}
101+
},
102+
"./types/typeUtils": {
103+
"import": {
104+
"types": "./dist/types/typeUtils.d.ts",
105+
"default": "./dist/types/typeUtils.js"
106+
},
107+
"require": {
108+
"types": "./dist/cjs/types/typeUtils.d.ts",
109+
"default": "./dist/cjs/types/typeUtils.js"
110+
}
111+
}
112+
},
113+
"main": "dist/queryBuilder.js",
114+
"types": "dist/queryBuilder.d.ts",
115+
"files": [
116+
"dist"
117+
],
118+
"scripts": {
119+
"build": "tsx ./build.ts",
120+
"build:pack": "yarn pack -o cedarjs-gqlorm.tgz",
121+
"build:types": "tsc --build --verbose tsconfig.build.json",
122+
"build:types-cjs": "tsc --build --verbose tsconfig.cjs.json",
123+
"build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"",
124+
"check:attw": "tsx ./attw.ts",
125+
"check:package": "concurrently npm:check:attw yarn publint",
126+
"prepublishOnly": "NODE_ENV=production yarn build",
127+
"test": "vitest run",
128+
"test:watch": "vitest watch"
129+
},
130+
"dependencies": {
131+
"@babel/runtime-corejs3": "7.29.0",
132+
"@cedarjs/auth": "workspace:*",
133+
"@cedarjs/server-store": "workspace:*",
134+
"@cedarjs/web": "workspace:*",
135+
"core-js": "3.48.0",
136+
"graphql": "16.12.0",
137+
"react": "19.2.3",
138+
"react-dom": "19.2.3",
139+
"react-server-dom-webpack": "19.2.4"
140+
},
141+
"devDependencies": {
142+
"@arethetypeswrong/cli": "0.18.2",
143+
"@babel/cli": "7.28.6",
144+
"@babel/core": "^7.26.10",
145+
"@cedarjs/framework-tools": "workspace:*",
146+
"@testing-library/jest-dom": "6.9.1",
147+
"@types/react": "^18.2.55",
148+
"@types/react-dom": "^18.2.19",
149+
"concurrently": "9.2.1",
150+
"publint": "0.3.17",
151+
"tstyche": "5.0.2",
152+
"tsx": "4.21.0",
153+
"typescript": "5.9.3",
154+
"vitest": "3.2.4"
155+
},
156+
"peerDependencies": {
157+
"react": "19.2.3",
158+
"react-dom": "19.2.3"
159+
},
160+
"publishConfig": {
161+
"access": "public"
162+
},
163+
"gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1"
164+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
buildQuery,
5+
buildQueryFromFunction,
6+
QueryBuilder,
7+
} from '../queryBuilder.js'
8+
import type * as OrmTypes from '../types/orm.js'
9+
10+
interface CedarUser {
11+
id: number
12+
createdAt: Date
13+
updatedAt: Date
14+
email: string
15+
name: string
16+
isActive: boolean
17+
}
18+
19+
interface CedarPost {
20+
id: number
21+
createdAt: Date
22+
updatedAt: Date
23+
title: string
24+
published: boolean
25+
}
26+
27+
declare module '../types/orm.js' {
28+
interface GqlormTypeMap {
29+
db: {
30+
user: OrmTypes.ModelDelegate<CedarUser>
31+
post: OrmTypes.ModelDelegate<CedarPost>
32+
}
33+
}
34+
}
35+
36+
describe('QueryBuilder', () => {
37+
it('builds a live query when requested', () => {
38+
const result = buildQuery(
39+
'user',
40+
'findMany',
41+
{ where: { isActive: true } },
42+
{ isLive: true },
43+
)
44+
45+
expect(result.query).toContain('@live')
46+
expect(result.query).toContain('users')
47+
expect(result.variables).toEqual({ var0: true })
48+
})
49+
50+
it('supports findUnique', () => {
51+
const result = buildQueryFromFunction(
52+
(gqlorm) =>
53+
gqlorm.user.findUnique({
54+
where: { id: 1 },
55+
select: { id: true, name: true },
56+
}),
57+
{ isLive: true },
58+
)
59+
60+
expect(result.query).toContain('user(')
61+
expect(result.query).toContain('@live')
62+
expect(result.query).toMatch(/\bname\b/)
63+
expect(result.query).not.toContain('createdAt')
64+
expect(Object.values(result.variables || {})).toEqual([1])
65+
})
66+
67+
it('supports findMany', () => {
68+
const result = buildQueryFromFunction((gqlorm) =>
69+
gqlorm.user.findMany({
70+
where: { isActive: true },
71+
select: {
72+
id: true,
73+
email: true,
74+
// @ts-expect-error - Making sure types are correct
75+
doesNotExist: false,
76+
},
77+
}),
78+
)
79+
80+
expect(result.query).toContain('query findManyUser')
81+
expect(result.query).toContain('users')
82+
expect(result.query).toMatch(/\bid\b/)
83+
expect(result.query).toMatch(/\bemail\b/)
84+
expect(Object.values(result.variables || {})).toEqual([true])
85+
})
86+
87+
it('supports findFirst', () => {
88+
const result = buildQueryFromFunction((gqlorm) =>
89+
gqlorm.post.findFirst({
90+
where: {
91+
AND: [{ published: true }, { createdAt: { gt: new Date(0) } }],
92+
},
93+
select: { id: true, title: true },
94+
}),
95+
)
96+
97+
expect(result.query).toContain('query findFirstPost')
98+
expect(result.query).toContain('post(')
99+
expect(result.query).toMatch(/\bid\b/)
100+
expect(result.query).toMatch(/\btitle\b/)
101+
expect(result.variables).toEqual({ var0: true, var1: new Date(0) })
102+
})
103+
104+
it('respects forceLiveQueries but allows explicit override', () => {
105+
const qb = new QueryBuilder({ forceLiveQueries: true })
106+
107+
const forcedLive = qb.build('user', 'findMany')
108+
expect(forcedLive.query).toContain('@live')
109+
110+
const explicitNonLive = qb.build('user', 'findMany', undefined, {
111+
isLive: false,
112+
})
113+
expect(explicitNonLive.query).not.toContain('@live')
114+
})
115+
})

0 commit comments

Comments
 (0)