Sustained.py

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

API reference

Errors reference

Every exception Sustained raises, and the condition behind it.

The exception tree

Exception
├── SustainedError              sustained.exceptions
│   ├── AmbiguousColumns
│   ├── DialectError
│   ├── GuardBlocked
│   ├── MigrationError
│   └── RehearsalRequired
├── RuntimeError
│   └── PoolTimeout             sustained.pool
├── ValueError
├── TypeError
└── AttributeError

SustainedError is the base class for the errors Sustained defines itself. PoolTimeout sits outside that tree, on RuntimeError, so an except RuntimeError around connection handling catches a pool timeout.

The rest are the standard builtins, raised where a builtin says the right thing. Sustained does not wrap driver exceptions, so a syntax error or a constraint violation reaches you as your driver’s own exception type.

AmbiguousColumns

A result set returns the same column name more than once, which a join over tables that share a column name does. A row is keyed by column name, so one value would replace the other. Sustained raises this before it hydrates the first row, from run(), to_dicts(), to_df(), to_arrow(), and their async twins. The columns attribute lists the repeated names.

This result set returns 'id' more than once, usually from a join over tables
that share a column name. A row keeps one value per name, so the others would
be lost. Alias them in select(), such as select('users.id AS user_id',
'accounts.id AS account_id').

DialectError

The query or the schema uses a feature the active dialect does not support. Sustained raises DialectError while the statement builds, never in the database.

The message names the unsupported feature and, where there is one, the alternative:

MSSQL does not support RETURNING. Use an OUTPUT clause via raw SQL.
DuckDB has no identity columns. Use a sequence with a DEFAULT expression instead.
QUALIFY is not supported by the 'POSTGRES' dialect. Wrap the window function
in a subquery instead.

See Dialect support for the full matrix.

MigrationError

Migration validation found problems.

The problems attribute is a list of strings. The message is Migration validation failed: followed by one - line per problem.

Migrator.up() and Migrator.validate() raise it. Call validate(raise_on_problems=False) to get the list back instead.

The problems validation reports:

Problem Fix
A migration has a failed attempt on record Clean up any partial changes by hand, then call repair()
An applied id is not registered with this migrator Register the migration, or point the migrator at the right migration set
A checksum no longer matches Restore the migration, or call repair() to accept the new contents
A pending migration is ordered before an applied one Call up(allow_out_of_order=True)

RehearsalRequired

A run would apply SQL that removes data, and no passing rehearsal covers that exact set of statements.

Migrator.up() and AsyncMigrator.up() raise it. Sustained checks the registered migrations before any statement runs. A run with models is checked a second time, against the migration generated from those models, which exists only once the registered migrations have applied. A refusal at that second check leaves the registered migrations applied and lists their ids on the exception’s applied attribute.

The message names the migration and the statement, then both ways forward:

This run removes data, and no rehearsal has proved these statements:
  004_trim  ALTER TABLE users DROP COLUMN legacy
Prove them first: sustained rehearse
Or apply them without proof: sustained migrate --unrehearsed

When a rehearsal of the same content ran and failed, the first line reads The last rehearsal of these statements failed instead.

The CLI exits 4 on this error. up(unrehearsed=True) waives the check and records an override row under the same key, which does not open the gate for a later run. A run that only adds does not raise it, and neither does a callable step, which renders no SQL to scan. See Rehearsal logging and tracking.

GuardBlocked

A guard returned a blocking verdict on a statement the run would apply.

Migrator.up() and AsyncMigrator.up() raise it before any statement runs. The verdicts attribute lists the blocking verdicts, in the order the guards returned them. The message names each rule and the statement it read:

A guard blocked this run:
  no_drops  ALTER TABLE users DROP COLUMN legacy
Fix the statement, or take the rule out of the guard list to run it anyway.

No flag waives a guard. sustained plan and sustained migrate exit 3 when a guard blocks the run. A warning verdict prints on stderr and raises nothing. See Guards.

PoolTimeout

ConnectionPool stayed exhausted past its timeout. The message names the timeout and the pool size. Raise max_size, shorten the work that keeps connections checked out, or catch the timeout and shed load.

ValueError

Invalid input the builder can detect. The method you call raises some of these, and the render raises the rest, which decides where the traceback points.

At call time:

Condition Where
An empty list to whereIn, in_(), or insert() Filters, writes
A row count that is negative or not an integer limit, top, offset, page
limit() and top() in one query, or either one set twice Paging
An operator outside the allowlist where, having
A Predicate passed with an operator or a value where, having
A subquery in from_() with no alias FROM
Rows in a multi-row insert with different columns insert
merge() or ignore() without onConflict() Upserts
Both skip_locked and nowait for_update
An index or migration with an empty name, or with no columns Schema, migrations
autoincrement on a column that is not an integer, or without primary_key ColumnDef
references without a dot ColumnDef
Duplicate migration ids Migrator
A repeatable with a down step, or a callable step with no checksum Migration
A model name in a relation that does not resolve Relations
An unknown relation name Joins, withGraphFetched

At render time:

Condition
UPDATE or DELETE with no where()
Two different subqueries sharing a CTE alias
merge() where every inserted column is a conflict column
A raw fragment whose ? count does not match its parameters
An INSERT with a WHERE clause
A model with no tableName in a statement that needs one
A string function argument that is neither a plain column path nor a Literal
for_update() combined with a union

From migrations:

Condition
An unknown migration target, or a target naming a repeatable
Reverting a migration with no down step
Rehearsing on a dialect whose schema changes do not roll back
Rehearsing on an autocommit connection, or inside a transaction() block
Generation refusing a drop, a NOT NULL change, or a new primary key column
A migration file matching none of the naming patterns
An empty migration file, or a down file with no up file
A ${placeholder} that is unknown or malformed

TypeError

A wrong type in a position that requires a specific one: a CTE or an insert_from source that is not a QueryBuilder, a using value that is not a list, an operator that is not a string, a row count that is not an integer.

bool(predicate) also raises TypeError, so a and b on two predicates fails instead of keeping only one side of the expression. Use & and |.

AttributeError

You accessed a column the model does not declare. The message lists the declared columns:

'Show' does not declare a column named 'titel'. Declared columns: id,
venue_id, title, starts_at, sold_out.

Declare columns, or tableColumns, to turn this check on. Without a declaration, every attribute resolves to a column name and a typo reaches the database.

RuntimeError

Condition The message names this fix
No connection resolved Model.bind(connection), or pass a connection to run()
No async adapter resolved Model.bind_async(adapter), or pass an adapter to arun()
An and or or variant as the first condition in a chain Use the plain form
andOn or orOn as the first join condition Use on
A join lambda that added no condition Add a condition
Using a closed ConnectionPool  
to_df() or to_arrow() without pandas or pyarrow The install command