Skip to content

Commit da1baeb

Browse files
author
Michal Warda
committed
refactor: Simplify unique key handling in oneOf function and update related types. Remove itemId from uniqueBy interface, streamline tests to reflect changes in option structure, and ensure compatibility with unique selection logic.
1 parent 6741b3c commit da1baeb

3 files changed

Lines changed: 78 additions & 121 deletions

File tree

packages/torque/src/schema-rng.ts

Lines changed: 42 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,29 @@ import { getCurrentMessageContext } from "./message-context";
44
import { hasValueBeenUsed, markValueAsUsed } from "./unique-selection";
55
import { random } from "./utils";
66

7-
type PrimitiveKey = string | number | boolean;
8-
type UniqueIdentifierResolver<T> =
9-
| keyof any
10-
| ((value: T) => PrimitiveKey | null | undefined);
11-
12-
export interface OneOfUniqueBy<T> {
7+
export interface OneOfUniqueBy {
138
collection: string;
14-
itemId?: UniqueIdentifierResolver<T>;
159
}
1610

1711
export interface OneOfOptions<T> {
18-
unique?: boolean;
19-
uniqueBy?: OneOfUniqueBy<T>;
12+
uniqueBy?: OneOfUniqueBy;
2013
}
2114

2215
type NormalizedWeightedOption<T> = {
2316
value: T;
2417
weight?: number;
18+
id?: string | number | boolean;
2519
uniqueKey?: string;
2620
};
2721

28-
type ResolvedUniqueBy<T> = {
22+
type ResolvedUniqueBy = {
2923
collection: string;
30-
itemId: UniqueIdentifierResolver<T>;
24+
};
25+
26+
export type OneOfOptionWithId<T> = {
27+
value: T;
28+
id: string | number | boolean;
29+
weight?: number;
3130
};
3231

3332
export function optional<T>(message: T): T | (() => []) {
@@ -59,6 +58,15 @@ export function randomSample<T>(n: number, array: T[]): T[] {
5958

6059
return result;
6160
}
61+
62+
export function oneOf<T>(
63+
options: Array<OneOfOptionWithId<T>>,
64+
config: { uniqueBy: OneOfUniqueBy }
65+
): T;
66+
export function oneOf<T>(
67+
options: Array<WeightedOneOfOption<T>>,
68+
config?: { uniqueBy?: never }
69+
): T;
6270
export function oneOf<T>(
6371
options: Array<WeightedOneOfOption<T>>,
6472
config?: OneOfOptions<T>
@@ -67,16 +75,8 @@ export function oneOf<T>(
6775
throw new Error("oneOf requires at least one option");
6876
}
6977

70-
const rawUniqueBy = config?.uniqueBy;
71-
const enforceUnique = Boolean(config?.unique ?? rawUniqueBy);
72-
73-
if (enforceUnique && !rawUniqueBy) {
74-
throw new Error(
75-
"oneOf unique mode requires a uniqueBy option with a collection name"
76-
);
77-
}
78-
79-
const uniqueBy = resolveUniqueBy(rawUniqueBy);
78+
const uniqueBy = resolveUniqueBy(config?.uniqueBy);
79+
const enforceUnique = Boolean(uniqueBy);
8080

8181
const normalized = options.map((option) =>
8282
isWeightedOption(option) ? { ...option } : { value: option }
@@ -123,18 +123,21 @@ export function oneOf<T>(
123123
}
124124
}
125125

126-
const candidateBase = normalized.map<NormalizedWeightedOption<T>>((option) => ({
127-
value: option.value,
128-
weight: option.weight,
129-
}));
126+
const candidateBase = normalized.map<NormalizedWeightedOption<T>>(
127+
(option) => ({
128+
value: option.value,
129+
weight: option.weight,
130+
id: option.id,
131+
})
132+
);
130133

131134
let candidateOptions = candidateBase;
132135

133136
if (enforceUnique && uniqueBy) {
134137
candidateOptions = candidateBase
135138
.map((option) => ({
136139
...option,
137-
uniqueKey: buildUniqueKey(option.value, uniqueBy),
140+
uniqueKey: buildUniqueKey(option.id),
138141
}))
139142
.filter(
140143
(option) => !hasValueBeenUsed(uniqueBy.collection, option.uniqueKey!)
@@ -185,7 +188,7 @@ export function oneOf<T>(
185188

186189
function recordUniqueSelection<T>(
187190
option: NormalizedWeightedOption<T>,
188-
uniqueBy: ResolvedUniqueBy<T> | undefined,
191+
uniqueBy: ResolvedUniqueBy | undefined,
189192
enforceUnique: boolean
190193
): void {
191194
if (!enforceUnique || !uniqueBy || !option.uniqueKey) {
@@ -200,51 +203,26 @@ function recordUniqueSelection<T>(
200203
markValueAsUsed(uniqueBy.collection, option.uniqueKey);
201204
}
202205

203-
function buildUniqueKey<T>(
204-
value: T,
205-
uniqueBy: ResolvedUniqueBy<T>
206-
): string {
207-
const rawValue =
208-
typeof uniqueBy.itemId === "function"
209-
? uniqueBy.itemId(value)
210-
: readProperty(value, uniqueBy.itemId);
211-
212-
if (
213-
rawValue === undefined ||
214-
rawValue === null ||
215-
(typeof rawValue !== "string" &&
216-
typeof rawValue !== "number" &&
217-
typeof rawValue !== "boolean")
218-
) {
219-
throw new Error(
220-
`oneOf uniqueBy.itemId "${String(
221-
uniqueBy.itemId
222-
)}" must resolve to a string, number, or boolean`
223-
);
224-
}
225-
226-
const prefix = typeof rawValue;
227-
return `${prefix}:${String(rawValue)}`;
228-
}
229-
230-
function readProperty<T>(value: T, key: keyof any): unknown {
206+
function buildUniqueKey(id: unknown): string {
231207
if (
232-
value === null ||
233-
(typeof value !== "object" && typeof value !== "function")
208+
id === undefined ||
209+
id === null ||
210+
(typeof id !== "string" &&
211+
typeof id !== "number" &&
212+
typeof id !== "boolean")
234213
) {
235214
throw new Error(
236-
`oneOf uniqueBy.itemId "${String(
237-
key
238-
)}" requires the option value to be an object or function`
215+
`oneOf uniqueBy requires options to be objects with a valid "id" property (string, number, or boolean)`
239216
);
240217
}
241218

242-
return (value as any)[key];
219+
const prefix = typeof id;
220+
return `${prefix}:${String(id)}`;
243221
}
244222

245-
function resolveUniqueBy<T>(
246-
uniqueBy?: OneOfUniqueBy<T>
247-
): ResolvedUniqueBy<T> | undefined {
223+
function resolveUniqueBy(
224+
uniqueBy?: OneOfUniqueBy
225+
): ResolvedUniqueBy | undefined {
248226
if (!uniqueBy) {
249227
return undefined;
250228
}
@@ -258,10 +236,7 @@ function resolveUniqueBy<T>(
258236
throw new Error("oneOf uniqueBy.collection must be a non-empty string");
259237
}
260238

261-
const itemId = uniqueBy.itemId ?? ("id" as UniqueIdentifierResolver<T>);
262-
263239
return {
264240
collection,
265-
itemId,
266241
};
267242
}

packages/torque/src/schema.test.ts

Lines changed: 34 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,22 @@ describe("oneOf", () => {
7171

7272
it("enforces uniqueBy across calls", async () => {
7373
const options = [
74-
{ value: { id: "a" } },
75-
{ value: { id: "b" } },
76-
{ value: { id: "c" } },
74+
{ id: "a", value: "a" },
75+
{ id: "b", value: "b" },
76+
{ id: "c", value: "c" },
7777
];
7878

7979
await runWithUniqueSelectionScope(async () => {
8080
const picks = [
8181
oneOf([...options], {
82-
uniqueBy: { collection: "tools", itemId: "id" },
83-
}).id,
82+
uniqueBy: { collection: "tools" },
83+
}),
8484
oneOf([...options], {
85-
uniqueBy: { collection: "tools", itemId: "id" },
86-
}).id,
85+
uniqueBy: { collection: "tools" },
86+
}),
8787
oneOf([...options], {
88-
uniqueBy: { collection: "tools", itemId: "id" },
89-
}).id,
88+
uniqueBy: { collection: "tools" },
89+
}),
9090
];
9191

9292
expect(new Set(picks).size).toBe(3);
@@ -95,68 +95,49 @@ describe("oneOf", () => {
9595

9696
it("throws when a unique collection is exhausted", async () => {
9797
const options = [
98-
{ value: { id: "only" } },
99-
{ value: { id: "only" } },
98+
{ id: "only", value: "only" },
99+
{ id: "only", value: "only" },
100100
] as const;
101101

102102
await runWithUniqueSelectionScope(async () => {
103103
oneOf([...options], {
104-
uniqueBy: { collection: "limited", itemId: "id" },
104+
uniqueBy: { collection: "limited" },
105105
});
106106

107107
expect(() =>
108108
oneOf([...options], {
109-
uniqueBy: { collection: "limited", itemId: "id" },
109+
uniqueBy: { collection: "limited" },
110110
})
111111
).toThrow('oneOf uniqueBy collection "limited" is exhausted');
112112
});
113113
});
114114

115-
it("supports functional unique key extractors", async () => {
115+
it("works with uniqueBy and weight together", async () => {
116116
const options = [
117-
{ value: { tool: { name: "weather" } } },
118-
{ value: { tool: { name: "calendar" } } },
117+
{ id: "a", value: "A", weight: 0.5 },
118+
{ id: "b", value: "B", weight: 0.3 },
119+
{ id: "c", value: "C", weight: 0.2 },
119120
];
120121

121122
await runWithUniqueSelectionScope(async () => {
122-
const first = oneOf([...options], {
123-
uniqueBy: {
124-
collection: "tools",
125-
itemId: (value) => value.tool.name,
126-
},
123+
await withSeed(100, async () => {
124+
const first = oneOf([...options], {
125+
uniqueBy: { collection: "weighted-unique" },
126+
});
127+
expect(["A", "B", "C"]).toContain(first);
128+
129+
const second = oneOf([...options], {
130+
uniqueBy: { collection: "weighted-unique" },
131+
});
132+
expect(["A", "B", "C"]).toContain(second);
133+
expect(second).not.toBe(first);
134+
135+
const third = oneOf([...options], {
136+
uniqueBy: { collection: "weighted-unique" },
137+
});
138+
expect(["A", "B", "C"]).toContain(third);
139+
expect(new Set([first, second, third]).size).toBe(3);
127140
});
128-
129-
expect(first.tool.name).toMatch(/weather|calendar/);
130-
131-
const second = oneOf([...options], {
132-
uniqueBy: {
133-
collection: "tools",
134-
itemId: (value) => value.tool.name,
135-
},
136-
});
137-
138-
expect(second.tool.name).not.toBe(first.tool.name);
139-
});
140-
});
141-
142-
it("requires uniqueBy when unique is enabled", () => {
143-
expect(() =>
144-
oneOf([{ value: "a" }, { value: "b" }], { unique: true })
145-
).toThrow("oneOf unique mode requires a uniqueBy option");
146-
});
147-
148-
it("defaults uniqueBy itemId to 'id' when omitted", async () => {
149-
const options = [{ value: { id: "alpha" } }, { value: { id: "beta" } }];
150-
151-
await runWithUniqueSelectionScope(async () => {
152-
const first = oneOf([...options], {
153-
uniqueBy: { collection: "tools" },
154-
}).id;
155-
const second = oneOf([...options], {
156-
uniqueBy: { collection: "tools" },
157-
}).id;
158-
159-
expect(new Set([first, second]).size).toBe(2);
160141
});
161142
});
162143
});

packages/torque/src/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,12 @@ export type WeightedOneOfOption<T> =
430430
| {
431431
value: T;
432432
weight?: number;
433+
id?: string | number | boolean;
433434
};
434435

435436
export function isWeightedOption<T>(
436437
option: WeightedOneOfOption<T>
437-
): option is { value: T; weight?: number } {
438+
): option is { value: T; weight?: number; id?: string | number | boolean } {
438439
return typeof option === "object" && option !== null && "value" in option;
439440
}
440441

0 commit comments

Comments
 (0)