Sustained.py

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

API reference

Schema types reference

Everything in sustained.schema. These objects go in a model’s tableColumns, indexes, and tableOptions, and the renderers below turn them into DDL.

Guide: Schema and Migrations.

Column types

Each type is a factory that returns a ColumnDef. Every factory accepts the full ColumnDef option set as keyword arguments.

Signature Logical type
Integer(**options) INTEGER
BigInteger(**options) BIGINT
String(length=255, **options) VARCHAR(length)
Text(**options) TEXT
Boolean(**options) BOOLEAN
Float(**options) FLOAT
Numeric(precision=18, scale=6, **options) NUMERIC(precision, scale)
Date(**options) DATE
Timestamp(**options) TIMESTAMP
Binary(**options) BINARY
Json(**options) JSON
Enum(*values, name, **options) ENUM (see Enum)

How types render per dialect

Logical Default, Presto Postgres MySQL MSSQL Athena DuckDB
INTEGER INTEGER INTEGER INT INTEGER INT INTEGER
BIGINT BIGINT BIGINT BIGINT BIGINT BIGINT BIGINT
VARCHAR VARCHAR VARCHAR VARCHAR NVARCHAR VARCHAR, or STRING with no length VARCHAR
TEXT TEXT TEXT TEXT NVARCHAR(MAX) STRING TEXT
BOOLEAN BOOLEAN BOOLEAN TINYINT(1) BIT BOOLEAN BOOLEAN
FLOAT DOUBLE PRECISION DOUBLE PRECISION DOUBLE FLOAT DOUBLE DOUBLE PRECISION
NUMERIC NUMERIC NUMERIC DECIMAL NUMERIC DECIMAL NUMERIC
DATE DATE DATE DATE DATE DATE DATE
TIMESTAMP TIMESTAMP TIMESTAMP DATETIME DATETIME2 TIMESTAMP TIMESTAMP
BINARY BLOB, or VARBINARY on Presto BYTEA BLOB VARBINARY(MAX) BINARY BLOB
JSON JSON JSONB JSON NVARCHAR(MAX) STRING JSON
ENUM VARCHAR(n) + CHECK the named type ENUM(...) NVARCHAR(n) + CHECK DialectError the named type

An ENUM column renders per the dialect’s enum strategy. Postgres and DuckDB reference a named type created with CREATE TYPE. MySQL writes the value list inline. The default dialect and MSSQL render a VARCHAR sized to the longest value, constrained to the list by a CHECK constraint named ck_<table>_<column>_enum. Presto raises like Athena. SQL Dialects has the details.

MySQL spells BOOLEAN as TINYINT(1) because its catalog reports the underlying type rather than the synonym. It spells TIMESTAMP as DATETIME because a MySQL TIMESTAMP column is four bytes, stops in 2038, and converts time zones, while Timestamp() describes a plain wall clock.

ColumnDef

ColumnDef(type_name, *, length=None, precision=None, scale=None, primary_key=False, nullable=True, unique=False, default=None, references=None, autoincrement=False, backfill=None, enum_name=None, enum_values=None, comment=None)

Use the factories above rather than constructing a ColumnDef yourself. Every keyword below works on every factory.

Option Type Meaning
primary_key bool The column is part of the primary key. Mark several columns for a composite key.
nullable bool Whether the column allows NULL. A primary key column is always NOT NULL, whatever you pass.
unique bool Adds UNIQUE. The DDL leaves UNIQUE off when the column is also a primary key.
default value or Expression A literal default, or raw SQL, as in Timestamp(default=Expression('CURRENT_TIMESTAMP')).
references 'table.column' Renders REFERENCES table (column). Raises ValueError when the string has no dot.
autoincrement bool Identity values. Requires an integer type and primary_key=True, or the factory raises ValueError.
backfill value or Expression The value migration generation gives existing rows when it adds this column NOT NULL, or tightens the column to NOT NULL. Plain DDL ignores backfill.
length int VARCHAR length.
precision, scale int NUMERIC precision and scale.
enum_name, enum_values str, values The type name and permitted values of an ENUM column. Valid only there; use the Enum factory, which fills both.
comment str A description stored in the database catalog on dialects that keep column comments. Must be a non-empty string, or the factory raises ValueError. Dialects without column comments render nothing. See Column comments.

default fills new rows in the database. backfill fills the rows that are already in the table, at migration time. A NOT NULL change needs one or the other.

Enum

Enum(*values, name, **options)

Enum returns an ENUM ColumnDef: a named, ordered list of permitted string values. Pass the values as strings, or pass one Python enum.Enum class whose member values are strings. Hydrated values stay plain strings.

