Foreign keys topic

This document aims to demonstrate how to use foreign keys when writing queries in package:typed_sql.

Examples throughout this document assume a database schema defined as follows:

abstract final class Bookstore extends Schema {
  Table<Author> get authors;
  Table<Book> get books;
}

@PrimaryKey(['authorId'])
abstract final class Author extends Row {
  @AutoIncrement()
  int get authorId;

  @Unique()
  String get name;
}

@PrimaryKey(['bookId'])
abstract final class Book extends Row {
  @AutoIncrement()
  int get bookId;

  String? get title;

  @References(table: 'authors', field: 'authorId', name: 'author', as: 'books')
  int get authorId;

  @DefaultValue(0)
  int get stock;
}

And that the database is loaded with the following examples:

final initialAuthors = [
  (name: 'Easter Bunny',),
  (name: 'Bucks Bunny',),
];

final initialBooks = [
  // By Easter Bunny
  (title: 'Are Bunnies Unhealthy?', authorId: 1, stock: 10),
  (title: 'Cooking with Chocolate Eggs', authorId: 1, stock: 0),
  (title: 'Hiding Eggs for dummies', authorId: 1, stock: 12),
  // By Bucks Bunny
  (title: 'Vegetarian Dining', authorId: 2, stock: 42),
  (title: 'Vegan Dining', authorId: 2, stock: 3),
];

Declaring foreign keys

In the schema above Book.authorId is declared as a foreign key using the @References annotation. The table and field parameters are required, these point to the table and field that is being referenced. The name and as properties are optional, if present they will be used to generate convenient subquery properties when building queries.

@PrimaryKey(['bookId'])
abstract final class Book extends Row {
  @AutoIncrement()
  int get bookId;

  String? get title;

  @References(
    // This fields references "authorId" from "authors" table
    table: 'authors',
    field: 'authorId',

    // The reference is _named_ "author", this gives rise to a
    // Expr<Book>.author property when building queries.
    name: 'author', // optional

    // This is referenced _as_ "books", this gives rise to a
    // Expr<Author>.books property when building queries.
    as: 'books', // optional
  )
  int get authorId;

  @DefaultValue(0)
  int get stock;
}

Note

At this point package:typed_sql does not support composite foreign keys. There is probably nothing that fundamentally makes it impossible to introduce support for composite foreign keys in the future. But in most cases, you should probably avoid such constructs when possible.

Following references in a query (using reference name)

With the @References annotation in place, package:typed_sql will use the name: 'author' parameter to generate an extension method Expr<Book>.author. The author extension method will return a subquery looking up the row in the authors table referenced by Book.authorId.

The following example shows, how we can use the Expr<Book>.author to lookup a book and its author in a single query.

final bookAndAuthor = await db.books
    .byKey(1)
    .select((book) => (
          book,
          book.author, // <-- use the 'author' subquery property
        ))
    .fetch(); // return Future<(Book, Author)?>

if (bookAndAuthor == null) {
  throw Exception('Book not found');
}
final (book, author) = bookAndAuthor;
check(book.title).equals('Are Bunnies Unhealthy?');
check(author.name).equals('Easter Bunny');

We can also use the Expr<Book>.author property in .where, and we can access properties on the Expr<Author> returned by Expr<Book>.author, as demonstrated in the following example:

final titleAndAuthor = await db.books
    .where((book) => book.author.name.equals(toExpr('Easter Bunny')))
    .select((book) => (
          book.title,
          book.author.name,
        ))
    .fetch();

check(titleAndAuthor).unorderedEquals([
  // title, author
  ('Are Bunnies Unhealthy?', 'Easter Bunny'),
  ('Cooking with Chocolate Eggs', 'Easter Bunny'),
  ('Hiding Eggs for dummies', 'Easter Bunny'),
]);

Had the Book.authorId been nullable, then Expr<Book>.author would have returned Expr<Author?>. But you can still access properties on Expr<Author?>, however, you'll find that all properties are nullable.

It's also worth noting that if we had omitted the name: 'author' parameter from the @References annotation, then the Expr<Book>.author extension method would not have been generated. So you can decide whether or not you want the these helper methods.

While subqueries are simple to use, and outright fantastic when writing point queries, as they make it easy to return a row along with any referenced rows in a single query. Subqueries can also lead to poor query performance, in which case joins might be a better option.

Warning

When accessing properties on a subquery like Expr<Book>.author.name it is important to remember that accessing two properties leads to two subqueries. It's possible that the query optimizer will coalesce them, but this might depend on your database.

Following referenced-by in a query (using reference as)

The @Reference annotation also has an optional as: books parameter. This will be used by package:typed_sql to generate an extension method Expr<Author>.books. The books extension method will return a SubQuery<(Expr<Book>,)> matching all the rows in the books table that reference the given row in the authors table.

We can't directly return a SubQuery in a.select projection, but we can use .first or an aggregate function on the SubQuery in a .select projection, or .where filter. The following example shows how lookup authorId = 1 along with the count of books referencing said row the authors table.

final authorAndCount = await db.authors
    .byKey(1)
    .select((author) => (
          author,
          author.books.count(),
        ))
    .fetch();

if (authorAndCount == null) {
  throw Exception('Author not found');
}
final (author, count) = authorAndCount;
check(author.name).equals('Easter Bunny');
check(count).equals(3);

This is particularly convenient for point queries as demonstrated above. But you can also use it for queries returning multiple rows, as in the example below.

final authorAndCount = await db.authors
    .select((author) => (
          author.name,
          author.books.count(),
        ))
    .fetch();

check(authorAndCount).unorderedEquals([
  // name, count
  ('Easter Bunny', 3),
  ('Bucks Bunny', 2),
]);

While it's possible to count authors for each book using a subquery, as in the example above. It may be easier for the query optimizer to optimize this query, if we used .join and .groupBy, see Aggregate functions for more details.

Composite foreign keys

As illustrated in the examples above the @References annotation is used on a field to define the field as a foreign key. This works great until you need to define a composite foreign key. If a foreign key consists of multiple fields, then we can't use the @References annotation. For this we need to use the @ForeignKey annotation on the row class.

The following example illustrates how to define a composite foreign key using the @ForeignKey annotation.

abstract final class BlogDatabase extends Schema {
  Table<Post> get posts;
  Table<Comment> get comments;
}

@PrimaryKey(['author', 'slug'])
abstract final class Post extends Row {
  String get author;
  String get slug;

  String get content;
}

@PrimaryKey(['commentId'])
@ForeignKey(
  ['author', 'postSlug'],
  table: 'posts',
  fields: ['author', 'slug'],
  name: 'post',
  as: 'comments',
)
abstract final class Comment extends Row {
  int get commentId;

  // composite foreign key
  String get author;
  String get postSlug;

  String get comment;
}

The @ForeignKey annotation works the same as the @References annotation, except that it is possible to include multiple fields in the foreign key. Similar to @References the optional name and as attributes give rise to extension methods on expressions for Expr<Comment> and Expr<Post> respectively.

The @ForeignKey annotation can be used to define foreign keys consistent of a single field, ie. foreign keys are that not composite foreign keys. When used in this manner the @ForeignKey annotation behaves the same as the @References annotation, the only difference being where the annotation is attached.

Tip

Prefer using the @References annotation when defining a foreign key consistent of a single field.

Classes

ForeignKey Schema definition Foreign keys
Annotation for declaring a composite foreign key.
References Schema definition Foreign keys
Annotation for fields that references fields from another table.