Thin MongoDB ODM built for Standard Schema
mongodb zod deno

fix default logic for updates

knotbin.com 903dfaab e0183687

verified
Changed files
+584 -16
model
tests
+40 -14
model/core.ts
···
UpdateResult,
WithId,
BulkWriteOptions,
+
UpdateFilter,
} from "mongodb";
import { ObjectId } from "mongodb";
import type { Schema, Infer, Input } from "../types.ts";
-
import { parse, parsePartial, parseReplace } from "./validation.ts";
+
import { parse, parsePartial, parseReplace, applyDefaultsForUpsert } from "./validation.ts";
/**
* Core CRUD operations for the Model class
···
/**
* Update multiple documents matching the query
*
+
* Case handling:
+
* - If upsert: false (or undefined) → Normal update, no defaults applied
+
* - If upsert: true → Defaults added to $setOnInsert for new document creation
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param query - MongoDB query filter
* @param data - Partial data to update
-
* @param options - Update options (including session for transactions)
+
* @param options - Update options (including session for transactions and upsert flag)
* @returns Update result
*/
export async function update<T extends Schema>(
···
options?: UpdateOptions
): Promise<UpdateResult<Infer<T>>> {
const validatedData = parsePartial(schema, data);
-
return await collection.updateMany(
-
query,
-
{ $set: validatedData as Partial<Infer<T>> },
-
options
-
);
+
let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
+
+
// If this is an upsert, apply defaults using $setOnInsert
+
if (options?.upsert) {
+
updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
+
}
+
+
return await collection.updateMany(query, updateDoc, options);
}
/**
* Update a single document matching the query
*
+
* Case handling:
+
* - If upsert: false (or undefined) → Normal update, no defaults applied
+
* - If upsert: true → Defaults added to $setOnInsert for new document creation
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param query - MongoDB query filter
* @param data - Partial data to update
-
* @param options - Update options (including session for transactions)
+
* @param options - Update options (including session for transactions and upsert flag)
* @returns Update result
*/
export async function updateOne<T extends Schema>(
···
options?: UpdateOptions
): Promise<UpdateResult<Infer<T>>> {
const validatedData = parsePartial(schema, data);
-
return await collection.updateOne(
-
query,
-
{ $set: validatedData as Partial<Infer<T>> },
-
options
-
);
+
let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
+
+
// If this is an upsert, apply defaults using $setOnInsert
+
if (options?.upsert) {
+
updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
+
}
+
+
return await collection.updateOne(query, updateDoc, options);
}
/**
* Replace a single document matching the query
*
+
* Case handling:
+
* - If upsert: false (or undefined) → Normal replace on existing doc, no additional defaults
+
* - If upsert: true → Defaults applied via parse() since we're passing a full document
+
*
+
* Note: For replace operations, defaults are automatically applied by the schema's
+
* parse() function which treats missing fields as candidates for defaults. This works
+
* for both regular replaces and upsert-creates since we're providing a full document.
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param query - MongoDB query filter
* @param data - Complete document data for replacement
-
* @param options - Replace options (including session for transactions)
+
* @param options - Replace options (including session for transactions and upsert flag)
* @returns Update result
*/
export async function replaceOne<T extends Schema>(
···
data: Input<T>,
options?: ReplaceOptions
): Promise<UpdateResult<Infer<T>>> {
+
// parseReplace will apply all schema defaults to missing fields
+
// This works correctly for both regular replaces and upsert-created documents
const validatedData = parseReplace(schema, data);
+
// Remove _id from validatedData for replaceOne (it will use the query's _id)
const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
return await collection.replaceOne(
+192 -2
model/validation.ts
···
import type { z } from "@zod/zod";
import type { Schema, Infer, Input } from "../types.ts";
import { ValidationError, AsyncValidationError } from "../errors.ts";
+
import type { Document, UpdateFilter, Filter } from "mongodb";
/**
* Validate data for insert operations using Zod schema
···
/**
* Validate partial data for update operations using Zod schema
*
+
* Important: This function only validates the fields that are provided in the data object.
+
* Unlike parse(), this function does NOT apply defaults for missing fields because
+
* in an update context, missing fields should remain unchanged in the database.
+
*
* @param schema - Zod schema to validate against
* @param data - Partial data to validate
-
* @returns Validated and typed partial data
+
* @returns Validated and typed partial data (only fields present in input)
* @throws {ValidationError} If validation fails
* @throws {AsyncValidationError} If async validation is detected
*/
···
schema: T,
data: Partial<z.infer<T>>,
): Partial<z.infer<T>> {
+
// Get the list of fields actually provided in the input
+
const inputKeys = Object.keys(data);
+
const result = schema.partial().safeParse(data);
// Check for async validation
···
if (!result.success) {
throw new ValidationError(result.error.issues, "update");
}
-
return result.data as Partial<z.infer<T>>;
+
+
// Filter the result to only include fields that were in the input
+
// This prevents defaults from being applied to fields that weren't provided
+
const filtered: Record<string, unknown> = {};
+
for (const key of inputKeys) {
+
if (key in result.data) {
+
filtered[key] = (result.data as Record<string, unknown>)[key];
+
}
+
}
+
+
return filtered as Partial<z.infer<T>>;
}
/**
···
}
return result.data as Infer<T>;
}
+
+
/**
+
* Extract default values from a Zod schema
+
* This parses an empty object through the schema to get all defaults applied
+
*
+
* @param schema - Zod schema to extract defaults from
+
* @returns Object containing all default values from the schema
+
*/
+
export function extractDefaults<T extends Schema>(schema: T): Partial<Infer<T>> {
+
try {
+
// Make all fields optional, then parse empty object to trigger defaults
+
// This allows us to see which fields get default values
+
const partialSchema = schema.partial();
+
const result = partialSchema.safeParse({});
+
+
if (result instanceof Promise) {
+
// Cannot extract defaults from async schemas
+
return {};
+
}
+
+
// If successful, the result contains all fields that have defaults
+
// Only include fields that were actually added (have values)
+
if (!result.success) {
+
return {};
+
}
+
+
// Filter to only include fields that got values from defaults
+
// (not undefined, which indicates no default)
+
const defaults: Record<string, unknown> = {};
+
const data = result.data as Record<string, unknown>;
+
+
for (const [key, value] of Object.entries(data)) {
+
if (value !== undefined) {
+
defaults[key] = value;
+
}
+
}
+
+
return defaults as Partial<Infer<T>>;
+
} catch {
+
return {};
+
}
+
}
+
+
/**
+
* Get all field paths mentioned in an update filter object
+
* This includes fields in $set, $unset, $inc, $push, etc.
+
*
+
* @param update - MongoDB update filter
+
* @returns Set of field paths that are being modified
+
*/
+
function getModifiedFields(update: UpdateFilter<Document>): Set<string> {
+
const fields = new Set<string>();
+
+
// Operators that modify fields
+
const operators = [
+
'$set', '$unset', '$inc', '$mul', '$rename', '$min', '$max',
+
'$currentDate', '$push', '$pull', '$addToSet', '$pop', '$bit',
+
'$setOnInsert',
+
];
+
+
for (const op of operators) {
+
if (update[op] && typeof update[op] === 'object') {
+
// Add all field names from this operator
+
for (const field of Object.keys(update[op] as Document)) {
+
fields.add(field);
+
}
+
}
+
}
+
+
return fields;
+
}
+
+
/**
+
* Get field paths that are fixed by equality clauses in a query filter.
+
* Only equality-style predicates become part of the inserted document during upsert.
+
*/
+
function getEqualityFields(filter: Filter<Document>): Set<string> {
+
const fields = new Set<string>();
+
+
const collect = (node: Record<string, unknown>) => {
+
for (const [key, value] of Object.entries(node)) {
+
if (key.startsWith("$")) {
+
if (Array.isArray(value)) {
+
for (const item of value) {
+
if (item && typeof item === "object" && !Array.isArray(item)) {
+
collect(item as Record<string, unknown>);
+
}
+
}
+
} else if (value && typeof value === "object") {
+
collect(value as Record<string, unknown>);
+
}
+
continue;
+
}
+
+
if (value && typeof value === "object" && !Array.isArray(value)) {
+
const objectValue = value as Record<string, unknown>;
+
const keys = Object.keys(objectValue);
+
const hasOperator = keys.some((k) => k.startsWith("$"));
+
+
if (hasOperator) {
+
if (Object.prototype.hasOwnProperty.call(objectValue, "$eq")) {
+
fields.add(key);
+
}
+
} else {
+
fields.add(key);
+
}
+
} else {
+
fields.add(key);
+
}
+
}
+
};
+
+
collect(filter as Record<string, unknown>);
+
return fields;
+
}
+
+
/**
+
* Apply schema defaults to an update operation using $setOnInsert
+
*
+
* This is used for upsert operations to ensure defaults are applied when
+
* a new document is created, but not when updating an existing document.
+
*
+
* For each default field:
+
* - If the field is NOT mentioned in any update operator ($set, $inc, etc.)
+
* - If the field is NOT fixed by an equality clause in the query filter
+
* - Add it to $setOnInsert so it's only applied on insert
+
*
+
* @param schema - Zod schema with defaults
+
* @param query - MongoDB query filter
+
* @param update - MongoDB update filter
+
* @returns Modified update filter with defaults in $setOnInsert
+
*/
+
export function applyDefaultsForUpsert<T extends Schema>(
+
schema: T,
+
query: Filter<Infer<T>>,
+
update: UpdateFilter<Infer<T>>
+
): UpdateFilter<Infer<T>> {
+
// Extract defaults from schema
+
const defaults = extractDefaults(schema);
+
+
// If no defaults, return update unchanged
+
if (Object.keys(defaults).length === 0) {
+
return update;
+
}
+
+
// Get fields that are already being modified
+
const modifiedFields = getModifiedFields(update as UpdateFilter<Document>);
+
const filterEqualityFields = getEqualityFields(query as Filter<Document>);
+
+
// Build $setOnInsert with defaults for unmodified fields
+
const setOnInsert: Partial<Infer<T>> = {};
+
+
for (const [field, value] of Object.entries(defaults)) {
+
// Only add default if field is not already being modified or fixed by filter equality
+
if (!modifiedFields.has(field) && !filterEqualityFields.has(field)) {
+
setOnInsert[field as keyof Infer<T>] = value as Infer<T>[keyof Infer<T>];
+
}
+
}
+
+
// If there are defaults to add, merge them into $setOnInsert
+
if (Object.keys(setOnInsert).length > 0) {
+
return {
+
...update,
+
$setOnInsert: {
+
...(update.$setOnInsert || {}),
+
...setOnInsert
+
} as Partial<Infer<T>>
+
};
+
}
+
+
return update;
+
}
+352
tests/defaults_test.ts
···
+
import { assertEquals, assertExists } from "@std/assert";
+
import { z } from "@zod/zod";
+
import { connect, disconnect, Model, type Input } from "../mod.ts";
+
import { applyDefaultsForUpsert } from "../model/validation.ts";
+
import { MongoMemoryServer } from "mongodb-memory-server-core";
+
+
/**
+
* Test suite for default value handling in different operation types
+
*
+
* This tests the three main cases:
+
* 1. Plain inserts - defaults applied directly
+
* 2. Updates without upsert - defaults NOT applied
+
* 3. Upserts that create - defaults applied via $setOnInsert
+
* 4. Upserts that match - $setOnInsert ignored (correct behavior)
+
* 5. Replace with upsert - defaults applied on creation
+
*/
+
+
// Schema with defaults for testing
+
const productSchema = z.object({
+
name: z.string(),
+
price: z.number().min(0),
+
category: z.string().default("general"),
+
inStock: z.boolean().default(true),
+
createdAt: z.date().default(() => new Date("2024-01-01T00:00:00Z")),
+
tags: z.array(z.string()).default([]),
+
});
+
+
type Product = z.infer<typeof productSchema>;
+
type ProductInsert = Input<typeof productSchema>;
+
+
let ProductModel: Model<typeof productSchema>;
+
let mongoServer: MongoMemoryServer;
+
+
Deno.test.beforeAll(async () => {
+
mongoServer = await MongoMemoryServer.create();
+
const uri = mongoServer.getUri();
+
await connect(uri, "test_defaults_db");
+
ProductModel = new Model("test_products_defaults", productSchema);
+
});
+
+
Deno.test.beforeEach(async () => {
+
await ProductModel.delete({});
+
});
+
+
Deno.test.afterAll(async () => {
+
await ProductModel.delete({});
+
await disconnect();
+
await mongoServer.stop();
+
});
+
+
Deno.test({
+
name: "Defaults: Case 1 - Plain insert applies defaults",
+
async fn() {
+
// Insert without providing optional fields with defaults
+
const result = await ProductModel.insertOne({
+
name: "Widget",
+
price: 29.99,
+
// category, inStock, createdAt, tags not provided - should use defaults
+
});
+
+
assertExists(result.insertedId);
+
+
// Verify defaults were applied
+
const product = await ProductModel.findById(result.insertedId);
+
assertExists(product);
+
+
assertEquals(product.name, "Widget");
+
assertEquals(product.price, 29.99);
+
assertEquals(product.category, "general"); // default
+
assertEquals(product.inStock, true); // default
+
assertExists(product.createdAt); // default function called
+
assertEquals(product.tags, []); // default empty array
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: Case 2 - Update without upsert does NOT apply defaults",
+
async fn() {
+
// First create a document without defaults (simulate old data)
+
const insertResult = await ProductModel.insertOne({
+
name: "Gadget",
+
price: 19.99,
+
category: "electronics",
+
inStock: false,
+
createdAt: new Date("2023-01-01"),
+
tags: ["test"],
+
});
+
+
assertExists(insertResult.insertedId);
+
+
// Now update it - defaults should NOT be applied
+
await ProductModel.updateOne(
+
{ _id: insertResult.insertedId },
+
{ price: 24.99 }
+
// No upsert flag
+
);
+
+
const updated = await ProductModel.findById(insertResult.insertedId);
+
assertExists(updated);
+
+
assertEquals(updated.price, 24.99); // updated
+
assertEquals(updated.category, "electronics"); // unchanged
+
assertEquals(updated.inStock, false); // unchanged
+
assertEquals(updated.tags, ["test"]); // unchanged
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: Case 3 - Upsert that creates applies defaults via $setOnInsert",
+
async fn() {
+
// Upsert with a query that won't match - will create new document
+
const result = await ProductModel.updateOne(
+
{ name: "NonExistent" },
+
{ price: 39.99 },
+
{ upsert: true }
+
);
+
+
assertEquals(result.upsertedCount, 1);
+
assertExists(result.upsertedId);
+
+
// Verify the created document has defaults applied
+
const product = await ProductModel.findOne({ name: "NonExistent" });
+
assertExists(product);
+
+
assertEquals(product.price, 39.99); // from $set
+
assertEquals(product.name, "NonExistent"); // from query
+
assertEquals(product.category, "general"); // default via $setOnInsert
+
assertEquals(product.inStock, true); // default via $setOnInsert
+
assertExists(product.createdAt); // default via $setOnInsert
+
assertEquals(product.tags, []); // default via $setOnInsert
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: Case 4 - Upsert that matches does NOT apply defaults",
+
async fn() {
+
// Create a document first with explicit non-default values
+
const insertResult = await ProductModel.insertOne({
+
name: "ExistingProduct",
+
price: 49.99,
+
category: "premium",
+
inStock: false,
+
createdAt: new Date("2023-06-01"),
+
tags: ["premium", "featured"],
+
});
+
+
assertExists(insertResult.insertedId);
+
+
// Upsert with matching query - should update, not insert
+
const result = await ProductModel.updateOne(
+
{ name: "ExistingProduct" },
+
{ price: 44.99 },
+
{ upsert: true }
+
);
+
+
assertEquals(result.matchedCount, 1);
+
assertEquals(result.modifiedCount, 1);
+
assertEquals(result.upsertedCount, 0); // No insert happened
+
+
// Verify defaults were NOT applied (existing values preserved)
+
const product = await ProductModel.findOne({ name: "ExistingProduct" });
+
assertExists(product);
+
+
assertEquals(product.price, 44.99); // updated via $set
+
assertEquals(product.category, "premium"); // preserved (not overwritten with default)
+
assertEquals(product.inStock, false); // preserved
+
assertEquals(product.tags, ["premium", "featured"]); // preserved
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: Case 5 - Replace without upsert uses defaults from parse",
+
async fn() {
+
// Create initial document
+
const insertResult = await ProductModel.insertOne({
+
name: "ReplaceMe",
+
price: 10.0,
+
category: "old",
+
inStock: true,
+
createdAt: new Date("2020-01-01"),
+
tags: ["old"],
+
});
+
+
assertExists(insertResult.insertedId);
+
+
// Replace with partial data - defaults should fill in missing fields
+
await ProductModel.replaceOne(
+
{ _id: insertResult.insertedId },
+
{
+
name: "Replaced",
+
price: 15.0,
+
// category, inStock, createdAt, tags not provided - defaults should apply
+
}
+
);
+
+
const product = await ProductModel.findById(insertResult.insertedId);
+
assertExists(product);
+
+
assertEquals(product.name, "Replaced");
+
assertEquals(product.price, 15.0);
+
assertEquals(product.category, "general"); // default applied
+
assertEquals(product.inStock, true); // default applied
+
assertExists(product.createdAt); // default applied
+
assertEquals(product.tags, []); // default applied
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: Case 6 - Replace with upsert (creates) applies defaults",
+
async fn() {
+
// Replace with upsert on non-existent document
+
const result = await ProductModel.replaceOne(
+
{ name: "NewViaReplace" },
+
{
+
name: "NewViaReplace",
+
price: 99.99,
+
// Missing optional fields - defaults should apply
+
},
+
{ upsert: true }
+
);
+
+
assertEquals(result.upsertedCount, 1);
+
assertExists(result.upsertedId);
+
+
const product = await ProductModel.findOne({ name: "NewViaReplace" });
+
assertExists(product);
+
+
assertEquals(product.name, "NewViaReplace");
+
assertEquals(product.price, 99.99);
+
assertEquals(product.category, "general"); // default
+
assertEquals(product.inStock, true); // default
+
assertExists(product.createdAt); // default
+
assertEquals(product.tags, []); // default
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: Upsert only applies defaults to unmodified fields",
+
async fn() {
+
// Upsert where we explicitly set some fields that have defaults
+
const result = await ProductModel.updateOne(
+
{ name: "CustomDefaults" },
+
{
+
price: 25.0,
+
category: "custom", // Explicitly setting a field that has a default
+
// inStock not set - should get default
+
},
+
{ upsert: true }
+
);
+
+
assertEquals(result.upsertedCount, 1);
+
+
const product = await ProductModel.findOne({ name: "CustomDefaults" });
+
assertExists(product);
+
+
assertEquals(product.name, "CustomDefaults"); // from query
+
assertEquals(product.price, 25.0); // from $set
+
assertEquals(product.category, "custom"); // from $set (NOT default)
+
assertEquals(product.inStock, true); // default via $setOnInsert
+
assertExists(product.createdAt); // default via $setOnInsert
+
assertEquals(product.tags, []); // default via $setOnInsert
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: insertMany applies defaults to all documents",
+
async fn() {
+
const result = await ProductModel.insertMany([
+
{ name: "Bulk1", price: 10 },
+
{ name: "Bulk2", price: 20, category: "special" },
+
{ name: "Bulk3", price: 30 },
+
]);
+
+
assertEquals(Object.keys(result.insertedIds).length, 3);
+
+
const products = await ProductModel.find({});
+
assertEquals(products.length, 3);
+
+
// All should have defaults where not provided
+
for (const product of products) {
+
assertExists(product.createdAt);
+
assertEquals(product.inStock, true);
+
assertEquals(product.tags, []);
+
+
if (product.name === "Bulk2") {
+
assertEquals(product.category, "special");
+
} else {
+
assertEquals(product.category, "general");
+
}
+
}
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: applyDefaultsForUpsert preserves existing $setOnInsert values",
+
fn() {
+
const schema = z.object({
+
name: z.string(),
+
flag: z.boolean().default(true),
+
count: z.number().default(0),
+
});
+
+
const update = {
+
$set: { name: "test" },
+
$setOnInsert: { flag: false },
+
};
+
+
const result = applyDefaultsForUpsert(schema, {}, update);
+
+
assertEquals(result.$setOnInsert?.flag, false);
+
assertEquals(result.$setOnInsert?.count, 0);
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});
+
+
Deno.test({
+
name: "Defaults: applyDefaultsForUpsert keeps query equality fields untouched",
+
fn() {
+
const schema = z.object({
+
status: z.string().default("pending"),
+
flag: z.boolean().default(true),
+
name: z.string(),
+
});
+
+
const query = { status: "queued" };
+
const update = { $set: { name: "upsert-test" } };
+
+
const result = applyDefaultsForUpsert(schema, query, update);
+
+
assertEquals(result.$setOnInsert?.status, undefined);
+
assertEquals(result.$setOnInsert?.flag, true);
+
},
+
sanitizeResources: false,
+
sanitizeOps: false,
+
});