Skip to content

Commit 788dd67

Browse files
committed
fix(fumadb): validate bulk upsert conflict shapes
1 parent 66be835 commit 788dd67

4 files changed

Lines changed: 59 additions & 8 deletions

File tree

packages/core/fumadb/src/adapters/drizzle/query.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ export function fromDrizzle(
315315
},
316316
async upsertMany(table, v) {
317317
if (v.values.length === 0) return;
318+
if (v.target.length === 0) {
319+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: adapter rejects invalid upsert shape
320+
throw new Error("[FumaDB] upsertMany requires at least one target column.");
321+
}
318322
if (v.update.length === 0) {
319323
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: adapter rejects invalid upsert shape
320324
throw new Error("[FumaDB] upsertMany requires at least one update column.");
@@ -367,14 +371,18 @@ export function fromDrizzle(
367371

368372
for (let i = 0; i < values.length; i += batchSize) {
369373
const batch = values.slice(i, i + batchSize);
370-
await (db as any)
371-
.insert(drizzleTable)
372-
.values(batch)
373-
.onConflictDoUpdate({
374-
target,
375-
set,
376-
...(where === undefined ? {} : { where }),
377-
});
374+
const insert = db.insert(drizzleTable).values(batch) as unknown as {
375+
onConflictDoUpdate: (input: {
376+
readonly target: typeof target;
377+
readonly set: typeof set;
378+
readonly where?: typeof where;
379+
}) => Promise<unknown>;
380+
};
381+
await insert.onConflictDoUpdate({
382+
target,
383+
set,
384+
...(where === undefined ? {} : { where }),
385+
});
378386
}
379387
},
380388
async findMany(table, v) {

packages/core/fumadb/src/adapters/memory/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ export function memoryAdapter(options: MemoryAdapterOptions = {}): FumaDBAdapter
175175
await this.create(table, v.create);
176176
},
177177
async upsertMany(table, v) {
178+
if (v.target.length === 0) {
179+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: adapter rejects invalid upsert shape
180+
throw new Error("[FumaDB] upsertMany requires at least one target column.");
181+
}
178182
if (v.update.length === 0) {
179183
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: adapter rejects invalid upsert shape
180184
throw new Error("[FumaDB] upsertMany requires at least one update column.");

packages/core/fumadb/src/query/orm/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,14 @@ export function toORM<S extends AnySchema>(
407407
async upsertMany(name, { target, update, values }) {
408408
const table = toTable(name);
409409
if (values.length === 0) return;
410+
if (target.length === 0) {
411+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: public query rejects invalid upsert shape
412+
throw new Error("[FumaDB] upsertMany requires at least one target column.");
413+
}
414+
if (update.length === 0) {
415+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: public query rejects invalid upsert shape
416+
throw new Error("[FumaDB] upsertMany requires at least one update column.");
417+
}
410418

411419
const targetColumns = target.map((columnName) => {
412420
const column = table.columns[columnName as string];

packages/core/fumadb/src/query/table-policy.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,37 @@ describe("FumaDB table policies", () => {
482482
}),
483483
);
484484

485+
it.effect("rejects invalid bulk upsert conflict shapes", () =>
486+
useHarness(async (orm) => {
487+
await seedTenants(orm);
488+
const tenantA = withQueryContext(orm, makeContext(["tenant-a"], "tenant-a"));
489+
const values = [
490+
{
491+
id: "post-a-bulk-upsert",
492+
tenantId: "tenant-a",
493+
authorId: "author-a",
494+
title: "A bulk upsert",
495+
},
496+
];
497+
498+
await expect(
499+
tenantA.upsertMany("posts", {
500+
target: [],
501+
update: ["title"],
502+
values,
503+
}),
504+
).rejects.toThrow("[FumaDB] upsertMany requires at least one target column.");
505+
506+
await expect(
507+
tenantA.upsertMany("posts", {
508+
target: ["id"],
509+
update: [],
510+
values,
511+
}),
512+
).rejects.toThrow("[FumaDB] upsertMany requires at least one update column.");
513+
}),
514+
);
515+
485516
it.effect("fails closed when a query wrapper does not forward context rebinding", () =>
486517
useHarness(async (orm) => {
487518
const wrapped = { ...orm };

0 commit comments

Comments
 (0)