Class RulesEngineBuilder<O>

java.lang.Object
io.github.brantunger.unruly.api.RulesEngineBuilder<O>
Type Parameters:
O - The type of the output object

public final class RulesEngineBuilder<O> extends Object
Configures and builds a RulesEngine. An engine's languages, imports, listeners, limit on compiled copies, copies made at load, run timeout and clock are set here and can't change once it's built; only its rules can, with RulesEngine.load(List).

Compiled copies: a run uses a copy of the rules that no other run is using, and an engine keeps as many as the most runs it has had in progress at once. A thread pool bounds that; virtual threads don't, so by default an engine limits runs on virtual threads to one copy for every two processors. maxCopies(int) sets a limit for every kind of thread, and unlimitedCopies() turns it off, leaving only build slots to pace new copies on virtual threads. See Compiled copies.

RulesEngine<LoanDecision> engine = RulesEngineBuilder.firstMatch(LoanDecision::new)
        .imports("java.time")
        .listener(new LoggingRuleListener())
        .build();
engine.load(rules);

Languages: an engine built without language(ExpressionLanguage) has the languages found with ServiceLoader, such as MVEL from the unruly-engine artifact. An engine built with it has exactly the languages given. A rule whose language is null is written in the engine's default language: the one named with defaultLanguage(String), or else the engine's only language.

A builder isn't thread-safe. build() can be called more than once, and each call builds a new engine with the settings at that moment.

