authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-11 15:53:59-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-11 16:27:27-07:00
log47304f56e98bf841b757d16e472fb64a01c99fd2
tree3ad08b3c5212b52b46b0f33e396d5c1928fce6fb
parent4b09ff4dba5fad5b7e2b44a0bdc2789918f74561
signaturebadge-check Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

chore: arrayUpsert and objArrayUpsert


3 files changed, 300 insertions(+), 11 deletions(-)

readme.md+2
......@@ -191,6 +191,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
191191 - `arrayRemove` - remove items by a `filter` function
192192 - `arrayFilter` - preserve items by a `filter` function
193193 - `arrayUpdate` - update items by a `filter` + `update` function
194 - `arrayUpsert` - update items by a `filter`, or insert when there is no match
194195 - `arrayInsertIndex` - insert an item at an index
195196- Queries that are complex objects. Each function takes a type-safe object path to
196197 evaluate.
......@@ -204,6 +205,7 @@ automatically implement `onRefetch` and `onRestore` callbacks. The current list
204205 - `objArrayRemove` - remove items from array by `filter`
205206 - `objArrayFilter` - preserve items from array by `filter`
206207 - `objArrayUpdate` - update items in array by `filter` + `update`
208 - `objArrayUpsert` - update items in array by `filter`, or insert when there is no match
207209 - `objArrayInsertIndex` - insert an item in an array at an index
208210
209211## Debouncing
src/tanstack-query.ts+106-11
......@@ -7,6 +7,10 @@ export type QueryKeyAndFn<T = unknown, Key extends QueryKey = QueryKey> = {
77 queryFn?: QueryFunction<T, any, never> | undefined;
88};
99
10type RequireAtLeastOneKey<T> = {
11 [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
12}[keyof T];
13
1014export function boundQueryClientGet(
1115 queryClient: QueryClient,
1216): <T>({ queryKey }: QueryKeyAndFn<T>) => T | undefined {
......@@ -50,7 +54,7 @@ class TanstackQueryOptimisticHelpers {
5054 set = <Data>(
5155 queryKey: QueryKeyAndFn<Data>,
5256 value: Data | ((prev: Data | undefined) => Data | undefined),
53 ) => {
57 ): void => {
5458 const prev = this.#get(queryKey);
5559
5660 const newValue = typeof value === "function"
......@@ -378,8 +382,11 @@ class TanstackQueryOptimisticHelpers {
378382 >(
379383 queryKey: QueryKeyAndFn<Data>,
380384 path: Path,
381 { filter, update }: {
382 filter: (
385 {
386 filter,
387 update,
388 }: {
389 filter?: (
383390 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
384391 index: number,
385392 ) => boolean;
......@@ -387,13 +394,62 @@ class TanstackQueryOptimisticHelpers {
387394 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
388395 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
389396 },
390 ) => {
397 ): { inserted: boolean } => {
398 return this.objArrayUpsert(queryKey, path, {
399 filter: filter ?? (() => true),
400 update,
401 });
402 };
403
404 /**
405 * Update items in an array that match a `filter`, or insert a new one if there was no match.
406 * If the query or path doesn't exist, the updater is skipped.
407 */
408 objArrayUpsert = <
409 Data extends object,
410 const Path extends AllObjectPaths<Data>,
411 >(
412 queryKey: QueryKeyAndFn<Data>,
413 path: Path,
414 {
415 filter,
416 update,
417 insert,
418 }:
419 & {
420 filter: (
421 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
422 index: number,
423 ) => boolean;
424 }
425 & RequireAtLeastOneKey<{
426 /** Defaults to the identity function */
427 update: (
428 item: GetObjectPath<Data, Path> extends Array<infer T> ? T : never,
429 ) => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
430 /** Defaults to not inserting */
431 insert: () => GetObjectPath<Data, Path> extends Array<infer T> ? T : never;
432 }>,
433 ): { inserted: boolean } => {
391434 const prev = this.#get(queryKey);
392 if (!prev) return;
435 if (!prev) return { inserted: false };
393436 const { value: original, exists } = getPath(prev, path);
394 if (!exists || !Array.isArray(original)) return;
437 if (!exists || !Array.isArray(original)) return { inserted: false };
395438
396 const newArray = original.map((item, index) => filter(item, index) ? update(item) : item);
439 let matched = false;
440 const newArray = original.map((item, index) => {
441 if (filter(item, index)) {
442 matched = true;
443 return update ? update(item) : item;
444 } else {
445 return item;
446 }
447 });
448 let inserted = false;
449 if (!matched && insert) {
450 newArray.push(insert());
451 inserted = true;
452 }
397453 this.#set(
398454 queryKey,
399455 (obj) => obj ? setPath(obj, path, newArray as any) : obj,
......@@ -402,6 +458,7 @@ class TanstackQueryOptimisticHelpers {
402458 // TODO: splice items back in case original changed
403459 this.#set(queryKey, (obj) => obj ? setPath(obj, path, original) : obj);
404460 });
461 return { inserted };
405462 };
406463
407464 /**
......@@ -439,7 +496,7 @@ class TanstackQueryOptimisticHelpers {
439496 };
440497
441498 /**
442 * Push item(s) to the end of an array at an object path.
499 * Push item(s) to the end of an array.
443500 * If the query or path doesn't exist, the updater is skipped.
444501 */
445502 arrayPush = <Data>(queryKey: QueryKeyAndFn<Data[] | null>, ...items: Data[]) => {
......@@ -531,16 +588,54 @@ class TanstackQueryOptimisticHelpers {
531588 filter?: (item: Data, index: number) => boolean;
532589 update: (item: Data) => Data;
533590 },
534 ) => {
591 ): { inserted: boolean } => {
592 return this.arrayUpsert(queryKey, { filter: filter ?? (() => true), update });
593 };
594
595 /**
596 * Update items in an array that match a `filter`, or insert a new one if there was no match.
597 * If the query doesn't exist, the updater is skipped.
598 */
599 arrayUpsert = <Data>(
600 queryKey: QueryKeyAndFn<Data[] | null>,
601 {
602 filter,
603 update,
604 insert,
605 }:
606 & {
607 filter: (item: Data, index: number) => boolean;
608 }
609 & RequireAtLeastOneKey<{
610 /** Defaults to the identity function */
611 update: (item: Data) => Data;
612 /** Defaults to not inserting */
613 insert: () => Data;
614 }>,
615 ): { inserted: boolean } => {
535616 const prev = this.#get(queryKey);
536 if (!prev || !Array.isArray(prev)) return;
617 if (!prev || !Array.isArray(prev)) return { inserted: false };
537618
538 const newArray = prev.map((item, index) => (filter ? filter(item, index) : true) ? update(item) : item);
619 let matched = false;
620 const newArray = prev.map((item, index) => {
621 if (filter(item, index)) {
622 matched = true;
623 return update ? update(item) : item;
624 } else {
625 return item;
626 }
627 });
628 let inserted = false;
629 if (!matched && insert) {
630 newArray.push(insert());
631 inserted = true;
632 }
539633 this.#set(queryKey, newArray);
540634 this.#onRestore(() => {
541635 // TODO: splice items back in case original changed
542636 this.#set(queryKey, prev);
543637 });
638 return { inserted };
544639 };
545640
546641 /**
test/tanstack-query-helpers.test.ts+192
......@@ -15,6 +15,11 @@ interface TestData {
1515 tags: string[];
1616}
1717
18interface TestArrayItem {
19 id: number;
20 label: string;
21}
22
1823const initialData: TestData = {
1924 name: "Test",
2025 count: 10,
......@@ -31,6 +36,12 @@ const initialData: TestData = {
3136 tags: ["alpha", "beta", "gamma"],
3237};
3338
39const initialArrayItems: TestArrayItem[] = [
40 { id: 1, label: "first" },
41 { id: 2, label: "second" },
42 { id: 3, label: "third" },
43];
44
3445function createTestQueryClient() {
3546 const client = new QueryClient({
3647 defaultOptions: { queries: { retry: false } },
......@@ -46,6 +57,21 @@ function createTestQueryClient() {
4657 return { client, queryTest };
4758}
4859
60function createArrayQueryClient() {
61 const client = new QueryClient({
62 defaultOptions: { queries: { retry: false } },
63 });
64
65 const queryArray = queryOptions({
66 queryKey: ["test-array"],
67 queryFn: (): TestArrayItem[] => initialArrayItems,
68 });
69
70 client.setQueryData(queryArray.queryKey, structuredClone(initialArrayItems));
71
72 return { client, queryArray };
73}
74
4975test("helpers can be spread and retain bound this", () => {
5076 const { client, queryTest } = createTestQueryClient();
5177
......@@ -663,6 +689,172 @@ test("arrayUpdateItem - predicate receives index", () => {
663689 assertEquals(result?.items[0].label, "FIRST");
664690});
665691
692test("arrayUpdateItem - should update all items when filter is omitted", () => {
693 const { client, queryTest } = createTestQueryClient();
694
695 const helpers = queryClientOptimisticHelpers(client)({
696 onRestore: () => {},
697 onRefetch: () => {},
698 });
699
700 helpers.objArrayUpdate(
701 queryTest,
702 ["items"],
703 {
704 update: (item) => ({ ...item, label: item.label.toUpperCase() }),
705 },
706 );
707
708 const result = client.getQueryData<TestData>(queryTest.queryKey);
709 assertEquals(result?.items.map((item) => item.label), ["FIRST", "SECOND", "THIRD"]);
710});
711
712// ============================================================================
713// objArrayUpsert() tests
714// ============================================================================
715
716test("objArrayUpsert - should update matching items without inserting", () => {
717 const { client, queryTest } = createTestQueryClient();
718 const restoreFns: Array<() => void> = [];
719
720 const helpers = queryClientOptimisticHelpers(client)({
721 onRestore: (fn) => restoreFns.push(fn),
722 onRefetch: () => {},
723 });
724
725 const upsertResult = helpers.objArrayUpsert(
726 queryTest,
727 ["items"],
728 {
729 filter: (item) => item.id === 2,
730 update: (item) => ({ ...item, label: "UPDATED" }),
731 insert: () => ({ id: 4, label: "fourth" }),
732 },
733 );
734
735 const result = client.getQueryData<TestData>(queryTest.queryKey);
736 assertEquals(upsertResult, { inserted: false });
737 assertEquals(result?.items.length, 3);
738 assertEquals(result?.items[1], { id: 2, label: "UPDATED" });
739
740 restoreFns[0]();
741 const restored = client.getQueryData<TestData>(queryTest.queryKey);
742 assertEquals(restored?.items, initialData.items);
743});
744
745test("objArrayUpsert - should insert a new item when nothing matches", () => {
746 const { client, queryTest } = createTestQueryClient();
747 const restoreFns: Array<() => void> = [];
748
749 const helpers = queryClientOptimisticHelpers(client)({
750 onRestore: (fn) => restoreFns.push(fn),
751 onRefetch: () => {},
752 });
753
754 const upsertResult = helpers.objArrayUpsert(
755 queryTest,
756 ["items"],
757 {
758 filter: (item) => item.id === 4,
759 insert: () => ({ id: 4, label: "fourth" }),
760 },
761 );
762
763 const result = client.getQueryData<TestData>(queryTest.queryKey);
764 assertEquals(upsertResult, { inserted: true });
765 assertEquals(result?.items.length, 4);
766 assertEquals(result?.items[3], { id: 4, label: "fourth" });
767
768 restoreFns[0]();
769 const restored = client.getQueryData<TestData>(queryTest.queryKey);
770 assertEquals(restored?.items, initialData.items);
771});
772
773// ============================================================================
774// arrayUpsert() tests
775// ============================================================================
776
777test("arrayUpsert - should update matching items without inserting", () => {
778 const { client, queryArray } = createArrayQueryClient();
779 const restoreFns: Array<() => void> = [];
780
781 const helpers = queryClientOptimisticHelpers(client)({
782 onRestore: (fn) => restoreFns.push(fn),
783 onRefetch: () => {},
784 });
785
786 const upsertResult = helpers.arrayUpsert(
787 queryArray,
788 {
789 filter: (item) => item.id === 2,
790 update: (item) => ({ ...item, label: "UPDATED" }),
791 insert: () => ({ id: 4, label: "fourth" }),
792 },
793 );
794
795 const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
796 assertEquals(upsertResult, { inserted: false });
797 assertEquals(result?.length, 3);
798 assertEquals(result?.[1], { id: 2, label: "UPDATED" });
799
800 restoreFns[0]();
801 const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
802 assertEquals(restored, initialArrayItems);
803});
804
805test("arrayUpsert - should insert a new item when nothing matches", () => {
806 const { client, queryArray } = createArrayQueryClient();
807 const restoreFns: Array<() => void> = [];
808
809 const helpers = queryClientOptimisticHelpers(client)({
810 onRestore: (fn) => restoreFns.push(fn),
811 onRefetch: () => {},
812 });
813
814 const upsertResult = helpers.arrayUpsert(
815 queryArray,
816 {
817 filter: (item) => item.id === 4,
818 insert: () => ({ id: 4, label: "fourth" }),
819 },
820 );
821
822 const result = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
823 assertEquals(upsertResult, { inserted: true });
824 assertEquals(result?.length, 4);
825 assertEquals(result?.[3], { id: 4, label: "fourth" });
826
827 restoreFns[0]();
828 const restored = client.getQueryData<TestArrayItem[]>(queryArray.queryKey);
829 assertEquals(restored, initialArrayItems);
830});
831
832test("arrayUpsert - should skip if query doesn't exist", () => {
833 const { client } = createTestQueryClient();
834 const restoreFns: Array<() => void> = [];
835
836 const helpers = queryClientOptimisticHelpers(client)({
837 onRestore: (fn) => restoreFns.push(fn),
838 onRefetch: () => {},
839 });
840
841 const queryNonexistent = queryOptions({
842 queryKey: ["nonexistent-array"],
843 queryFn: (): TestArrayItem[] => initialArrayItems,
844 });
845
846 const upsertResult = helpers.arrayUpsert(
847 queryNonexistent,
848 {
849 filter: (item) => item.id === 4,
850 insert: () => ({ id: 4, label: "fourth" }),
851 },
852 );
853
854 assertEquals(upsertResult, { inserted: false });
855 assertEquals(restoreFns.length, 0);
856});
857
666858// ============================================================================
667859// arrayInsertIndex() tests
668860// ============================================================================