Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,41 @@ from Qtil::Product<Person, City>::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<two/0>::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.
Expand Down
149 changes: 149 additions & 0 deletions src/qtil/tuple/AggregableTuple.qll
Original file line number Diff line number Diff line change
@@ -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<Chars::comma/0>::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<two/0>::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<StringTuple>::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<Nullary::Ret<int>::pred/0 columns> {
bindingset[this]
class Sum extends InfInstance<StringTuple>::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())
}
}
}
}
1 change: 1 addition & 0 deletions test/qtil/tuple/AggregableTupleTest.expected
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
| All 12 tests passed. |
155 changes: 155 additions & 0 deletions test/qtil/tuple/AggregableTupleTest.ql
Original file line number Diff line number Diff line change
@@ -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<one/0>::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<one/0>::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<one/0>::Sum)
.getAsJoinedString(0, ",") = "test1,test2" and
concat(string s | s = AggregableTuple::initString(["test1", "test2", "test3"]) | s, ",")
.(AggregableTuple::Sum<one/0>::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<one/0>::Sum)
.getAsSummedInt(0) = 3 and
concat(string s | s = AggregableTuple::initInt([1, 2, 3]) | s, ",")
.(AggregableTuple::Sum<one/0>::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<one/0>::Sum)
.countColumn(0) = 2 and
concat(string s | s = AggregableTuple::initString(["test1", "test2", "test3"]) | s, ",")
.(AggregableTuple::Sum<one/0>::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<one/0>::Sum)
.countColumn(0) = 2 and
concat(string s | s = AggregableTuple::initInt([1, 2, 3]) | s, ",")
.(AggregableTuple::Sum<one/0>::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<four/0>::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")
}
}