Skip to content

Commit e74f592

Browse files
hwisuigalklebanov
andcommitted
feat: deny passing BLOB-like columns to SQLite JSON helpers (#1698)
Co-authored-by: Igal Klebanov <igalklebanov@gmail.com>
1 parent 198e9b3 commit e74f592

4 files changed

Lines changed: 269 additions & 7 deletions

File tree

src/helpers/sqlite.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@ import type { SelectQueryNode } from '../operation-node/select-query-node.js'
33
import type { SelectQueryBuilderExpression } from '../query-builder/select-query-builder-expression.js'
44
import type { RawBuilder } from '../raw-builder/raw-builder.js'
55
import { sql } from '../raw-builder/sql.js'
6+
import type { KyselyTypeError } from '../util/type-error.js'
67
import { getJsonObjectArgs } from '../util/json-object-args.js'
78
import type {
9+
IsNever,
810
ShallowDehydrateObject,
911
ShallowDehydrateValue,
1012
Simplify,
1113
} from '../util/type-utils.js'
1214

15+
/**
16+
* Used to check for `BLOB` column existence in result records.
17+
*/
18+
export type ExtractBlobs<O> = {
19+
[K in keyof O]: O[K] extends Expression<infer V>
20+
? Extract<V, Uint8Array>
21+
: Extract<O[K], Uint8Array>
22+
}[keyof O]
23+
1324
/**
1425
* A SQLite helper for aggregating a subquery into a JSON array.
1526
*
@@ -72,10 +83,15 @@ import type {
7283
* ```
7384
*/
7485
export function jsonArrayFrom<O>(
75-
expr: SelectQueryBuilderExpression<O>,
86+
expr: IsNever<ExtractBlobs<O>> extends true
87+
? SelectQueryBuilderExpression<O>
88+
: KyselyTypeError<'SQLite does not support passing `BLOB` values to `json_object`. Cast to `TEXT`.'>,
7689
): RawBuilder<Simplify<ShallowDehydrateObject<O>>[]> {
7790
return sql`(select coalesce(json_group_array(json_object(${sql.join(
78-
getSqliteJsonObjectArgs(expr.toOperationNode(), 'agg'),
91+
getSqliteJsonObjectArgs(
92+
(expr as SelectQueryBuilderExpression<O>).toOperationNode(),
93+
'agg',
94+
),
7995
)})), '[]') from ${expr} as agg)`
8096
}
8197

@@ -143,10 +159,15 @@ export function jsonArrayFrom<O>(
143159
* ```
144160
*/
145161
export function jsonObjectFrom<O>(
146-
expr: SelectQueryBuilderExpression<O>,
162+
expr: IsNever<ExtractBlobs<O>> extends true
163+
? SelectQueryBuilderExpression<O>
164+
: KyselyTypeError<'SQLite does not support passing `BLOB` values to `json_object`. Cast to `TEXT`.'>,
147165
): RawBuilder<Simplify<ShallowDehydrateObject<O>> | null> {
148166
return sql`(select json_object(${sql.join(
149-
getSqliteJsonObjectArgs(expr.toOperationNode(), 'obj'),
167+
getSqliteJsonObjectArgs(
168+
(expr as SelectQueryBuilderExpression<O>).toOperationNode(),
169+
'obj',
170+
),
150171
)}) from ${expr} as obj)`
151172
}
152173

@@ -207,7 +228,9 @@ export function jsonObjectFrom<O>(
207228
* ```
208229
*/
209230
export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
210-
obj: O,
231+
obj: IsNever<ExtractBlobs<O>> extends true
232+
? O
233+
: KyselyTypeError<'SQLite does not support passing `BLOB` values to `json_object`. Cast to `TEXT`.'>,
211234
): RawBuilder<
212235
Simplify<{
213236
[K in keyof O]: O[K] extends Expression<infer V>
@@ -216,7 +239,10 @@ export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
216239
}>
217240
> {
218241
return sql`json_object(${sql.join(
219-
Object.keys(obj).flatMap((k) => [sql.lit(k), obj[k]]),
242+
Object.keys(obj).flatMap((k) => [
243+
sql.lit(k),
244+
(obj as Record<string, unknown>)[k],
245+
]),
220246
)})`
221247
}
222248

test/node/src/json.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ const jsonFunctions = {
7272
jsonArrayFrom: sqlite_jsonArrayFrom,
7373
jsonObjectFrom: sqlite_jsonObjectFrom,
7474
jsonBuildObject: sqlite_jsonBuildObject,
75+
} as never as {
76+
jsonArrayFrom: typeof pg_jsonArrayFrom
77+
jsonObjectFrom: typeof pg_jsonObjectFrom
78+
jsonBuildObject: typeof pg_jsonBuildObject
7579
},
7680
} as const
7781

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { expectError, expectType } from 'tsd'
2+
import {
3+
jsonArrayFrom,
4+
jsonBuildObject,
5+
jsonObjectFrom,
6+
} from '../../../dist/helpers/sqlite.js'
7+
import { type Kysely, sql } from '../index.js'
8+
9+
interface Database {
10+
blob_test: {
11+
id: number
12+
name: string
13+
data: Buffer
14+
nullable_data: Buffer | null
15+
uint8_data: Uint8Array
16+
any_data: any
17+
mixed_data: Buffer | string
18+
}
19+
}
20+
21+
async function testJsonArrayFrom(db: Kysely<Database>) {
22+
const r1 = await db
23+
.selectFrom('blob_test')
24+
.select((eb) => [
25+
'id',
26+
jsonArrayFrom(
27+
eb
28+
.selectFrom('blob_test')
29+
.select(['blob_test.id', 'blob_test.name'])
30+
.where('blob_test.id', '>', 0),
31+
).as('rows'),
32+
])
33+
.execute()
34+
35+
expectType<{ id: number; rows: { id: number; name: string }[] }[]>(r1)
36+
37+
const r2 = await db
38+
.selectFrom('blob_test')
39+
.select((eb) => [
40+
'id',
41+
jsonArrayFrom(
42+
eb
43+
.selectFrom('blob_test')
44+
.select(['id', sql`1`.as('one')])
45+
.where('blob_test.id', '>', 0),
46+
).as('rows'),
47+
])
48+
.execute()
49+
50+
expectType<{ id: number; rows: { id: number; one: unknown }[] }[]>(r2)
51+
52+
expectError(
53+
db
54+
.selectFrom('blob_test')
55+
.select((eb) => [
56+
'id',
57+
jsonArrayFrom(eb.selectFrom('blob_test').select(['id', 'data'])).as(
58+
'rows',
59+
),
60+
]),
61+
)
62+
63+
expectError(
64+
db
65+
.selectFrom('blob_test')
66+
.select((eb) => [
67+
'id',
68+
jsonArrayFrom(
69+
eb.selectFrom('blob_test').select(['id', 'nullable_data']),
70+
).as('rows'),
71+
]),
72+
)
73+
74+
expectError(
75+
db
76+
.selectFrom('blob_test')
77+
.select((eb) => [
78+
'id',
79+
jsonArrayFrom(
80+
eb.selectFrom('blob_test').select(['id', 'uint8_data']),
81+
).as('rows'),
82+
]),
83+
)
84+
85+
expectError(
86+
db
87+
.selectFrom('blob_test')
88+
.select((eb) => [
89+
'id',
90+
jsonArrayFrom(eb.selectFrom('blob_test').select(['id', 'any_data'])).as(
91+
'rows',
92+
),
93+
]),
94+
)
95+
}
96+
97+
async function testJsonObjectFrom(db: Kysely<Database>) {
98+
const r1 = await db
99+
.selectFrom('blob_test')
100+
.select((eb) => [
101+
'id',
102+
jsonObjectFrom(
103+
eb
104+
.selectFrom('blob_test')
105+
.select(['blob_test.id', 'blob_test.name'])
106+
.where('blob_test.id', '>', 0)
107+
.limit(1),
108+
).as('row'),
109+
])
110+
.execute()
111+
112+
expectType<{ id: number; row: { id: number; name: string } | null }[]>(r1)
113+
114+
const r2 = await db
115+
.selectFrom('blob_test')
116+
.select((eb) => [
117+
'id',
118+
jsonObjectFrom(
119+
eb
120+
.selectFrom('blob_test')
121+
.select(['id', sql`1`.as('one')])
122+
.where('blob_test.id', '>', 0)
123+
.limit(1),
124+
).as('row'),
125+
])
126+
.execute()
127+
128+
expectType<{ id: number; row: { id: number; one: unknown } | null }[]>(r2)
129+
130+
expectError(
131+
db
132+
.selectFrom('blob_test')
133+
.select((eb) => [
134+
'id',
135+
jsonObjectFrom(
136+
eb.selectFrom('blob_test').select(['id', 'data']).limit(1),
137+
).as('row'),
138+
]),
139+
)
140+
141+
expectError(
142+
db
143+
.selectFrom('blob_test')
144+
.select((eb) => [
145+
'id',
146+
jsonObjectFrom(
147+
eb.selectFrom('blob_test').select(['id', 'nullable_data']).limit(1),
148+
).as('row'),
149+
]),
150+
)
151+
152+
expectError(
153+
db
154+
.selectFrom('blob_test')
155+
.select((eb) => [
156+
'id',
157+
jsonObjectFrom(
158+
eb.selectFrom('blob_test').select(['id', 'mixed_data']).limit(1),
159+
).as('row'),
160+
]),
161+
)
162+
163+
expectError(
164+
db
165+
.selectFrom('blob_test')
166+
.select((eb) => [
167+
'id',
168+
jsonObjectFrom(
169+
eb.selectFrom('blob_test').select(['id', 'any_data']),
170+
).as('row'),
171+
]),
172+
)
173+
}
174+
175+
async function testJsonBuildObject(db: Kysely<Database>) {
176+
const r1 = await db
177+
.selectFrom('blob_test')
178+
.select((eb) => [
179+
'id',
180+
jsonBuildObject({
181+
name: eb.ref('name'),
182+
computed: sql<string>`upper(name)`,
183+
}).as('obj'),
184+
])
185+
.execute()
186+
187+
expectType<{ id: number; obj: { name: string; computed: string } }[]>(r1)
188+
189+
const r2 = await db
190+
.selectFrom('blob_test')
191+
.select((eb) => [
192+
'id',
193+
jsonBuildObject({
194+
name: eb.ref('name'),
195+
one: sql`1`,
196+
}).as('obj'),
197+
])
198+
.execute()
199+
200+
expectType<{ id: number; obj: { name: string; one: unknown } }[]>(r2)
201+
202+
expectError(
203+
db.selectFrom('blob_test').select((eb) => [
204+
'id',
205+
jsonBuildObject({
206+
name: eb.ref('name'),
207+
data: eb.ref('data'),
208+
}).as('obj'),
209+
]),
210+
)
211+
212+
expectError(
213+
db.selectFrom('blob_test').select((eb) => [
214+
'id',
215+
jsonBuildObject({
216+
name: eb.ref('name'),
217+
data: eb.ref('nullable_data'),
218+
}).as('obj'),
219+
]),
220+
)
221+
222+
expectError(
223+
db.selectFrom('blob_test').select((eb) => [
224+
'id',
225+
jsonBuildObject({
226+
name: eb.ref('name'),
227+
data: eb.ref('any_data'),
228+
}).as('obj'),
229+
]),
230+
)
231+
}

test/typings/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"compilerOptions": {
33
"exactOptionalPropertyTypes": true,
4-
"target": "es2022"
4+
"target": "es2022",
5+
"types": ["node"]
56
}
67
}

0 commit comments

Comments
 (0)