java.lang.Object
io.github.brantunger.unruly.api.language.FactProperties

public final class FactProperties extends Object
Reads a property of a fact the way rules expect: a record component, a JavaBean getter or a Map key.

Rules everywhere are written applicant.creditScore, where the fact may be a record, a bean or a map. The engine hands a language the fact objects as they are, so making that work is each language's job, and every language that does it by hand does it slightly differently: one reads a record's component as a method and silently evaluates the condition to false, another fails only for records, a third converts every fact to a map on every evaluation. A language may use this instead.

Writing a property is the other direction, and the engine already has it: OutputWriter.beansAndMaps() puts into a map or calls a bean setter. There is deliberately no write here, so there's only one implementation to keep correct. Records can't be written to at all.

Accessors are looked up once for each class and shared by every thread.

  • Method Details

    • read

      public static @Nullable Object read(Object target, String property)
      Reads a property of a fact.
      • A Map: the value under that exact key. A key that isn't there is an error, and a key whose value is null isn't. A map whose keys aren't strings has no property a rule can name, even though toData(java.lang.Object, int) gives those keys a string form.
      • A record: the component of that name, or one of its own getters when no component matches.
      • Anything else: the public no-argument getProperty(), or isProperty() when it returns a boolean or a Boolean. The getter's first letter is lower-cased by Character.toLowerCase(char), unless its second letter is upper case: getURL() is read as URL, and getİsim() as isim. Where two getters are read as one property, a getProperty() beats an isProperty(), and between two of the same kind the one whose name is greatest in String.compareTo(java.lang.String) order wins, so getİsim() beats getIsim(). Only the tie between two getProperty() is broken as java.beans breaks it; the other two rules are this class's own. The choice never depends on the order reflection reports methods in. Public fields aren't read. A getter whose name after get or is has a letter above U+00FF where its case decides the property's name, such as the İ of getİsim(), is read with the JDK's table of the first plane, java.lang.CharacterData00, which the JVM builds on its first use, and the first read of any property of a class reads all its getters, maybe deep in a stack with no engine built. So until the table is known to be built, by such a read, by a bean write through OutputWriter.beansAndMaps() or by the JVM's first engine build, a read of such a getter first checks that the stack has room for building it, as the engine's first build does, and throws StackOverflowError if it hasn't, before the table is touched, rather than leave it unusable for the life of the JVM (#1297). After that, no read checks.

      A fact whose own class isn't public is read through a public type above it that declares the accessor, which is what makes a fact from a factory, or an anonymous implementation of a public interface, readable. When nothing public declares it, the accessor is called directly if the class's package is open to this module: every package on the class path is, and on the module path the application opens it with opens. Exporting the package isn't enough, because calling a method of a class that isn't public is deep reflection.

      Rules can't read their way into the JVM through a fact. An enum's getDeclaringClass() isn't a property, as getClass() isn't one. And a Class, a ClassLoader, a Module, a ModuleLayer, a Package, a ProtectionDomain, a CodeSource, a Thread, an application's own subclass of one of them, or an object of a class in java.lang.reflect, such as a Method, has no properties here at all, so toData(java.lang.Object, int) leaves one as it is too. A fact's own property may still return one of them as its value; only reading a property of that value is refused.

      Parameters:
      target - The fact, which must not be null
      property - The property's name, as the rule wrote it
      Returns:
      The property's value, which may be null
      Throws:
      NullPointerException - if target or property is null
      IllegalArgumentException - if the fact has no such property, or is one of the JVM's own objects whose properties aren't read. A language should let this reach the engine, which fails the rule with it: a missing property is a mistake in the rule, and evaluating it to false or to undefined hides it
      IllegalStateException - if the property exists but can't be read, because nothing public declares its accessor and its package isn't open to this module; or if the accessor threw, with what it threw as the cause. The message, such as Reading 'price' on a com.example.Item failed: price service down, ends with a colon and the message of what the accessor threw, whole, however long, for the engine to shorten when it logs it. That is left out when what it threw has no message or reading it throws, and when it's a failure the engine has already logged and names in the rule's failure as a nested run() failed: and its text, or as its text and (already logged) when no run the code around the read started logged it: a nested run's failure, whichever thread the read is on, or a fatal Error a run the code around the read started logged, in either case with nothing of its own around it. The cause still holds it. What a getter throws is always wrapped, so that one throwing IllegalArgumentException isn't read as a missing property. A map's own containsKey and get are its accessors, and an exception from them is wrapped too, with one exception: a ClassCastException or NullPointerException from containsKey is taken for the map refusing a key of that type, as a sorted map refuses a string key, and reported as no such property, though a map that fails that way for a reason of its own reads the same. Once containsKey has found the key, whatever get throws is the map's own failure. When the accessor was interrupted, the thread is left interrupted, unless the getter threw a VirtualMachineError, so a caller that catches this doesn't lose the interrupt
    • has

      public static boolean has(Object target, String property)
      Says whether a fact has a property, without reading it: whether read(java.lang.Object, java.lang.String) would find the property rather than throw IllegalArgumentException. No getter is called, and of a map only its own containsKey, so a getter that is slow, has side effects or throws doesn't change the answer, which is what a language needs for its own "is it there" questions, such as JavaScript's in or Python's hasattr.

      A map has the property when its containsKey says so, a record or a bean when it has an accessor of that name, by the same rules as read(java.lang.Object, java.lang.String), a fact whose class isn't public included. A property whose accessor throws, or can't be called, is still there: read(java.lang.Object, java.lang.String) fails for it with IllegalStateException, not as a missing property. A String, a number or a collection has the properties its public getters give it, as read(java.lang.Object, java.lang.String) reads them, such as a string's empty; one of the JVM's own objects whose properties read(java.lang.Object, java.lang.String) refuses has none.

      Parameters:
      target - The fact, which must not be null
      property - The property's name, as the rule wrote it
      Returns:
      true if read(java.lang.Object, java.lang.String) would read the property, false if it would report it missing
      Throws:
      NullPointerException - if target or property is null
      IllegalStateException - if target is a map whose containsKey throws, with what it threw as the cause, as read(java.lang.Object, java.lang.String) wraps it. A ClassCastException or NullPointerException from it is taken for the map refusing a key of that type, as read(java.lang.Object, java.lang.String) takes it, and answers false. When containsKey was interrupted, the thread is left interrupted, as read(java.lang.Object, java.lang.String) leaves it
    • propertyNames

      public static Set<String> propertyNames(Object target)
      Lists the properties read(java.lang.Object, java.lang.String) can read on a fact, without reading any of them, for a language that enumerates an object's properties, such as JavaScript's Object.keys or Python's dir. No getter is called, and of a map only its own keySet, through its forEach, so a synchronized map lists its keys under its lock.

      A map's properties are its String keys, in its iteration order. A key of another type, or null, isn't one read(java.lang.Object, java.lang.String) can reach, so it's left out, though toData(java.lang.Object, int) gives it a string form. A record's are its components in the order it declares them, then any other getters it has, and a bean's are sorted by name: the names, in the order, of toData(target, 1).keySet(), with no getter called.

      Unlike toData(java.lang.Object, int), which takes apart only a record, a bean or a map, this answers for any fact read(java.lang.Object, java.lang.String) reads: a String, a number or a collection has the properties its public getters give it, such as a string's empty. One of the JVM's own objects whose properties read(java.lang.Object, java.lang.String) refuses has none. has(java.lang.Object, java.lang.String) is true for every name listed; a map whose containsKey finds keys it doesn't list, such as one that ignores case, has more.

      Parameters:
      target - The fact, which must not be null
      Returns:
      The names, unmodifiable, in the same order every time for the same class, or for a map, in its iteration order
      Throws:
      NullPointerException - if target is null
      IllegalStateException - if target is a map whose keySet or its forEach throws, with what it threw as the cause, as Reading the keys of a ... failed. When it was interrupted, the thread is left interrupted, as read(java.lang.Object, java.lang.String) leaves it
    • toData

      public static Map<String,@Nullable Object> toData(Object target, int depth)
      Converts a record, a bean or a map into a map of its properties, for a language that reads only maps.

      Every step down costs one level, whether it's into a property, into a map's value or into a collection's element. At 1 the values are whatever the properties hold; at 2 a value that is itself convertible becomes a map, and a list becomes a list of the elements as they are; at 3 the elements of that list are converted too.

      A value already being converted higher up the same path is left as it is rather than converted again, so a fact that holds itself, or two facts that hold each other, cost no more than a tree of the same depth. That stops a value repeating down one path; it doesn't make every graph cheap. A value reachable by several different paths is converted once for each of them, so an object graph that fans out and rejoins can still cost far more than it holds. depth is what bounds that, and it's chosen by the caller, not by the facts: convert as few levels as the language needs.

      A map's values keep their keys, with a key that isn't a String converted by String.valueOf(java.lang.Object). Two keys that read the same then collapse into one entry and the later wins, and a null key becomes the entry "null", which a real "null" key can't be told apart from. read(java.lang.Object, java.lang.String) takes the key as it is, so a converted map can show a property that reading the fact directly can't reach. A map's entries are copied through its entry set's forEach, so a synchronized map's are copied under its lock, each entry's getKey() and getValue() and each key's toString() included. Its values are converted after the lock is released, so their getters don't run under it.

      A value is converted when it's a map, a collection, an array of objects, a record, or an application class with at least one getter. Everything the JVM itself provides is left as it is, which is what keeps a String, a Path, a LocalDate, an Optional, an enum, a lambda or an array of primitives from being taken apart into the properties of its implementation. A proxy over an interface is data, because that's a shape facts take: a projection, a lazy association, a test double. A lambda or an anonymous class is converted when it's the target, because then the caller asked for its properties, and left alone when it's met as a value. An object whose properties read(java.lang.Object, java.lang.String) doesn't read, such as an application's own class loader or thread, is left as it is too, and can't be the target.

      The rule is "has getters", so it can't tell data from a library type that merely looks like it. A JSON tree node or a multimap from a third-party library has isEmpty(), isArray() and the like, so converting a fact that holds one returns those flags instead of its contents. Read such a fact with read(java.lang.Object, java.lang.String), or have the language convert it itself.

      Parameters:
      target - The record, bean or map to convert, which must not be null
      depth - How many levels to convert, from 1 to 20
      Returns:
      The properties by name: a record's components in the order it declares them, then any other getters it has, and a bean's sorted by name, because reflection reports a class's methods in no particular order. A new modifiable map
      Throws:
      NullPointerException - if target is null
      IllegalArgumentException - if depth is outside 1 to 20, or target is a value this doesn't take apart, such as a String, a number or a collection
      IllegalStateException - if an accessor can't be called, with what it threw as the cause, and its message after a colon as read(java.lang.Object, java.lang.String) appends it. Reading a map's entries (its entrySet(), the entry set's forEach, each entry's getKey() and getValue() and each key's toString()) and a collection's size() and forEach count as accessors, so what they throw is wrapped too, as Reading the entries of a ... failed or Reading the elements of a ... failed. When an accessor was interrupted, the thread is left interrupted, as read(java.lang.Object, java.lang.String) leaves it, unless a getter threw a VirtualMachineError