Repository: maxnowack/signaldb
Pull request #1524 (2025-03-19 11:23:23)

feat: implement upserting in updateOne and updateMany

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/maxnowack/signaldb/pull/1524?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`a24cc2a`)](https://app.codecov.io/gh/maxnowack/signaldb/commit/a24cc2a05b8e61fb6f978d18465f7b5deec33069?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) to head [(`bff2d93`)](https://app.codecov.io/gh/maxnowack/signaldb/commit/bff2d93614844acd291bda302c869ad41634f452?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main     #1524   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         1733      1749   +16     
  Branches       392       401    +9     
=========================================
+ Hits          1733      1749   +16     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/maxnowack/signaldb/pull/1524?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/docs/reference/core/collection/index.md b/docs/reference/core/collection/index.md
index 3c5ccf9..c42ebfe 100644
--- a/docs/reference/core/collection/index.md
+++ b/docs/reference/core/collection/index.md
@@ -111,7 +111,7 @@ Inserts multiple items into the collection and returns the IDs of the newly inse
 Parameters
 * `item`: The item to be inserted into the collection.
 
-### `updateMany(selector: Selector<T>, modifier: Modifier<T>)`
+### `updateMany(selector: Selector<T>, modifier: Modifier<T>, options?: { upsert?: boolean })`
 
 Updates multiple items in the collection that match a given selector with the specified modifier.
 Also check out the [data manipulation section](/data-manipulation/).
@@ -119,9 +119,10 @@ Also check out the [data manipulation section](/data-manipulation/).
 Parameters
 * `selector`: A function to filter items in the collection.
 * `modifier`: An object describing how to modify the matching items.
+* `options`: An object with additional options. Currently only `upsert` is supported, which will insert a document based on the modifier, if the selector doesn't match any documents.
 
 
-### `updateOne(selector: Selector<T>, modifier: Modifier<T>)`
+### `updateOne(selector: Selector<T>, modifier: Modifier<T>, options?: { upsert?: boolean })`
 
 Behaves the same like `.updateMany()` but only updates the first found document.
 
diff --git a/packages/base/core/CHANGELOG.md b/packages/base/core/CHANGELOG.md
index 1a6dfae..df73347 100644
--- a/packages/base/core/CHANGELOG.md
+++ b/packages/base/core/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+* Implemented `{ upsert: true }` option for `updateOne` and `updateMany` methods
+
 ### Changed
 
 * Check for already existing ids in `updateMany`
diff --git a/packages/base/core/src/Collection/index.ts b/packages/base/core/src/Collection/index.ts
index d39e59e..98bc5fc 100644
--- a/packages/base/core/src/Collection/index.ts
+++ b/packages/base/core/src/Collection/index.ts
@@ -749,23 +749,48 @@ export default class Collection<
    * Updates a single item in the collection that matches the given selector.
    * @param selector - The criteria to select the item to update.
    * @param modifier - The modifications to apply to the item.
+   * @param [options] - Optional settings for the update operation.
+   * @param [options.upsert] - If `true`, creates a new item if no item matches the selector.
    * @returns The number of items updated (0 or 1).
    * @throws {Error} If the collection is disposed or invalid arguments are provided.
    */
-  public updateOne(selector: Selector<T>, modifier: Modifier<T>) {
+  public updateOne(
+    selector: Selector<T>,
+    modifier: Modifier<T>,
+    options?: { upsert?: boolean },
+  ) {
     if (this.isDisposed) throw new Error('Collection is disposed')
     if (!selector) throw new Error('Invalid selector')
     if (!modifier) throw new Error('Invalid modifier')
+    const { $setOnInsert, ...restModifier } = modifier
 
     const { item, index } = this.getItemAndIndex(selector)
-    if (item == null) return 0
-    const modifiedItem = modify(deepClone(item), modifier)
-    if (!isEqual(item, { ...item, id: modifiedItem.id })) throw new Error('Item with same id already exists')
-    this.memory().splice(index, 1, modifiedItem)
-    this.rebuildIndices()
-    this.emit('changed', modifiedItem, modifier)
+    if (item == null) {
+      if (options?.upsert) {
+        // if upsert is enabled, insert a new item
+        const newItem: Omit<T, 'id'> & Partial<Pick<T, 'id'>> = modify({} as T, {
+          ...restModifier,
+          $set: {
+            ...$setOnInsert,
+            ...restModifier.$set,
+          },
+        })
+        if (newItem.id != null
+          && this.getItemAndIndex({ id: newItem.id } as Selector<T>).item != null) {
+          throw new Error('Item with same id already exists')
+        }
+        this.insert(newItem)
+      }
+    } else {
+      const modifiedItem = modify(deepClone(item), restModifier)
+      if (!isEqual(item, { ...item, id: modifiedItem.id })) throw new Error('Item with same id already exists')
+      this.memory().splice(index, 1, modifiedItem)
+      this.rebuildIndices()
+      this.emit('changed', modifiedItem, restModifier)
+    }
     this.emit('updateOne', selector, modifier)
     this.executeInDebugMode(callstack => this.emit('_debug.updateOne', callstack, selector, modifier))
+    if (item == null && !options?.upsert) return 0
     return 1
   }
 
@@ -773,19 +798,41 @@ export default class Collection<
    * Updates multiple items in the collection that match the given selector.
    * @param selector - The criteria to select the items to update.
    * @param modifier - The modifications to apply to the items.
+   * @param [options] - Optional settings for the update operation.
+   * @param [options.upsert] - If `true`, creates new items if no items match the selector.
    * @returns The number of items updated.
    * @throws {Error} If the collection is disposed or invalid arguments are provided.
    */
-  public updateMany(selector: Selector<T>, modifier: Modifier<T>) {
+  public updateMany(
+    selector: Selector<T>,
+    modifier: Modifier<T>,
+    options?: { upsert?: boolean },
+  ) {
     if (this.isDisposed) throw new Error('Collection is disposed')
     if (!selector) throw new Error('Invalid selector')
     if (!modifier) throw new Error('Invalid modifier')
+    const { $setOnInsert, ...restModifier } = modifier
 
     const items = this.getItems(selector)
+    if (items.length === 0 && options?.upsert) {
+      const newItem: Omit<T, 'id'> & Partial<Pick<T, 'id'>> = modify({} as T, {
+        ...restModifier,
+        $set: {
+          ...$setOnInsert,
+          ...restModifier.$set,
+        },
+      })
+      if (newItem.id != null
+        && this.getItemAndIndex({ id: newItem.id } as Selector<T>).item != null) {
+        throw new Error('Item with same id already exists')
+      }
+      this.insert(newItem)
+    }
+
     const changes = items.map((item) => {
       const { index } = this.getItemAndIndex({ id: item.id } as Selector<T>)
       if (index === -1) throw new Error(`Cannot resolve index for item with id '${item.id as string}'`)
-      const modifiedItem = modify(deepClone(item), modifier)
+      const modifiedItem = modify(deepClone(item), restModifier)
       if (!isEqual(item, { ...item, id: modifiedItem.id })) throw new Error(`Item with same id '${modifiedItem.id as string}' already exists`)
       return {
         item: modifiedItem,
@@ -797,11 +844,11 @@ export default class Collection<
     })
     this.rebuildIndices()
     changes.forEach(({ item }) => {
-      this.emit('changed', item, modifier)
+      this.emit('changed', item, restModifier)
     })
     this.emit('updateMany', selector, modifier)
     this.executeInDebugMode(callstack => this.emit('_debug.updateMany', callstack, selector, modifier))
-    return changes.length
+    return changes.length === 0 && options?.upsert ? 1 : changes.length
   }
 
   /**


Test patch:
diff --git a/packages/base/core/__tests__/Collection.spec.ts b/packages/base/core/__tests__/Collection.spec.ts
index 4abdf0e..5c6583b 100644
--- a/packages/base/core/__tests__/Collection.spec.ts
+++ b/packages/base/core/__tests__/Collection.spec.ts
@@ -198,6 +198,49 @@ describe('Collection', () => {
 
       expect(eventHandler).toHaveBeenCalledWith({ id: '1' }, { $set: { name: 'Jane' } })
     })
+
+    it('should not upsert items if upsert option was not specified', () => {
+      expect(collection.updateOne({ id: 'asdf' }, {
+        $set: { name: 'Upsert' },
+      })).toEqual(0)
+      expect(collection.findOne({ name: 'Upsert' })).toEqual(undefined)
+    })
+
+    it('should upsert items if upsert option is true', () => {
+      expect(collection.updateOne({ id: 'asdf' }, {
+        $set: { name: 'Upsert' },
+      }, { upsert: true })).toEqual(1)
+      expect(collection.findOne({ name: 'Upsert' })).toMatchObject({ name: 'Upsert' })
+    })
+
+    it('should use $setOnInsert if upsert option is true', () => {
+      expect(collection.updateOne({ id: 'asdf' }, {
+        $set: { name: 'Upsert' },
+        $setOnInsert: {
+          upserted: true,
+        },
+      }, { upsert: true })).toEqual(1)
+      expect(collection.findOne({ name: 'Upsert' })).toMatchObject({ name: 'Upsert', upserted: true })
+    })
+
+    it('should ignore $setOnInsert if item was not upserted', () => {
+      collection.insert({ id: '1', name: 'John' })
+      expect(collection.updateOne({ id: '1' }, {
+        $set: { name: 'Jane' },
+        $setOnInsert: { upserted: true },
+      }, { upsert: true })).toEqual(1)
+
+      expect(collection.findOne({ id: '1' })).toEqual({ id: '1', name: 'Jane' })
+    })
+
+    it('should fail if there is an id conflict during upsert', () => {
+      collection.insert({ id: '1', name: 'John' })
+
+      expect(() => collection.updateOne({ name: 'Jane' }, {
+        $set: { name: 'Jane' },
+        $setOnInsert: { id: '1' },
+      }, { upsert: true })).toThrow()
+    })
   })
 
   describe('updateMany', () => {
@@ -284,6 +327,53 @@ describe('Collection', () => {
 
       expect(eventHandler).toHaveBeenCalledWith({ id: '1' })
     })
+
+    it('should not upsert items if upsert option was not specified', () => {
+      expect(collection.updateMany({ id: 'asdf' }, {
+        $set: { name: 'Upsert' },
+      })).toEqual(0)
+      expect(collection.findOne({ name: 'Upsert' })).toEqual(undefined)
+    })
+
+    it('should upsert items if upsert option is true', () => {
+      expect(collection.updateMany({ id: 'asdf' }, {
+        $set: { name: 'Upsert' },
+      }, { upsert: true })).toEqual(1)
+      expect(collection.findOne({ name: 'Upsert' })).toMatchObject({ name: 'Upsert' })
+    })
+
+    it('should use $setOnInsert if upsert option is true', () => {
+      expect(collection.updateMany({ id: 'asdf' }, {
+        $set: { name: 'Upsert' },
+        $setOnInsert: {
+          upserted: true,
+        },
+      }, { upsert: true })).toEqual(1)
+      expect(collection.findOne({ name: 'Upsert' })).toMatchObject({ name: 'Upsert', upserted: true })
+    })
+
+    it('should ignore $setOnInsert if item was not upserted', () => {
+      collection.insert({ id: '1', name: 'John' })
+      collection.insert({ id: '2', name: 'Jane' })
+      expect(collection.updateMany({}, {
+        $set: { updated: true },
+        $setOnInsert: { upserted: true },
+      }, { upsert: true })).toEqual(2)
+
+      expect(collection.find().fetch()).toEqual([
+        { id: '1', name: 'John', updated: true },
+        { id: '2', name: 'Jane', updated: true },
+      ])
+    })
+
+    it('should fail if there is an id conflict during upsert', () => {
+      collection.insert({ id: '1', name: 'John' })
+
+      expect(() => collection.updateMany({ name: 'Jane' }, {
+        $set: { name: 'Jane' },
+        $setOnInsert: { id: '1' },
+      }, { upsert: true })).toThrow()
+    })
   })
 
   describe('removeMany', () => {