///|
/// A plan owns a deep snapshot of its validated model.
pub struct Plan {
model : Model
context : Context
entity_order : Array[Int]
field_orders : Array[Array[Int]]
} derive(Debug)
///|
pub fn Plan::entity_names(self : Plan) -> Array[String] {
self.entity_order.map(i => self.model.entities[i].name)
}
///|
fn ordered_nodes(
names : Array[String],
dependencies : Array[Array[String]],
path : String,
) -> Result[Array[Int], Array[Issue]] {
let order : Array[Int] = []
let visited : Map[String, Bool] = Map([])
while order.length() < names.length() {
let before = order.length()
for i in 0.. visited.contains(name)) {
order.push(i)
visited[names[i]] = true
}
}
if before == order.length() {
let blocked = names.filter(name => !visited.contains(name))
return Err([
issue(
"dependency_cycle",
path,
"Unresolved cyclic dependencies: " + blocked.join(", "),
),
])
}
}
Ok(order)
}
///|
fn check_generator(generator : Generator, path : String) -> Array[Issue] {
let errors : Array[Issue] = []
match generator {
IntegerRange(min, max) | DateOffset(min, max) => {
let width = max.to_int64() - min.to_int64() + 1L
if width <= 0L || width > 2147483647L {
errors.push(
issue(
"invalid_range", path, "Range must contain 1..2147483647 values",
),
)
}
}
BooleanChance(chance) =>
if chance < 0 || chance > 1000 {
errors.push(
issue("invalid_probability", path, "Probability must be in 0..1000"),
)
}
Choice(values) => {
if values.is_empty() {
errors.push(
issue("empty_choice", path, "Choice requires at least one value"),
)
}
if values.any(v => v == Null) {
errors.push(
issue(
"null_choice", path, "Use null_per_mille to express nullable values",
),
)
}
}
WeightedChoice(values) => {
let mut total = 0L
for (value, weight) in values {
if value == Null || weight <= 0 {
errors.push(
issue(
"invalid_weight", path, "Weighted values must be non-null with positive weights",
),
)
}
total += weight.to_int64()
}
if total <= 0L || total > 2147483647L {
errors.push(
issue(
"weight_total", path, "Total choice weight must be 1..2147483647",
),
)
}
}
Pattern(alphabet, length) =>
if alphabet.is_empty() || length < 0 || length > 65536 {
errors.push(
issue(
"invalid_pattern", path, "Pattern requires an alphabet and length in 0..65536",
),
)
}
Concat(names, _) =>
if names.is_empty() {
errors.push(issue("empty_concat", path, "Concat requires fields"))
}
_ => ()
}
errors
}
///|
/// Validate names, cardinalities, references and DAGs before generating rows.
pub fn compile(
model : Model,
context? : Context = Context::new(1U),
) -> Result[Plan, Array[Issue]] {
let errors : Array[Issue] = []
let limits = context.limits
let reference = match reference_date(context.reference_time) {
Ok(date) => date
Err(error) => return Err([error])
}
if limits.max_entities <= 0 ||
limits.max_fields <= 0 ||
limits.max_rows <= 0 ||
limits.max_cells <= 0 ||
limits.max_attempts <= 0 ||
limits.max_text_units <= 0 {
return Err([
issue("invalid_limits", "limits", "All limits must be positive"),
])
}
if !valid_name(model.name) {
errors.push(
issue(
"invalid_name", "model", "Expected 1..128 ASCII identifier characters",
),
)
}
if model.entities.length() > limits.max_entities {
return Err([issue("entity_limit", "entities", "Too many entities")])
}
let entities : Map[String, Entity] = Map([])
let mut total_rows = 0L
let mut total_cells = 0L
for entity in model.entities {
if !valid_name(entity.name) {
errors.push(issue("invalid_name", entity.name, "Invalid entity name"))
}
if entities.contains(entity.name) {
errors.push(
issue("duplicate_entity", entity.name, "Entity names must be unique"),
)
}
entities[entity.name] = entity
if entity.count < 0 {
errors.push(
issue("negative_count", entity.name, "Row count cannot be negative"),
)
}
total_rows += entity.count.max(0).to_int64()
total_cells += entity.count.max(0).to_int64() *
entity.fields.length().to_int64()
if entity.fields.length() > limits.max_fields {
errors.push(issue("field_limit", entity.name, "Too many fields"))
}
let fields : Map[String, Bool] = Map([])
let mut primary_count = 0
for field in entity.fields {
let path = entity.name + "." + field.name
if !valid_name(field.name) {
errors.push(issue("invalid_name", path, "Invalid field name"))
}
if fields.contains(field.name) {
errors.push(
issue("duplicate_field", path, "Field names must be unique"),
)
}
fields[field.name] = true
if field.primary {
primary_count += 1
}
if field.null_per_mille < 0 ||
field.null_per_mille > 1000 ||
(field.primary && field.null_per_mille != 0) {
errors.push(
issue(
"invalid_nullable", path, "Nullable probability must be 0..1000; primary keys cannot be nullable",
),
)
}
for error in check_generator(field.generator, path) {
errors.push(error)
}
if field.generator is DateOffset(min, max) {
if reference.add_days(min) is Err(_) ||
reference.add_days(max) is Err(_) {
errors.push(
issue("date_overflow", path, "Date offset exceeds years 1..9999"),
)
}
}
if field.generator is Sequence(start, step) && entity.count > 0 {
let end = start.to_int64() +
(entity.count - 1).to_int64() * step.to_int64()
if end < -2147483648L || end > 2147483647L {
errors.push(
issue("sequence_overflow", path, "Sequence exceeds Int32 bounds"),
)
}
}
}
if primary_count > 1 {
errors.push(
issue("multiple_primary", entity.name, "Use one primary key field"),
)
}
for field in entity.fields {
for dep in field.generator.field_dependencies() {
if !fields.contains(dep) {
errors.push(
issue(
"missing_field",
entity.name + "." + field.name,
"Unknown dependency: " + dep,
),
)
}
}
}
}
if total_rows > limits.max_rows.to_int64() {
errors.push(issue("row_limit", "entities", "Total rows exceed the limit"))
}
if total_cells > limits.max_cells.to_int64() {
errors.push(issue("cell_limit", "entities", "Total cells exceed the limit"))
}
for entity in model.entities {
for field in entity.fields {
let path = entity.name + "." + field.name
let reference = match field.generator {
Reference(target, key) => Some((target, key, None))
Lookup(_, target, key, value) => Some((target, key, Some(value)))
_ => None
}
if reference is Some((target, key, value)) {
match entities.get(target) {
None =>
errors.push(
issue("missing_entity", path, "Unknown entity: " + target),
)
Some(parent) => {
match parent.field(key) {
None =>
errors.push(
issue("missing_key", path, "Unknown reference key: " + key),
)
Some(key_field) => {
if !key_field.unique && !key_field.primary {
errors.push(
issue(
"nonunique_reference", path, "References require a unique key",
),
)
}
if key_field.null_per_mille != 0 {
errors.push(
issue(
"nullable_reference_key", path, "Reference key cannot be nullable",
),
)
}
}
}
if value is Some(name) && parent.field(name) is None {
errors.push(
issue("missing_lookup", path, "Unknown lookup field: " + name),
)
}
if parent.count == 0 &&
entity.count > 0 &&
field.null_per_mille < 1000 {
errors.push(
issue(
"empty_reference", path, "Nonempty child requires parent rows",
),
)
}
}
}
}
}
}
if !errors.is_empty() {
return Err(errors)
}
let names = model.entities.map(e => e.name)
let deps = model.entities.map(e => {
let result : Array[String] = []
for field in e.fields {
if field.generator.entity_dependency() is Some(name) {
result.push(name)
}
}
result
})
let entity_order = match ordered_nodes(names, deps, "entities") {
Ok(order) => order
Err(errors) => return Err(errors)
}
let field_orders : Array[Array[Int]] = []
for entity in model.entities {
match
ordered_nodes(
entity.fields.map(f => f.name),
entity.fields.map(f => f.generator.field_dependencies()),
entity.name,
) {
Ok(order) => field_orders.push(order)
Err(errors) => return Err(errors)
}
}
Ok({ model: model.snapshot(), context, entity_order, field_orders, })
}
///|
pub extend Plan with @moonbitlang/core/debug.Debug::{to_repr}