Course / Transactions & operations / Lesson 30

Lesson 30 · Transactions & operations

Import and migrations

Create and evolve the database from the recipe, and read an existing PostgreSQL catalog.

about 10 minutes · screenplay estimate10 scenesPrerequisites: lessons 3, 4, 5, 11, 25

Development course · Reading edition · API compatibility

Scene 01

Two directions

Asqueel connects the recipe and the database in two directions. From the recipe to the database, the migration compares the model with the live database and applies the difference. From the database to Python, the importer reads the PostgreSQL catalog. The migration needs the extra named migration. On PostgreSQL, it also needs the extra named postgresql.

On screen (addition): pip install 'asqueel[postgresql,migration]'

Example & screen notes

Diagram with two boxes, "Recipe (db.model)" on the left and "PostgreSQL" on the right. Arrow left to right labelled "db.migration_plan() / db.migrate()". Arrow right to left labelled "inspect_postgres(connection, schemas)".

Scene 02

The first migration

db dot migration_plan compares the model with the live database. It changes nothing. It returns a MigrationPlan with three fields. commands is the DDL text. warnings lists the warnings of the migration engine. skipped lists the changes the backend cannot apply. The property empty is True when there is no command. On a new server, the plan starts with CREATE DATABASE. The database name comes from the connection settings, here the DSN, not from the name of the recipe. db dot migrate computes a new plan, applies it, and returns it. Then it compares again, and raises MigrationError if differences remain. A second plan is empty.

Example & screen notes

Code block, then the first lines of plan.commands.

from asqueel import AsqueelDb
from invoicing import Invoicing

db = AsqueelDb(Invoicing)
plan = db.migration_plan()
plan.commands.splitlines()[:2]
# ['CREATE DATABASE "asqueel_course_p5" ENCODING \'UNICODE\';', 'CREATE SCHEMA "invc";']
db.migrate()
db.migration_plan().empty        # True
db.closeConnection()

Bullets:

  • MigrationPlan(commands, warnings, skipped), property empty
  • The database name comes from the connection settings

Scene 03

What the migration creates

The plan creates the database, the schema of the package, and every table. The table name carries the package prefix, because the package declares sqlprefix. The sys fields of lesson 25 add the underscore underscore timestamp columns and their indexes. unique equal True on inv number becomes a UNIQUE constraint. Every relation column gets an index, unless the relation says indexed equal False. A foreign key constraint exists only where the relation declares foreign key equal True. It carries ON UPDATE CASCADE by default. onDelete equal cascade adds no ON DELETE clause. That cascade is a Python relation action, as in lesson 23. Virtual columns, such as aliasColumn and formulaColumn, create nothing.

Example & screen notes

Excerpt of the DDL, three parts highlighted.

CREATE TABLE "invc"."invc_invoice"(
 "id" character(22) NOT NULL,
 "__ins_ts" timestamp with time zone,
 ...
 "inv_number" character varying(10),
 "customer_id" character(22),
 ...
 PRIMARY KEY ("id")
);
ALTER TABLE "invc"."invc_invoice"
ADD CONSTRAINT "cst_74a8dffe" UNIQUE ("inv_number");
CREATE INDEX "idx_3e295060" ON "invc"."invc_invoice" USING btree ("customer_id");
...
ALTER TABLE "invc"."invc_invoice"
 ADD CONSTRAINT "fk_935fad9a" FOREIGN KEY ("customer_id")
 REFERENCES "invc"."invc_customer" ("id") ON UPDATE CASCADE;

Bullets:

  • Database, schemas, tables, primary keys, unique constraints
  • Foreign keys only for foreign_key=True, with ON UPDATE CASCADE
  • Indexes: relation columns, indexed= columns, sys_fields timestamps

Scene 04

Evolving the recipe

