Course / The model / Lesson 04

Lesson 04 · The model

Keys and constraints

Declare the primary key, multi-column keys, constraints, indexes and extensions, and read the DDL that migration produces.

about 5 minutes · screenplay estimate8 scenesPrerequisites: lesson 03

Development course · Reading edition · API compatibility

Scene 01

The primary key

Every table declares its primary key with the pkey argument of table. pkey names one column. In the invoice table the key is id. The recipe does not declare id as a column. The sys_fields option adds it, together with other system columns. Lesson 25 covers sys_fields. A list of names separated by commas is not accepted. AsqueelDb raises a ValueError that says the pkey is not a column or a compositeColumn. A key made of several columns is declared through a compositeColumn, which the next scene shows. How asqueel generates key values on insert is the subject of lesson 20.

Example & screen notes

Slide title "pkey". Code from the course model:

tbl = pkg.table('invoice', pkey='id', name_long='Invoice', caption_field='inv_number')
tbl.options().sys_fields()

Three bullets:

  • pkey names one column, or one compositeColumn
  • pkey='product_id,year' raises ValueError
  • How key values are generated: lesson 20

Scene 02

compositeColumn

A compositeColumn packs several physical columns of the same table under one name. It does not create a column in the database. Here product_year_key packs product_id and year. With unique True, migration creates one UNIQUE constraint over both columns. A price can exist once per product and year. The name product_year_key is also a key that a relation can point to, and a name that pkey can use. When pkey names a compositeColumn, the primary key of the table is made of its member columns.

Example & screen notes

Slide title "A key over two columns". Code, an addition to the course model copied from examples/test_invoice_pg/invc.py:

def price_year(pkg):
    tbl = pkg.table('price_year', pkey='id', name_long='Price year')
    tbl.options().sys_fields()
    tbl.column('product_id', size='22').relation(
        'product.id', relation_name='price_years', onDelete='cascade', foreign_key=True)
    tbl.column('year', dtype='L')
    tbl.column('unit_price', dtype='money')
    tbl.compositeColumn('product_year_key', columns='product_id,year', unique=True)

Highlight the compositeColumn line. Below, the DDL line it produces:

CONSTRAINT "cst_8b8d4128" UNIQUE ("product_id", "year")

Scene 03

Table constraints

A named table constraint goes in the constraints collection of the table. You call constraints, then constraint. The constraint_type is UNIQUE or CHECK. Any other value raises a validation error. A UNIQUE constraint needs columns, a list of names separated by commas. A CHECK constraint needs check_clause, an SQL expression. A missing columns or check_clause raises when the model is built. Unlike column, constraint is not routed for you. Calling constraint directly on the table raises an error that asks for the constraints collection.

Example & screen notes

Slide title "constraints().constraint(...)". Code:

tbl.constraints().constraint('price_year_positive', constraint_type='CHECK',
                             check_clause='(unit_price >= (0)::numeric)')

note = pkg.table('invoice_note', pkey='id')
note.constraints().constraint('invoice_note_one_type', constraint_type='UNIQUE',
                              columns='invoice_id,note_type')

DDL produced:

CONSTRAINT "price_year_positive" CHECK (unit_price >= (0)::numeric)

ALTER TABLE "invc"."invc_invoice_note"
ADD CONSTRAINT "invoice_note_one_type" UNIQUE ("invoice_id", "note_type");

Scene 04

Write clauses in the form PostgreSQL stores

Migration compares the clause in the recipe with the clause that PostgreSQL stores. PostgreSQL rewrites a clause into its own normal form. For example, unit_price greater or equal to zero is stored with brackets and a cast to numeric. If the recipe keeps the short text, every later migration sees a difference. It drops the constraint and creates it again. Then migrate raises MigrationError, because differences remain after applying. Write the clause in the form PostgreSQL stores. You can read that form with psql, in the output of backslash d on the table. The same rule applies to the where clause of a partial index.

Example & screen notes

Slide title "Clause text and migration". Two rows:

check_clause Second migration_plan()
'unit_price >= 0' drops and re-creates the constraint
'(unit_price >= (0)::numeric)' empty

Scene 05

Indexes

An index is declared with index on the table. columns is a string of names, a list, or a dictionary. The dictionary maps each name to None or to DESC, for a descending column. name is the index name. Without a name, migration generates one. where makes a partial index. unique makes a unique index. method chooses the access method, and the default is btree. The second index on the slide is descending and partial. The third is unique over two columns of the note table.

Example & screen notes

Slide title "index(...)". Code:

tbl.index(columns='year,product_id')
tbl.index(columns={'year': 'DESC'}, name='price_year_recent',
          where='(unit_price IS NOT NULL)')
note.index(columns='invoice_id,priority', unique=True, name='invoice_note_priority')

DDL produced:

CREATE INDEX "idx_2bff00ca" ON "invc"."invc_price_year" USING btree ("year", "product_id");
CREATE INDEX "price_year_recent" ON "invc"."invc_price_year" USING btree ("year" DESC)
    WHERE unit_price IS NOT NULL;
CREATE UNIQUE INDEX "invoice_note_priority" ON "invc"."invc_invoice_note"
    USING btree ("invoice_id", "priority");

Scene 06

Indexes declared on a column

A column can ask for its own index with indexed. True creates a plain index. A dictionary carries options. sorting sets the direction. unique without where makes the column unique and creates no index. A column that carries a relation always receives an index. The DDL of the course model shows it, for example on customer_id of the invoice table. Two dictionary options do not work at column level in this release. A where or a method makes migration raise UnsupportedFeatureError. For a partial index or another method, use index on the table, as in the previous scene.

Example & screen notes

Slide title "column(indexed=...)". Table:

Declaration Result
indexed=True CREATE INDEX ... ("email")
indexed=dict(sorting='DESC') CREATE INDEX ... ("email" DESC)
indexed=dict(unique=True) UNIQUE ("email") constraint
.relation(...) on the column an index on the column
indexed=dict(where=...) or dict(method=...) UnsupportedFeatureError at migration

Scene 07

Extensions

A PostgreSQL extension is declared on the database. You call extensions, then extension with the extension name. Migration creates it with CREATE EXTENSION IF NOT EXISTS. Migration never drops an extension. The extension must be available on the server. Declaring it only asks PostgreSQL to install it in this database.

Example & screen notes

Slide title "extensions().extension(...)". Code:

db.extensions().extension('pg_trgm')

DDL produced:

CREATE EXTENSION IF NOT EXISTS "pg_trgm";

Scene 08

Recap

The primary key names one column or one compositeColumn. Composite columns, constraints and indexes become DDL through migration. Clauses must be written in the form PostgreSQL stores, or every migration re-creates them. The next lesson covers relations.

Example & screen notes

Three bullets:

  • pkey names one column or one compositeColumn
  • compositeColumn(unique=True), constraints().constraint(...) and index(...) produce constraints and indexes
  • Write CHECK and WHERE clauses in the form PostgreSQL stores