Course / The model / Lesson 05

Lesson 05 · The model

To-one and inverse relations

Declare relations, read related columns through `@` paths, and know what the inverse side of a relation allows.

about 6 minutes · screenplay estimate9 scenesPrerequisites: lessons 03-04

Development course · Reading edition · API compatibility

Scene 01

Declaring a relation

A relation is declared on the column that holds the key. You call relation on the column. The first argument is the target, written as package dot table dot column. Inside the same package, table dot column is enough. The invoice column customer_id points to the id of customer. foreign_key True asks for a physical foreign key. In a package, a relation gets a foreign key even when you omit the argument. Migration creates the constraint with ON UPDATE CASCADE and an index on the column.

Example & screen notes

Slide title "relation(...)". Code from the course model:

tbl.column('customer_id', size='22').relation(
    'customer.id', relation_name='invoices', onDelete='raise', foreign_key=True)

Below, the DDL produced by migration:

ALTER TABLE "invc"."invc_invoice"
 ADD CONSTRAINT "fk_935fad9a" FOREIGN KEY ("customer_id")
 REFERENCES "invc"."invc_customer" ("id") ON UPDATE CASCADE;
CREATE INDEX "idx_3e295060" ON "invc"."invc_invoice" USING btree ("customer_id");

Scene 02

Python actions and SQL actions

A relation can say what happens when the target row is deleted. The argument onDelete is an action that asqueel runs in Python, during its own delete. The argument onDelete_sql is an action written into the foreign key, so PostgreSQL runs it. The two are separate. The DDL of the previous scene has no ON DELETE clause, because the course model declares only onDelete. Lesson 23 explains both families of actions. In this lesson, relations are used to read.

Example & screen notes

Slide title "onDelete and onDelete_sql". Table:

Argument Who runs it Values
onDelete asqueel, in Python raise, cascade, setnull, ignore
onDelete_sql PostgreSQL, in the foreign key cascade, restrict, no action, setnull, setdefault

Bottom line: "Details in lesson 23."

Scene 03

A path through a relation

A query reads a related column with a path. The path starts with an at sign and the name of the column that holds the key. Here it is at customer_id. A dot follows, and then a column of the target table. The relation is named after the key column, not after the target table. The compiler turns the path into a LEFT JOIN. Both columns of the customer use the same join, with the alias t1. One join is created per path, and every column on that path reuses it. The result column has a name built from the path. Each character that is not a letter, a digit or an underscore becomes an underscore. So the path becomes underscore customer_id underscore account_name.

Example & screen notes

Slide title "@customer_id.account_name". Code:

db.table('invc.invoice').query(
    columns='$inv_number, @customer_id.account_name, @customer_id.email').sqltext

Output:

SELECT "t0"."inv_number" AS "inv_number",
"t1"."account_name" AS "_customer_id_account_name",
"t1"."email" AS "_customer_id_email"
 FROM "invc"."invc_invoice" AS "t0"
       LEFT JOIN "invc"."invc_customer" AS "t1" ON (("t0"."customer_id")="t1"."id")
WHERE ("t0"."__del_ts" IS NULL)

Highlight @customer_id, then LEFT JOIN, then the alias _customer_id_account_name.

Scene 04

Several hops

A path can cross several relations. Each step is an at sign and a key column of the table reached so far. The last element is a plain column. From an invoice row, the path goes to the invoice, to the customer, to the state and to the region. The compiler writes one LEFT JOIN per step. Paths work in where and order_by too. A path used only in where still adds its joins to the query.

Example & screen notes

Slide title "@a.@b.c". Code:

db.table('invc.invoice_row').query(
    columns='$quantity, @invoice_id.@customer_id.@state.@region_code.name').sqltext

Output:

SELECT "t0"."quantity" AS "quantity",
"t4"."name" AS "_invoice_id__customer_id__state__region_code_name"
 FROM "invc"."invc_invoice_row" AS "t0"
       LEFT JOIN "invc"."invc_invoice" AS "t1" ON (("t0"."invoice_id")="t1"."id")
       LEFT JOIN "invc"."invc_customer" AS "t2" ON (("t1"."customer_id")="t2"."id")
       LEFT JOIN "invc"."invc_state" AS "t3" ON (("t2"."state")="t3"."code")
       LEFT JOIN "invc"."invc_region" AS "t4" ON (("t3"."region_code")="t4"."code")
WHERE ("t0"."__del_ts" IS NULL)

Scene 05

The inverse name

Every relation also exists in the other direction. relation_name gives the name of that inverse side. The invoice_row relation declares rows. So, from an invoice, at rows leads to its rows. Without relation_name, the inverse side gets a name made from the package, the table and the column, for example invc_customer_profile_customer_id. The inverse side reaches many rows. A query cannot project it as a column. at rows alone raises ValueError. at rows dot quantity raises UnsupportedFeatureError. asqueel never multiplies the rows of a query through a join towards many. The inverse side is used through aggregation, which lesson 06 covers.

