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 const must meet the other constraints, and an enum keeps only the values that do, so enum [a, bbbb] with maxLength 1 generates a. format is not checked on these values, because it is an annotation in Draft 2020-12. A pattern is checked with java.util.regex and searches the string, as in JSON Schema.
  • Numeric bounds intersect, so minimum 100 with exclusiveMinimum 0 starts at 100. OpenAPI 3.0’s boolean exclusiveMinimum and exclusiveMaximum work as flags on the inclusive bound. An integer rounds a fractional bound inward and stays within the range of a long.
  • A pattern or format value is drawn again until it fits minLength and maxLength, up to 10,000 times each. Length is counted in characters, not UTF-16 units.
  • A value generated from a pattern matches the whole pattern, which also satisfies the search. A pattern that RegexGenerator cannot generate from, such as one with a lookahead, is rejected with its schema path. A const or enum beside 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.username
  • person.contact_info, person.person_info, person.job_info
  • company.name, company.email, company.url, company.buzzword, company.catch_phrase, company.industry, company.info
  • address.address_info
  • commerce.product_info
  • address.street_address, address.city, address.state, address.postal_code, address.country, address.phone_number
  • internet.url, internet.domain, internet.hostname
  • finance.money, finance.currency, finance.bank_info, finance.credit_card_info
  • datetime.date, datetime.time
  • text.word, text.sentence, text.paragraph, text.format
  • code.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.