See Also:
  • Method Details

    • firstMatch

      public static <O> RulesEngineBuilder<O> firstMatch(Supplier<O> outputFactory)
      Starts building an engine that fires the action of the highest-priority rule whose condition is true: the first match, in DMN's terms. Rules with equal priorities keep their list order.

      Conditions are evaluated in that order, and the run stops at the first match, so the rules below it are never evaluated: they're neither matched nor unmatched, the run's result reports them as not evaluated, and a broken condition among them can't fail a run that's already decided. Use allMatches(Supplier) or uniqueMatch(Supplier) when every condition must be evaluated. A rule the run skips is never evaluated, and is reported as skipped wherever it is; see Rule.

      Type Parameters:
      O - The type of the output object
      Parameters:
      outputFactory - Creates the output object. It is called once per run that matches a rule and must return a new, non-null object each time.
      Returns:
      A new builder
      Throws:
      NullPointerException - if outputFactory is null
    • allMatches

      public static <O> RulesEngineBuilder<O> allMatches(Supplier<O> outputFactory)
      Starts building an engine that fires the action of every rule whose condition is true, in priority order, all changing the same output object: DMN's rule order. Every condition, except those of rules the run skips, is evaluated before any action runs.
      Type Parameters:
      O - The type of the output object
      Parameters:
      outputFactory - Creates the output object. It is called once per run that matches a rule and must return a new, non-null object each time.
      Returns:
      A new builder
      Throws:
      NullPointerException - if outputFactory is null
    • uniqueMatch

      public static <O> RulesEngineBuilder<O> uniqueMatch(Supplier<O> outputFactory)
      Starts building an engine that fires the action of the one rule whose condition is true, and fails the run when more than one is: DMN's unique match, for a decision table whose rows must not overlap. Every condition, except those of rules the run skips, is evaluated, in priority order, before anything fires, so the failure names every rule that matched.

      No match returns null, like the other engines. More than one match fires nothing, doesn't call the output supplier, and throws a RuleExecutionException that names each matched rule in priority order and belongs to no rule: listeners get afterEvaluate for every rule the run doesn't skip, then onRunError. It depends on nothing language-specific: it counts the conditions that were true.

      Type Parameters:
      O - The type of the output object
      Parameters:
      outputFactory - Creates the output object. It is called once per run that matches exactly one rule and must return a new, non-null object each time.
      Returns:
      A new builder
      Throws:
      NullPointerException - if outputFactory is null
    • language

      public RulesEngineBuilder<O> language(ExpressionLanguage language)
      Adds an expression language that rules can be written in, chosen by each rule's language. Once this is called, the engine has exactly the languages added, and finds none with ServiceLoader.

      The language's ExpressionLanguage.name() is read once, here, and the engine knows the language by that name from then on.

      Adding a language initializes no class of the engine's, nor, whatever its name's characters, one of the JDK's character tables, as build() first makes room on the stack for that (#1292). A language this rejects is the exception: rejecting it initializes the engine's small class that names the problem, before any room is made, so a rejected language added deep in a stack can still leave that class unusable for the life of the JVM. On Linux and macOS, so can the JVM's first read of a string with a character above U+00FF, which initializes java.lang.StringUTF16: a name with such a character may be that read, if the JVM hasn't read such a string already (#1295).

      Parameters:
      language - The language
      Returns:
      This builder
      Throws:
      IllegalArgumentException - if the language's name is null or blank, or a language with the same name was added already
      NullPointerException - if language is null
    • defaultLanguage

      public RulesEngineBuilder<O> defaultLanguage(String name)
      Names the language of the rules whose language is null. Fact names are checked against it too when the loaded rule list is empty. Without this, the default is the engine's only language.
      Parameters:
      name - The name of one of the engine's languages
      Returns:
      This builder
      Throws:
      NullPointerException - if name is null
    • imports

      public RulesEngineBuilder<O> imports(String... names)
      Adds imports that every language compiles rules with, so rule expressions can refer to classes by their simple names. Each string is a fully qualified package name ("java.util") or class name ("java.time.LocalDate", or "java.util.Map.Entry" for a nested class). A language without imports ignores them. Imports that aren't Java classes or packages, such as a JavaScript module, are given to their language alone, with languageImports(String, String...).

      Whether a string names a class is decided by build(), with the building thread's context class loader, or this library's class loader if the thread has none. A string must be a valid package name, each dot-separated part a Java identifier, even to name a class; one that loader doesn't find as a class is imported as a package. Classes in imported packages are looked up each time rules are loaded, with the loading thread's context class loader.

      An import may have at most 1,000 characters and at most 64 dot-separated parts. build() checks both, and the import's shape, before it looks the import up, and rejects one that fails any of them.

      Parameters:
      names - Package or class names
      Returns:
      This builder
      Throws:
      NullPointerException - if names or any element is null; nothing is added
    • imports

      public RulesEngineBuilder<O> imports(Collection<String> names)
      Adds imports, as imports(String...) does.
      Parameters:
      names - Package or class names
      Returns:
      This builder
      Throws:
      NullPointerException - if names or any element is null; nothing is added
    • languageImports

      public RulesEngineBuilder<O> languageImports(String language, String... names)
      Adds imports for one expression language only, which it reads from CompileContext.languageImports() when rules are loaded: as written, in order, duplicates included. The engine doesn't resolve them, and its other languages don't see them. What an import means, such as a module, a file or a namespace, is up to the language, which can reject one it can't use by throwing from newCompiler. The engine creates a language's compiler when a rule list given to RulesEngine.load(List) or RulesEngine.validate(List) has a rule in that language, and creates the default language's compiler for a list with no rules too. A throw from newCompiler then fails that rule list: load() throws, and validate() returns, a RuleCompilationException with the throw as its cause. A language no such rule list uses never has its imports checked. Adding imports for the same language again adds them after the others.

      An import may have at most 1,000 characters, which build() checks. build() also fails if language isn't one of the engine's languages, as it does for option(...). A call with no names still records the language, so build() fails for it all the same.

      Parameters:
      language - The name of one of the engine's languages
      names - The imports, as the language writes them, such as "lodash/fp"
      Returns:
      This builder
      Throws:
      NullPointerException - if language, names or any element is null; nothing is added
    • languageImports

      public RulesEngineBuilder<O> languageImports(String language, Collection<String> names)
      Adds imports for one expression language only, as languageImports(String, String...) does.
      Parameters:
      language - The name of one of the engine's languages
      names - The imports, as the language writes them
      Returns:
      This builder
      Throws:
      NullPointerException - if language, names or any element is null; nothing is added
    • listener

      public RulesEngineBuilder<O> listener(RuleListener listener)
      Adds a listener that is told about every condition evaluated and every action run.
      Parameters:
      listener - The listener
      Returns:
      This builder
      Throws:
      NullPointerException - if listener is null
    • listeners

      public RulesEngineBuilder<O> listeners(Collection<? extends RuleListener> listeners)
      Adds listeners, in the order given, as listener(RuleListener) does.
      Parameters:
      listeners - The listeners
      Returns:
      This builder
      Throws:
      NullPointerException - if listeners or any element is null; nothing is added
    • outputType

      public RulesEngineBuilder<O> outputType(Class<? super O> type)
      Tells expression languages the type of the output object, through CompileContext.outputType(). A language may use it, for example to check the properties its actions return; the engine doesn't. Without this, languages are told Object.
      Parameters:
      type - The output object's class, or a supertype of it, such as Map.class for a Map<String, Object> output
      Returns:
      This builder
      Throws:
      NullPointerException - if type is null
    • outputWriter

      public RulesEngineBuilder<O> outputWriter(OutputWriter<? super O> writer)
      Sets how the engine sets the properties an action returns with ActionResult.set(Map) on the output object. Without this, it's OutputWriter.beansAndMaps(). Actions that change the output themselves don't use it. The engine calls OutputWriter.set(Object, String, Object, OutputConverter), so a writer that overrides it can have the rule's language convert a value for the type it needs; one written as a lambda gets the value as the action returned it.
      Parameters:
      writer - The writer, which runs on many threads at once
      Returns:
      This builder
      Throws:
      NullPointerException - if writer is null
    • option

      public RulesEngineBuilder<O> option(String language, String key, String value)
      Sets an option for one expression language, which the language reads from CompileContext.options() when rules are loaded. What the options are is up to the language. Setting the same key again replaces its value.
      Parameters:
      language - The name of one of the engine's languages
      key - The option's name
      value - Its value
      Returns:
      This builder
      Throws:
      NullPointerException - if an argument is null
    • fact

      public RulesEngineBuilder<O> fact(String name, Class<?> type)
      Declares a fact's name and type, so the engine checks a run's value against the type and a language can check its expressions against it.

      The engine fails a run with IllegalArgumentException when the fact is present and its value isn't an instance of type, or, for a primitive type, its wrapper or a boxed primitive Java widens to it, as described below. A null value passes: nothing about it contradicts the declaration. So a fact declared with a primitive type can still be null: the engine doesn't turn it into the primitive's default, and OutputWriter.beansAndMaps() never passes it to a setter taking a primitive, so it fails with IllegalArgumentException where no other setter of the property takes null. What a language makes of it is up to the language; see Declaring facts. A run that doesn't supply the fact at all is unaffected, unless requireDeclaredFacts() is also set. Declaring a fact twice replaces the first declaration.

      A primitive type accepts its wrapper, and any boxed primitive that Java widens to it (JLS 5.1.2), lossy conversions included: fact("n", long.class) accepts an Integer as well as a Long. The engine converts such a value to the declared type's wrapper before the run starts, so listeners, from RuleListener.beforeRun(RunContext) on, and every language see a Long; the fact store keeps the Integer. Nothing is narrowed, a Boolean converts to nothing else, and a BigDecimal or BigInteger is never converted. A wrapper type converts nothing: fact("n", Long.class) rejects an Integer, as Java never converts one wrapper to another. Languages are told the wrapper, such as Long. RulesEngine.load(List) checks every declared name with the language of each rule, as a run checks the names it's given, so a name no language can refer to fails loading rather than every run.

      Languages are told what was declared, and use it as they can, for example to check their expressions when the rules load; the engine's own check happens whatever the language does. See each language's documentation.

      Declaring a fact initializes no class of the engine's, nor, whatever the name's characters, one of the JDK's character tables, as build() first makes room on the stack for that (#1292). A name this rejects is the exception: rejecting it initializes the engine's small class that names the problem, before any room is made, so a rejected name declared deep in a stack can still leave that class unusable for the life of the JVM. On Linux and macOS, so can the JVM's first read of a string with a character above U+00FF, which initializes java.lang.StringUTF16: a name with such a character may be that read, if the JVM hasn't read such a string already (#1295).

      Parameters:
      name - The fact's name, as rules refer to it
      type - The type a run's value must be an instance of, or, for a primitive type, be widened to. Declaring Object or a Map says the fact's shape isn't fixed, which no language can type-check
      Returns:
      This builder
      Throws:
      NullPointerException - if name or type is null
      IllegalArgumentException - if name is blank. A name one of the engine's languages reserves, such as "output", which actions use for the output object by default (see ExpressionLanguage.reservedFactNames()), is rejected by build(), once the languages are known; or, if the language reserves it only for the rule lists that use it (ExpressionLanguage.reservesForEveryRuleList()), by the load() of such a rule list, which validate() reports too
    • facts

      public RulesEngineBuilder<O> facts(Map<String,? extends Class<?>> types)
      Declares several facts at once, as fact(String, Class) does one.
      Parameters:
      types - The type of each fact, by name; copied, so later changes to the map don't change the engine
      Returns:
      This builder
      Throws:
      NullPointerException - if types, a name or a type is null; nothing is declared
      IllegalArgumentException - if a name is blank; nothing is declared. A name one of the engine's languages reserves is rejected by build(), or by load(), as for fact(String, Class)
    • requireDeclaredFacts

      public RulesEngineBuilder<O> requireDeclaredFacts()
      Rejects a run that supplies a fact nobody declared, or leaves a declared fact out. Without this, declaring a fact only says what its type is when it's there.

      It says that fact(String, Class) lists every fact a run may supply, which is what lets a language reject an expression that refers to anything else, when the language offers that and it's turned on: for MVEL, its strongTyping option, which also needs this. The engine's own checks on a run's facts don't depend on any language.

      Returns:
      This builder
    • maxCopies

      public RulesEngineBuilder<O> maxCopies(int maxCopies)
      Keeps at most maxCopies compiled copies of the rules, for runs on every kind of thread, instead of the default limit on runs from virtual threads.

      A run that starts while all of them are in use waits until one is free, so at most maxCopies runs make progress at once. A run started from inside another run on the same thread, and a run that has waited five seconds without one single copy being given back, don't wait: each gets an extra copy that isn't kept. A run that is interrupted while it waits, or that passes its deadline there, throws a RuleExecutionException; an interrupted thread keeps its interrupt status set. A rule list whose languages all return Session.none() needs no copies, and no limit applies to it.

      The limit bounds the copies of each rule list, and the runs holding a copy across reloads: a run still using rules that RulesEngine.load(List) replaced holds its copy under the same limit as runs on the new rules. The replaced rules also keep copies given back idle for their runs still waiting for one, at most one for each such run and never more than maxCopies, which hold no place under the limit. So for a short time after a reload the engine can have up to maxCopies more copies for each replaced rule list that still has runs waiting, until the last run on those rules has finished.

      Parameters:
      maxCopies - The most compiled copies of the rules to keep, and so the most runs making progress at once; at least 1
      Returns:
      This builder
      Throws:
      IllegalArgumentException - if maxCopies is less than 1
      See Also:
    • unlimitedCopies

      public RulesEngineBuilder<O> unlimitedCopies()
      Makes as many compiled copies of the rules as the runs in progress need, on any kind of thread, instead of the default limit on runs from virtual threads.

      Like an engine before 2.0, its runs never wait for a copy, which suits a thread pool, whose size bounds the copies. On a virtual thread, a run that finds no idle copy, while no run on that thread holds or is getting a copy, first waits for one of the engine's build slots, one for each processor, and holds it until the new copy's first run ends, which is when a language such as MVEL compiles the expressions. The slots pace the new copies without bounding them: the copies still grow with the runs in progress, so rules that wait — on I/O, a database or another service — can make many. In a language such as MVEL, each copy recompiles every expression and generates its own accessor classes. For a hard bound, use maxCopies(int) sized for the runs you want waiting at once; on JDK 21 to 23, with a language whose expressions contend on a lock the whole JVM shares, such as MVEL, below the number of carriers, since a limit at or above it can deadlock every carrier there, and so can this.

      A run with a deadline waits for a slot for at most half the time it has left, and a run that sees no slot given back for five seconds stops waiting; either then makes its copy without a slot, so no run fails for want of a copy. A run interrupted while it waits throws a RuleExecutionException, and its thread keeps its interrupt status set. Runs on platform threads never wait.

      Returns:
      This builder
      See Also:
    • copiesAtLoad

      public RulesEngineBuilder<O> copiesAtLoad(int count)
      Makes count compiled copies of the rules each time RulesEngine.load(List) runs, so the first runs borrow copies that are ready instead of making them. Without this, no copy is made until a run needs one.

      Making a copy compiles the rules again, in each language that keeps state between runs, and prepares its sessions with warmUp: a language such as MVEL compiles every condition and action into each session. load() makes the copies one after another on its own thread, after compiling the rules and before runs can see them, so runs go on using the rules it replaces meanwhile, and load() takes that much longer: Making copies at load gives a measurement. Rules whose languages all keep no state between runs get one set of sessions that every run shares, whatever count is.

      It's for runs on virtual threads on JDK 24 and later, where a run that makes a copy can keep its carrier while a language that loads classes as it compiles, such as MVEL, loads them. It doesn't move what a language does during a copy's first runs, such as MVEL generating accessor classes, and a copy made during a run, such as one for a run nested in another, is made as before. See Class loading pins carriers.

      Parameters:
      count - How many copies to make; zero, the default, makes none. With maxCopies(int), it can't be more than the limit, since no more copies than that are used at once. The default limit, on runs from virtual threads only, and unlimitedCopies() allow any number.
      Returns:
      This builder
      Throws:
      IllegalArgumentException - if count is negative; build() throws it if count is more than maxCopies(int)
    • runTimeout

      public RulesEngineBuilder<O> runTimeout(Duration timeout)
      Stops a run that is still going after timeout. Without this, a run has no deadline.

      The deadline is taken from when RulesEngine.run(FactStore) is called, and waiting for a compiled copy of the rules counts towards it. The engine checks it before each condition and each action, again when each one returns, and once the OutputWriter has set the properties an action returned, so a run whose last condition or action returns past its deadline fails even though that rule finished. It doesn't stop an expression that is already running, nor interrupt the thread; only a language that checks EvaluationContext.isCancelled(), or registers an action with EvaluationContext.onCancel(Runnable) that stops its runtime at the deadline, can stop inside one. A run past its deadline throws a RuleExecutionException caused by a TimeoutException.

      Parameters:
      timeout - How long a run may take; positive. RunOptions.withTimeoutOf(Duration) gives a single run one instead.
      Returns:
      This builder
      Throws:
      IllegalArgumentException - if timeout is zero or negative
      NullPointerException - if timeout is null
      See Also:
    • clock

      public RulesEngineBuilder<O> clock(Clock clock)
      Sets the clock a run reads, once when it starts, to decide which rules are within their validity window (Rule.getValidFrom(), Rule.getValidTo()). Without this, the engine uses Clock.systemUTC(). A test can give a fixed clock, such as Clock.fixed(Instant.parse("2027-06-01T00:00:00Z"), ZoneOffset.UTC), to run the rules as they're scheduled at that time.

      Only the validity window uses the clock. A run timeout is measured with a monotonic clock whatever clock is set here, so a step of the system clock doesn't move a running deadline. Anything the clock throws fails the run unchanged, before any listener is told the run started, and isn't logged; a null instant fails it the same way, with a NullPointerException.

      The instant a run read is its RunContext.startedAt(), which listeners get, and its RunResult.startedAt(), so an audit record can say what time judged the windows. Its conditions and actions read the same instant as EvaluationContext.startedAt(), so a condition that compares against the current time agrees with the windows, and with a fixed clock.

      Parameters:
      clock - The clock
      Returns:
      This builder
      Throws:
      NullPointerException - if clock is null
    • build

      public RulesEngine<O> build()
      Builds an engine with this builder's settings. Load its rules with RulesEngine.load(List) before the first run.
      Returns:
      A new engine
      Throws:
      IllegalStateException - if the engine has no expression language; if it has several and no default language; if the default language, a language given an option, or one given imports, isn't one of its languages; if a language found with ServiceLoader has a null or blank name, or two found languages have the same name; or if one of the engine's languages, given or found, returns null, or a set holding null, from ExpressionLanguage.reservedFactNames(). Anything ServiceLoader or a language throws while it's found, such as a ServiceConfigurationError, is thrown unchanged, and so is anything a language's reservedFactNames() or reservesForEveryRuleList() throws.
      IllegalArgumentException - if a fact is declared with a name one of the engine's languages, given or found, reserves for every rule list (see ExpressionLanguage.reservedFactNames() and ExpressionLanguage.reservesForEveryRuleList()); if a language's own import has more than 1,000 characters; if an import has more than 1,000 characters or more than 64 dot-separated parts, or has a dot-separated part that isn't a Java identifier ('...' is neither a class nor a valid package name, with no cause), all checked before any class lookup, so even a class of that name fails; if it names a class that exists but can't be loaded, for example because a class it depends on is missing; or if copiesAtLoad(int) is more than maxCopies(int)
      StackOverflowError - if this is the JVM's first engine and the calling thread has too little stack left to build it safely. The first engine initializes the classes engines use to build, load, validate, run and close, and the room for that is checked first, before any of them is touched and before any setting is checked, so a first engine built too deep with wrong settings throws this, not their exception; none is left unusable, and the next build() checks again. Thrown too, before any language is prepared, if a language this builder names, with defaultLanguage(String), language(io.github.brantunger.unruly.api.language.ExpressionLanguage) or settings for it, is of a class no engine has prepared yet (see ExpressionLanguage.prepare()) and too little stack is left to prepare it