diff --git a/README.md b/README.md index 03aed26..ecc8e8c 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,41 @@ from Qtil::Product::Product product select product.getFirst(), product.getSecond() ``` +**AggregableTuple**: A class that can aggregate multiple values at a time, which can be useful for +creating generic APIs involving unknown/configurable aggregation steps. + +```ql +AggregableTuple::Piece getData(Person p) { + result = initString(p.getName()).addInt(p.getAge()) +} + +int two() { result = 2 } + +predicate useSum(AggregableTuple::Sum::Sum agg) { + exists(int countVal, string nameJoin, int ageSum | + countVal = agg.countTotal() and + nameJoin = agg.asJoinedString(", ") and + ageSum = agg.asSummedInt() and + ... // Use the aggregation results in some way + ) +} +``` + +To aggregate the `AggregableTuple::Piece` values, each should be cast to a string and concatenated +with a comma separator. The resulting value can be cast to an `AggregableTuple::Sum` type. + +```ql +predicate createAndUseSum() { + exists(string agg | + agg = concat(string piece | piece = getData(getAPerson()) | piece, ",") and + useSum(agg) + ) +} +``` + +It is very important that every tuple is the same width and type, and that the `Sum` type is given +the correct width as a parameter, otherwise the aggregation will not work correctly. + ### Lists **Ordered**: Takes orderable data, and automatically adds `getPrevious()`, `getNext()` predicate members for ease of traversal. diff --git a/src/qtil/tuple/AggregableTuple.qll b/src/qtil/tuple/AggregableTuple.qll new file mode 100644 index 0000000..39882bb --- /dev/null +++ b/src/qtil/tuple/AggregableTuple.qll @@ -0,0 +1,149 @@ +private import qtil.parameterization.SignatureTypes +private import qtil.parameterization.SignaturePredicates +private import qtil.tuple.StringTuple as CustomStringTuple +private import qtil.strings.Chars +private import qtil.inheritance.Instance +private import codeql.util.Boolean + +class StringTuple = CustomStringTuple::StringTuple::Tuple; + +/** + * A module that allows multiple values to be aggregated at the same time, where each value + * (including the aggregated value) acts like a tuple. + * + * The tuple may contain any number of the following types of columns: + * - `string` columns, which are concatenated with a separator + * - `int` columns, which are summed + * + * Additionally, the unique values of each column can be counted, and the total number of unique + * aggregated tuples can be counted. + * + * This can be useful for writing generic code where a module may wish to perform an unknown number + * of aggregations in a context where it cannot perform the aggregation for itself. + * + * Each value to be aggregated should be of type `AggregableTuple::Piece`, and pieces should be + * aggregated with `concat(Piece p | p, ",")`, as the underlying representation is a comma + * -separated string (a `StringTuple`). + * + * After aggregation, the result should be cast to a `AggregableTuple::Sum` to access the + * aggregated values of each column. + * + * Note: This will not be as performant as individual aggregations, and should only be used in cases + * where a single aggregation is not practical. + * + * Example usage: + * ```ql + * // What values a "person" may aggregate over defined here: + * AggregableTuple::Piece personAggregant(Person p) { + * result = AggregableTuple::initString(p.name) + * .appendInt(p.age) + * } + * + * // A usage of that aggregation can be defined separately: + * predicate useAggregation(AggregableTuple::Sum::Sum aggregated) { + * exists(int counted, string names, int totalAge | + * counted = aggregated.getCountTotal() and + * names = aggregated.getAsJoinedString(0, ",") and + * totalAge = aggregated.getAsSummedInt(1) and + * // Use `counted`, `names`, and `totalAge` as needed + * ) + * } + * ``` + */ +module AggregableTuple { + /** + * Begin the construction of a new piece of an aggregable tuple with a `string` column. + * + * Sets the first column of this tuple to be the given `string` value. The `Piece` + * returned by this predicate can have additional columns appended to it of any type. + */ + bindingset[s] + Piece initString(string s) { result = s } + + /** + * Begin the construction of a new piece of an aggregable tuple with an `int` column. + * + * Sets the first column of this tuple to be the given `int` value. The `Piece` + * returned by this predicate can have additional columns appended to it of any type. + */ + bindingset[i] + Piece initInt(int i) { result = i.toString() } + + /** + * A piece of an aggregable tuple, which can be used to aggregate multiple values at the same + * time. + * + * This class can be built up one column at a time, beginning with one of the predicates `asInc`, + * `asString`, or `asInt`. Additional columns can be appended to the piece using the `appendInc`, + * `appendString`, or `appendInt` predicates. + * + * After all of the columns have been appended, the piece can be aggregated with + * `concat(Piece p | p, ",")`. Then the result can be cast to `AggregableTuple::Sum` to access the + * aggregated values of each column. + */ + bindingset[this] + class Piece extends InfInstance::Type { + bindingset[this, s] + Piece appendString(string s) { result = inst().append(s) } + + bindingset[this, i] + Piece appendInt(int i) { result = inst().append(i.toString()) } + } + + module Sum::pred/0 columns> { + bindingset[this] + class Sum extends InfInstance::Type { + bindingset[this] + int getCountTotal() { result = inst().size() / columns() } + + /** + * Since the underlying representation is a comma-separated string, the ith value of + * the nth column can be found at the index `i * columns() + n`. + * + * This predicate returns all such indexes for the nth column. + */ + bindingset[this] + int getARawColumnValueIndex(int colIdx) { + colIdx in [0 .. columns()] and + exists(int rowIdx | + rowIdx = [0 .. getCountTotal() - 1] and + result = rowIdx * columns() + colIdx + ) + } + + /** + * Get all of the raw string values for the nth column of aggregated tuples. + */ + bindingset[this] + string getARawColumn(int colIdx) { + colIdx in [0 .. columns()] and + result = inst().get(getARawColumnValueIndex(colIdx)) + } + + bindingset[this] + int countColumn(int colIdx) { + colIdx in [0 .. columns()] and + result = count(string item | item = getARawColumn(colIdx)) + } + + /** + * Get the nth column of aggregated tuples, treated as strings and joined with the given + * separator. + */ + bindingset[this, sep] + string getAsJoinedString(int colIdx, string sep) { + colIdx in [0 .. columns()] and + result = concat(string item | item = getARawColumn(colIdx) | item, sep) + } + + /** + * Get the nth column of aggregated tuples, treated as integers and summed. + */ + bindingset[this] + int getAsSummedInt(int colIdx) { + colIdx in [0 .. columns()] and + result = sum(int item | item = getARawColumn(colIdx).toInt()) + } + } + } +} diff --git a/test/qtil/tuple/AggregableTupleTest.expected b/test/qtil/tuple/AggregableTupleTest.expected new file mode 100644 index 0000000..aee1d54 --- /dev/null +++ b/test/qtil/tuple/AggregableTupleTest.expected @@ -0,0 +1 @@ +| All 12 tests passed. | diff --git a/test/qtil/tuple/AggregableTupleTest.ql b/test/qtil/tuple/AggregableTupleTest.ql new file mode 100644 index 0000000..b9476bf --- /dev/null +++ b/test/qtil/tuple/AggregableTupleTest.ql @@ -0,0 +1,155 @@ +import qtil.tuple.AggregableTuple +import qtil.testing.Qnit + +class TestInitString extends Test, Case { + override predicate run(Qnit test) { + if AggregableTuple::initString("test") = "test" + then test.pass("Correctly initialized a string") + else test.fail("initString did not initialize correctly") + } +} + +class TestInitInt extends Test, Case { + override predicate run(Qnit test) { + if AggregableTuple::initInt(42) = "42" + then test.pass("Correctly initialized an integer") + else test.fail("initInt did not initialize correctly") + } +} + +class TestInitAppendString extends Test, Case { + override predicate run(Qnit test) { + if + AggregableTuple::initString("test1").appendString("test2") = "test1,test2" and + AggregableTuple::initString("test1").appendString("test2").appendString("test3") = + "test1,test2,test3" + then test.pass("Correctly appended multiple strings") + else test.fail("appendString did not append multiple strings correctly") + } +} + +class TestInitAppendInt extends Test, Case { + override predicate run(Qnit test) { + if + AggregableTuple::initInt(1).appendInt(2) = "1,2" and + AggregableTuple::initInt(1).appendInt(2).appendInt(3) = "1,2,3" + then test.pass("Correctly appended multiple integers") + else test.fail("appendInt did not append multiple integers correctly") + } +} + +class TestInitAppendMixed extends Test, Case { + override predicate run(Qnit test) { + if + AggregableTuple::initString("test").appendInt(42) = "test,42" and + AggregableTuple::initInt(42).appendString("test") = "42,test" + then test.pass("Correctly appended mixed types") + else test.fail("appendMixed did not append mixed types correctly") + } +} + +int one() { result = 1 } + +class TestConcatSingleString extends Test, Case { + override predicate run(Qnit test) { + if + concat(string s | s = AggregableTuple::initString("test") | s, ",") + .(AggregableTuple::Sum::Sum) + .getAsJoinedString(0, ",") = "test" + then test.pass("Correctly concatenated single string") + else test.fail("concat did not concatenate single string correctly") + } +} + +class TestSumSingleInt extends Test, Case { + override predicate run(Qnit test) { + if + concat(string s | s = AggregableTuple::initInt(42) | s, ",") + .(AggregableTuple::Sum::Sum) + .getAsSummedInt(0) = 42 + then test.pass("Correctly summed single integer") + else test.fail("concat did not sum single integer correctly") + } +} + +class TestJoinMultipleStrings extends Test, Case { + override predicate run(Qnit test) { + if + concat(string s | s = AggregableTuple::initString(["test1", "test2"]) | s, ",") + .(AggregableTuple::Sum::Sum) + .getAsJoinedString(0, ",") = "test1,test2" and + concat(string s | s = AggregableTuple::initString(["test1", "test2", "test3"]) | s, ",") + .(AggregableTuple::Sum::Sum) + .getAsJoinedString(0, ",") = "test1,test2,test3" + then test.pass("Correctly joined multiple strings") + else test.fail("concat did not join multiple strings correctly") + } +} + +class TestSumMultipleIntegers extends Test, Case { + override predicate run(Qnit test) { + if + concat(string s | s = AggregableTuple::initInt([1, 2]) | s, ",") + .(AggregableTuple::Sum::Sum) + .getAsSummedInt(0) = 3 and + concat(string s | s = AggregableTuple::initInt([1, 2, 3]) | s, ",") + .(AggregableTuple::Sum::Sum) + .getAsSummedInt(0) = 6 + then test.pass("Correctly summed multiple integers") + else test.fail("concat did not sum multiple integers correctly") + } +} + +class TestCountMultipleStrings extends Test, Case { + override predicate run(Qnit test) { + if + concat(string s | s = AggregableTuple::initString(["test1", "test2"]) | s, ",") + .(AggregableTuple::Sum::Sum) + .countColumn(0) = 2 and + concat(string s | s = AggregableTuple::initString(["test1", "test2", "test3"]) | s, ",") + .(AggregableTuple::Sum::Sum) + .countColumn(0) = 3 + then test.pass("Correctly counted multiple strings") + else test.fail("concat did not count multiple strings correctly") + } +} + +class TestCountMultipleIntegers extends Test, Case { + override predicate run(Qnit test) { + if + concat(string s | s = AggregableTuple::initInt([1, 2]) | s, ",") + .(AggregableTuple::Sum::Sum) + .countColumn(0) = 2 and + concat(string s | s = AggregableTuple::initInt([1, 2, 3]) | s, ",") + .(AggregableTuple::Sum::Sum) + .countColumn(0) = 3 + then test.pass("Correctly counted multiple integers") + else test.fail("concat did not count multiple integers correctly") + } +} + +int four() { result = 4 } + +class TestAggregateMultiColumnPieces extends Test, Case { + override predicate run(Qnit test) { + if + exists(AggregableTuple::Sum::Sum summed | + summed = + concat(string s | + s = + AggregableTuple::initString(["test1", "test2"]) + .appendInt([1, 2, 3]) + .appendInt([2, 3, 4]) + .appendString(["test3", "test4"]) + | + s, "," + ) and + summed.getAsJoinedString(0, ",") = "test1,test2" and + summed.getAsSummedInt(1) = 6 and + summed.getAsSummedInt(2) = 9 and + summed.getAsJoinedString(3, ",") = "test3,test4" + ) + then test.pass("Correctly aggregated multi-column pieces") + else test.fail("concat did not aggregate multi-column pieces correctly") + } +}