Skip to content

Commit ca74388

Browse files
authored
feat: add unique and uniqueByInput methods
feat: add unique and uniqueByInput methods
2 parents 5439caf + 31fa913 commit ca74388

File tree

7 files changed

+751
-4
lines changed

7 files changed

+751
-4
lines changed

README.md

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ copycat.phoneNumber('foo')
354354
```
355355
```js
356356
copycat.phoneNumber('foo', { prefixes: ['+3319900', '+3363998'], min: 1000, max: 9999 })
357-
// => '+33639983457'
357+
// => '+33639987662'
358358
```
359359

360360
**note** The strings _resemble_ phone numbers, but will not always be valid. For example, the country dialing code may not exist, or for a particular country, the number of digits may be incorrect. Please let us know if you need valid
@@ -377,6 +377,90 @@ copycat.username('foo')
377377
#### `options`
378378
- **`limit`:** Constrain generated values to be less than or equal to `limit` number of chars
379379

380+
### `copycat.unique(input, method, store, options)`
381+
382+
The `unique` function is tailored to maintain uniqueness of values after they have undergone a specific transformation.
383+
This method is especially useful when the transformed values need to be unique, regardless of whether the input values are identical.
384+
It will do so by trying to generate a new value multiples times (up to attempts time) until it finds a unique one.
385+
386+
**note** This method will try its best to generate unique values, but be aware of these limitations:
387+
1. The uniqueness is not guaranteed, but the probability of generating a duplicate is lower as the number of attempts increases.
388+
2. On the contrary of the other methods, the `unique` method is not stateless. It will store the generated values in the `store` object to ensure uniqueness.
389+
Meaning that the deterministic property over input is not guaranteed anymore. Now the determinism is based over a combination of:
390+
- the `input` value
391+
- the state of the `store` object
392+
- the number of `attempts`
393+
3. The `unique` method as it alter the global copycat hashKey between attemps before restoring the original one, it is not thread safe.
394+
4. If duplicates exists in the passed `input` accross calls, the method might hide those duplicates by generating a unique value for each of them.
395+
If you want to ensure duplicate value for duplicate input you should use the `uniqueByInput` method.
396+
397+
#### `parameters`
398+
399+
- **`input`** (_Input_): The seed input for the generation method.
400+
- **`method`** (_Function_): A deterministic function that takes `input` and returns a value of type `T`.
401+
- **`store`** (_Store<T>_): A store object to track generated values and ensure uniqueness. It must have `has(value: T): boolean` and `add(value: T): void` methods.
402+
- **`options`** (_UniqueOptions_): An optional configuration object for additional control.
403+
404+
#### `options`
405+
406+
- **`attempts`** (_number_): The maximum number of attempts to generate a unique value. Defaults to 10.
407+
- **`attemptsReached`** (_Function_): An optional callback function that is called when the maximum number of attempts is reached.
408+
409+
```js
410+
// Define a method to generate a value
411+
const generateValue = (seed) => {
412+
return copycat.int(seed, { max: 3 });
413+
};
414+
// Create a store to track unique values
415+
const store = new Set();
416+
// Use the unique method to generate a unique number
417+
copycat.unique('exampleSeed', generateValue, store);
418+
// => 3
419+
copycat.unique('exampleSeed1', generateValue, store);
420+
// => 1
421+
copycat.unique('exampleSeed', generateValue, store);
422+
// => 0
423+
```
424+
425+
### `copycat.uniqueByInput(input, method, inputStore, resultStore, options)`
426+
427+
The `uniqueByInput` function is designed to generate unique values while preserving duplicates for identical inputs.
428+
It is particularly useful in scenarios where input consistency needs to be maintained alongside the uniqueness of the transformed values.
429+
- **Preserving Input Duplication**: If the same input is provided multiple times, `uniqueByInput` ensures that the transformed value is consistently the same for each occurrence of that input.
430+
- **Uniqueness Preservation**: For new and unique inputs, `uniqueByInput` employs the `unique` method to generate distinct values, avoiding duplicates in the `resultStore`.
431+
432+
#### `parameters`
433+
434+
- **`input`** (_Input_): The seed input for the generation method.
435+
- **`method`** (_Function_): A deterministic function that takes `input` and returns a value of type `T`.
436+
- **`inputStore`** (_Store_): A store object to track the inputs and ensure consistent output for duplicate inputs.
437+
- **`resultStore`** (_Store_): A store object to track the generated values and ensure their uniqueness.
438+
- **`options`** (_UniqueOptions_): An optional configuration object for additional control.
439+
440+
#### `options`
441+
442+
- **`attempts`** (_number_): The maximum number of attempts to generate a unique value after transformation. Defaults to 10.
443+
- **`attemptsReached`** (_Function_): An optional callback function that is invoked when the maximum number of attempts is reached.
444+
445+
```js
446+
// Define a method to generate a value
447+
const method = (seed) => {
448+
return copycat.int(seed, { max: 3 });
449+
};
450+
451+
// Create stores to track unique values and inputs
452+
const resultStore = new Set();
453+
const inputStore = new Set();
454+
455+
// Generate a unique number or retrieve the existing one for duplicate input
456+
copycat.uniqueByInput('exampleSeed', method, inputStore, resultStore);
457+
// => 3
458+
copycat.uniqueByInput('exampleSeed1', method, inputStore, resultStore);
459+
// => 1
460+
copycat.uniqueByInput('exampleSeed', method, inputStore, resultStore);
461+
// => 3
462+
```
463+
380464
### `copycat.password(input)`
381465

382466
Takes in an [`input`](#input) value and returns a string value resembling a password.

src/copycat.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ export * from './ipv4'
1919
export * from './mac'
2020
export * from './userAgent'
2121
export * from './scramble'
22-
export * from './hash'
2322
export * from './oneOfString'
23+
export { generateHashKey, setHashKey } from './hash'
24+
export { unique } from './unique'
25+
export { uniqueByInput } from './uniqueByInput'

src/hash.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
import { hash } from 'fictional'
1+
import { HashKey, hash } from 'fictional'
2+
3+
// We need to be able to get and set the hash key used by fictional from outside of fictional.
4+
// for the copycat.unique method to work.
5+
const hashKey = {
6+
value: hash.generateKey('chinochinochino!') as string | HashKey,
7+
}
8+
export function getHashKey() {
9+
return hashKey.value
10+
}
11+
12+
function setKey(key: string | HashKey) {
13+
hashKey.value = key
14+
return hash.setKey(key)
15+
}
216

317
// We'll use this function to generate a hash key using fictional requirement
418
// for a 16 character string from arbitrary long string input.
@@ -19,7 +33,7 @@ function derive16CharacterString(input: string) {
1933
return output.padEnd(16, '0')
2034
}
2135

22-
export const setHashKey = hash.setKey
36+
export const setHashKey = setKey
2337

2438
export const generateHashKey = (input: string) => {
2539
return input.length === 16

src/unique.test.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { copycat } from '.'
2+
import { randomUUID } from 'crypto'
3+
4+
test('should be able to generate 5 unique numbers with low possibility space', () => {
5+
const collisionsStore = new Set()
6+
let numberOfCollisions = 0
7+
const method = (seed): number => {
8+
return copycat.int(seed, { min: 0, max: 5 })
9+
}
10+
const store = new Set<ReturnType<typeof method>>()
11+
const results: Array<number> = []
12+
for (let i = 0; i <= 5; i++) {
13+
const result = copycat.unique(i, method, store, {
14+
attempts: 100,
15+
})
16+
results.push(result)
17+
if (collisionsStore.has(result)) {
18+
numberOfCollisions++
19+
} else {
20+
collisionsStore.add(result)
21+
}
22+
}
23+
results.sort()
24+
// Without unique would be [1,1,1,2,2,4]
25+
expect(results).toEqual([0, 1, 2, 3, 4, 5])
26+
expect(numberOfCollisions).toBe(0)
27+
})
28+
29+
test('should be able to generate 5 unique numbers with low possibility space based on random seed', () => {
30+
const collisionsStore = new Set()
31+
let numberOfCollisions = 0
32+
const store = new Set<ReturnType<typeof method>>()
33+
const method = (seed): number => {
34+
return copycat.int(seed, { min: 0, max: 5 })
35+
}
36+
const results: Array<number> = []
37+
for (let i = 0; i <= 5; i++) {
38+
const result = copycat.unique(randomUUID(), method, store, {
39+
attempts: 100,
40+
})
41+
results.push(result)
42+
if (collisionsStore.has(result)) {
43+
numberOfCollisions++
44+
} else {
45+
collisionsStore.add(result)
46+
}
47+
}
48+
results.sort()
49+
expect(results).toEqual([0, 1, 2, 3, 4, 5])
50+
expect(numberOfCollisions).toBe(0)
51+
})
52+
53+
test('should work with scramble method and not alter method input value', () => {
54+
const collisionsStore = new Set()
55+
let numberOfCollisions = 0
56+
const store = new Set<ReturnType<typeof method>>()
57+
const method = (seed): string => {
58+
return copycat.scramble(seed)
59+
}
60+
const results: Array<string> = []
61+
const textInputs = [
62+
'hello world',
63+
'hello world',
64+
'this is another test',
65+
'never gonna give you up',
66+
'never gonna let you down',
67+
'never gonna run around and desert you',
68+
]
69+
for (let i = 0; i <= 5; i++) {
70+
const result = copycat.unique(textInputs[i], method, store, {
71+
attempts: 100,
72+
})
73+
results.push(result)
74+
if (collisionsStore.has(result)) {
75+
numberOfCollisions++
76+
} else {
77+
collisionsStore.add(result)
78+
}
79+
}
80+
// Without unique the two first results would be the same
81+
expect(results).toEqual([
82+
'zmnmq ngcsx',
83+
// Since we are using the same input, we should keep the same "shape"
84+
'zvzkv uviju',
85+
'wnzx dd tqjjomm jmnz',
86+
'ugwfa zugvm xhky hbn fo',
87+
'bxhyr wojeg cvi kcu untf',
88+
'daylm ztnpp enf clrzkb toe bjhgzu yfu',
89+
])
90+
expect(numberOfCollisions).toBe(0)
91+
})
92+
93+
test('should call attempsReached when no more attempts are available', () => {
94+
const store = new Set<ReturnType<typeof method>>()
95+
const method = (seed): number => {
96+
return copycat.int(seed, { min: 0, max: 5 })
97+
}
98+
let attemptReachedCalled = false
99+
for (let i = 0; i <= 5; i++) {
100+
copycat.unique(i, method, store, {
101+
attempts: 1,
102+
attemptsReached: () => {
103+
attemptReachedCalled = true
104+
},
105+
})
106+
}
107+
expect(attemptReachedCalled).toBe(true)
108+
})
109+
110+
test('should restore the same hashKey when done', () => {
111+
const store = new Set<ReturnType<typeof method>>()
112+
const originalResult = copycat.int(1)
113+
const method = (seed): number => {
114+
return copycat.int(seed, { min: 0, max: 5 })
115+
}
116+
for (let i = 0; i <= 5; i++) {
117+
copycat.unique(i, method, store, {
118+
attempts: 100,
119+
})
120+
}
121+
// The hashKey should be restored to its original value
122+
// so that the next call to copycat.int(1) returns the same result
123+
// as if copycat.unique was never called.
124+
expect(copycat.int(1)).toBe(originalResult)
125+
})
126+
127+
test('should rethrow too much attempts error', () => {
128+
const store = new Set<ReturnType<typeof method>>()
129+
const method = (seed): number => {
130+
return copycat.int(seed, { min: 0, max: 5 })
131+
}
132+
// Ensure the store is already full
133+
store.add(0)
134+
store.add(1)
135+
store.add(2)
136+
store.add(3)
137+
store.add(4)
138+
store.add(5)
139+
expect(() =>
140+
copycat.unique(1, method, store, {
141+
attempts: 1,
142+
attemptsReached: () => {
143+
throw new Error('Too much attempts')
144+
},
145+
})
146+
).toThrowError('Too much attempts')
147+
})
148+
149+
test('should not alter hash key if method throw an error', () => {
150+
const store = new Set<ReturnType<typeof method>>()
151+
const originalResult = copycat.int(1)
152+
const method = (): number => {
153+
throw new Error('Method error')
154+
}
155+
// Ensure the store is already full
156+
store.add(0)
157+
store.add(1)
158+
store.add(2)
159+
store.add(3)
160+
store.add(4)
161+
store.add(5)
162+
expect(() =>
163+
copycat.unique(1, method, store, {
164+
attempts: 1,
165+
attemptsReached: () => {
166+
throw new Error('Too much attempts')
167+
},
168+
})
169+
).toThrowError('Method error')
170+
// The hashKey should be restored to its original value
171+
// so that the next call to copycat.int(1) returns the same result
172+
// as if copycat.unique was never called.
173+
expect(copycat.int(1)).toBe(originalResult)
174+
})
175+
176+
test('should restore the same hashKey even if throwing error after too much attempts', () => {
177+
const store = new Set<ReturnType<typeof method>>()
178+
const originalResult = copycat.int(1)
179+
const method = (seed): number => {
180+
return copycat.int(seed, { min: 0, max: 5 })
181+
}
182+
183+
// Ensure the store is already full
184+
store.add(0)
185+
store.add(1)
186+
store.add(2)
187+
store.add(3)
188+
store.add(4)
189+
store.add(5)
190+
expect(() =>
191+
copycat.unique(1, method, store, {
192+
attempts: 1,
193+
attemptsReached: () => {
194+
throw new Error('Too much attempts')
195+
},
196+
})
197+
).toThrowError('Too much attempts')
198+
// The hashKey should be restored to its original value
199+
// so that the next call to copycat.int(1) returns the same result
200+
// as if copycat.unique was never called.
201+
expect(copycat.int(1)).toBe(originalResult)
202+
})
203+
204+
test('should be able to generate 8999 unique fictional french phone numbers', () => {
205+
const collisionsStore = new Set()
206+
let numberOfCollisions = 0
207+
const method = (seed): string => {
208+
return copycat.phoneNumber(seed, {
209+
prefixes: ['+3319900'],
210+
min: 1000,
211+
max: 9999,
212+
})
213+
}
214+
const store = new Set<ReturnType<typeof method>>()
215+
const results: Array<string> = []
216+
for (let i = 0; i <= 8999; i++) {
217+
const result = copycat.unique(i, method, store, {
218+
// Bellow 2500 attempts, the test fails because of collisions
219+
attempts: 2500,
220+
})
221+
results.push(result)
222+
if (collisionsStore.has(result)) {
223+
numberOfCollisions++
224+
} else {
225+
collisionsStore.add(result)
226+
}
227+
}
228+
expect(numberOfCollisions).toBe(0)
229+
})
230+
231+
test('should be able to generate 8999 unique fictional french phone numbers with uuid', () => {
232+
const collisionsStore = new Set()
233+
let numberOfCollisions = 0
234+
const method = (seed): string => {
235+
return copycat.phoneNumber(seed, {
236+
prefixes: ['+3319900'],
237+
min: 1000,
238+
max: 9999,
239+
})
240+
}
241+
const store = new Set<ReturnType<typeof method>>()
242+
const results: Array<string> = []
243+
for (let i = 0; i <= 8999; i++) {
244+
const result = copycat.unique(randomUUID(), method, store, {
245+
// Bellow 100k with random uuid there is still a 1/1000 chance of collision
246+
attempts: 100000,
247+
})
248+
results.push(result)
249+
if (collisionsStore.has(result)) {
250+
numberOfCollisions++
251+
} else {
252+
collisionsStore.add(result)
253+
}
254+
}
255+
expect(numberOfCollisions).toBe(0)
256+
})

0 commit comments

Comments
 (0)