Aggregation¶
Aggregation happens in the rule head, together with the distinct keyword. The ? marks the aggregated output column.
@OrderBy(Stats, "category");
Stats(category:, total? += amount, count? += 1) distinct :- Sales(category:, amount:);
Non-aggregated head columns (category above) become the grouping key, like GROUP BY in SQL.
Aggregation operators¶
| Operator | Meaning |
|---|---|
col? += expr |
Sum (use += 1 to count) |
col? Min= expr |
Minimum |
col? Max= expr |
Maximum |
col? Avg= expr |
Average |
col? List= expr |
Collect all values into an array |
col? Set= expr |
Collect distinct values into an array |
col? Count= expr |
Number of distinct values, nulls not counted |
col? ArgMax= item -> score |
The item with the highest score |
col? ArgMin= item -> score |
The item with the lowest score |
# Sum
Revenue(total? += amount) distinct :- Orders(amount:);
# Count
OrderCount(n? += 1) distinct :- Orders(order_id:);
# Min / Max
Cheapest(min_price? Min= price) distinct :- Products(price:);
Priciest(max_price? Max= price) distinct :- Products(price:);
# Average
AvgOrder(avg? Avg= amount) distinct :- Orders(amount:);
# Collect into list / set
AllNames(names? List= name) distinct :- Users(name:);
UniqueNames(names? Set= name) distinct :- Users(name:);
# Value with max key
TopSeller(name? ArgMax= name -> revenue) distinct :- Sales(name:, revenue:);
Counting
Never use Count(). Use count? += 1 instead.
More aggregating functions¶
In addition to the operators above: Array= key -> value (the values in an array, ordered by their key), StringAgg= x (the values as text, joined with ,, in no particular order; null when they are all null), 1= x (any single value), and ArgMaxK and ArgMinK (the k items of highest or lowest score). These two take how many items to keep: name one with its count, then aggregate with the name:
Top3(x) = ArgMaxK(x, 3);
Podium(race:, top? Top3= runner -> points) distinct :- Result(race:, runner:, points:);
An aggregate aggregates only as an operator (n? Max= x) or in a combine: called as a value (Q(t: Max(x))) or in a condition (x == Max(x)), it is refused.
Nulls and empty groups¶
+=, Min=, Max=, Avg= and Count= skip a null value; List= and Set= collect it like any other (List= x over 1, null, 2 is [1, null, 2]). Min= and Max= order booleans false before true, and text by code point ("B" before "a").
With a grouping key, a group exists only when it has rows, so an aggregation over no rows gives no row. Without one, it gives a single row: +=, Min=, Max=, Avg=, List=, Set= and Array= are null there and Count= is 0. To count 0 instead of null, use Coalesce:
Big(n? += 1) distinct :- Orders(amount:), amount > 1000; # one row: null when no order is over 1000
BigCount(n: Coalesce(c, 0)) :- Big(n: c);
combine: an aggregate as a value¶
(combine Op= expr :- body) is the aggregate of body's rows, used as a value anywhere a value is: in a head, a condition, a function. The body sees the variables of the enclosing rule, so the aggregate is computed for each of its rows, like a correlated subquery:
# Each customer's total, null for a customer without orders.
CustomerTotal(customer_id:, total: (combine += amount :- Orders(customer_id:, amount:))) :-
Customer(customer_id:);
# Orders above their customer's average.
AboveAverage(order_id:) :-
Orders(order_id:, customer_id:, amount:),
amount > (combine Avg= a :- Orders(customer_id:, amount: a));
# Customers who spent over 1000: a combine in a condition.
BigSpender(customer_id:) :- Customer(customer_id:),
(combine += amount :- Orders(customer_id:, amount:)) > 1000;
Every aggregating operator works in a combine. Over no rows it follows the rule above: null, and 0 for Count=; write Coalesce((combine += 1 :- ...), 0) to count 0. The body can range over the elements of a list of the enclosing row ((combine Max= y :- y in l, y > 1)), and can hold conditions and negations, but no disjunction: put the alternatives in a predicate of their own.
Deduplication without aggregation¶
distinct on its own deduplicates rows, and this is how concepts extract unique entities:
Complete example¶
Sum, count, min/max/avg, Set= collection and ArgMax= over a small sales table:
# run: Stats, PriceStats, UniqueRegions, TopSeller
@Engine("duckdb");
Sales(category: "books", region: "eu", seller: "ann", amount: 120);
Sales(category: "books", region: "us", seller: "bob", amount: 80);
Sales(category: "games", region: "eu", seller: "ann", amount: 300);
Sales(category: "games", region: "eu", seller: "cid", amount: 150);
Sales(category: "games", region: "us", seller: "bob", amount: 220);
# Sum and count per category. Non-aggregated columns form the grouping key.
@OrderBy(Stats, "category");
Stats(category:, total? += amount, count? += 1) distinct :- Sales(category:, amount:);
# Min, max and average.
@OrderBy(PriceStats, "category");
PriceStats(category:, lo? Min= amount, hi? Max= amount, avg? Avg= amount) distinct :-
Sales(category:, amount:);
# Collect distinct values into an array.
@OrderBy(UniqueRegions, "category");
UniqueRegions(category:, regions? Set= region) distinct :- Sales(category:, region:);
# The seller with the highest total revenue.
@OrderBy(SellerRevenue, "seller");
SellerRevenue(seller:, revenue? += amount) distinct :- Sales(seller:, amount:);
TopSeller(name? ArgMax= seller -> revenue) distinct :- SellerRevenue(seller:, revenue:);
Generated SQL and execution results
$ synalog.check('aggregation.l')
No errors found.
$ synalog.compile('aggregation.l', 'Stats')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Sales AS (SELECT * FROM (
SELECT
'books' AS category,
'eu' AS region,
'ann' AS seller,
120 AS amount
UNION ALL
SELECT
'books' AS category,
'us' AS region,
'bob' AS seller,
80 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'ann' AS seller,
300 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'cid' AS seller,
150 AS amount
UNION ALL
SELECT
'games' AS category,
'us' AS region,
'bob' AS seller,
220 AS amount
) AS UNUSED_TABLE_NAME )
SELECT
Sales.category AS category,
SUM(Sales.amount) AS total,
SUM(1) AS count
FROM
t_0_Sales AS Sales
GROUP BY Sales.category ORDER BY category;
-- Executed on DuckDB:
| category | total | count |
|----------|-------|-------|
| books | 200 | 2 |
| games | 670 | 3 |
(2 rows)
$ synalog.compile('aggregation.l', 'PriceStats')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Sales AS (SELECT * FROM (
SELECT
'books' AS category,
'eu' AS region,
'ann' AS seller,
120 AS amount
UNION ALL
SELECT
'books' AS category,
'us' AS region,
'bob' AS seller,
80 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'ann' AS seller,
300 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'cid' AS seller,
150 AS amount
UNION ALL
SELECT
'games' AS category,
'us' AS region,
'bob' AS seller,
220 AS amount
) AS UNUSED_TABLE_NAME )
SELECT
Sales.category AS category,
MIN(Sales.amount) AS lo,
MAX(Sales.amount) AS hi,
AVG(Sales.amount) AS avg
FROM
t_0_Sales AS Sales
GROUP BY Sales.category ORDER BY category;
-- Executed on DuckDB:
| category | lo | hi | avg |
|----------|-----|-----|--------------------|
| books | 80 | 120 | 100.0 |
| games | 150 | 300 | 223.33333333333334 |
(2 rows)
$ synalog.compile('aggregation.l', 'UniqueRegions')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_0_Sales AS (SELECT * FROM (
SELECT
'books' AS category,
'eu' AS region,
'ann' AS seller,
120 AS amount
UNION ALL
SELECT
'books' AS category,
'us' AS region,
'bob' AS seller,
80 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'ann' AS seller,
300 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'cid' AS seller,
150 AS amount
UNION ALL
SELECT
'games' AS category,
'us' AS region,
'bob' AS seller,
220 AS amount
) AS UNUSED_TABLE_NAME )
SELECT
Sales.category AS category,
ARRAY_AGG(DISTINCT Sales.region ORDER BY Sales.region) AS regions
FROM
t_0_Sales AS Sales
GROUP BY Sales.category ORDER BY category;
-- Executed on DuckDB:
| category | regions |
|----------|--------------|
| books | ['eu', 'us'] |
| games | ['eu', 'us'] |
(2 rows)
$ synalog.compile('aggregation.l', 'TopSeller')
-- Initializing DuckDB environment.
create schema if not exists logica_home;
-- Empty record, has to have a field by DuckDB syntax.
drop type if exists logicarecord893574736 cascade; create type logicarecord893574736 as struct(nirvana numeric);
create sequence if not exists eternal_logical_sequence;
WITH t_2_Sales AS (SELECT * FROM (
SELECT
'books' AS category,
'eu' AS region,
'ann' AS seller,
120 AS amount
UNION ALL
SELECT
'books' AS category,
'us' AS region,
'bob' AS seller,
80 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'ann' AS seller,
300 AS amount
UNION ALL
SELECT
'games' AS category,
'eu' AS region,
'cid' AS seller,
150 AS amount
UNION ALL
SELECT
'games' AS category,
'us' AS region,
'bob' AS seller,
220 AS amount
) AS UNUSED_TABLE_NAME ),
t_1_SellerRevenue AS (SELECT
Sales.seller AS seller,
SUM(Sales.amount) AS revenue
FROM
t_2_Sales AS Sales
GROUP BY Sales.seller ORDER BY seller)
SELECT
argmax(SellerRevenue.seller, SellerRevenue.revenue) AS name
FROM
t_1_SellerRevenue AS SellerRevenue;
-- Executed on DuckDB:
| name |
|------|
| ann |
(1 row)