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), propertyempty- 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, withON UPDATE CASCADE - Indexes: relation columns,
indexed=columns,sys_fieldstimestamps
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,BIGSERIALdtype='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:
AsqueelDband 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
SqlBuilderclass, 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 againallow_removals,foreignKeys,force,backupcontrol removals, foreign keys and type conversionsinspect_postgresreturnsImportResult(model, warnings), a description to inspect or emit
Next: "Lesson 31 — The command line".