Skip to content

Commit 619213c

Browse files
committed
Add UniqueOrderedList class to manage unique items in insertion order
1 parent 23daa63 commit 619213c

2 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* A function that extracts a unique identifier from an item.
3+
* The identifier is used to determine uniqueness in the list.
4+
*
5+
* @template T - The type of items in the list
6+
* @param item - The item to extract an identifier from
7+
* @returns A value that uniquely identifies the item
8+
*/
9+
export type Identifier<T> = (item: T) => unknown;
10+
11+
export type UniqueOrderedListOptions<T> = {
12+
/**
13+
* A function to extract a unique identifier from items.
14+
* If not provided, items themselves are used as identifiers.
15+
*/
16+
identifier?: Identifier<T>;
17+
};
18+
19+
/**
20+
* A list that maintains insertion order while ensuring all items are unique.
21+
*
22+
* This class provides a data structure that combines the properties of an array
23+
* (ordered) and a set (unique items). Items are kept in the order they were first
24+
* added, and duplicate items (as determined by the identifier function) are ignored.
25+
*
26+
* @template T - The type of items stored in the list
27+
*
28+
* @example
29+
* ```typescript
30+
* type User = { id: number; name: string };
31+
*
32+
* // Using with objects and custom identifier
33+
* const users = new UniqueOrderedList<User>([], {
34+
* identifier: (user) => user.id
35+
* });
36+
* users.push({ id: 1, name: "Alice" });
37+
* users.push({ id: 2, name: "Bob" });
38+
* users.push({ id: 1, name: "Alice Updated" }); // Ignored due to duplicate id
39+
* console.log(users.items); // [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
40+
*
41+
* // Using with primitives (default identifier)
42+
* const numbers = new UniqueOrderedList<number>();
43+
* numbers.push(3, 1, 4, 1, 5, 9); // Duplicates are ignored
44+
* console.log(numbers.items); // [3, 1, 4, 5, 9]
45+
*
46+
* // Initializing with items (duplicates are filtered)
47+
* const nums = new UniqueOrderedList<number>([1, 2, 3, 2, 1]);
48+
* console.log(nums.items); // [1, 2, 3]
49+
* ```
50+
*/
51+
export class UniqueOrderedList<T> {
52+
#seen: Set<unknown> = new Set();
53+
#identifier: Identifier<T>;
54+
#items: T[];
55+
56+
/**
57+
* Creates a new UniqueOrderedList instance.
58+
*
59+
* @param init - Optional array of initial items. Duplicates will be filtered out.
60+
* @param options - Optional configuration object
61+
* @param options.identifier - Function to extract unique identifiers from items.
62+
* If not provided, items themselves are used as identifiers.
63+
*/
64+
constructor(init?: readonly T[], options?: UniqueOrderedListOptions<T>) {
65+
this.#identifier = options?.identifier ?? ((item) => item);
66+
this.#items = Array.from(this.#uniq(init?.slice() ?? []));
67+
}
68+
69+
/**
70+
* Gets a readonly array of all items in the list.
71+
* Items are returned in the order they were first added.
72+
*
73+
* @returns A readonly array containing all unique items
74+
*/
75+
get items(): readonly T[] {
76+
return this.#items;
77+
}
78+
79+
/**
80+
* Gets the number of unique items in the list.
81+
*
82+
* @returns The count of unique items
83+
*/
84+
get size(): number {
85+
return this.#items.length;
86+
}
87+
88+
*#uniq(items: Iterable<T>): Iterable<T> {
89+
const seen = this.#seen;
90+
const identifier = this.#identifier;
91+
for (const item of items) {
92+
const id = identifier(item);
93+
if (!seen.has(id)) {
94+
seen.add(id);
95+
yield item;
96+
}
97+
}
98+
}
99+
100+
/**
101+
* Adds one or more items to the list.
102+
*
103+
* Items are added in the order provided, but only if their identifier
104+
* hasn't been seen before. Duplicate items (based on the identifier) are
105+
* silently ignored and do not affect the existing order.
106+
*
107+
* @param items - The items to add to the list
108+
*/
109+
push(...items: readonly T[]): void {
110+
for (const item of this.#uniq(items)) {
111+
this.#items.push(item);
112+
}
113+
}
114+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { assertEquals } from "jsr:@std/assert@^1.0.0";
2+
import { UniqueOrderedList } from "./unique_ordered_list.ts";
3+
4+
type User = { id: number; name: string };
5+
6+
Deno.test("UniqueOrderedList: push unique items", () => {
7+
const list = new UniqueOrderedList<User>([], {
8+
identifier: (user) => user.id,
9+
});
10+
list.push({ id: 1, name: "Alice" });
11+
list.push({ id: 2, name: "Bob" });
12+
13+
assertEquals(list.size, 2);
14+
assertEquals(list.items, [
15+
{ id: 1, name: "Alice" },
16+
{ id: 2, name: "Bob" },
17+
]);
18+
});
19+
20+
Deno.test("UniqueOrderedList: skip duplicate items", () => {
21+
const list = new UniqueOrderedList<User>([], {
22+
identifier: (user) => user.id,
23+
});
24+
list.push({ id: 1, name: "Alice" });
25+
list.push({ id: 1, name: "Alice (dup)" }); // 重複
26+
list.push({ id: 2, name: "Bob" });
27+
28+
assertEquals(list.size, 2);
29+
assertEquals(list.items, [
30+
{ id: 1, name: "Alice" },
31+
{ id: 2, name: "Bob" },
32+
]);
33+
});
34+
35+
Deno.test("UniqueOrderedList: order is preserved", () => {
36+
const list = new UniqueOrderedList<number>();
37+
list.push(3, 1, 4, 1, 5, 9, 2, 6, 5); // 重複あり
38+
39+
assertEquals(list.items, [3, 1, 4, 5, 9, 2, 6]);
40+
});
41+
42+
Deno.test("UniqueOrderedList: accepts string keys", () => {
43+
const list = new UniqueOrderedList<{ key: string }>([], {
44+
identifier: (item) => item.key,
45+
});
46+
list.push({ key: "a" });
47+
list.push({ key: "b" });
48+
list.push({ key: "a" });
49+
50+
assertEquals(list.size, 2);
51+
assertEquals(list.items.map((i) => i.key), ["a", "b"]);
52+
});
53+
54+
Deno.test("UniqueOrderedList: initializes with items", () => {
55+
const initialItems = [
56+
{ id: 1, name: "Alice" },
57+
{ id: 2, name: "Bob" },
58+
{ id: 1, name: "Alice (dup)" }, // Duplicate id
59+
];
60+
const list = new UniqueOrderedList<User>(initialItems, {
61+
identifier: (user) => user.id,
62+
});
63+
64+
// Only unique items are kept from initialization
65+
assertEquals(list.size, 2);
66+
assertEquals(list.items, [
67+
{ id: 1, name: "Alice" },
68+
{ id: 2, name: "Bob" },
69+
]);
70+
71+
// Pushing duplicate IDs is properly prevented
72+
list.push({ id: 1, name: "Alice (another dup)" });
73+
assertEquals(list.size, 2); // Still 2, duplicate was ignored
74+
});
75+
76+
Deno.test("UniqueOrderedList: works without identifier function", () => {
77+
const list = new UniqueOrderedList<number>([1, 2, 3, 2, 1]); // Has duplicates
78+
79+
// Initial duplicates are filtered
80+
assertEquals(list.size, 3);
81+
assertEquals(list.items, [1, 2, 3]);
82+
83+
list.push(4, 2, 5); // 2 is duplicate and will be ignored
84+
assertEquals(list.size, 5);
85+
assertEquals(list.items, [1, 2, 3, 4, 5]);
86+
});

0 commit comments

Comments
 (0)