Repository: maxnowack/signaldb
Pull request #258 (2023-10-11 19:48:35)

fix: projected fields should always include id unless deactivated

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/maxnowack/signaldb/pull/258?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) Report
> Merging [#258](https://app.codecov.io/gh/maxnowack/signaldb/pull/258?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) (92f530a) into [main](https://app.codecov.io/gh/maxnowack/signaldb/commit/956b0bbebc7e138cffbdfec8b57c0e1efa96f224?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) (956b0bb) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff            @@
##              main      #258   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           18        18           
  Lines          371       387   +16     
  Branches        93        99    +6     
=========================================
+ Hits           371       387   +16     
```


| [Files](https://app.codecov.io/gh/maxnowack/signaldb/pull/258?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack) | Coverage Δ | |
|---|---|---|
| [src/Collection/Cursor.ts](https://app.codecov.io/gh/maxnowack/signaldb/pull/258?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack#diff-c3JjL0NvbGxlY3Rpb24vQ3Vyc29yLnRz) | `100.00% <100.00%> (ø)` | |
| [src/utils/project.ts](https://app.codecov.io/gh/maxnowack/signaldb/pull/258?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack#diff-c3JjL3V0aWxzL3Byb2plY3QudHM=) | `100.00% <100.00%> (ø)` | |
| [src/utils/set.ts](https://app.codecov.io/gh/maxnowack/signaldb/pull/258?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Max+Nowack#diff-c3JjL3V0aWxzL3NldC50cw==) | `100.00% <100.00%> (ø)` | |



Patch:
diff --git a/src/Collection/Cursor.ts b/src/Collection/Cursor.ts
index 71e5268..be8d1ee 100644
--- a/src/Collection/Cursor.ts
+++ b/src/Collection/Cursor.ts
@@ -56,7 +56,14 @@ export default class Cursor<T extends BaseItem, U = T> {
     const sorted = sort ? sortItems(filtered, sort) : filtered
     const skipped = skip ? sorted.slice(skip) : sorted
     const limited = limit ? skipped.slice(0, limit) : skipped
-    return limited.map(item => (this.options.fields ? project(item, this.options.fields) : item))
+    const idExcluded = this.options.fields && this.options.fields.id === 0
+    return limited.map((item) => {
+      if (!this.options.fields) return item
+      return {
+        ...idExcluded ? {} : { id: item.id },
+        ...project(item, this.options.fields),
+      }
+    })
   }
 
   private depend(changeEvents: { [P in keyof ObserveCallbacks<U>]?: true }) {
diff --git a/src/utils/project.spec.ts b/src/utils/project.spec.ts
index 5aa6536..4d51ef4 100644
--- a/src/utils/project.spec.ts
+++ b/src/utils/project.spec.ts
@@ -30,6 +30,23 @@ describe('project', () => {
     })
   })
 
+  it('should limit fields by disabling fields', () => {
+    const result = project(obj, {
+      age: 0,
+      address: 0,
+    })
+
+    expect(result).toEqual({
+      name: 'John Doe',
+      hobbies: ['reading', 'gaming'],
+    })
+  })
+
+  it('should remove nulls if deactivated', () => {
+    const result = project({ foo: null }, { foo: 0 })
+    expect(result).toEqual({})
+  })
+
   it('should handle nested fields that do not exist in the object', () => {
     const result = project(obj, {
       'address.zipCode': 1,
diff --git a/src/utils/project.ts b/src/utils/project.ts
index e2e5ab4..8b4f170 100644
--- a/src/utils/project.ts
+++ b/src/utils/project.ts
@@ -5,6 +5,16 @@ export default function project<T extends Record<string, any>>(
   item: T,
   fields: { [P in keyof T]?: 0 | 1 } & Record<string, 0 | 1>,
 ) {
+  const allFieldsDeactivated = Object.values(fields).every(value => value === 0)
+  if (allFieldsDeactivated) {
+    const result = { ...item }
+    Object.keys(fields).forEach((key) => {
+      const fieldValue = get(item, key)
+      if (fieldValue === undefined) return
+      set(result, key, undefined, true)
+    })
+    return result
+  }
   const result = {} as T
   Object.entries(fields).forEach(([key, value]) => {
     const fieldValue = get(item, key)
diff --git a/src/utils/set.spec.ts b/src/utils/set.spec.ts
index 6ca58e5..f546352 100644
--- a/src/utils/set.spec.ts
+++ b/src/utils/set.spec.ts
@@ -31,4 +31,10 @@ describe('set', () => {
     const result = set(obj, 'foo', 'baz')
     expect(result).toBe(obj)
   })
+
+  it('should delete the property if the value is undefined', () => {
+    const obj = { foo: 'bar', bar: 'baz' }
+    const result = set(obj, 'foo', undefined, true)
+    expect(result).toEqual({ bar: 'baz' })
+  })
 })
diff --git a/src/utils/set.ts b/src/utils/set.ts
index f038f8e..49cdde0 100644
--- a/src/utils/set.ts
+++ b/src/utils/set.ts
@@ -3,6 +3,7 @@ export default function set<T extends object, K>(
   obj: T,
   path: string,
   value: K,
+  deleteIfUndefined = false,
 ): T {
   if (obj == null) return obj
   const segments = path.split(/[.[\]]/g)
@@ -18,6 +19,10 @@ export default function set<T extends object, K>(
       }
       apply(node[key])
     } else {
+      if (deleteIfUndefined && value === undefined) {
+        delete node[segments[0]]
+        return
+      }
       node[segments[0]] = value
     }
   }


Test patch:
diff --git a/__tests__/Cursor.spec.ts b/__tests__/Cursor.spec.ts
index ab69561..a327f00 100644
--- a/__tests__/Cursor.spec.ts
+++ b/__tests__/Cursor.spec.ts
@@ -13,9 +13,9 @@ describe('Cursor', () => {
   }
 
   const items: TestItem[] = [
-    { id: 1, name: 'Item 1' },
-    { id: 2, name: 'Item 2' },
-    { id: 3, name: 'Item 3' },
+    { id: 1, name: 'Item 1', test: true },
+    { id: 2, name: 'Item 2', test: false },
+    { id: 3, name: 'Item 3', test: true },
   ]
 
   const collection = new Collection<TestItem>()
@@ -66,6 +66,37 @@ describe('Cursor', () => {
       expect(result).toEqual(expected)
     })
 
+    it('should return projected items when fields option is provided', () => {
+      expect(collection.find({}, { fields: { id: 1 } }).fetch()).toEqual([
+        { id: 1 },
+        { id: 2 },
+        { id: 3 },
+      ])
+      expect(collection.find({}, { fields: { name: 1 } }).fetch()).toEqual([
+        { id: 1, name: 'Item 1' },
+        { id: 2, name: 'Item 2' },
+        { id: 3, name: 'Item 3' },
+      ])
+      expect(collection.find({}, { fields: { name: 0 } }).fetch()).toEqual([
+        { id: 1, test: true },
+        { id: 2, test: false },
+        { id: 3, test: true },
+      ])
+    })
+
+    it('should include the id when when fields option is provided', () => {
+      expect(collection.find({}, { fields: { name: 1 } }).fetch()).toEqual([
+        { id: 1, name: 'Item 1' },
+        { id: 2, name: 'Item 2' },
+        { id: 3, name: 'Item 3' },
+      ])
+      expect(collection.find({}, { fields: { id: 0 } }).fetch()).toEqual([
+        { name: 'Item 1', test: true },
+        { name: 'Item 2', test: false },
+        { name: 'Item 3', test: true },
+      ])
+    })
+
     it('should return projected, sorted, limited, and skipped items when options are provided', () => {
       const cursor = collection.find({
         id: { $gt: 1 },