Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion docs/documentation/custom-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ This will print something like the following:

### Weighted random selection

> **Note**: Usage of weighted random selector is in the POC stage and is currently available only for custom hardcoded providers.
> **Note**: Usage of weighted random selector is in the POC stage. Custom YAML providers also support the percentage syntax described below.

Since version 2.4.2 of Datafaker it's possible to use a random selector based on weights, add a `WeightedRandomSelector` and weighted values to your provider:

Expand Down Expand Up @@ -154,6 +154,29 @@ en:
bees: ['Bumblebee', 'Euglossine bee', 'Honeybee', 'Carpenter bee', 'Leaf-cutter bee', 'Mining bee']
```

### Weighted YAML choices

Custom files loaded with `addPath` or `addUrl` can use a list of percentage-key mappings:

```yaml
en:
faker:
insectsfromfile:
ants:
- "60%": "Driver ant"
- "40%": "#{other_ant}"
other_ant: ["Fire ant", "Harvester ant"]
domains:
- "90%": ".it"
- "10%": [".ee", ".lv", ".lt"]
```

Each entry must have exactly one quoted decimal percentage key and a string template or a non-empty list of string templates. Percentages must be finite, positive and total exactly 100 (decimal fractions such as `33.3%` are supported). Branch values must be unique, matching `WeightedRandomSelector`'s validation. Mixing weighted entries with ordinary entries, or nesting weighted choices inside a branch, is not supported.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quoting isn't required: in YAML, - 60%: x already reads the key as a string. This section is also quite long; the last two paragraphs could be one sentence each.


The weighted branch is selected first; a list branch then selects one entry uniformly. Templates resolve in the same way as ordinary custom-provider templates, including local and other provider references. Selection uses the current Faker's random service, so seeded Fakers remain reproducible. Percentages describe probabilities, not guaranteed counts in a finite sample.

This syntax applies only to custom files. Ordinary lists, maps and scalar values, bundled locale data, and the Java weighted-selection API retain their existing behavior. An empty ordinary list still uses the existing fallback behavior.

### Register provider

Registering the provider would happen like this:
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/net/datafaker/service/FakeValues.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
Expand Down Expand Up @@ -46,12 +47,34 @@ private Map<String, Object> loadFromUrl() {
try (var stream = url.openStream()) {
Map<String, Object> result = readFromStream(stream);
enrichMapWithJavaNames(result);
if (result != null) {
result.forEach((provider, value) -> prepareCustomValues(value, provider));
}
return result;
} catch (IOException e) {
throw new RuntimeException("Failed to read fake values from %s".formatted(url), e);
}
}

private void prepareCustomValues(Object node, String provider) {
if (node instanceof Map<?, ?> rawMap) {
@SuppressWarnings("unchecked")
Map<Object, Object> map = (Map<Object, Object>) rawMap;
map.replaceAll((key, value) -> {
if (value instanceof List<?> list) {
WeightedYamlValues weighted = WeightedYamlValues.from(list, template -> {
List<Object> singleton = new ArrayList<>(List.of(template));
prefixUnqualifiedExpressions(singleton, provider);
return (String) singleton.get(0);
});
if (weighted != null) return weighted;
}
prepareCustomValues(value, provider);
return value;
});
}
}

private Map<String, Object> loadValues() {
Map<String, Object> result = loadFromUrl();
if (result != null) return result;
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/net/datafaker/service/FakeValuesService.java
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ public void addUrl(Locale locale, URL url) {
public Object fetch(String key, FakerContext context) {
List<?> valuesArray = null;
final Object o = fetchObject(key, context);
if (o instanceof WeightedYamlValues weighted) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchObject is public and will now return a package-private WeightedYamlValues for weighted keys, which code outside the package can't use. For example, Unique.fetchFromYaml("weighted_custom.strings") throws "No values found". That's probably acceptable, but it should be a deliberate choice and mentioned in the docs.

Also, branch strings skip the /regex/ → #{regexify ...} conversion that safeFetch applies to ordinary values.

return weighted.select(context.getRandomService());
}
if (o instanceof List<?> list) {
valuesArray = list;
final int size = valuesArray.size();
Expand Down Expand Up @@ -196,6 +199,9 @@ public String safeFetch(String key, FakerContext context, @Nullable String defau
Object o = fetchObject(key, context);
String str;
if (o == null) return defaultIfNull;
if (o instanceof WeightedYamlValues weighted) {
return weighted.select(context.getRandomService());
}
if (o instanceof List<?> list) {
final List<String> values = (List<String>) list;
final int size = values.size();
Expand Down
91 changes: 91 additions & 0 deletions src/main/java/net/datafaker/service/WeightedYamlValues.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package net.datafaker.service;

import org.jspecify.annotations.Nullable;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.function.UnaryOperator;

/** A custom YAML choice, parsed once but sampled using the current Faker's random service. */
final class WeightedYamlValues {
private final List<Map<String, Object>> choices;

private WeightedYamlValues(List<Map<String, Object>> choices) {
this.choices = List.copyOf(choices);
}

@Nullable
static WeightedYamlValues from(List<?> values, UnaryOperator<String> qualify) {
// Ordinary lists (including lists of maps) retain their existing meaning.
boolean weighted = values.stream().anyMatch(value -> value instanceof Map<?, ?> map
&& map.keySet().stream().anyMatch(key -> key instanceof String s && s.endsWith("%")));
if (!weighted) return null;

List<Map<String, Object>> choices = new ArrayList<>(values.size());
var uniqueValues = new HashSet<Object>();
BigDecimal total = BigDecimal.ZERO;
for (Object value : values) {
if (!(value instanceof Map<?, ?> map) || map.size() != 1) {
throw invalid("each entry must be a single percentage-to-value mapping");
}
var entry = map.entrySet().iterator().next();
if (!(entry.getKey() instanceof String key) || !key.matches("[0-9]+(?:\\.[0-9]+)?%")) {
throw invalid("percentage keys must be decimal strings such as '60%'");
}
BigDecimal percentage = new BigDecimal(key.substring(0, key.length() - 1));
double weight = percentage.doubleValue();
if (percentage.signum() <= 0 || !Double.isFinite(weight) || weight <= 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the key regex [0-9]+(?:\.[0-9]+)?% already rules out NaN, Infinity and negative numbers, so only percentage.signum() <= 0 does anything here.

throw invalid("percentages must be finite and positive");
}
total = total.add(percentage);
Object branch = entry.getValue();
Object qualified;
if (branch instanceof String string) {
qualified = qualify.apply(string);
} else if (branch instanceof List<?> list && !list.isEmpty()) {
List<String> strings = new ArrayList<>(list.size());
for (Object item : list) {
if (!(item instanceof String string)) {
throw invalid("list branches must contain only string templates");
}
strings.add(qualify.apply(string));
}
qualified = List.copyOf(strings);
} else {
throw invalid("each branch must be a string template or a non-empty list of string templates");
}
if (!uniqueValues.add(qualified)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uniqueness rule exists only because WeightedRandomSelector requires it. If select() uses the precomputed cumulative weights directly (see the comment on select), duplicate branch values become harmless and this check (and the matching sentence in the docs) can go.

throw invalid("branch values must be unique, as required by WeightedRandomSelector");
}
choices.add(Map.of("weight", weight, "value", qualified));
}
if (total.compareTo(BigDecimal.valueOf(100)) != 0) {
throw invalid("percentages must total 100");
}
return new WeightedYamlValues(choices);
}

String select(RandomService random) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This subclasses new Random(0) and overrides only nextDouble(). That works only as long as WeightedRandomSelector never calls any other Random method. If it ever does, values would silently come from a stream seeded with 0. Each call also runs WeightedRandomSelector.select, which rebuilds a HashSet, re-checks every item and recomputes the cumulative weights on every resolve, although the data was already validated at load time.

Suggestion: compute double[] cumulative and Object[] values once in from(), then:

Object selected = WeightedRandomSelector.selectWeightedElement(random.nextDouble() * total, cumulative, values);

(selectWeightedElement is already package-private and static.) This removes the shim and the per-call overhead.

// The Java provider API accepts Random, while RandomService also supports other
// RandomGenerators. Forward its draw without creating an independent random stream.
Random delegate = new Random(0) {
@Override
public double nextDouble() {
return random.nextDouble();
}
};
Object selected = new WeightedRandomSelector(delegate).select(choices);
if (selected instanceof List<?> list) {
return (String) list.get(list.size() == 1 ? 0 : random.nextInt(list.size()));
}
return (String) selected;
}

private static IllegalArgumentException invalid(String reason) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error doesn't say which key or file is broken. Values are loaded lazily, one whole file at a time, so Invalid weighted YAML choices: percentages must total 100 appears on the first lookup of any key in the file, often far from the bad entry. Could you pass the key path (and URL) into from(), or wrap the exception in FakeValues.loadFromUrl and add them there?

return new IllegalArgumentException("Invalid weighted YAML choices: " + reason);
}
}
103 changes: 103 additions & 0 deletions src/test/java/net/datafaker/service/WeightedYamlProviderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package net.datafaker.service;

import net.datafaker.providers.base.BaseFaker;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Random;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class WeightedYamlProviderTest {
@TempDir
Path directory;

@Test
void resolvesCustomWeightedYamlAndPreservesOrdinaryValues() {
BaseFaker faker = custom(new Random(1));
faker.addUrl(Locale.ENGLISH, getClass().getResource("/weighted-custom.yml"));
for (int i = 0; i < 20; i++) {
assertThat(faker.resolve("weighted_custom.strings")).isIn("first", "second");
assertThat(faker.resolve("weighted_custom.domains")).isIn(".it", ".ee", ".lv", ".lt");
assertThat(faker.resolve("weighted_custom.local_template")).isEqualTo("chosen!");
assertThat(faker.resolve("weighted_custom.reference")).isNotEmpty();
assertThat(faker.resolve("weighted_custom.ordinary")).isIn("a", "b");
assertThat(faker.resolve("weighted_custom.scalar")).isEqualTo("plain");
assertThat(faker.resolve("weighted_custom.nested.value")).isEqualTo("nested");
}
assertThat(faker.fakeValuesService().fetchString("weighted_custom.strings", faker.getContext())).isIn("first", "second");
Object ordinary = faker.fakeValuesService().fetchObject("weighted_custom.ordinary", faker.getContext());
assertThat(ordinary).isEqualTo(java.util.List.of("a", "b"));
assertThat(faker.fakeValuesService().safeFetch("weighted_custom.empty", faker.getContext(), "fallback")).isEqualTo("fallback");
// Custom data is merged with the ordinary locale fallback, not substituted for it.
assertThat(faker.resolve("name.first_name")).isNotEmpty();
}

@Test
void sameSeedAndSharedYamlDoNotShareSamples() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name says "do not share samples", but the test asserts the two outputs are equal. Maybe sameSeedProducesSameSequence? Also, a few tests here write out java.util.List / java.util.Map in full; imports would be cleaner.

BaseFaker first = custom(new Random(123));
BaseFaker second = custom(new Random(123));
for (BaseFaker faker : java.util.List.of(first, second)) {
faker.addUrl(Locale.ENGLISH, getClass().getResource("/weighted-custom.yml"));
}
for (int i = 0; i < 100; i++) {
assertThat(first.resolve("weighted_custom.domains")).isEqualTo(second.resolve("weighted_custom.domains"));
}
}

@Test
void customSourcesAndRandomServicesAreIsolated() throws IOException {
Path a = directory.resolve("a.yml");
Path b = directory.resolve("b.yml");
Files.writeString(a, "en:\n faker:\n weighted_custom:\n value:\n - '100%': 'source-a'\n");
Files.writeString(b, "en:\n faker:\n weighted_custom:\n value:\n - '100%': 'source-b'\n");
BaseFaker first = custom(new Random(1));
BaseFaker second = custom(new Random(1));
first.addPath(Locale.ENGLISH, a);
second.addPath(Locale.ENGLISH, b);
assertThat(first.resolve("weighted_custom.value")).isEqualTo("source-a");
assertThat(second.resolve("weighted_custom.value")).isEqualTo("source-b");
first.getContext().setRandomService(new RandomService(new Random(2)));
assertThat(first.resolve("weighted_custom.value")).isEqualTo("source-a");
}

@Test
void preservesIntegerKeysInOrdinaryNestedMaps() throws IOException {
Path file = directory.resolve("integer-keys.yml");
Files.writeString(file, """
en:
faker:
weighted_custom:
sibling: 'ordinary sibling'
original:
1: 'one'
2: ['two', 'deux']
""");
BaseFaker faker = custom(new Random(1));
faker.addPath(Locale.ENGLISH, file);
assertThat(faker.resolve("weighted_custom.sibling")).isEqualTo("ordinary sibling");
Object original = faker.fakeValuesService().fetchObject("weighted_custom.original", faker.getContext());
assertThat(original).isEqualTo(java.util.Map.of(1, "one", 2, java.util.List.of("two", "deux")));
}

@Test
void malformedCustomYamlFailsClearly() throws IOException {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the error message includes the key, it would be good to assert that here as well (e.g. hasMessageContaining("weighted_custom.value")).

Path bad = directory.resolve("bad.yml");
Files.writeString(bad, "en:\n faker:\n weighted_custom:\n value:\n - '60%': 'a'\n - '30%': 'b'\n");
BaseFaker faker = custom(new Random(1));
assertThatThrownBy(() -> {
faker.addPath(Locale.ENGLISH, bad);
faker.resolve("weighted_custom.value");
})
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("percentages must total 100");
}

private static BaseFaker custom(Random random) {
return new BaseFaker(Locale.ENGLISH, random);
}
}
Loading
Loading