Skip to content

Commit bc5b16f

Browse files
authored
feat: add UniqueEnforcer and UniqueFairy for unique value generation
Add fairy.unique() returning UniqueFairy (Person by email, Company by name, IBAN/CreditCard by number) and UniqueEnforcer.of() for custom key extraction. Throws UniqueGenerationException after max retries (default 10,000). Closes #9
1 parent 3f22d4e commit bc5b16f

6 files changed

Lines changed: 395 additions & 0 deletions

File tree

src/main/java/com/devskiller/jfairy/Fairy.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,24 @@ public CreditCard creditCard() {
132132
public NetworkProducer networkProducer() {
133133
return networkProducer;
134134
}
135+
136+
/**
137+
* Returns a {@link UniqueFairy} that ensures generated objects are unique
138+
* by their natural key (email for Person, name for Company, etc.).
139+
*
140+
* @return A {@link UniqueFairy} instance
141+
*/
142+
public UniqueFairy unique() {
143+
return new UniqueFairy(this, UniqueEnforcer.DEFAULT_MAX_RETRIES);
144+
}
145+
146+
/**
147+
* Returns a {@link UniqueFairy} with custom max retries.
148+
*
149+
* @param maxRetries maximum generation attempts before throwing
150+
* @return A {@link UniqueFairy} instance
151+
*/
152+
public UniqueFairy unique(int maxRetries) {
153+
return new UniqueFairy(this, maxRetries);
154+
}
135155
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.devskiller.jfairy;
2+
3+
import java.util.HashSet;
4+
import java.util.Objects;
5+
import java.util.Set;
6+
import java.util.function.Function;
7+
import java.util.function.Supplier;
8+
9+
/**
10+
* Wraps a generator to ensure unique values based on a key extractor.
11+
* Throws {@link UniqueGenerationException} after max retries.
12+
*
13+
* <p>This class is not thread-safe. Each thread should use its own instance.</p>
14+
*
15+
* <pre>{@code
16+
* Fairy fairy = Fairy.create();
17+
* UniqueEnforcer<Person> unique = UniqueEnforcer.of(fairy::person, Person::getEmail);
18+
* Person p1 = unique.next(); // guaranteed unique email
19+
* Person p2 = unique.next(); // different email than p1
20+
* unique.reset(); // clear history
21+
* }</pre>
22+
*
23+
* @param <T> type of generated object
24+
*/
25+
public final class UniqueEnforcer<T> {
26+
27+
static final int DEFAULT_MAX_RETRIES = 10_000;
28+
29+
private final Supplier<T> defaultGenerator;
30+
private final Function<T, ?> keyExtractor;
31+
private final int maxRetries;
32+
private final Set<Object> seen = new HashSet<>();
33+
34+
private UniqueEnforcer(Supplier<T> defaultGenerator, Function<T, ?> keyExtractor, int maxRetries) {
35+
this.defaultGenerator = Objects.requireNonNull(defaultGenerator, "generator must not be null");
36+
this.keyExtractor = Objects.requireNonNull(keyExtractor, "keyExtractor must not be null");
37+
if (maxRetries < 1) {
38+
throw new IllegalArgumentException("maxRetries must be positive, got: " + maxRetries);
39+
}
40+
this.maxRetries = maxRetries;
41+
}
42+
43+
public static <T> UniqueEnforcer<T> of(Supplier<T> generator, Function<T, ?> keyExtractor) {
44+
return new UniqueEnforcer<>(generator, keyExtractor, DEFAULT_MAX_RETRIES);
45+
}
46+
47+
public static <T> UniqueEnforcer<T> of(Supplier<T> generator, Function<T, ?> keyExtractor, int maxRetries) {
48+
return new UniqueEnforcer<>(generator, keyExtractor, maxRetries);
49+
}
50+
51+
public T next() {
52+
return next(defaultGenerator);
53+
}
54+
55+
/**
56+
* Generate a unique value using a custom supplier, tracking uniqueness
57+
* in the same seen-set as {@link #next()}.
58+
*/
59+
public T next(Supplier<T> generator) {
60+
for (int i = 0; i < maxRetries; i++) {
61+
T value = generator.get();
62+
Object key = keyExtractor.apply(value);
63+
if (seen.add(key)) {
64+
return value;
65+
}
66+
}
67+
throw new UniqueGenerationException(
68+
"Could not generate a unique value after " + maxRetries + " retries. "
69+
+ seen.size() + " unique values were generated before exhaustion.");
70+
}
71+
72+
public void reset() {
73+
seen.clear();
74+
}
75+
76+
public int size() {
77+
return seen.size();
78+
}
79+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.devskiller.jfairy;
2+
3+
import com.devskiller.jfairy.producer.company.Company;
4+
import com.devskiller.jfairy.producer.company.CompanyProperties;
5+
import com.devskiller.jfairy.producer.payment.CreditCard;
6+
import com.devskiller.jfairy.producer.payment.IBAN;
7+
import com.devskiller.jfairy.producer.payment.IBANProperties;
8+
import com.devskiller.jfairy.producer.person.Person;
9+
import com.devskiller.jfairy.producer.person.PersonProperties;
10+
11+
/**
12+
* Convenience wrapper that ensures generated objects are unique by their
13+
* natural key (email for Person, name for Company, etc.).
14+
*
15+
* <p>Uniqueness is tracked per entity type. A person email will not conflict
16+
* with a company name. Call {@link #reset()} to clear all tracked values.</p>
17+
*
18+
* <p>This class is not thread-safe. Store the reference and reuse it —
19+
* calling {@code fairy.unique()} in a loop creates independent instances
20+
* with no shared tracking.</p>
21+
*
22+
* <pre>{@code
23+
* UniqueFairy unique = fairy.unique();
24+
* Person p1 = unique.person(); // unique by email
25+
* Person p2 = unique.person(); // different email than p1
26+
* Company c = unique.company(); // unique by name
27+
* unique.reset();
28+
* }</pre>
29+
*/
30+
public final class UniqueFairy {
31+
32+
private final Fairy fairy;
33+
private final UniqueEnforcer<Person> personEnforcer;
34+
private final UniqueEnforcer<Company> companyEnforcer;
35+
private final UniqueEnforcer<IBAN> ibanEnforcer;
36+
private final UniqueEnforcer<CreditCard> creditCardEnforcer;
37+
38+
UniqueFairy(Fairy fairy, int maxRetries) {
39+
this.fairy = fairy;
40+
this.personEnforcer = UniqueEnforcer.of(fairy::person, Person::getEmail, maxRetries);
41+
this.companyEnforcer = UniqueEnforcer.of(fairy::company, Company::getName, maxRetries);
42+
// Lambda needed: fairy::iban is ambiguous (overloaded no-arg and vararg)
43+
this.ibanEnforcer = UniqueEnforcer.of(() -> fairy.iban(), IBAN::getIbanNumber, maxRetries);
44+
this.creditCardEnforcer = UniqueEnforcer.of(fairy::creditCard, CreditCard::getCardNumber, maxRetries);
45+
}
46+
47+
public Person person(PersonProperties.PersonProperty... personProperties) {
48+
if (personProperties.length == 0) {
49+
return personEnforcer.next();
50+
}
51+
return personEnforcer.next(() -> fairy.person(personProperties));
52+
}
53+
54+
public Company company(CompanyProperties.CompanyProperty... companyProperties) {
55+
if (companyProperties.length == 0) {
56+
return companyEnforcer.next();
57+
}
58+
return companyEnforcer.next(() -> fairy.company(companyProperties));
59+
}
60+
61+
public IBAN iban(IBANProperties.Property... properties) {
62+
if (properties.length == 0) {
63+
return ibanEnforcer.next();
64+
}
65+
return ibanEnforcer.next(() -> fairy.iban(properties));
66+
}
67+
68+
public CreditCard creditCard() {
69+
return creditCardEnforcer.next();
70+
}
71+
72+
public void reset() {
73+
personEnforcer.reset();
74+
companyEnforcer.reset();
75+
ibanEnforcer.reset();
76+
creditCardEnforcer.reset();
77+
}
78+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.devskiller.jfairy;
2+
3+
public class UniqueGenerationException extends RuntimeException {
4+
5+
public UniqueGenerationException(String message) {
6+
super(message);
7+
}
8+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.devskiller.jfairy
2+
3+
import com.devskiller.jfairy.producer.person.Person
4+
import com.devskiller.jfairy.producer.company.Company
5+
import spock.lang.Specification
6+
7+
class UniqueEnforcerSpec extends Specification {
8+
9+
private Fairy fairy = Fairy.create()
10+
11+
def "should generate unique persons by email"() {
12+
given:
13+
def unique = UniqueEnforcer.of(fairy.&person, { Person p -> p.email })
14+
when:
15+
def persons = (1..50).collect { unique.next() }
16+
def emails = persons.collect { it.email }
17+
then:
18+
emails.toSet().size() == 50
19+
}
20+
21+
def "should generate unique companies by name"() {
22+
given:
23+
def unique = UniqueEnforcer.of(fairy.&company, { Company c -> c.name })
24+
when:
25+
def companies = (1..20).collect { unique.next() }
26+
def names = companies.collect { it.name }
27+
then:
28+
names.toSet().size() == 20
29+
}
30+
31+
def "should throw after max retries with small pool"() {
32+
given:
33+
int counter = 0
34+
def unique = UniqueEnforcer.of({ -> counter++ % 3 }, { it }, 100)
35+
when:
36+
unique.next() // 0
37+
unique.next() // 1
38+
unique.next() // 2
39+
unique.next() // should fail - only 3 unique values possible
40+
then:
41+
thrown(UniqueGenerationException)
42+
}
43+
44+
def "should reset tracked values"() {
45+
given:
46+
int counter = 0
47+
def unique = UniqueEnforcer.of({ -> counter++ % 2 }, { it }, 100)
48+
when:
49+
unique.next() // 0
50+
unique.next() // 1
51+
unique.reset()
52+
unique.next() // 0 again - OK after reset
53+
then:
54+
unique.size() == 1
55+
}
56+
57+
def "should track size"() {
58+
given:
59+
def unique = UniqueEnforcer.of(fairy.&person, { Person p -> p.email })
60+
when:
61+
(1..10).each { unique.next() }
62+
then:
63+
unique.size() == 10
64+
}
65+
66+
def "should allow custom max retries"() {
67+
given:
68+
def unique = UniqueEnforcer.of({ -> "same" }, { it }, 5)
69+
when:
70+
unique.next() // OK first time
71+
unique.next() // should fail after 5 retries
72+
then:
73+
def e = thrown(UniqueGenerationException)
74+
e.message.contains("5 retries")
75+
}
76+
77+
def "should share seen set between default and custom suppliers"() {
78+
given:
79+
int counter = 0
80+
def unique = UniqueEnforcer.of({ -> counter++ }, { it }, 100)
81+
when:
82+
unique.next() // 0 via default
83+
unique.next({ -> counter++ } as java.util.function.Supplier) // 1 via custom
84+
unique.next() // 2 via default
85+
then:
86+
unique.size() == 3
87+
}
88+
89+
def "should reject zero maxRetries"() {
90+
when:
91+
UniqueEnforcer.of({ -> "x" }, { it }, 0)
92+
then:
93+
thrown(IllegalArgumentException)
94+
}
95+
96+
def "should reject negative maxRetries"() {
97+
when:
98+
UniqueEnforcer.of({ -> "x" }, { it }, -1)
99+
then:
100+
thrown(IllegalArgumentException)
101+
}
102+
103+
def "should reject null generator"() {
104+
when:
105+
UniqueEnforcer.of(null, { it })
106+
then:
107+
thrown(NullPointerException)
108+
}
109+
110+
def "should reject null keyExtractor"() {
111+
when:
112+
UniqueEnforcer.of({ -> "x" }, null)
113+
then:
114+
thrown(NullPointerException)
115+
}
116+
}

0 commit comments

Comments
 (0)