Skip to content

Commit ed0cae4

Browse files
committed
feat: Add upsertOne() to db model driver
1 parent d73c679 commit ed0cae4

File tree

8 files changed

+146
-19
lines changed

8 files changed

+146
-19
lines changed

features/@app-core/models/Settings.model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z, schema } from '@green-stack/schemas'
2-
import { createSchemaModel } from '@db/driver'
2+
import { createSchemaModel, validateDriverModel } from '@db/driver'
33

44
/* --- Schema ---------------------------------------------------------------------------------- */
55

@@ -18,4 +18,4 @@ export const SettingsModel = createSchemaModel(Setting)
1818

1919
/* --- Drivers --------------------------------------------------------------------------------- */
2020

21-
export const driverModel = SettingsModel.driver
21+
export const driverModel = validateDriverModel(SettingsModel.driver)

packages/@db-driver/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export const createSchemaModel = dbDriver['createSchemaModel']
1212
/* --- Export Driver Helpers ------------------------------------------------------------------- */
1313

1414
export { validateDriver } from './utils/validateDriver.db'
15+
export { validateDriverModel } from './utils/validateDriverModel.db'

packages/@db-driver/scripts/collect-models.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const collectModels = () => {
5151

5252
// Skip if not a valid model
5353
const modelFileContents = fs.readFileSync(modelFilePath, 'utf-8')
54-
const isValidModel = modelFileContents.includes('export const driverModel = ')
54+
const isValidModel = modelFileContents.includes('export const driverModel = validateDriverModel(')
5555
if (!isValidModel) return acc
5656

5757
// Figure out model workspace from filename

packages/@db-driver/utils/createSchemaModel.mock.test.ts

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ test('createSchemaModel() creates a new schema model with DB driver methods atta
6262
})
6363

