Skip to content

Rules as expressions ​

A rule has two halves, and they extend differently:

  • What it tests is an expression, FormRule.when, an Expr<bool> of vyuh_expression. It is not a kind: there is no condition kind to register. An app extends what a rule can test with a fact.
  • What it does is a list of effects, and each effect is run by an effect kind registered in a FormKitExtension's ruleKinds and read through a kit's kit.rules.

The package README has the model in one place: Rules are expressions. The concepts page, Rules and formulas, has the operators, the three-valued reading and the engine.

The test ​

dart
final when = severity.isAnyOf(['major', 'critical']) & ~notes.isEmpty;            // the DSL
final text = expression(r'$qty * $unit_price > $limit', scope: scopeOf([...]));   // text
final nodes = ex.And([ex.Not(ex.IsNull(...)), ex.Comparison<ex.Rational>(...)]);   // nodes

All three are an Expr<bool>, saved with the rule as the vyuh.expression document under when. A test reads three-valued: an empty value makes a comparison unknown, an unknown test does not pass (also under not), and a rule applies only while its test is definitely true.

FormExpressions.of(definition, kit.rules) is the one owner of reading and evaluating a rule's test for a form:

MemberGives
scope, fieldTypes, labels, choicesOf(key)what a test may name, typed and labelled
parse(source)the text read as a true/false test, with diagnostics
evaluate(expr, values, {entry, zone, now, pins}), passes(...)true, false or unknown, the trace and the values refused
readsOf(expr)the field keys it reads, those of its facts included
issuesOf(expr)its design issues, each at a path
refusalsOf(values)the values not of their field's type, form level and per entry

Facts ​

A question only the app can answer, such as "equipment is in calibration", is a FormFact. It is a typed port: a name, a version, parameters with the types they take, the field keys it reads for given arguments, and a synchronous resolver over a snapshot of the values. It never calls a provider; anything asynchronous is resolved into the values first.

dart
import 'package:vyuh_expression/vyuh_expression.dart' as ex;

final inCalibration = FormFact(
  name: 'elog.in_calibration',
  version: '1',
  title: 'Equipment is in calibration',
  description: 'The equipment named by a field is in calibration.',
  parameters: [ex.ExprParameterSpec('field', const [ex.ValueType.text])],
  returnType: ex.ValueType.boolean, // the default
  reads: (arguments) => {arguments.single as String},
  resolve: (arguments, scope) =>
      scope.valueOf('${arguments.single}_calibrated') == true,
  renamed: (arguments, renames) => [renames.field(arguments.single as String)],
  check: (arguments) {
    // optional: throw an ex.ExprException to refuse these arguments
  },
);

A rule asks it with inCalibration.ask<bool>(['press']), an ex.HostFact node that joins any other test: inCalibration.ask<bool>(['press']) & (qty > 0). A text expression cannot name a fact (it has no text form), so a rule editor offers facts as pickers (kit.rules.facts.values: title, description, labelled parameters, return type).

What the engine does with a fact:

  • Once per scope. It resolves each distinct (name, arguments) once per scope in a pass (the form's, or one repeating group entry's), and the rules of that scope share the answer. A rule or formula that writes a field the fact reads makes it be asked again by the next rule that needs it.
  • Fails closed. A fact whose port nobody registered, whose arguments or version the port refuses, whose resolver throws, or whose answer is not of the returnType is not pinned, so it is unknown and a gate that depends on it does not pass, also under not.
  • Reads decide order. reads is how the rule plan puts a rule after the rule that sets the field its fact reads.
  • Checked as a design issue. An unregistered fact is a warning, a refused one an error, and so is a fact reading a field the form lacks.

Facts are registered in the extension, with a name unique across extensions:

dart
final quality = FormKitExtension(
  id: 'elog.quality',
  facts: [inCalibration],
  ruleKinds: [flag], // effect kinds
);

kit.rules.facts lists them by port name; kit.rules.factPorts is the FactPortRegistry that checks a fact node.

The effect kinds ​

