Formulas and calculated attributes
An attribute becomes calculated when you give it an expression. The expression is the marker: the attribute keeps its declared value type, so a calculated integer sorts, renders, exports and freezes exactly like one a person typed. Nobody fills a calculated field in, and the platform works out for itself what the expression reads and when to recompute it.
This page covers how an expression is written and when its value is computed. Creating the attribute and assigning it to an item type is on the meta-model page.
Writing an expression
Section titled “Writing an expression”An expression reads the item it belongs to, and may reach other items through the relationships between them.
// The item's own fields, by attribute slug'severity' * 'occurrence'
// A field of this item, where the slug would not parse barethis['risk-class']
// Items reached across a relationship type, then a field of eachrelated('mitigated-by').pluck('status')
// A whole path, and an aggregate over what it reachestraverse('evaluated-by/can-arise-in').pluck('harm-severity').max()
// Enumeration values carry a rank, which is what scoring is built onenumNumericValue('severity')Functions and methods are listed in full in the reference below.
When the value is computed
Section titled “When the value is computed”Every calculated attribute has a kind, worked out when the configuration is verified rather than declared by the author, and the kind follows from what the expression reads:
| Kind | What the expression reads | When it is computed |
|---|---|---|
| Exact | Fields of the item itself | In the transaction that writes the item, so the value is never behind |
| Maintained | Items it reaches through relationships | On read, and cached until something it reads changes |
| On demand | A query across the project | When the value is asked for |
The platform tells you the kind as you write the expression, so a formula that would be expensive to maintain says so before you save it.
Two rules decide what a reader sees:
- A value is correct or absent. Any transaction that could falsify a cached value deletes it inside that same transaction, so a stale number is never served. An absent value is recomputed on the next read.
- A traversal reads each item as its own page shows it. An item that has left draft contributes its frozen value, a draft contributes its live one, so a roll-up never contradicts a signed child and never drops a draft.
Freezing at review
Section titled “Freezing at review”When an item leaves draft, its calculated values are frozen into that version, and the signature hash covers them. The number on a released item is therefore the number its reviewer saw, whatever has happened to its inputs since. Rejecting or revising the item thaws them, and the live expression takes over again.
When an expression fails
Section titled “When an expression fails”A calculated value that cannot be produced is absent rather than wrong. A missing input, a value a function cannot read, or a traversal that reaches nothing all leave the field empty, and the item is otherwise untouched.
Two failures are refused earlier, at verification, rather than producing an empty field:
- An expression that does not parse, or that names an attribute the subject does not carry.
- A circular dependency, where two expressions read each other.
A worked example
Section titled “A worked example”VitalPulse Pro’s risk type scores ISO 14971 risk twice, before and after its controls, and both are calculated:
severityandoccurrenceare enumerations whose values carry numeric ranks, soenumNumericValuecan resolve a rank from the label a person chose.risk-beforereaches the hazardous situation the risk evaluates, then the harms that can arise in it, and takes the highest harm severity:traverse('evaluated-by/can-arise-in')reads both hops in one call.- It adds the risk’s own occurrence rank and answers Acceptable below a fixed sum.
- Because the expression reads other items, its kind is maintained: the value is cached and the cache is dropped the moment a harm’s severity changes.
- When the risk is released, both values freeze into that version, and the risk matrix on the assembly’s page renders the frozen numbers for released risks and live ones for drafts.
Reference
Section titled “Reference”Every function and method the expression language offers. This reference is generated from the engine itself and regenerates whenever a function changes, so it cannot drift from what your project will actually evaluate.
Math
| Call | What it does |
|---|---|
abs(number) | The distance from zero, so a negative number comes back positive.abs(-3) -> 3 |
avg(numbers) | The mean of the numbers in one array. An array holding no numbers gives null rather than 0, which keeps an empty rollup distinguishable from a zero one.avg([1, 2, 3, 4]) -> 2.5 |
ceil(number) | The smallest whole number that is not below the argument, so rounding always goes up. A null argument gives null, and a non-numeric one is an error.ceil(3.1) -> 4 |
clamp(number, minimum, maximum) | Holds a number inside a range, returning the nearest bound when it falls outside. Any of the three arguments being non-numeric gives null.clamp(10, 0, 5) -> 5 |
exp(number) | e raised to the argument, the inverse of log.exp(0) -> 1 |
floor(number) | The largest whole number that is not above the argument, so rounding always goes down. A null argument gives null, and a non-numeric one is an error.floor(3.9) -> 3 |
log(number) | The natural logarithm, to base e. Use log10 for the base-10 logarithm.log(1) -> 0 |
log10(number) | The base-10 logarithm, which answers how many digits a magnitude spans.log10(1000) -> 3 |
max(number, ...) | The largest of the arguments, or of the single array given in their place. Non-numeric arguments are ignored, and the answer is null when none of them is a number.max(3, 1, 4) -> 4 |
median(numbers) | The middle value once the numbers are ordered, or the mean of the two middle values when the count is even. An array holding no numbers gives null.median([1, 2, 3, 4]) -> 2.5 |
min(number, ...) | The smallest of the arguments, or of the single array given in their place. Non-numeric arguments are ignored, and the answer is null when none of them is a number.min(3, 1, 4) -> 1 |
pow(base, exponent) | The base raised to the exponent. A non-numeric argument on either side gives null.pow(2, 3) -> 8 |
random() | A new value at or above 0 and below 1, taking no arguments. It answers differently on every evaluation, so a calculated attribute built on it is not reproducible.random() -> 0.4271 on one call, another value on the next |
round(number, digits?) | Rounds to the given number of decimal places, or to a whole number when digits is omitted.round(2.345, 1) -> 2.3 |
sign(number) | Which side of zero a number is on: -1 below, 1 above, 0 for zero itself.sign(-4) -> -1 |
sqrt(number) | The square root. A negative argument gives NaN rather than an error, so guard the input where a negative value is possible.sqrt(16) -> 4 |
stddev(numbers) | The population standard deviation of the numbers in one array, dividing by the count rather than by the count less one. An array holding no numbers gives null.stddev([2, 4, 4, 4, 5, 5, 7, 9]) -> 2 |
sum(numbers) | Adds the numbers in one array, ignoring entries that are not numbers. An empty array sums to 0, and an argument that is not an array gives null.sum([1, 2, 3]) -> 6 |
trunc(number) | Drops the fractional part, rounding toward zero rather than down, so a negative number rises instead of falling.trunc(-2.7) -> -2 |
variance(numbers) | The population variance of the numbers in one array, the square of stddev. An array holding no numbers gives null.variance([2, 4, 4, 4, 5, 5, 7, 9]) -> 4 |
Text
| Call | What it does |
|---|---|
concat(value, ...) | Joins every argument into one piece of text with nothing between. Each argument is read as its text form, and a null argument contributes nothing.concat("DI", "-", "001") -> "DI-001" |
contains(text, search) | Whether the text holds the search text anywhere in it, matching case. A null argument gives false rather than null.contains("hello", "ell") -> true |
endsWith(text, suffix) | Whether the text ends with the suffix, matching case. A null argument gives false.endsWith("hello", "llo") -> true |
extract(text, pattern) | The first stretch of text a regular expression matches, or null when nothing matches or the pattern does not compile.extract("DI-001", "[0-9]+") -> "001" |
format(template, value?, ...) | Fills the placeholders {0}, {1} and so on in the template from the values that follow it. The template alone is a legal call, a placeholder with no value behind it is left in the text as written, and a null value leaves its placeholder empty.format("{0}-{1}", "DI", "001") -> "DI-001" |
indexOf(text, search) | The zero-based position of the first occurrence of the search text. Absent search text gives -1, and so does a null argument, so test the result rather than its truth.indexOf("hello", "l") -> 2 |
join(items, delimiter) | Joins an array into one piece of text with the delimiter between entries. A null entry contributes nothing, and a first argument that is not an array gives null.join(["a", "b", "c"], "-") -> "a-b-c" |
length(value) | How long a value is: characters for text, entries for an array. A null argument gives null, and any other value is read as its text form first.length("hello") -> 5 |
lower(text) | The text in lower case. A non-text argument is read as its text form first, and null stays null.lower("HELLO") -> "hello" |
matches(text, pattern) | Whether a regular expression matches anywhere in the text, not only across the whole of it. A pattern that does not compile gives false, so anchor the pattern when a whole match is what you mean.matches("hello123", "[0-9]+") -> true |
padEnd(text, length, padCharacter?) | Pads the end of the text until it reaches the given length, with spaces when no pad character is given. Only the first character of padCharacter is used, and text already at or above the length is returned unchanged.padEnd("42", 5, "0") -> "42000" |
padStart(text, length, padCharacter?) | Pads the start of the text until it reaches the given length, with spaces when no pad character is given. Only the first character of padCharacter is used, and text already at or above the length is returned unchanged.padStart("42", 5, "0") -> "00042" |
repeat(text, count) | The text written count times with nothing between. A count of zero or less gives empty text, and a non-numeric count returns the text unchanged.repeat("ab", 3) -> "ababab" |
replace(text, find, replacement) | Replaces the first occurrence of find, taken literally rather than as a pattern. Use replaceAll for every occurrence.replace("hello", "l", "L") -> "heLlo" |
replaceAll(text, pattern, replacement) | Replaces every match of a regular expression. A pattern that does not compile falls back to replacing the pattern as literal text, so the expression still returns a value.replaceAll("hello", "[eo]", "x") -> "hxllx" |
reverse(value) | Turns a value back to front: characters for text, entries for an array. A null argument gives null.reverse("abc") -> "cba" |
split(text, delimiter) | Breaks the text into an array at each occurrence of the delimiter, which is taken literally. Consecutive delimiters produce empty entries.split("a,b,c", ",") -> ["a", "b", "c"] |
startsWith(text, prefix) | Whether the text begins with the prefix, matching case. A null argument gives false.startsWith("hello", "hel") -> true |
substring(text, start, end?) | The characters from the zero-based start position up to but excluding the end position, or to the end of the text when end is omitted. Both positions are held inside the text, so an out-of-range position shortens the answer instead of raising an error.substring("hello", 1, 4) -> "ell" |
trim(text) | Removes whitespace from both ends, leaving whitespace inside the text alone.trim(" hello ") -> "hello" |
trimEnd(text) | Removes whitespace from the end of the text only.trimEnd(" hello ") -> " hello" |
trimStart(text) | Removes whitespace from the start of the text only.trimStart(" hello ") -> "hello " |
upper(text) | The text in upper case. A non-text argument is read as its text form first, and null stays null.upper("hello") -> "HELLO" |
Logic
| Call | What it does |
|---|---|
and(value, value, ...) | Whether every argument is true, by the same reading of truth that if uses. It takes at least two arguments and evaluates all of them, so it does not stop at the first false one.and(true, 1) -> true |
coalesce(value, ...) | The first argument that is not null, reading them left to right. Every argument is evaluated, and the answer is null when all of them are null. Empty text counts as a value, so it wins over the arguments behind it.coalesce(null, null, 10) -> 10 |
default(value, fallback) | The value, or the fallback when the value is null. Only null is replaced, so empty text is kept as it is.default(null, 10) -> 10 |
if(condition, thenValue, elseValue) | Chooses between two values on a condition, which counts as false when it is null, zero, empty text or an empty array. Both branches are evaluated before the choice is made, so neither one can be used to guard the other.if(true, "yes", "no") -> "yes" |
isEmpty(value) | Whether a value holds nothing: null, empty text, an empty array and an empty object are all empty. A number or a boolean is never empty, so zero and false answer false.isEmpty("") -> true |
isNotEmpty(value) | The opposite of isEmpty: whether the value holds something. Zero and false count as holding something.isNotEmpty("hello") -> true |
isNotNull(value) | Whether the value is present, which empty text and zero both are.isNotNull(0) -> true |
isNull(value) | Whether the value is absent. Only null answers true: empty text and zero are present values, so use isEmpty when blank text should count.isNull("") -> false |
not(value) | The truth of a value inverted, where null, zero, empty text and an empty array are all false and everything else is true.not(0) -> true |
or(value, value, ...) | Whether any argument is true, by the same reading of truth that if uses. It takes at least two arguments and evaluates all of them, so it does not stop at the first true one.or(false, "") -> false |
switch(value, cases, ...) | Matches a value against cases and returns the result of the first match. The cases are written as pairs of case and result, which a lone trailing argument closes as the default, or as one object whose "_default" key carries the fallback. No match and no default gives null.switch(1, 1, "one", 2, "two", "other") -> "one" |
Types and conversion
| Call | What it does |
|---|---|
isArray(value) | Whether the value is an array. Text that reads like one answers false.isArray([1, 2, 3]) -> true |
isBoolean(value) | Whether the value is a boolean. The numbers 1 and 0 are not booleans, so they answer false.isBoolean(1) -> false |
isDate(value) | Whether the value is a date or a timestamp. A date written as text answers false until toDate has parsed it.isDate(today()) -> true |
isNumber(value) | Whether the value is a number. Text that looks numeric is still text, so convert it with toNumber before testing.isNumber("42") -> false |
isObject(value) | Whether the value is an object of keys and values, which is what groupBy returns and what an object literal writes.isObject({a: 1}) -> true |
isString(value) | Whether the value is text. Null answers false.isString("hello") -> true |
toArray(value) | Wraps a value as an array: an array is returned as it is, null becomes an empty array, an object becomes its entries, and text becomes one entry per character rather than a single entry.toArray("ab") -> ["a", "b"] |
toBoolean(value) | Reads a value as a boolean. The text "true", "yes", "1" and "on" are true in any case and all other text is false, a number is true when it is not zero, null is false, and any other value is true.toBoolean("yes") -> true |
toDate(value, format?) | Reads a value as a date: text is parsed with the given pattern, or with the common ISO layouts when no pattern is given, and a number is read as milliseconds since the epoch. Text that does not parse gives null rather than an error.toDate("2026-03-01") -> 2026-03-01 |
toNumber(value) | Reads a value as a number: text is parsed, true becomes 1 and false becomes 0. Text that does not parse gives null rather than an error, as does null itself.toNumber("42") -> 42 |
toString(value) | Reads a value as text, with a date or timestamp written in ISO form. Null stays null rather than becoming the word null, and a number carries its decimal part, so toString(42) gives "42.0".toString(true) -> "true" |
typeof(value) | Names the kind of a value: "null", "number", "string", "boolean", "date", "array", "object", or "unknown" for anything else.typeof(42) -> "number" |
Dates
| Call | What it does |
|---|---|
addBusinessDays(value, days) | Counts that many Saturday and Sunday skipping days forward from the value's date, or backward when days is negative. An amount of 0 returns the date untouched, even when it is itself a weekend day.addBusinessDays("2026-04-03", 1) -> 2026-04-06 |
date(value) | Reads a date from an ISO text value such as "2026-04-01", or passes an existing date through. A timestamp is not converted here, so date(now()) is null: use year(), month() and day() to read calendar fields off an instant.date("2026-04-01") -> 2026-04-01 |
dateAdd(value, amount, unit) | Moves a date or timestamp forward by whole units: "years", "months", "weeks", "days", "hours", "minutes" or "seconds", singular or plural, in any case. The three time units need a timestamp, since a date-only value has no clock to advance.dateAdd("2026-04-01", 30, "days") -> 2026-05-01 |
dateDiff(from, to, unit) | The whole units from the first value to the second, negative when the first is the later one, with any remainder dropped. Both values are compared as instants, a date counting from midnight, which is why the unit may be "days", "hours", "minutes" or "seconds" but not weeks, months or years.dateDiff("2026-04-01", "2026-04-15", "days") -> 14 |
dateSub(value, amount, unit) | Moves a date or timestamp backward by whole units, taking the same unit names as dateAdd(). A negative amount moves forward again.dateSub("2026-04-01", 1, "month") -> 2026-03-01 |
datetime(value) | Reads an instant from an ISO timestamp such as "2026-04-01T08:30:00Z", or converts a date and time already in hand. A date-only text value is null, because it names no moment.datetime("2026-04-01T08:30:00Z") -> that instant |
day(value) | The day of the month, 1 to 31. For the day of the week use weekday().day("2026-04-17") -> 17 |
endOfDay(value) | The last instant of the value's date, 23:59:59.999999999 in the server's time zone, so a comparison against it takes in the whole day.endOfDay("2026-04-01") -> 2026-04-01T23:59:59.999999999 in the server's zone |
endOfMonth(value) | The last day of the value's month, 28 to 31 as the calendar requires. It answers with a date, not a timestamp.endOfMonth("2026-02-10") -> 2026-02-28 |
formatDate(value, pattern) | Renders a date or timestamp as text through a Java date pattern, an instant being rendered in the server's time zone. A pattern the value cannot satisfy, such as one asking for an hour from a date-only value, gives null rather than an error.formatDate("2026-04-01", "yyyy/MM/dd") -> "2026/04/01" |
hour(value) | The hour of a timestamp, 0 to 23, read in the server's time zone. A date-only value reads as midnight, so the answer is 0 rather than null.hour(startOfDay(today())) -> 0 |
isBusinessDay(value) | Whether the value's date falls on a Monday through Friday, the exact complement of isWeekend(). No holiday calendar is consulted, so a public holiday counts as a business day.isBusinessDay("2026-04-03") -> true, a Friday |
isWeekend(value) | Whether the value's date falls on a Saturday or a Sunday. The week is fixed, so a region whose weekend falls elsewhere is not described by it.isWeekend("2026-04-04") -> true |
minute(value) | The minutes of a timestamp, 0 to 59, read in the server's time zone. A date-only value reads as midnight, so the answer is 0.minute(endOfDay(today())) -> 59 |
month(value) | The month of a date or timestamp as 1 to 12, counting January as 1 rather than 0.month("2026-04-01") -> 4 |
now() | The current instant, read when the expression is evaluated. It is an input that moves without anyone writing, so a formula calling it is classified as maintained rather than exact.now() -> the instant of evaluation, 2026-04-01T08:30:00Z |
second(value) | The whole seconds of a timestamp, 0 to 59, read in the server's time zone. Fractions are not reported.second(endOfDay(today())) -> 59 |
startOfDay(value) | Midnight at the start of the value's date, as a timestamp in the server's time zone.startOfDay("2026-04-01") -> 2026-04-01T00:00 in the server's zone |
startOfMonth(value) | The first day of the value's month. It answers with a date, not a timestamp, unlike startOfDay().startOfMonth("2026-04-17") -> 2026-04-01 |
today() | The current date in the server's time zone, with no time of day. Like now(), it makes the formula that calls it time sensitive.today() -> 2026-04-01 on that day |
weekday(value) | The day of the week as 0 to 6 with Sunday as 0, so Monday is 1 and Saturday is 6. The numbering is not the ISO one, where Monday is 1 and Sunday 7.weekday("2026-04-01") -> 3, a Wednesday |
year(value) | The calendar year of a date or timestamp, an instant being read in the server's time zone. A text argument must be an ISO date, "2026-04-01"; a text timestamp reads as null, so wrap it in datetime() first.year("2026-04-01") -> 2026 |
Arrays
| Call | What it does |
|---|---|
count(value) | How many entries a list or an object holds, or how many characters a text value holds. Null counts as 0 and any other single value as 1, so it never fails on a value that is not a collection.count([1, 2, 3]) -> 3 |
first(value) | The first entry of a list, or the first character of a text value. An empty list gives null, and a value that is not a collection comes back as it is.first([3, 1, 2]) -> 3 |
flatten(list) | One list holding every entry of the nested lists, to any depth, in the order they were met.flatten([[1, 2], [3, [4]]]) -> [1, 2, 3, 4] |
includes(list, value) | Whether the list holds that value. Numbers are compared by their numeric value, so a whole 1 matches a decimal 1.0, and a first argument that is not a list answers false.includes([1, 2, 3], 2) -> true |
last(value) | The last entry of a list, or the last character of a text value. An empty list gives null.last([3, 1, 2]) -> 2 |
nth(value, index) | The entry at that position, counting from 0. An index past the end gives null rather than an error.nth(["a", "b", "c"], 1) -> "b" |
range(start, end?, step?) | A list of whole numbers from start up to but not including end, stepping by 1 unless told otherwise. Called with one argument that argument is the end and counting starts at 0; a negative step counts down, and a step of 0 gives an empty list.range(1, 4) -> [1, 2, 3] |
skip(value, n) | Everything after the first n entries of a list, or after the first n characters of a text value. Skipping more than the list holds gives an empty list.skip([1, 2, 3, 4], 2) -> [3, 4] |
slice(value, start, end) | The entries from start up to but not including end, counting from 0. Both bounds are pulled inside the collection, so an end past the last entry stops at the end instead of failing.slice([1, 2, 3, 4], 1, 3) -> [2, 3] |
sort(list) | The list in ascending order: numbers compared numerically, text compared character by character, nulls first. A value that is not a list gives null.sort([3, 1, 2]) -> [1, 2, 3] |
sortDesc(list) | The list in descending order, the exact reverse of sort(), so nulls come last.sortDesc([3, 1, 2]) -> [3, 2, 1] |
take(value, n) | The first n entries of a list, or the first n characters of a text value. A list shorter than n comes back whole.take([1, 2, 3, 4], 2) -> [1, 2] |
unique(list) | The list with later repeats of a value dropped, keeping the first occurrence and its position. Equality is exact rather than numeric, so a whole 1 and a decimal 1.0 are two values.unique([1, 2, 2, 3]) -> [1, 2, 3] |
zip(list1, list2) | Pairs the two lists entry by entry, by position. It stops at the shorter list, so the surplus of the longer one is dropped.count(zip([1, 2, 3], ["a", "b"])) -> 2 |
Reaching other items
| Call | What it does |
|---|---|
countRelated(relationshipType?, directionOrOptions?) | How many items related() would reach, taking the same arguments. It counts items and not links, so an item reached by two links counts once, and it counts what the walk found before limit trimmed the list, so a limited call still reports the full number.countRelated("verified-by") -> 3 when three distinct items verify this one |
hasRelated(relationshipType?, directionOrOptions?) | Whether at least one matching link leaves the item being evaluated, taking the same relationship type, direction and options as related(). It stops at the first match instead of reading the whole set, so it is the form to use where a count is not wanted.hasRelated("verified-by") -> true when this item has at least one verified-by link downstream |
pluck(items, field) | Reads one field from every row of a traversal or query result, answering with a list of those values. The field may be id, number, itemTypeId (also spelled type), status, revision, seq, relationshipTypeId (also relationshipType), relationshipId, suspect or depth, and any other name is read as an attribute slug; a row holding no value for it is dropped, so the list can come back shorter than the one that went in.related("verified-by").pluck("number") -> ["VT-001", "VT-002"] |
related(relationshipType?, directionOrOptions?) | Follows links from the item being evaluated and answers with the items at the other end, one hop outgoing unless told otherwise, where outgoing means the links on which this item is the upstream end. The second argument is either a direction ("outgoing", "incoming" or "both") or an options object carrying direction, maxDepth (at most 10), minDepth, types, itemTypes, statuses, includeSuspect, onlySuspect and limit; an item reachable by several routes is reported once, at its shallowest depth, and obsolete items stay in the result, unlike the coverage and impact surfaces.related("mitigates", { direction: "incoming", maxDepth: 2 }) -> the items up to two hops upstream of this one through mitigates |
traverse(path, options?) | Follows a slash-separated path of relationship types, one type per hop in the order written, and answers with only the items at the far end: the items in between are walked through, not returned. The path fixes the depth, so maxDepth and minDepth in the options are not consulted, direction and the item type, status and suspect filters still apply to every hop, and a path naming more than 10 types is refused rather than cut short.traverse("derives-from/verified-by") -> the items that verify the items this one derives from |
where(items, field, value) | Keeps the rows whose field equals the value, reading the same fields as pluck() apart from the two relationship ids, which are read as attribute slugs here and so answer null. The comparison is exact, and a status is held in upper case, so compare against "RELEASED"; for a condition rather than an equality, use the filter array method with a lambda.related("verified-by").where("status", "RELEASED") -> the rows whose item is released |
whereStatus(items, status) | Keeps the rows whose item sits in that lifecycle status, comparing without regard to case, so "released" and "RELEASED" both match. Entries that are not traversal or query rows are dropped rather than kept.related("verified-by").whereStatus("released").count() -> how many of the items verifying this one are released |
whereSuspect(items, isSuspect?) | Keeps the rows whose link is suspect, or, given false as the second argument, the rows whose link is not; with one argument it answers the suspect ones. Suspect is derived at read time from the upstream as this branch resolves it, so the answer belongs to the reading branch, and a query result, which carries no link, is never suspect.related("verified-by").whereSuspect().count() -> how many of those links are awaiting re-verification |
whereType(items, itemType) | Keeps the rows whose item is of that item type, matching the slug exactly and with regard to case. It is how a mixed result, such as one from related() with no relationship type named, is narrowed to one kind of item.traverse("contains").whereType("part") -> only the rows whose item is a part |
Queries
| Call | What it does |
|---|---|
query(itemTypeId, filterOrOptions?) | Answers with every item of that type the reading branch resolves, each as a row carrying the fields a traversal row carries, except that there is no link, so the relationship fields are empty, suspect is false and depth is 0. The second argument narrows the set and is either a filter expression or an object with a filter key: the expression is evaluated once per item with this bound to that item, and only the items it answers true for are kept; no status is excluded by the query itself, so drafts and obsolete items are in the result unless the filter takes them out.query("risk-item", { filter: "this.rpn > 100" }).count() -> how many risk items on this branch score above 100 |
Enumerations
| Call | What it does |
|---|---|
enumNumericValue(attributeSlug, value?) | Resolves the numeric rank an enumeration value carries: it finds the value's position among the options the attribute declares and answers with the number declared at that same position, which is the arithmetic ISO 14971 severity and occurrence scoring is built on. With one argument the value is read from the item being evaluated and an empty slot answers null; with two, the second argument is the value to resolve against the named attribute, which is looked up by slug across the whole project, so a risk can score the severity it read off a harm, and an unknown slug, an attribute carrying no numeric mapping, or a value outside the declared options is an evaluation error rather than a null.enumNumericValue("severity") -> 4 when this item's severity holds the option mapped to 4 |
Array methods
| Call | What it does |
|---|---|
array.all(x => condition) | True when every element satisfies the condition. An empty array answers true, because no element fails it.[2, 4, 6].all(x => x % 2 == 0) -> true |
array.any(x => condition) | True when at least one element satisfies the condition. An empty array answers false.[1, 2, 3].any(x => x > 2) -> true |
array.avg(x => number?) | The mean of the numeric elements, or of the numbers a given lambda returns. It answers null for an empty array and for an array holding no numbers, so an absence never reads as 0.[2, 4, 9].avg() -> 5 |
array.count() | How many elements the array holds, nulls included. An empty array counts 0.[1, 2, 3].count() -> 3 |
array.distinct() | Drops repeated elements, keeping the first occurrence of each in its original position. Elements are compared by value, so two equal numbers collapse into one.['a', 'b', 'a'].distinct() -> ['a', 'b'] |
array.filter(x => condition) | Keeps the elements whose condition is truthy, the lambda's parameter bound to each element in turn. The kept elements stay in their original order, and nothing matching gives an empty array.[1, 2, 3, 4, 5].filter(x => x > 2) -> [3, 4, 5] |
array.find(x => condition) | The first element whose condition is truthy, or null when none matches. A null answer does not distinguish no match from a matched element that is itself null.[1, 2, 3, 4, 5].find(x => x > 3) -> 4 |
array.findIndex(x => condition) | The zero-based position of the first element whose condition is truthy, or -1 when none matches.[5, 8, 12].findIndex(x => x > 6) -> 1 |
array.first() | The first element, or null when the array is empty.[3, 7].first() -> 3 |
array.flatten() | Merges nested arrays into one, one level deep. An element that is not an array is carried through as it is, and an array nested two levels deep stays an array.[[1, 2], [3]].flatten() -> [1, 2, 3] |
array.groupBy(x => key) | Groups the elements into a map from the key the lambda returns to the elements that produced it. Each group keeps its elements in their original order, and the keys appear in the order first seen.[1, 2, 3, 4].groupBy(x => x % 2 == 0) -> { false: [1, 3], true: [2, 4] } |
array.join(separator?) | Joins the elements into one text with the separator between them, defaulting to no separator when it is omitted. A null element contributes empty text rather than the word null.['a', 'b'].join('-') -> 'a-b' |
array.last() | The last element, or null when the array is empty.[3, 7].last() -> 7 |
array.map(x => expression) | One result per element, from the expression evaluated with the lambda's parameter bound to that element. The result has the same length as the array, so an expression returning null keeps a null in place rather than dropping the element.[1, 2, 3].map(x => x * 2) -> [2, 4, 6] |
array.max(x => number?) | The largest numeric element, or the largest number a given lambda returns. Non-numeric elements are skipped, and the answer is null when none is left.[4, 2, 9].max() -> 9 |
array.min(x => number?) | The smallest numeric element, or the smallest number a given lambda returns. Non-numeric elements are skipped, and the answer is null when none is left.[4, 2, 9].min() -> 2 |
array.none(x => condition) | True when no element satisfies the condition, the negation of any. An empty array answers true.[1, 2, 3].none(x => x > 5) -> true |
array.reduce((acc, x) => expression, initial?) | Folds the array into one value: the lambda's first parameter is the running result, its second is the element, and the expression becomes the next running result. The initial value starts the fold and defaults to null, which is also what an empty array returns.[1, 2, 3].reduce((acc, x) => acc + x, 0) -> 6 |
array.sort(x => key?) | A new array in ascending order, by the elements themselves or by the key a given lambda returns. Nulls sort before any value, and the array the method was called on is left as it was.[3, 1, 2].sort() -> [1, 2, 3] |
array.sum(x => number?) | Adds the elements, or the numbers a given lambda returns for each element. Without a lambda the non-numeric elements are ignored, with one a result that is not a number counts as zero, and an empty array sums to 0.[1, 2, 3].sum() -> 6 |
Text methods
| Call | What it does |
|---|---|
text.contains(search) | True when search appears anywhere in the text, compared case-sensitively; contains and includes are the same method. A missing argument searches for empty text, which every text holds, so the answer is true.'DI-001'.contains('DI') -> true |
text.endsWith(suffix) | True when the text ends with the suffix, compared case-sensitively. A missing argument tests for empty text and answers true.'DI-001'.endsWith('001') -> true |
text.includes(search) | True when search appears anywhere in the text, compared case-sensitively; includes and contains are the same method. A missing argument searches for empty text, which every text holds, so the answer is true.'DI-001'.includes('DI') -> true |
text.indexOf(search) | The zero-based position where search first appears, or -1 when it does not appear at all. A missing argument answers 0, the position of empty text at the start.'DI-001'.indexOf('-') -> 2 |
text.length() | How many characters the text holds, whitespace counted. Empty text has length 0.'Avera'.length() -> 5 |
text.lower() | The text with every letter lowercased, other characters left as they are. lower, toLowerCase and toLower are the same method.'REV A'.lower() -> 'rev a' |
text.replace(find, replacement?) | Replaces the first occurrence of find, matched literally rather than as a pattern, and leaves any later occurrence in place. An omitted replacement removes that occurrence.'a-b-c'.replace('-', '+') -> 'a+b-c' |
text.replaceAll(find, replacement?) | Replaces every occurrence of find, matched literally rather than as a pattern. An omitted replacement removes each occurrence.'a-b-c'.replaceAll('-', '+') -> 'a+b+c' |
text.split(separator) | Splits the text into an array of parts at each occurrence of the separator, which is matched literally rather than as a pattern. A missing separator splits on empty text, which puts one character in each part and an empty part at each end.'a,b,c'.split(',') -> ['a', 'b', 'c'] |
text.startsWith(prefix) | True when the text begins with the prefix, compared case-sensitively. A missing argument tests for empty text and answers true.'DI-001'.startsWith('DI') -> true |
text.substring(start, end?) | The characters from start up to but not including end, both positions zero-based. The end defaults to the end of the text, and an end beyond the length is clamped to it, so the result stops at the last character.'DI-001'.substring(3) -> '001' |
text.toLower() | The text with every letter lowercased, other characters left as they are. toLower, lower and toLowerCase are the same method.'REV A'.toLower() -> 'rev a' |
text.toLowerCase() | The text with every letter lowercased, other characters left as they are. toLowerCase, lower and toLower are the same method.'REV A'.toLowerCase() -> 'rev a' |
text.toUpper() | The text with every letter uppercased, other characters left as they are. toUpper, upper and toUpperCase are the same method.'rev a'.toUpper() -> 'REV A' |
text.toUpperCase() | The text with every letter uppercased, other characters left as they are. toUpperCase, upper and toUpper are the same method.'rev a'.toUpperCase() -> 'REV A' |
text.trim() | Removes whitespace from both ends of the text, leaving whitespace inside it untouched.' DI-001 '.trim() -> 'DI-001' |
text.upper() | The text with every letter uppercased, other characters left as they are. upper, toUpperCase and toUpper are the same method.'rev a'.upper() -> 'REV A' |