6464
test('Model.driver.insertOne() inserts a new record in memory', async () => {
65-
const UserModel = createSchemaModel(UserSchema, 'User1')
65+
const UserModel = createSchemaModel(UserSchema, 'UserInsertOne1')
6666
const newUser = await UserModel.driver.insertOne({
6767
name: 'Thorr',
6868
email: 'thorr@codinsonn.dev',
@@ -74,7 +74,7 @@ test('Model.driver.insertOne() inserts a new record in memory', async () => {
7474
})
7575

7676
test('Model.driver.insertOne() throws if inserted record does not match schema', async () => {
77-
const UserModel = createSchemaModel(UserSchema, 'User2')
77+
const UserModel = createSchemaModel(UserSchema, 'UserInsertOne2')
7878
// Check that we throw an error when inserting a record with invalid data
7979
await expect(UserModel.driver.insertOne({})).rejects.toThrow() // Missing required fields
8080
await expect(UserModel.driver.insertOne({ name: 'Thorr' })).rejects.toThrow() // Missing required field email
@@ -83,7 +83,7 @@ test('Model.driver.insertOne() throws if inserted record does not match schema',
8383
})
8484

8585
test('Model.driver.insertMany() inserts multiple records in memory', async () => {
86-
const UserModel = createSchemaModel(UserSchema, 'User3')
86+
const UserModel = createSchemaModel(UserSchema, 'UserInsertMany1')
8787
const newUsers = await UserModel.driver.insertMany([
8888
{
8989
name: 'Thorr',
@@ -107,7 +107,7 @@ test('Model.driver.insertMany() inserts multiple records in memory', async () =>
107107
})
108108

109109
test('Model.driver.insertMany() throws if inserted records do not match schema', async () => {
110-
const UserModel = createSchemaModel(UserSchema, 'User4')
110+
const UserModel = createSchemaModel(UserSchema, 'UserInsertMany2')
111111
// Check that we throw an error when inserting records with invalid data
112112
await expect(UserModel.driver.insertMany([{}])).rejects.toThrow() // Missing required fields
113113
await expect(UserModel.driver.insertMany([{ name: 'Thorr' }])).rejects.toThrow() // Missing required field email
@@ -116,7 +116,7 @@ test('Model.driver.insertMany() throws if inserted records do not match schema',
116116
})
117117

118118
test('Model.driver.findOne() finds a record stored in memory', async () => {
119-
const UserModel = createSchemaModel(UserSchema, 'User5')
119+
const UserModel = createSchemaModel(UserSchema, 'UserFindOne1')
120120
const newUser = await UserModel.driver.insertOne({
121121
name: 'Thorr',
122122
email: 'thorr@codinsonn.dev',
@@ -145,7 +145,7 @@ test('Model.driver.findOne() finds a record stored in memory', async () => {
145145
})
146146

147147
test('Model.driver.findMany() finds multiple records stored in memory', async () => {
148-
const UserModel = createSchemaModel(UserSchema, 'User6')
148+
const UserModel = createSchemaModel(UserSchema, 'UserFindMany1')
149149
const newUsers = await UserModel.driver.insertMany([
150150
{
151151
name: 'Thorr',
@@ -168,7 +168,7 @@ test('Model.driver.findMany() finds multiple records stored in memory', async ()
168168
})
169169

170170
test('Model.driver.updateOne() updates a record stored in memory', async () => {
171-
const UserModel = createSchemaModel(UserSchema, 'User7')
171+
const UserModel = createSchemaModel(UserSchema, 'UserUpdateOne1')
172172
const newUser = await UserModel.driver.insertOne({
173173
name: 'Thorr',
174174
email: 'thorr@codinsonn.dev',
@@ -183,7 +183,7 @@ test('Model.driver.updateOne() updates a record stored in memory', async () => {
183183
})
184184

185185
test('Model.driver.updateOne() with errorOnUnmatched: true throws if updated record does not match schema', async () => {
186-
const UserModel = createSchemaModel(UserSchema, 'User8')
186+
const UserModel = createSchemaModel(UserSchema, 'UserUpdateOne2')
187187
const newUser = await UserModel.driver.insertOne({
188188
name: 'Thorr',
189189
email: 'thorr@codinsonn.dev',
@@ -202,7 +202,7 @@ test('Model.driver.updateOne() with errorOnUnmatched: true throws if updated rec
202202
})
203203

204204
test('Model.driver.updateMany() updates multiple records stored in memory', async () => {
205-
const UserModel = createSchemaModel(UserSchema, 'User9')
205+
const UserModel = createSchemaModel(UserSchema, 'UserUpdateMany1')
206206
const newUsers = await UserModel.driver.insertMany([
207207
{
208208
name: 'Thorr',
@@ -223,7 +223,7 @@ test('Model.driver.updateMany() updates multiple records stored in memory', asyn
223223
})
224224

225225
test('Model.driver.updateMany() with errorOnUnmatched: true throws if updated records do not match schema', async () => {
226-
const UserModel = createSchemaModel(UserSchema, 'User10')
226+
const UserModel = createSchemaModel(UserSchema, 'UserUpdateMany2')
227227
const newUsers = await UserModel.driver.insertMany([
228228
{
229229
name: 'Thorr',
@@ -246,8 +246,59 @@ test('Model.driver.updateMany() with errorOnUnmatched: true throws if updated re
246246
await expect(() => UserModel.driver.updateMany({ email: '' }, { name: 'Thorr' })).not.toThrow()
247247
})
248248

249+
test('Model.driver.upsertOne() inserts a new record if it does not exist', async () => {
250+
const UserModel = createSchemaModel(UserSchema, 'UserUpsertOne1')
251+
// Check that we can insert a new record
252+
const newUser = await UserModel.driver.upsertOne({
253+
name: 'Thorr',
254+
}, {
255+
name: 'Thorr',
256+
email: 'thorr@codinsonn.dev',
257+
})
258+
// Check that the record was inserted
259+
expect(newUser).toBeDefined()
260+
expect(newUser.id).toBeDefined()
261+
expect(newUser.name).toBe('Thorr')
262+
expect(newUser.email).toBe('thorr@codinsonn.dev')
263+
// Check that we can find the record by id
264+
const foundById = await UserModel.driver.findOne({ id: newUser.id })
265+
expect(foundById).toBeDefined()
266+
expect(foundById.id).toBe(newUser.id)
267+
expect(foundById.name).toBe('Thorr')
268+
expect(foundById.email).toBe('thorr@codinsonn.dev')
269+
})
270+
271+
test('Model.driver.upsertOne() updates an existing record if it exists', async () => {
272+
const UserModel = createSchemaModel(UserSchema, 'UserUpsertOne2')
273+
const onlyUser = await UserModel.driver.insertOne({
274+
name: 'Thorr',
275+
email: 'thorr@codinsonn.dev',
276+
})
277+
// Check that we can update an existing record
278+
const updatedUser = await UserModel.driver.upsertOne({
279+
name: 'Thorr',
280+
}, {
281+
name: 'Thorr',
282+
email: 'thorr@fullproduct.dev',
283+
})
284+
// Check that the record was updated
285+
expect(updatedUser).toBeDefined()
286+
expect(updatedUser.id).toBe(onlyUser.id)
287+
expect(updatedUser.name).toBe('Thorr')
288+
expect(updatedUser.email).toBe('thorr@fullproduct.dev')
289+
// Check that we can find the record by id
290+
const foundById = await UserModel.driver.findOne({ id: onlyUser.id })
291+
expect(foundById).toBeDefined()
292+
expect(foundById.id).toBe(onlyUser.id)
293+
expect(foundById.name).toBe('Thorr')
294+
expect(foundById.email).toBe('thorr@fullproduct.dev')
295+
// Check that we can no longer find the record by the old email
296+
const notFoundByEmail = await UserModel.driver.findOne({ email: 'thorr@codinsonn.dev' })
297+
expect(notFoundByEmail).toBeUndefined()
298+
})
299+
249300
test('Model.driver.deleteOne() deletes a record stored in memory', async () => {
250-
const UserModel = createSchemaModel(UserSchema, 'User11')
301+
const UserModel = createSchemaModel(UserSchema, 'UserDeleteOne1')
251302
const newUsers = await UserModel.driver.insertMany([
252303
{
253304
name: 'Thorr',
@@ -275,7 +326,7 @@ test('Model.driver.deleteOne() deletes a record stored in memory', async () => {
275326
})
276327

277328
test('Model.driver.deleteMany() deletes multiple records stored in memory', async () => {
278-
const UserModel = createSchemaModel(UserSchema, 'User12')
329+
const UserModel = createSchemaModel(UserSchema, 'UserDeleteMany1')
279330
const newUsers = await UserModel.driver.insertMany([
280331
{
281332
name: 'Thorr',
@@ -301,7 +352,7 @@ test('Model.driver.deleteMany() deletes multiple records stored in memory', asyn
301352
})
302353

303354
test('Model.driver.deleteMany() returns an empty array by default if no records are found', async () => {
304-
const UserModel = createSchemaModel(UserSchema, 'User13')
355+
const UserModel = createSchemaModel(UserSchema, 'UserDeleteMany2')
305356
const newUsers = await UserModel.driver.insertMany([
306357
{
307358
name: 'Thorr',
@@ -319,7 +370,7 @@ test('Model.driver.deleteMany() returns an empty array by default if no records
319370
})
320371

321372
test('Model query filters support logical $and, $or, $nor & $not operators', async () => {
322-
const UserModel = createSchemaModel(UserSchema, 'User14')
373+
const UserModel = createSchemaModel(UserSchema, 'UserQueryFilters1')
323374
const newUsers = await UserModel.driver.insertMany([
324375
{
325376
name: 'Thorr',
@@ -364,7 +415,7 @@ test('Model query filters support logical $and, $or, $nor & $not operators', asy
364415
})
365416

366417
test('Model query filters support conditional field operators $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin', async () => {
367-
const UserModel = createSchemaModel(UserSchema, 'User15')
418+
const UserModel = createSchemaModel(UserSchema, 'UserQueryFilters2')
368419
const newUsers = await UserModel.driver.insertMany([
369420
{
370421
name: 'Thorr',
@@ -446,7 +497,7 @@ test('Model query filters support conditional field operators $eq, $ne, $gt, $gt
446497
})
447498

448499
test("Model query filters support nested fields", async () => {
449-
const UserModel = createSchemaModel(UserSchema, 'User16')
500+
const UserModel = createSchemaModel(UserSchema, 'UserQueryFilters3')
450501
const newUsers = await UserModel.driver.insertMany([
451502
{
452503
name: 'Thorr',

packages/@db-driver/utils/createSchemaModel.mock.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,25 @@ export const createSchemaModel = <
284284
}
285285
}
286286

287+
/** --- upsertOne() ------------------------------------------------------------------------ */
288+
/** -i- Updates or inserts a single record in the collection */
289+
const upsertOne = async (query: QueryFilter, record: Partial<DataType>) => {
290+
memoryDB[modelKey] = memoryDB[modelKey] || {}
291+
try {
292+
// Find the record to update
293+
const recordToUpdate = await findOne(query) as DataType
294+
if (recordToUpdate) {
295+
// Update the record
296+
return updateOne(query, record as Prettify<Partial<Omit<DataType, 'id'>>>, true)
297+
} else {
298+
// Insert the record
299+
return insertOne(record)
300+
}
301+
} catch (error: any$FixMe) {
302+
throw new Error(`Failed to upsert record in "${modelKey}" collection: ${error.message}`)
303+
}
304+
}
305+
287306
/** --- deleteOne() ------------------------------------------------------------------------ */
288307
/** -i- Removes a single record from the collection */
289308
const deleteOne = async (query: QueryFilter, errorOnUnmatched = false) => {
@@ -340,6 +359,8 @@ export const createSchemaModel = <
340359
update: updateOne,
341360
updateOne,
342361
updateMany,
362+
upsertOne,
363+
upsert: upsertOne,
343364
delete: deleteOne,
344365
deleteOne,
345366
deleteMany,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { z } from '@green-stack/schemas'
2+
3+
/* --- Schema ---------------------------------------------------------------------------------- */
4+
5+
export const DbDriverModelSchema = z.object({
6+
insertOne: z.function().args(z.any()).returns(z.promise(z.any())),
7+
insertMany: z.function().args(z.array(z.any())).returns(z.promise(z.array(z.any()))),
8+
findOne: z.function().args(z.any()).returns(z.promise(z.any())),
9+
findMany: z.function().args(z.any()).returns(z.promise(z.array(z.any()))),
10+
updateOne: z.function().args(z.any(), z.any(), z.boolean().optional()).returns(z.promise(z.any())),
11+
updateMany: z.function().args(z.any(), z.any(), z.boolean().optional()).returns(z.promise(z.array(z.any()))),
12+
upsertOne: z.function().args(z.any(), z.any()).returns(z.promise(z.any())),
13+
deleteOne: z.function().args(z.any(), z.boolean().optional()).returns(z.promise(z.any())),
14+
deleteMany: z.function().args(z.any(), z.boolean().optional()).returns(z.promise(z.array(z.any()))),
15+
})
16+
17+
/* --- Types ----------------------------------------------------------------------------------- */
18+
19+
export type DbModelShape = z.infer<typeof DbDriverModelSchema>
20+
21+
/** --- validateDriverModel() ----------------------------------------------------------------------- */
22+
/** -i- Validates whether a DB model matches the expected methods */
23+
export const validateDriverModel = <DB_MODEL extends DbModelShape>(model: DB_MODEL) => {
24+
DbDriverModelSchema.parse(model)
25+
return model
26+
}

packages/@green-stack-core/schemas/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,19 +122,38 @@ declare module 'zod' {
122122
Input = z.objectInputType<T, Catchall, UnknownKeys>
123123
> {
124124
nameSchema(name: string): this
125+
125126
extendSchema<S extends z.ZodRawShape>(name: string, shape: S): ZodObject<T & S, UnknownKeys, Catchall>
127+
126128
pickSchema<Mask extends z.util.Exactly<{ [k in keyof T]?: true; }, Mask>>(
127129
schemaName: string,
128130
mask: Mask
129131
): z.ZodObject<Pick<T, Extract<keyof T, keyof Mask>>, UnknownKeys, Catchall>
132+
130133
omitSchema<Mask extends z.util.Exactly<{ [k in keyof T]?: true; }, Mask>>(
131134
schemaName: string,
132135
mask: Mask
133136
): z.ZodObject<Omit<T, keyof Mask>, UnknownKeys, Catchall>
137+
134138
applyDefaults<D extends Record<string, unknown> = Record<string, unknown>>(
135139
data: D,
136140
logErrors?: boolean
137141
): D & Output
142+
143+
// -- Deprecations --
144+
145+
/** @deprecated Use `.extendSchema('NewName', { ...shape })` instead */
146+
extend<S extends z.ZodRawShape>(shape: S): ZodObject<T & S, UnknownKeys, Catchall>
147+
148+
/** @deprecated Use `.pickSchema('NewName', { ...mask })` instead */
149+
pick<Mask extends z.util.Exactly<{ [k in keyof T]?: true; }, Mask>>(
150+
mask: Mask
151+
): z.ZodObject<Pick<T, Extract<keyof T, keyof Mask>>, UnknownKeys, Catchall>
152+
153+
/** @deprecated Use `.omitSchema('NewName', { ...mask })` instead */
154+
omit<Mask extends z.util.Exactly<{ [k in keyof T]?: true; }, Mask>>(
155+
mask: Mask
156+
): z.ZodObject<Omit<T, keyof Mask>, UnknownKeys, Catchall>
138157
}
139158
}
140159

packages/@green-stack-core/schemas/tests/schemas.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const User = schema('User', {
99
age: z.number(),
1010
})
1111

12+
type User = z.infer<typeof User>
13+
1214
test("Schemas can be introspected", () => {
1315
expect(User.introspect).toBeInstanceOf(Function)
1416
expect(User.introspect()).toEqual({
@@ -36,6 +38,8 @@ const Primitives = schema('Primitives', {
3638
date: z.date().default(new Date('2024-01-01')).example(new Date('2020-01-01')).describe('Date'),
3739
})
3840

41+
type Primitives = z.infer<typeof Primitives>
42+
3943
test("Optionality, defaults & example values persist in schema introspection", () => {
4044
const metadata = Primitives.introspect() as Meta$Schema
4145
// Optionality
@@ -200,6 +204,8 @@ const AdvancedTypes = schema('AdvancedTypes', {
200204
array: z.array(z.string()).min(0).max(5).length(1).default([]).example(['world']),
201205
})
202206

207+
type AdvancedTypes = z.infer<typeof AdvancedTypes>
208+
203209
test("Advanced types z.enum(), z.tuple(), z.union() & z.array() work as expected", () => {
204210
const metadata = AdvancedTypes.introspect() as Meta$Schema
205211
// Base Types
@@ -276,6 +282,7 @@ test("Deriving schemas with .extendSchema(), .omitSchema(), .pickSchema() work a
276282
const Extended = Primitives.extendSchema('Extended', {
277283
newField: z.string().default('Hello'),
278284
})
285+
type Extended = z.infer<typeof Extended>
279286
expect(Extended.introspect().name).toBe('Extended')
280287
expect(Extended.introspect().schema).toHaveProperty('newField')
281288
expect(Extended.parse({ newField: 'World' })).toEqual({
@@ -287,6 +294,7 @@ test("Deriving schemas with .extendSchema(), .omitSchema(), .pickSchema() work a
287294
})
288295
// Omit
289296
const Omitted = Primitives.omitSchema('Omitted', { str: true })
297+
type Omitted = z.infer<typeof Omitted>
290298
expect(Omitted.introspect().name).toBe('Omitted')
291299
expect(Omitted.introspect().schema).not.toHaveProperty('str')
292300
expect(Omitted.parse({ num: 42 })).toEqual({
@@ -296,6 +304,7 @@ test("Deriving schemas with .extendSchema(), .omitSchema(), .pickSchema() work a
296304
})
297305
// Pick
298306
const Picked = Primitives.pickSchema('Picked', { str: true })
307+
type Picked = z.infer<typeof Picked>
299308
expect(Picked.introspect().name).toBe('Picked')
300309
expect(Picked.introspect().schema).toHaveProperty('str')
301310
expect(Picked.introspect().schema).not.toHaveProperty('num')

0 commit comments

Comments
 (0)