dart
abstract class EffectKind<E extends FormEffect> {
  String get typeId;  String get title;  String get category;
  EffectTargetKind get targetKind;                  // node ids or field keys
  bool canTarget(FormNode node, FormFieldDefinition? field);
  E create(String target);  E retarget(E effect, String target);
  Set<String> targets(E effect);
  void apply(E effect, EffectSink sink);            // called whether the rule holds or not
  Set<String> reads(E effect);  Set<String> writes(E effect);
  // ... settings, encode, decode, designIssue, renamed
}

The built-ins are ShowKind, HideKind, EnableKind, DisableKind, RequireKind, SetValueKind and ApplyValidationKind, registered the same way an app's kinds are. An effect talks to an EffectSink: show, hide, enable, disable, require, setValue and applyValidation, each meaning "while this rule holds", with sink.holds saying whether it does. The engine calls every effect whether its rule holds or not, so a show can make its node hidden by default.

This mirrors custom validators: an app effect saves its settings in a CustomEffect<E>, which share one settings model with validators: the settings type implements KindConfig, each editable value is a CustomKindSetting with a SettingEditor, and settings saved without the kind are kept as an UnresolvedConfig.

An app effect ​

CustomEffectKind<E> names what it targets and what it tells the sink:

dart
final class Flag implements KindConfig {
  const Flag(this.nodeId, this.code);
  final String nodeId;
  final String code;
}

final flag = CustomEffectKind<Flag>(
  typeId: 'qa.flag',
  title: 'Flag for review',
  category: 'Quality',
  targetKind: EffectTargetKind.node,
  defaultConfig: const Flag('', 'QA-1'),
  canTarget: (node, field) => node is FieldSlot,
  targetOf: (config) => config.nodeId,
  withTarget: (config, target) => Flag(target, config.code),
  apply: (config, sink) => sink.disable(config.nodeId),
  encodeConfig: (config) => {'nodeId': config.nodeId, 'code': config.code},
  decodeConfig: (json, version) =>
      Flag(json['nodeId']! as String, json['code']! as String),
);

An effect's settings may also name fields (reads, writes) so the plan orders it, and validations (validations) so removing one is refused.

Registering them ​

Register the facts and effect kinds once, in the app's extension, and pass the kit built from it everywhere the form is edited, filled or saved. Keep the kit: the form's rule plan and expressions are built once per definition and kit, so a new kit on every build rebuilds them each time.

dart
final kit = FormKit([FormKitExtension.standard, quality]); // built once, not in build()

FormKitEditorController(
  definition,
  kit: FormKit([FormEditorStandard.extension, quality]),
);
FormView(form: FormKitForm(definition, kit: kit, zone: const DeviceZone()));
kit.decode(json);
FormRuleEngine.evaluate(definition, values, kit: kit, zone: tenantZone);

The designer lists the kit's effects in the action dropdown, in the kit's order, and edits their settings with the validator setting editors. Its facts are the pickers of the rule editor. The Definition JSON reads and writes through the controller's kit.

Every effect has an id, unique within its rule and saved with it. An effect built without one is given the next free e<n> when its rule is built, and keeps it through every edit; the designer finds an effect's held draft by it. (A test has no id: a node of it is addressed by a path, through the operands of an and or or and index 0 through a not.)

Every effect is saved as its id, its kind's type id under type and its kind's own settings, and read back by the kind the kit registers for that type id: the built-ins and an app's, through one path. An effect whose type id the kit doesn't register is kept as a CustomEffect whose settings are an UnresolvedConfig: it saves back byte for byte, does nothing, and is reported as a design issue. Settings of the app's own type can only be saved by their kind, so saving them where the kind isn't registered throws rather than losing them: kit.rules.encodeEffect a FormatException, and kit.encode a KitCodecException at the rule (rules[0]). Loaded again with the kind, its settings are decoded at the version they were saved with.

A rule's test is kept the same way: a node of a kind from an extension (a namespaced kind such as acme.x) decodes and saves back unchanged, but evaluates to unknown, so the rule never holds. The design check reports it as an error until the node is removed.