Schema and Provider Hub
Schema and Provider Hub
Schema DSL (Field + Schema)
Field f = Generators.ofField(Locale.US);
Map<String, SchemaValueProvider> fields = new LinkedHashMap<>();
fields.put("name", f.bind("person.full_name"));
fields.put("email", f.bind("person.email"));
fields.put("tags", f.list(f.bind("text.word"), 2, 4));
fields.put("address", f.nested(Map.of(
"city", f.bind("address.city"),
"country", f.bind("address.country")
)));
Schema schema = Generators.ofSchema(Locale.US, fields);
List<Map<String, Object>> batch = schema.generateBatch(10);
String jsonl = schema.toJsonLines(10);
String json = schema.toJson(10);
String csv = schema.toCsv(10);
String xml = schema.toXml(10);
String sql = schema.toSqlInserts("public.orders", 10);
String yaml = schema.toYaml(10);
String toml = schema.toToml(10);
Field.bind(...) covers both scalar tokens and composite provider payloads. Composite records are
kept as nested JSON objects in JSONL, JSON, YAML, TOML, and JSON Schema, and as JSON cell/literal
values in CSV, XML, and SQL exports:
Field field = Generators.ofField(Locale.US);
Map<String, SchemaValueProvider> fields = new LinkedHashMap<>();
fields.put("order", field.bind("commerce.order_info"));
fields.put("company", field.bind("company.info"));
fields.put("payment", field.bind("finance.payment_info"));
Schema schema = Generators.ofSchema(Locale.US, fields);
String jsonl = schema.toJsonLines(1);
String json = schema.toJson(1);
String csv = schema.toCsv(1);
String xml = schema.toXml(1);
String sql = schema.toSqlInserts("public.orders", 1);
String yaml = schema.toYaml(1);
String toml = schema.toToml(1);
Map<String, Object> jsonSchema = schema.toJsonSchema();
Schema.toJsonSchema() is metadata-driven and does not call providers. This means exporting a
JSON Schema will not advance seeded generator sequences. Built-in Field.bind(...), Field.list(...),
Field.nested(...), constants, and templates carry JSON Schema metadata automatically. For custom
lambda providers, the default JSON Schema fragment is unconstrained ({}) unless you attach metadata:
SchemaValueProvider typedAmount = SchemaValueProvider.withSample(
ctx -> 1000 + ctx.recordIndex(),
1000
);
SchemaValueProvider typedPayload = SchemaValueProvider.withJsonSchema(
ctx -> Map.of("status", "READY"),
Map.of(
"type", "object",
"additionalProperties", false,
"properties", Map.of("status", Map.of("type", "string")),
"required", List.of("status")
)
);
JSON Schema import subset
SchemaParser.fromJsonSchema(...) implements a deliberately small JSON Schema/OpenAPI subset. It
is a fixture-schema importer, not a general JSON Schema validator or reference resolver:
| Construct | Supported behavior |
|---|---|
| Root | An object with a non-empty properties map is required. |
| Types | string, integer, number, boolean, array, object, and null; a type list may contain exactly one concrete type plus null. |
| Scalars | const, a non-empty enum, numeric bounds, string length bounds, and a pattern in the syntax RegexGenerator supports. |
| Strings | email/idn-email, uri/url, uuid, date, date-time, time, ipv4, ipv6, and hostname receive dedicated generation. An unknown string format deliberately uses semantic-name or bounded-string fallback rather than pretending to implement it. A schema with no type generates a string under the same rules. |
| Structures | items and minItems/maxItems for arrays; nested properties for objects. An array without items produces strings. |
| OpenAPI controls | OpenAPI 3.0 nullable, plus $schema, required, and additionalProperties emitted by toJsonSchema(). The latter three are accepted as document controls rather than generation constraints. |
Constraints apply together, and a keyword constrains only values of its own JSON type:
- A
constmust meet the other constraints, and anenumkeeps only the values that do, soenum[a, bbbb]withmaxLength1 generatesa.formatis not checked on these values, because it is an annotation in Draft 2020-12. Apatternis checked withjava.util.regexand searches the string, as in JSON Schema. - Numeric bounds intersect, so
minimum100 withexclusiveMinimum0 starts at 100. OpenAPI 3.0’s booleanexclusiveMinimumandexclusiveMaximumwork as flags on the inclusive bound. Anintegerrounds a fractional bound inward and stays within the range of along. - A
patternorformatvalue is drawn again until it fitsminLengthandmaxLength, up to 10,000 times each. Length is counted in characters, not UTF-16 units. - A value generated from a
patternmatches the whole pattern, which also satisfies the search. ApatternthatRegexGeneratorcannot generate from, such as one with a lookahead, is rejected with its schema path. Aconstorenumbeside it is only checked against it, so it can use any Java regex.
A combination that leaves no value is rejected with its schema path, such as
$.properties.parent.properties.code. That includes a nested items or property schema, even where
an empty array or an object without that property would be valid, because the importer resolves every
schema it is given. Pattern and format lengths are judged by sampling 1,000 values while parsing,
rather than by analysing the pattern. A combination that rarely fits can be rejected too, and one
that passes parsing can still fail on a later record. A pattern’s *, +, and {n,} repeat up to
twice minLength when that is more than their usual 10 times, up to 10,000, so ^[a-z]+$ with
minLength 20 generates 20 to 40 characters.
$ref, dynamic/recursive references, allOf, anyOf, oneOf, not, conditional schemas,
and pattern/dependent-property composition are unsupported. They fail during parsing with the nested
schema path. Malformed type, format, and enum values also fail during parsing with that path;
they never silently become an unconstrained field. A Java map that recursively contains itself is
rejected for the same reason: the importer has no document-ID or reference-resolution model.
Schema export is metadata-driven and side-effect free. Built-in Field providers carry an exact
fragment; a custom lambda with no metadata deliberately exports {} to mean unknown, not an
invented type. Attach SchemaValueProvider.withJsonSchema(...) or withSample(...) when a custom
provider can make a narrower claim. Schema, SchemaProjection, and parser-created generators are
stateful: use one instance per thread (or Generators.threadLocal(...)) and share only immutable
configuration and completed provider registrations.
Use the streaming writer methods when you want payload output without materializing the whole batch first:
StringBuilder out = new StringBuilder();
schema.writeJsonLines(out, 1000);
schema.writeJson(out, 1000);
schema.writeCsv(out, 1000);
schema.writeXml(out, 1000);
schema.writeSqlInserts(out, "public.orders", 1000);
schema.writeYaml(out, 1000);
schema.writeToml(out, 1000);
For stream-based output, writeTo(OutputStream, OutputFormat, count) writes UTF-8 and flushes its adapter writer without closing the caller-owned stream. The Writer overload also leaves ownership with the caller.
JSON is an array document; JSONL stays one object per line. YAML renders an ordinary sequence
and TOML renders an array of records tables. TOML has no null value, so its writer rejects a
null field instead of silently changing data.
Projecting existing objects
SchemaProjection<T> applies named extractors to objects you already have. It consumes an
Iterable once and writes incrementally, so it does not require a duplicate map batch:
record Order(String id, int quantity) {}
Map<String, Function<Order, ?>> columns = new LinkedHashMap<>();
columns.put("id", Order::id);
columns.put("quantity", Order::quantity);
SchemaProjection<Order> projection = SchemaProjection.of(columns);
List<Order> orders = List.of(new Order("ORD-1", 2));
String json = projection.toJson(orders);
String yaml = projection.toYaml(orders);
projection.writeTo(writer, OutputFormat.SQL, orders, "public.orders");
Projection supports the same JSONL, JSON, CSV, XML, SQL, YAML, and TOML formats as generated
schemas. Schema is still the right API when values must be generated from Field providers;
use SchemaProjection when the values already exist.
FieldLookup is now an extensible token registry rather than a fixed table, and Field exposes the same registration flow:
Field field = Generators.ofField(GeneratorConfig.builder().locale(Locale.US).seed(42L).build())
.register("custom.order_id", ctx -> "ORD-" + ctx.recordIndex())
.registerAlias("custom.order", "custom.order_id")
.registerProvider("text.word.provider",
WordGenerator::new,
WordGenerator.class,
WordGenerator::generateWord);
SchemaValueProvider orderId = field.bind("custom.order");
SchemaValueProvider extraWord = field.bind("text.word.provider");
The same token registry now powers declarative templates:
Field field = Generators.ofField(Locale.US)
.register("custom.order_id", ctx -> 1000 + ctx.recordIndex())
.registerAlias("custom.order", "custom.order_id");
SchemaValueProvider subject = field.template("Order for (??-##)");
SchemaValueProvider payload = field.template(Map.of(
"orderId", "",
"customer", "",
"email", "",
"reference", "ORD-##",
"tags", List.of("", "")
));
String templates always render to strings. Payload templates recurse through maps, lists, and arrays; strings that contain only a single `` resolve to the raw generated value, so numbers and nested objects stay typed inside the generated payload.
Provider hub
ProviderHub is a generic registry with aliases, locale propagation, runtime extensibility, and a broader dotted taxonomy.
ProviderHub hub = Generators.ofProviderHub(Locale.US);
FullNameGenerator person = hub.get("person.full_name", FullNameGenerator.class);
EmailGenerator email = hub.get("person.email", EmailGenerator.class);
ContactInfoGenerator contacts = hub.get("person.contact_info", ContactInfoGenerator.class);
ProductInfoGenerator products = hub.get("commerce.product_info", ProductInfoGenerator.class);
OrderInfoGenerator orders = hub.get("commerce.order_info", OrderInfoGenerator.class);
ShipmentInfoGenerator shipments = hub.get("commerce.shipment_info", ShipmentInfoGenerator.class);
BankInfoGenerator banks = hub.get("finance.bank_info", BankInfoGenerator.class);
InvoiceInfoGenerator invoices = hub.get("finance.invoice_info", InvoiceInfoGenerator.class);
PaymentInfoGenerator payments = hub.get("finance.payment_info", PaymentInfoGenerator.class);
CompanyInfoGenerator companies = hub.get("company.info", CompanyInfoGenerator.class);
CityGenerator city = hub.get("address.city", CityGenerator.class);
URLGenerator internet = hub.get("url", URLGenerator.class); // alias for internet.url
TextFormatProvider format = hub.get("text.format", TextFormatProvider.class);
hub.register("custom-product-code", cfg -> new IdentifierMaskGenerator(cfg));
hub.registerAlias("sku", "custom-product-code");
String sku = format.template("SKU-??-####");
String coupon = format.lexify("promo-????");
String token = format.asciify("***-***");
String reference = format.regexify("[A-Z]{3}\\d{4}");
String invoice = format.examplify("INV-2026-AB12");
The old coarse names like person, address, internet, finance, datetime, text, and code still work, but the preferred naming style is dotted and explicit:
person.full_name,person.first_name,person.last_name,person.email,person.usernameperson.contact_info,person.person_info,person.job_infocompany.name,company.email,company.url,company.buzzword,company.catch_phrase,company.industry,company.infoaddress.address_infocommerce.product_infoaddress.street_address,address.city,address.state,address.postal_code,address.country,address.phone_numberinternet.url,internet.domain,internet.hostnamefinance.money,finance.currency,finance.bank_info,finance.credit_card_infodatetime.date,datetime.timetext.word,text.sentence,text.paragraph,text.formatcode.uuid
ProviderHub also exposes uniqueness helpers directly, so provider-based generation can stay on the same surface:
UniqueGenerator<UUID> uniqueIds = hub.unique(new UUIDGenerator());
UUID first = uniqueIds.generate();
UUID second = uniqueIds.generate();
Conflict policies
ConflictPolicy.FAIL(default): reject duplicate registration.ConflictPolicy.REPLACE: override an existing provider or alias.