Sustained.py

A Python ORM, SQL query builder, and schema migration tool, inspired by Objection.js and Knex

Guides

Grouping and Having Clauses

groupBy() collapses rows, and having() filters what is left:

(Ticket.query()
    .select('show_id')
    .count('id', alias='sold')
    .groupBy('show_id')
    .having('COUNT(id)', '>', 100))
# SELECT show_id, COUNT(id) AS sold FROM tickets GROUP BY show_id HAVING COUNT(id) > 100

WHERE runs before the grouping, and HAVING runs after, so a filter on a raw column belongs in where() and a filter on an aggregate belongs in having(). The database rejects an aggregate in where(), and Sustained does not check for one.

The examples use the venue booking schema from Getting Started.

Grouping rows

groupBy() takes any number of columns:

Ticket.query().groupBy('show_id')
# SELECT * FROM tickets GROUP BY show_id

Show.query().groupBy('venue_id', 'sold_out')
# SELECT * FROM shows GROUP BY venue_id, sold_out

Pair it with an aggregate from Queries:

(Ticket.query()
    .select('show_id')
    .sum('price', alias='gross')
    .groupBy('show_id')
    .orderBy('gross', 'desc'))
# SELECT show_id, SUM(price) AS gross FROM tickets GROUP BY show_id ORDER BY gross DESC

Filtering groups

having(), andHaving(), and orHaving() chain the same way their where counterparts do, and take the same three arguments:

(Ticket.query()
    .groupBy('show_id')
    .having('COUNT(id)', '>', 100)
    .andHaving('SUM(price)', '>', 5000))
# SELECT * FROM tickets GROUP BY show_id HAVING COUNT(id) > 100 AND SUM(price) > 5000

(Ticket.query()
    .groupBy('show_id')
    .having('AVG(price)', '<', 25)
    .orHaving('MAX(price)', '>', 250))
# SELECT * FROM tickets GROUP BY show_id HAVING AVG(price) < 25 OR MAX(price) > 250

The first condition in a chain must be a plain having(). Starting with andHaving() or orHaving() raises RuntimeError.

Write the aggregate expression itself rather than its alias. Standard SQL evaluates HAVING before the SELECT list exists, so the database rejects having('gross', '>', 5000) even though gross appears in the select list:

# Works everywhere.
(Ticket.query()
    .select('show_id')
    .sum('price', alias='gross')
    .groupBy('show_id')
    .having('SUM(price)', '>', 5000)
)

The having family

The having methods have the same and and or forms as the where family, take the same arguments, and raise the same exceptions.

Method Arguments
having column, operator, value
havingIn / havingNotIn column, list or query
havingBetween / havingNotBetween column, low, high
havingLike / havingILike column, pattern
havingNull / havingNotNull column
havingExists / havingNotExists query or callable
havingRaw SQL, parameters
Show.query().groupBy('venue_id').havingIn('venue_id', [1, 2, 3])
# SELECT * FROM shows GROUP BY venue_id HAVING venue_id IN (1, 2, 3)

Venue.query().groupBy('city').havingLike('city', 'Minne%')
# SELECT * FROM venues GROUP BY city HAVING city LIKE 'Minne%'

Ticket.query().groupBy('show_id').havingRaw('SUM(price) % ? = ?', [10, 0])
# SELECT * FROM tickets GROUP BY show_id HAVING (SUM(price) % 10 = 0)

havingILike() follows the same rule as whereILike(): native ILIKE on Postgres and DuckDB, a lowercased comparison everywhere else.

Grouping conditions

Pass a callable to any having method to build a parenthesized group, and call the having methods on the builder it receives:

(Ticket.query()
    .groupBy('show_id')
    .having('COUNT(id)', '>', 50)
    .andHaving(lambda q: (
        q.having('SUM(price)', '<', 1000).orHaving('MAX(price)', '>=', 250)
    ))
)
# SELECT * FROM tickets GROUP BY show_id
# HAVING COUNT(id) > 50 AND (SUM(price) < 1000 OR MAX(price) >= 250)

Groups can nest as deep as the logic needs. having() also accepts a typed predicate built from Model.c and combined with & and | in place of the three arguments, which replaces the nested lambdas with parentheses.

Subtotals and multiple grains

groupByRollup() adds a subtotal for each prefix of the column list, ending in a grand total. The column order defines the hierarchy, so ROLLUP (venue_id, show_id) produces a subtotal for each venue but not for each show on its own:

Ticket.query().select('price').groupByRollup('show_id')
# SELECT price FROM tickets GROUP BY ROLLUP (show_id)

groupByCube() adds a subtotal for every combination of the columns, so column order has no effect and the number of grouping sets doubles with each column you add.

groupByGroupingSets() takes the combinations explicitly, as tuples. An empty tuple is the grand total:

Ticket.query().groupByGroupingSets(('show_id',), ('show_id', 'sold_at'), ())

Rows produced by a subtotal contain NULL in the columns they aggregate over, which is indistinguishable from a real NULL in the data. Engines expose a GROUPING() function to tell them apart, and you can call it through select_func().

Where to go next

You want to Read
Filter rows before the grouping Filtering
Build the aggregates being grouped Queries
Rank inside a group instead of collapsing it Queries
Run the query Executing Queries
See every method and what it raises QueryBuilder reference