name is a required keyword. The same name with the same values in two models is one type; the same name with different values raises ValueError when the tables render or diff.

Enum raises ValueError for a missing or empty name, an empty value list, a value that is not a non-empty string, a duplicate value, and a default that is not one of the values.

Guide: Enum columns.

Check

Check(name, expression)

A named CHECK constraint. List instances in the model’s tableConstraints attribute. The expression is SQL and renders as written. Check raises ValueError for an empty name or an empty expression.

ForeignKey

ForeignKey(name, columns, references, on_delete=None, on_update=None)

A named FOREIGN KEY constraint, listed in tableConstraints. columns is a string or a sequence of the constrained columns, in order. references is a 'table.column' string, or a sequence of them, one per constrained column; every target column must belong to the same table. on_delete and on_update accept CASCADE, SET NULL, RESTRICT, NO ACTION, and SET DEFAULT, case-insensitively; FOREIGN_KEY_ACTIONS is that tuple.

ForeignKey raises ValueError for an empty name, no columns, a column and target count that differ, a target without a dot, targets in more than one table, and an action outside the list.

For a single column with no actions, references='table.column' on the column definition renders the same constraint. TableConstraint is the union type of Check and ForeignKey, and tableConstraints accepts it.

Guide: Table constraints.

Identity per dialect

Dialect autoincrement renders
Default (SQLite) nothing, because INTEGER PRIMARY KEY is a rowid alias
Postgres GENERATED BY DEFAULT AS IDENTITY
MySQL AUTO_INCREMENT
MSSQL IDENTITY(1,1)
DuckDB, Presto, Athena DialectError

The DuckDB message names the alternative: a sequence with a DEFAULT expression.

Index

Index(name, *columns, unique=False)

An Index has name, columns, and unique. It raises ValueError for an empty name or for no columns.

from sustained.schema import Index

class Show(Model):
    tableName = 'shows'
    tableColumns = {...}
    indexes = [Index('ix_shows_venue', 'venue_id')]

create_table() creates the indexes, and migration generation keeps them in step with the model. Athena raises DialectError, because Athena has no indexes; partition the table instead.

TableOptions

TableOptions(location=None, partitioned_by=None, properties=None)

TableOptions declares storage clauses for engines that need them. Athena renders PARTITIONED BY, LOCATION, and TBLPROPERTIES after the column list. Every other dialect raises DialectError when the model sets tableOptions.

from sustained.schema import TableOptions

class Event(Model):
    tableName = 'events'
    tableColumns = {...}
    tableOptions = TableOptions(
        location='s3://bucket/warehouse/events/',
        partitioned_by=['day(created_at)'],
        properties={'table_type': 'ICEBERG'},
    )

Partition entries render as written, so an Iceberg transform reaches the engine unchanged.

Athena column rules

Athena tables are files in S3 and enforce no constraints, so validate_column_def raises DialectError for a column that declares primary_key, unique, default, references, autoincrement, or nullable=False. The message lists every problem it found in the column. Declare Athena columns plain and nullable.

MySQL column rules

MySQL needs a prefix length for a key on a TEXT, JSON, or BLOB column, so validate_column_def raises DialectError for a Text(), Json(), or Binary() column that declares unique or primary_key. Use String(n) with a length that fits, or declare the prefix index in a hand-written migration. MySQL also takes no literal DEFAULT on those types, so a default on them raises as well. Set the value in the application, or give the column a backfill so the migration sets it.

DDL rendering

Both renderers take a compiler, which you get from Dialects.get_compiler(dialect). Model.create_table_sql() calls them for you.

render_column_sql(compiler, name, col, inline_pk) -> str

One column clause, for CREATE TABLE or ADD COLUMN.

build_create_table_sql(compiler, table_sql, columns, if_not_exists=False, options=None, extras=None, constraints=None) -> str

The whole CREATE TABLE statement. constraints is a sequence of declared Check and ForeignKey objects, rendered after the constraints the columns themselves imply.

A single primary key column renders inline. Several primary key columns become a table-level PRIMARY KEY (...) constraint.

build_create_table_sql raises ValueError when columns is empty, and when an autoincrement column is combined with a composite primary key.

Expression

Expression(value)

Raw SQL that renders as written, in both str(query) and to_sql(). Use an Expression for a default or a backfill the database computes:

from sustained.schema import Expression, Timestamp

'created_at': Timestamp(default=Expression('CURRENT_TIMESTAMP'))

Expression is defined in sustained.types and re-exported here.