A layer adds a phone column to customer and a new table, invoice log. The plan adds the column and creates the table. foreignKeys equal False creates no foreign key constraint. The index of the relation column stays. Foreign keys that already exist in the database are kept. With the default, True, the plan also adds the foreign key. After db dot migrate, a new plan is empty.

Example & screen notes

Code block, the layer; then the plan with foreignKeys=False and the plan with defaults.

class Additions(SqlDatabaseConfig):
    def main(self, root):
        invc = root.db('course', implementation='postgres').packages().package('invc')
        invc.table('customer').column('phone', size=':20')
        log = invc.table('invoice_log', pkey='id', name_long='Invoice log')
        log.column('id', dtype='serial4')
        log.column('invoice_id', size='22').relation(
            'invoice.id', relation_name='log', onDelete='cascade', foreign_key=True)
        log.column('message')


db = AsqueelDb(Additions, parents=[Invoicing])
print(db.migration_plan(foreignKeys=False).commands)
ALTER TABLE "invc"."invc_customer"
ADD COLUMN "phone" character varying(20);
CREATE TABLE "invc"."invc_invoice_log"(
 "id" serial4 NOT NULL,
 "invoice_id" character(22),
 "message" text,
 PRIMARY KEY ("id")
);
CREATE INDEX "idx_405c9a3e" ON "invc"."invc_invoice_log" USING btree ("invoice_id");

Caption: "Without foreignKeys=False, the plan also adds fk_460909f5."

Scene 05

serial and serial4 keys

The key of invoice log has dtype serial4. serial4 is a 32-bit PostgreSQL serial column. serial is the 64-bit form. On insert, the database assigns the key. The insert reads it back and writes it into the record.

Example & screen notes

Code block and output.

inv = db.table('invc.invoice').insert({'inv_number': 'X-1'})
row = db.table('invc.invoice_log').insert({'invoice_id': inv['id'], 'message': 'created'})
row['id']        # 1
db.commit()

Bullets:

  • dtype='serial': 64-bit, BIGSERIAL
  • dtype='serial4': 32-bit, SERIAL
  • The database assigns the key; the insert writes it into the record

Scene 06

Removals and type changes

Removals are disabled by default. Without phone in the recipe, the plan is empty. allow_removals equal True enables the supported removals. In this release, the supported removals are columns. Whole tables, indexes, relations and constraints are not removed. The second example changes product code from text to a 64-bit integer. The table holds the codes 100 and P-2. The plan itself raises MigrationError, because a value would not convert. force equal True converts anyway, and values that do not convert become NULL. backup equal True implies force. It first copies the column into code underscore underscore A, named after the old type. After migrate, product P-2 has a NULL code, and its old value is in the backup column. The returned plan warns about every backup column that keeps lost values.

Example & screen notes

Two code blocks with their output.

db = AsqueelDb(Invoicing)                      # the recipe without phone
db.migration_plan().empty                      # True
print(db.migration_plan(allow_removals=True).commands)
# ALTER TABLE "invc"."invc_customer"
# DROP COLUMN "phone";
class CodeAsNumber(SqlDatabaseConfig):
    def main(self, root):
        invc = root.db('course', implementation='postgres').packages().package('invc')
        invc.table('product').column('code', dtype='L', size=None)

db = AsqueelDb(CodeAsNumber, parents=[Invoicing, Additions])
db.migration_plan()
# MigrationError: Incompatible type conversion A→L on non-empty column invc_product.code.
# Use --force to convert (non-matching values become NULL) or --backup to create backup columns first.
plan = db.migrate(backup=True)
plan.warnings
# ('invc.invc_product.code: 1 value(s) not converted, kept in backup column code__A',)

Scene 07

Reading a catalog

inspect_postgres takes a psycopg connection and a list of schema names. It reads the catalog and changes nothing. It does not commit, roll back or close your connection. The example opens a read-only transaction to read one consistent snapshot. It returns an ImportResult with two fields, model and warnings. A schema that does not exist produces a warning. The model holds the physical tables, with their columns, keys and foreign keys. Table names are the physical names, with the prefix. A foreign key is a relation named after its constraint. Common PostgreSQL types become asqueel type codes, and the original type stays in raw underscore type. The importer needs PostgreSQL 15 or newer.

