diff --git a/README.md b/README.md index 7f94683..d0df7a6 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,27 @@ select listEntry.getItem().getName(), listEntry.getDivision().getName(), listEntry.getNext().getItem().getName() ``` +**PlaceholderList**: Collapses multiple `(element, placeholder)` input rows into a single alert row with a formatted list message. Useful when a single alert element is related to a variable number of placeholder elements. + +_Note: the input message must contain exactly one `$@` placeholder, which will be expanded._ + +```ql +module MyConfig implements Qtil::PlaceholderListSig { + predicate problems(Function f, string msg, Variable v, string vStr) { + v = f.getAParameter() and + msg = f.getName() + " has parameters $@." and + vStr = v.getName() + } +} + +// Import to get a `query predicate problems(...)` with up to 5 placeholder pairs: +import Qtil::PlaceholderList +// Result for a function with 3 params: "foo has parameters $@, $@, and $@.", p1, p2, p3 +// Result for a function with 7 params: "foo has parameters $@, $@, $@, $@, and $@ and 2 more.", p1-p5 +``` + +Custom `maxResults` (default 5) and `orderBy` are configurable via the signature module. + ### Strings **join(sep, ...)**: The first argument is used as a separator to join the remaining two to eight arguments. diff --git a/src/qtil/Qtil.qll b/src/qtil/Qtil.qll index 3595e30..bd2f497 100644 --- a/src/qtil/Qtil.qll +++ b/src/qtil/Qtil.qll @@ -1,6 +1,7 @@ module Qtil { import qtil.list.CondensedList import qtil.list.Ordered + import qtil.list.PlaceholderList import qtil.locations.NullLocation import qtil.locations.OptionalLocation import qtil.locations.StringLocation diff --git a/src/qtil/list/PlaceholderList.qll b/src/qtil/list/PlaceholderList.qll new file mode 100644 index 0000000..06859a0 --- /dev/null +++ b/src/qtil/list/PlaceholderList.qll @@ -0,0 +1,282 @@ +/** + * A module for combining multiple placeholder results into a single alert message, when + * there may be a variable number of placeholder values per element. + * + * When a query finds multiple related elements (placeholders) for a single alert element, + * this module combines them into a single alert with a formatted list message. + * + * For example, given a predicate: + * ```ql + * predicate problems(Element e, string msg, Placeholder p, string pStr) { + * e.meetsSomeCondition() and + * p.meetsSomeOtherCondition() and + * e.isSomehowRelatedTo(p) and + * msg = e.toString() + " is related to $@." and + * pStr = p.toString() + * } + * ``` + * + * If element `e` is related to a single placeholder, the message will be: + * "foo is related to $@." + * If element `e` is related to two placeholders, the message will be: + * "foo is related to $@ and $@." + * If element `e` is related to three placeholders, the message will be: + * "foo is related to $@, $@, and $@." + * If element `e` is related to more than `maxResults()` placeholders, the message will be: + * "foo is related to $@, $@, $@, $@, $@, and N more." + * + * To use this module, define a configuration module that implements `PlaceholderListSig`: + * ```ql + * module MyConfig implements PlaceholderListSig { + * predicate problems(MyElement e, string msg, MyPlaceholder p, string pStr) { ... } + * int maxResults() { result = 5 } + * string orderBy(MyPlaceholder p) { result = p.toString() } + * } + * + * import PlaceholderList + * ``` + */ + +private import qtil.parameterization.SignatureTypes + +/** + * A signature module for `PlaceholderList` configuration. + * + * Implement this module to provide the `problems` predicate and optional configuration. + * + * The `problems` predicate should associate each element with a message template (containing + * exactly one `$@` placeholder) and zero or more `(placeholder, string)` pairs. + * + * The `maxResults` predicate controls how many placeholders are shown per alert. + * If there are more placeholders than `maxResults()`, the remaining count is included in the + * message as plain text: "and N more". + * + * The `orderBy` predicate controls the order in which placeholders are presented. + * + * Example: + * ```ql + * module MyConfig implements PlaceholderListSig { + * predicate problems(Function f, string msg, Variable v, string vStr) { + * v = f.getAParameter() and + * msg = f.getName() + " has parameter $@." and + * vStr = v.getName() + * } + * int maxResults() { result = 5 } + * string orderBy(Variable v) { result = v.getName() } + * } + * ``` + */ +signature module PlaceholderListSig { + /** + * Defines problems as a set of (element, message, placeholder, placeholderString) tuples. + * + * For a given `(element, message)` pair, there may be multiple `(placeholder, placeholderString)` + * results — one for each related element. The message should contain exactly one `$@` placeholder, + * which will be expanded by the `PlaceholderList` module into the appropriate list format. + */ + predicate problems(Element e, string msg, Placeholder p, string pStr); + + /** + * The maximum number of placeholders to show per alert. When there are more placeholders than + * this limit, the message will include "and N more" as plain text. + * + * The effective maximum is capped at 5, which is the number of placeholder pairs in the output + * query predicate. + * + * Defaults to 5. + */ + default int maxResults() { result = 5 } + + /** + * An ordering key used to sort placeholders within each alert. Placeholders are shown in + * ascending order by this key. + * + * Defaults to the string representation of the placeholder. + */ + default string orderBy(Placeholder p) { result = p.toString() } +} + +/** + * A module for combining multiple placeholder results into a single alert message. + * + * This module takes a `problems` predicate that may have multiple `(placeholder, string)` pairs + * for a single `(element, message)` pair, and combines them into a single output row with an + * expanded message. Up to five placeholder pairs are shown; additional placeholders are + * represented as "and N more" in the message text. + * + * The output `query predicate problems` always has exactly 5 placeholder pairs. When there are + * fewer than 5 actual placeholders, the remaining slots are filled with the first placeholder + * and an empty string (so they are not highlighted in the UI). + * + * See `PlaceholderListSig` for configuration options. + */ +module PlaceholderList< + FiniteStringableType Element, FiniteStringableType Placeholder, + PlaceholderListSig Config> +{ + /** + * Count the number of distinct placeholder values for a given `(element, message)` pair. + */ + private int countPlaceholders(Element e, string msg) { + Config::problems(e, msg, _, _) and + result = count(Placeholder p | Config::problems(e, msg, p, _)) + } + + /** + * Get the `n`th (1-based) placeholder for a given `(element, message)` pair, ordered + * ascending by `Config::orderBy`, with the placeholder's `toString()` as a secondary sort key. + */ + private Placeholder getNthPlaceholder(Element e, string msg, int n) { + result = + rank[n](Placeholder p | Config::problems(e, msg, p, _) | + p order by Config::orderBy(p), p.toString() + ) + } + + /** + * Get the string representation of the `n`th placeholder for a given `(element, message)` pair. + * + * If the same placeholder has multiple string representations in the input predicate, the + * lexicographically smallest one is used. + */ + private string getNthPlaceholderStr(Element e, string msg, int n) { + Config::problems(e, msg, _, _) and + result = + min(string pStr | + Config::problems(e, msg, getNthPlaceholder(e, msg, n), pStr) + | + pStr + ) + } + + /** + * Build the placeholder part of the expansion string (without "and N more" suffix). + * + * Returns the comma-separated (with Oxford comma) list of `$@` placeholders for `n` items. + */ + bindingset[n] + private string placeholderExpansion(int n) { + n = 1 and result = "$@" + or + n = 2 and result = "$@ and $@" + or + n = 3 and result = "$@, $@, and $@" + or + n = 4 and result = "$@, $@, $@, and $@" + or + n = 5 and result = "$@, $@, $@, $@, and $@" + } + + /** + * Build the overflow suffix for the expansion string. + * + * Returns `""` when `moreCount = 0` (no overflow), or `" and N more"` when there are + * additional placeholders beyond the visible maximum. + */ + bindingset[moreCount] + private string moreString(int moreCount) { + moreCount = 0 and result = "" + or + moreCount > 0 and result = " and " + moreCount + " more" + } + + /** + * Build the full expansion string that replaces the single `$@` placeholder in the original + * message. + * + * `showCount` is the number of `$@` placeholders to include (1–5), and `moreCount` is the + * number of additional placeholders not shown (0 or more). + */ + bindingset[showCount, moreCount] + private string expansion(int showCount, int moreCount) { + result = placeholderExpansion(showCount) + moreString(moreCount) + } + + /** + * Get the effective number of placeholders to show for a given `(element, message)` pair. + * + * This is the minimum of the total placeholder count and `Config::maxResults()`, capped at 5 + * (the number of placeholder pairs in the output query predicate). + */ + private int showCount(Element e, string msg) { + exists(int total, int maxR, int cappedMax | + total = countPlaceholders(e, msg) and + maxR = Config::maxResults() and + (maxR <= 5 and cappedMax = maxR or maxR > 5 and cappedMax = 5) and + (total <= cappedMax and result = total or total > cappedMax and result = cappedMax) + ) + } + + /** + * Get the expanded message for a given `(element, message)` pair, with the single `$@` + * in the original message replaced by the appropriate comma-separated list of `$@` placeholders + * and optional "and N more" suffix. + */ + private string expandedMsg(Element e, string origMsg) { + exists(int total, int sc, int mc | + total = countPlaceholders(e, origMsg) and + sc = showCount(e, origMsg) and + (total > sc and mc = total - sc or total <= sc and mc = 0) and + result = origMsg.replaceAll("$@", expansion(sc, mc)) + ) + } + + /** + * The combined problems query predicate. + * + * For each `(element, message)` pair in the input `Config::problems` predicate, this produces + * a single output row with an expanded message and up to 5 placeholder pairs. + * + * The message is expanded from the single `$@` in the input to a comma-separated list of `$@` + * placeholders (up to `Config::maxResults()`, capped at 5), with "and N more" appended if there + * are additional placeholders beyond the maximum. + * + * Placeholder pairs beyond the number of actual placeholders are filled with the first + * placeholder value and an empty string, so they are not highlighted in the UI. + */ + query predicate problems( + Element e, string outMsg, + Placeholder p1, string str1, + Placeholder p2, string str2, + Placeholder p3, string str3, + Placeholder p4, string str4, + Placeholder p5, string str5 + ) { + exists(string origMsg, int sc | + Config::problems(e, origMsg, _, _) and + outMsg = expandedMsg(e, origMsg) and + sc = showCount(e, origMsg) and + p1 = getNthPlaceholder(e, origMsg, 1) and + str1 = getNthPlaceholderStr(e, origMsg, 1) and + ( + sc >= 2 and + p2 = getNthPlaceholder(e, origMsg, 2) and + str2 = getNthPlaceholderStr(e, origMsg, 2) + or + sc < 2 and p2 = p1 and str2 = "" + ) and + ( + sc >= 3 and + p3 = getNthPlaceholder(e, origMsg, 3) and + str3 = getNthPlaceholderStr(e, origMsg, 3) + or + sc < 3 and p3 = p1 and str3 = "" + ) and + ( + sc >= 4 and + p4 = getNthPlaceholder(e, origMsg, 4) and + str4 = getNthPlaceholderStr(e, origMsg, 4) + or + sc < 4 and p4 = p1 and str4 = "" + ) and + ( + sc >= 5 and + p5 = getNthPlaceholder(e, origMsg, 5) and + str5 = getNthPlaceholderStr(e, origMsg, 5) + or + sc < 5 and p5 = p1 and str5 = "" + ) + ) + } +} + diff --git a/test/qtil/list/PlaceholderListTest.expected b/test/qtil/list/PlaceholderListTest.expected new file mode 100644 index 0000000..12e4115 --- /dev/null +++ b/test/qtil/list/PlaceholderListTest.expected @@ -0,0 +1,11 @@ +testDefault +| 1 | $@ is cool | 1 | 1 | 1 | | 1 | | 1 | | 1 | | +| 2 | $@ and $@ is cool | 1 | 1 | 2 | 2 | 1 | | 1 | | 1 | | +| 3 | $@, $@, and $@ is cool | 1 | 1 | 2 | 2 | 3 | 3 | 1 | | 1 | | +| 4 | $@, $@, $@, and $@ is cool | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 1 | | +| 5 | $@, $@, $@, $@, and $@ is cool | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 5 | +| 6 | $@, $@, $@, $@, and $@ and 1 more is cool | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 | 5 | +testMaxTwo +| 1 | $@ is related | 1 | 1 | 1 | | 1 | | 1 | | 1 | | +| 2 | $@ and $@ is related | 1 | 1 | 2 | 2 | 1 | | 1 | | 1 | | +| 3 | $@ and $@ and 1 more is related | 1 | 1 | 2 | 2 | 1 | | 1 | | 1 | | diff --git a/test/qtil/list/PlaceholderListTest.ql b/test/qtil/list/PlaceholderListTest.ql new file mode 100644 index 0000000..ed69c6b --- /dev/null +++ b/test/qtil/list/PlaceholderListTest.ql @@ -0,0 +1,77 @@ +import qtil.list.PlaceholderList + +/** + * A finite stringable type for use as test elements. + */ +class TestElement extends int { + TestElement() { this in [1..6] } + + string toString() { exists(int n | n = this | result = n.toString()) } +} + +/** + * A finite stringable type for use as test placeholders. + */ +class TestPlaceholder extends int { + TestPlaceholder() { this in [1..7] } + + string toString() { exists(int n | n = this | result = n.toString()) } +} + +/** + * A test configuration using default maxResults (5). + * + * - Element 1: 1 placeholder (p=1) + * - Element 2: 2 placeholders (p=1,2) + * - Element 3: 3 placeholders (p=1,2,3) + * - Element 4: 4 placeholders (p=1,2,3,4) + * - Element 5: 5 placeholders (p=1..5) + * - Element 6: 6 placeholders (p=1..6) — more than maxResults + */ +module TestConfig implements PlaceholderListSig { + predicate problems(TestElement e, string msg, TestPlaceholder p, string pStr) { + p in [1..e] and pStr = p.toString() and msg = "$@ is cool" + } +} + +module DefaultMax = PlaceholderList; + +query predicate testDefault( + TestElement e, string outMsg, + TestPlaceholder p1, string s1, + TestPlaceholder p2, string s2, + TestPlaceholder p3, string s3, + TestPlaceholder p4, string s4, + TestPlaceholder p5, string s5 +) { + DefaultMax::problems(e, outMsg, p1, s1, p2, s2, p3, s3, p4, s4, p5, s5) +} + +/** + * A test configuration with maxResults = 2. + * + * - Element 1: 1 placeholder + * - Element 2: 2 placeholders (exact max) + * - Element 3: 3 placeholders — overflow by 1 + */ +module TestConfigMaxTwo implements PlaceholderListSig { + predicate problems(TestElement e, string msg, TestPlaceholder p, string pStr) { + e in [1..3] and p in [1..e] and pStr = p.toString() and msg = "$@ is related" + } + + int maxResults() { result = 2 } +} + +module MaxTwo = PlaceholderList; + +query predicate testMaxTwo( + TestElement e, string outMsg, + TestPlaceholder p1, string s1, + TestPlaceholder p2, string s2, + TestPlaceholder p3, string s3, + TestPlaceholder p4, string s4, + TestPlaceholder p5, string s5 +) { + MaxTwo::problems(e, outMsg, p1, s1, p2, s2, p3, s3, p4, s4, p5, s5) +} +