Skip to content

Commit 444ca0f

Browse files
authored
fix(db-d1-sqlite): avoid bound parameter limit when querying relationships and inserting rows (#14099)
Fixes https://discord.com/channels/967097582721572934/1422639568808841329/1425037080051978261 This PR avoids bound parameters usage for `IN` and `NOT_IN` querying on `id` to respect the D1 limit https://developers.cloudflare.com/d1/platform/limits/ And also batches inserts when inserting arrays/blocks/hasMany relationships etc. This is needed because we can't avoid using bound parameters there, but still want to respect the 100 variables limit per query.
1 parent e8140ed commit 444ca0f

File tree

7 files changed

+190
-2
lines changed

7 files changed

+190
-2
lines changed

packages/db-d1-sqlite/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export function sqliteD1Adapter(args: Args): DatabaseAdapterObj<SQLiteD1Adapter>
109109
}),
110110
idType: sqliteIDType,
111111
initializing,
112+
limitedBoundParameters: true,
112113
localesSuffix: args.localesSuffix || '_locales',
113114
logger: args.logger,
114115
operators,

packages/drizzle/src/queries/parseParams.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import type { FlattenedField, Operator, Sort, Where } from 'payload'
33

44
import { and, isNotNull, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
55
import { PgUUID } from 'drizzle-orm/pg-core'
6-
import { QueryError } from 'payload'
6+
import { APIError, QueryError } from 'payload'
77
import { validOperatorSet } from 'payload/shared'
88

99
import type { DrizzleAdapter, GenericColumn } from '../types.js'
1010
import type { BuildQueryJoinAliases } from './buildQuery.js'
1111

1212
import { getNameFromDrizzleTable } from '../utilities/getNameFromDrizzleTable.js'
13+
import { isValidStringID } from '../utilities/isValidStringID.js'
1314
import { DistinctSymbol } from '../utilities/rawConstraint.js'
1415
import { buildAndOrConditions } from './buildAndOrConditions.js'
1516
import { getTableColumnFromPath } from './getTableColumnFromPath.js'
@@ -387,10 +388,59 @@ export function parseParams({
387388
orConditions.push(isNull(resolvedColumn))
388389
resolvedQueryValue = queryValue.filter((v) => v !== null)
389390
}
391+
390392
let constraint = adapter.operators[queryOperator](
391393
resolvedColumn,
392394
resolvedQueryValue,
393395
)
396+
397+
if (
398+
adapter.limitedBoundParameters &&
399+
(operator === 'in' || operator === 'not_in') &&
400+
relationOrPath === 'id' &&
401+
Array.isArray(queryValue)
402+
) {
403+
let isInvalid = false
404+
for (const val of queryValue) {
405+
if (typeof val === 'number' || val === null) {
406+
continue
407+
}
408+
if (typeof val === 'string') {
409+
if (!isValidStringID(val)) {
410+
isInvalid = true
411+
break
412+
} else {
413+
continue
414+
}
415+
}
416+
isInvalid = true
417+
break
418+
}
419+
420+
if (isInvalid) {
421+
throw new APIError(`Invalid ID value in ${JSON.stringify(queryValue)}`)
422+
}
423+
424+
constraints.push(
425+
sql.raw(
426+
`${resolvedColumn.name} ${operator === 'in' ? 'IN' : 'NOT IN'} (${queryValue
427+
.map((e) => {
428+
if (e === null) {
429+
return `NULL`
430+
}
431+
432+
if (typeof e === 'number') {
433+
return e
434+
}
435+
436+
return `'${e}'`
437+
})
438+
.join(',')})`,
439+
),
440+
)
441+
break
442+
}
443+
394444
if (orConditions.length) {
395445
orConditions.push(constraint)
396446
constraint = or(...orConditions)

packages/drizzle/src/sqlite/insert.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ export const insert: Insert = async function (
88
): Promise<Record<string, unknown>[]> {
99
const table = this.tables[tableName]
1010

11+
// Batch insert if limitedBoundParameters: true
12+
if (this.limitedBoundParameters && Array.isArray(values)) {
13+
const results: Record<string, unknown>[] = []
14+
const colsPerRow = Object.keys(values[0]).length
15+
const maxParams = 100
16+
const maxRowsPerBatch = Math.max(1, Math.floor(maxParams / colsPerRow))
17+
18+
for (let i = 0; i < values.length; i += maxRowsPerBatch) {
19+
const batch = values.slice(i, i + maxRowsPerBatch)
20+
21+
const batchResult = onConflictDoUpdate
22+
? await db.insert(table).values(batch).onConflictDoUpdate(onConflictDoUpdate).returning()
23+
: await db.insert(table).values(batch).returning()
24+
25+
results.push(...(batchResult as Record<string, unknown>[]))
26+
}
27+
28+
return results
29+
}
30+
1131
const result = onConflictDoUpdate
1232
? await db.insert(table).values(values).onConflictDoUpdate(onConflictDoUpdate).returning()
1333
: await db.insert(table).values(values).returning()

packages/drizzle/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,8 @@ export interface DrizzleAdapter extends BaseDatabaseAdapter {
344344
drizzle: LibSQLDatabase | PostgresDB
345345
dropDatabase: DropDatabase
346346
enums?: never | Record<string, unknown>
347-
348347
execute: Execute<unknown>
348+
349349
features: {
350350
json?: boolean
351351
}
@@ -358,6 +358,7 @@ export interface DrizzleAdapter extends BaseDatabaseAdapter {
358358
indexes: Set<string>
359359
initializing: Promise<void>
360360
insert: Insert
361+
limitedBoundParameters?: boolean
361362
localesSuffix?: string
362363
logger: DrizzleConfig['logger']
363364
operators: Operators
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { isValidStringID } from './isValidStringID.js'
2+
3+
describe('isValidStringID', () => {
4+
it('should pass', () => {
5+
expect(isValidStringID('1')).toBe(true)
6+
expect(isValidStringID('a_b_c')).toBe(true)
7+
expect(isValidStringID('8cc2df6d-6e07-4da4-be48-5fa747c3b92b')).toBe(true)
8+
})
9+
10+
it('should not pass', () => {
11+
expect(isValidStringID('1 2 3')).toBe(false)
12+
expect(isValidStringID('1@')).toBe(false)
13+
})
14+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function isValidStringID(value: string) {
2+
return /^[\w-]+$/.test(value)
3+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Payload } from 'payload'
2+
3+
/* eslint-disable jest/require-top-level-describe */
4+
import path from 'path'
5+
import { fileURLToPath } from 'url'
6+
7+
import { initPayloadInt } from '../helpers/initPayloadInt.js'
8+
9+
const filename = fileURLToPath(import.meta.url)
10+
const dirname = path.dirname(filename)
11+
12+
const describeSqlite = process.env.PAYLOAD_DATABASE?.startsWith('sqlite') ? describe : describe.skip
13+
14+
let payload: Payload
15+
16+
describeSqlite('database - sqlite bound parameters limit', () => {
17+
beforeAll(async () => {
18+
;({ payload } = await initPayloadInt(dirname))
19+
})
20+
21+
afterAll(async () => {
22+
await payload.destroy()
23+
})
24+
25+
it('should not use bound parameters for where querying on ID with IN if limitedBoundParameters: true', async () => {
26+
const defaultExecute = payload.db.drizzle.$client.execute.bind(payload.db.drizzle.$client)
27+
28+
// Limit bounds parameters length
29+
payload.db.drizzle.$client.execute = async function execute(...args) {
30+
const res = await defaultExecute(...args)
31+
const [{ args: boundParameters }] = args as [{ args: any[] }]
32+
33+
// eslint-disable-next-line jest/no-conditional-in-test
34+
if (boundParameters.length > 100) {
35+
throw new Error('Exceeded limit of bound parameters!')
36+
}
37+
return res
38+
}
39+
40+
payload.db.limitedBoundParameters = false
41+
42+
const IN = Array.from({ length: 300 }, (_, i) => i)
43+
44+
// Should fail here because too the length exceeds the limit
45+
await expect(
46+
payload.find({
47+
collection: 'simple',
48+
pagination: false,
49+
where: { id: { in: IN } },
50+
}),
51+
).rejects.toBeTruthy()
52+
53+
// Should fail here because too the length exceeds the limit
54+
await expect(
55+
payload.find({
56+
collection: 'simple',
57+
pagination: false,
58+
where: { id: { not_in: IN } },
59+
}),
60+
).rejects.toBeTruthy()
61+
62+
payload.db.limitedBoundParameters = true
63+
64+
// Should not fail because limitedBoundParameters: true
65+
await expect(
66+
payload.find({
67+
collection: 'simple',
68+
pagination: false,
69+
where: { id: { in: IN } },
70+
}),
71+
).resolves.toBeTruthy()
72+
73+
// Should not fail because limitedBoundParameters: true
74+
await expect(
75+
payload.find({
76+
collection: 'simple',
77+
pagination: false,
78+
where: { id: { not_in: IN } },
79+
}),
80+
).resolves.toBeTruthy()
81+
82+
// Verify that "in" still works properly
83+
84+
const docs = await Promise.all(
85+
Array.from({ length: 300 }, () => payload.create({ collection: 'simple', data: {} })),
86+
)
87+
88+
const res = await payload.find({
89+
collection: 'simple',
90+
pagination: false,
91+
where: { id: { in: docs.map((e) => e.id) } },
92+
})
93+
94+
expect(res.totalDocs).toBe(300)
95+
for (const docInRes of res.docs) {
96+
expect(docs.some((doc) => doc.id === docInRes.id)).toBeTruthy()
97+
}
98+
})
99+
})

0 commit comments

Comments
 (0)