Example & screen notes

Slide title "relation_name". Two lines:

# invoice_row
tbl.column('invoice_id', size='22').relation(
    'invoice.id', relation_name='rows', onDelete='cascade', foreign_key=True)

Table:

Expression on invc.invoice Result
@rows ValueError: Unknown column invc.invoice.@rows
@rows.quantity UnsupportedFeatureError: ... a path through the many relation @rows is not available in this release
@rows.aggr(count() AS n) one column, lesson 06

Scene 06

one_one

one_one True states that each target row has at most one row on the other side. A customer has at most one profile. With this flag, the inverse side is a path towards one. From the customer, at profile dot website is a LEFT JOIN, like a forward path. The flag does not create a unique constraint. If the data holds two profiles for one customer, the join returns two rows. Declare a unique column or a constraint when the database must enforce it.

Example & screen notes

Slide title "one_one=True". Code, an addition to the course model:

profile = pkg.table('customer_profile', pkey='id')
profile.options().sys_fields()
profile.column('customer_id', size='22').relation(
    'invc.customer.id', relation_name='profile', one_one=True, onDelete='cascade')
profile.column('website')
db.table('invc.customer').query(columns='$account_name, @profile.website').sqltext
SELECT "t0"."account_name" AS "account_name",
"t1"."website" AS "_profile_website"
 FROM "invc"."invc_customer" AS "t0"
       LEFT JOIN "invc"."invc_customer_profile" AS "t1" ON (("t0"."id")="t1"."customer_id")
WHERE ("t0"."__del_ts" IS NULL) AND ("t0"."__is_draft" IS NOT TRUE)

Scene 07

weak_relation

weak_relation declares a relation that only navigates. It never creates a foreign key. It can sit on a physical column or on a virtual column. Here the formula last_invoice_id selects the id of the latest invoice of the customer. Lesson 08 explains formulas. The weak relation makes the formula a key. A query can then read at last_invoice_id dot inv_number. The join compares the result of the formula with the invoice id. relation on a virtual column behaves the same way. It refuses foreign_key True.

Example & screen notes

Slide title "weak_relation: navigation without a foreign key". Code, an addition copied from examples/test_invoice_pg/invc.py:

customer.formulaColumn('last_invoice_id', select=dict(
    table='invc.invoice', columns='$id', where='$customer_id=#THIS.id',
    order_by='$date DESC', limit=1), dtype='T').weak_relation(
    'invoice.id', relation_name='last_of_customer')
db.table('invc.customer').query(
    columns='$account_name, @last_invoice_id.inv_number, @last_invoice_id.date')

Output, the join only:

LEFT JOIN "invc"."invc_invoice" AS "t1" ON (((  ( SELECT "t0_t0"."id" AS "id"
 FROM "invc"."invc_invoice" AS "t0_t0"
WHERE ("t0_t0"."customer_id"="t0"."id")
ORDER BY "t0_t0"."date" DESC
LIMIT 1 )  ))="t1"."id")

Scene 08

An alias inherits the relation

An alias column reads a column through a relation path. Lesson 08 covers aliases in full. When the target of the alias carries a relation, the alias inherits it. On the invoice row, the alias customer_id reads the customer_id of the invoice. That column points to the customer. So the alias points to the customer too. The path at customer_id dot account_name starts from the row and reaches the customer through the invoice. The alias gets only the target of the relation. It gets no foreign key, no inverse name and no delete action.

Example & screen notes

Slide title "aliasColumn keeps the relation". Code, an addition copied from examples/test_invoice_pg/invc.py:

row = pkg.table('invoice_row', pkey='id')
row.aliasColumn('customer_id', relation_path='@invoice_id.customer_id')
db.table('invc.invoice_row').query(columns='$quantity, @customer_id.account_name')
SELECT "t0"."quantity" AS "quantity",
"t1"."account_name" AS "_customer_id_account_name"
 FROM "invc"."invc_invoice_row" AS "t0"
       LEFT JOIN "invc"."invc_invoice" AS "t2" ON (("t0"."invoice_id")="t2"."id")
       LEFT JOIN "invc"."invc_customer" AS "t1" ON (("t2"."customer_id")="t1"."id")

Scene 09

Recap

A path names the key column and becomes one LEFT JOIN per path. The inverse side has the name given by relation_name, and towards many it is used only through aggregation. one_one, weak relations and aliases change navigation only. The next lesson covers aggregations over the inverse side.

Example & screen notes

Three bullets:

  • @<key column>.<column> reads a related column through one LEFT JOIN per path
  • relation_name names the inverse side; towards many it needs aggregation
  • one_one, weak_relation and an alias column change how a relation is navigated, not the database