Example & screen notes

Code block and output.

import psycopg
from asqueel import inspect_postgres

with psycopg.connect(dsn) as connection:
    connection.execute('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY')
    result = inspect_postgres(connection, ['invc', 'nowhere'])

type(result).__name__, type(result.model).__name__   # ('ImportResult', 'ResolvedModel')
result.warnings          # ('nowhere: requested schema does not exist',)
invoice = result.model.table('invc.invc_invoice')
invoice.pkey             # ('id',)
list(invoice.relations)  # ['fk_935fad9a']
column = invoice.columns['inv_number']
column.dtype, column.attributes['size'], column.attributes['raw_type']
# ('A', '0:10', 'character varying(10)')

Scene 08

What the imported model is for

In this release, the imported model is a description. The query compiler refuses it with TypeError, because it needs a model built from a recipe. To query a database, you write a recipe, as in lesson 3. The imported model can be projected for the migration engine. A model with import warnings cannot be projected, so we inspect only the existing schema. allow_constraint_rename equal True accepts that constraint names differ from the generated ones. SqlModelReader turns the projection into a builder tree. SqlPythonEmitter emits the Python source of that tree.

Example & screen notes

Bullets, then code.

  • Inspect tables, columns, keys and warnings
  • Project it for the migration engine
  • Not a database you can query: AsqueelDb and the query compiler need a recipe
from asqueel import SqlMigrationRenderer, SqlModelReader, SqlPythonEmitter, to_physical_builder

clean = inspect_postgres(connection, ['invc'])
structure = SqlMigrationRenderer(
    to_physical_builder(clean.model, allow_constraint_rename=True)).render()
source = SqlPythonEmitter(SqlModelReader(structure).to_builder()).emit(class_name='ImportedInvoicing')

Scene 09

The emitted recipe

The emitted source is a SqlBuilder class. It uses schemas and tables, not packages, and physical names. Every index of the database is an explicit index element with its database name. The emitted module has no connection. In this release, it does not load as a configured database when its tables have relations. The explicit indexes repeat the indexes that the relations already imply, and validation refuses the duplicates. Use the emitted source as the starting point of a recipe you edit by hand.

Example & screen notes

Excerpt of source.

class ImportedInvoicing(SqlBuilder):
    """Recipe of a database read from its normalized structure."""

    def main(self, root):
        db = root.db(name="asqueel_course_p5")

        schemas = db.schemas()

        invc = schemas.schema(name="invc")
        tables = invc.tables()

        invc_customer = tables.table(name="invc_customer", pkey="id")
        columns = invc_customer.columns()
        columns.column(name="id", dtype="C", size="22")
        ...
        state = columns.column(name="state", dtype="A", size="0:5")
        state.relation(to="invc.invc_state.code", foreign_key=True, onUpdate_sql="cascade")
        ...
        indexes = invc_customer.indexes()
        indexes.index(columns={"state": None}, name="idx_14d21116")

Bullets:

  • A SqlBuilder class, written with schemas and tables, physical names
  • Every index explicit, with its database name
  • A starting point to edit, not a ready configuration

Scene 10

Recap

migration_plan shows the DDL, and migrate applies it and checks the result. Four options control removals, foreign keys and type conversions. inspect_postgres returns a description of the catalog, which you inspect or emit as source. The next lesson runs the same migrations from the command line.

Example & screen notes

Slide "Recap". Bullets:

  • db.migration_plan() shows the DDL, db.migrate() applies it and checks again
  • allow_removals, foreignKeys, force, backup control removals, foreign keys and type conversions
  • inspect_postgres returns ImportResult(model, warnings), a description to inspect or emit

Next: "Lesson 31 — The command line".