typed_sql 0.1.3 copy "typed_sql: ^0.1.3" to clipboard
typed_sql: ^0.1.3 copied to clipboard

Package for doing SQL with some type safety.

Type-safe SQL in Dart #

This package aims to offer a slightly opinionated type-safe API for interacting with an SQL database from Dart. Thus, offering users the power of SQL, with the type-safety of Dart (including null-safety!).

Features:

  • Type safety: If your Dart code is type-safe you will not get SQL syntax errors at runtime.
  • Null-safety: If you return a field that has a NOT NULL constraint, the deserialized Dart type will be non-nullable!
  • Fluent API: Queries and expressions are build with chainable extension methods making it easy to discover available operators using auto-completion!
  • Expressiveness: Support for a wide range of SQL features, including:
    • Powerful arbitrary SQL expressions, including subqueries.
    • Insert, update and delete with
      • Returning clause for projections of affected rows.
      • Update/Delete filtering with SQL expressions.
      • Update with SQL expression on existing rows.
    • Select queries with
      • Projections with SQL expressions,
      • Aggregations (sum, count, avg, min, max) and GROUP BY,
      • Joins
      • Subqueries (including convenient lookups of foreign keys)
      • Ordering, limiting, and offsetting
      • Transactions (including nested transactions)
    • Schema definition with
      • Auto increment
      • Composite primary keys
      • Unique constraints
      • Not null constraints (linked to nullability in Dart!)
      • Default values
      • Foreign keys
  • Database agnostic: This package already supports
    • Sqlite, and,
    • Postgres!

Example #

This packages requires that you define a schema for code-generation with build_runner. This is usually defined in a model.dart, with the generated code ending up in a model.g.dart part-file.

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;
}

To use this you must first install build_runner as dev-dependency, and run it:

dart pub add typed_sql
dart pub add dev:build_runner
dart run build_runner build

Once the model.g.dart file has been generated, you can create tables and insert/update/delete rows as follows:

// Connect to database
final db = Database<Bookstore>(
  DatabaseAdapter.sqlite3(Uri.parse(file)),
  SqlDialect.sqlite(),
);

// Create tables
await db.createTables();

// Insert an author and return the authorId!
final authorId = await db.authors
    .insert(
      name: toExpr('Bucks Bunny'),
    )
    .returning((author) => (author.authorId,))
    .executeAndFetch();

// Insert a book, omitting stock since it has a default value!
await db.books
    .insert(
      title: toExpr('Vegan Dining'),
      authorId: toExpr(authorId), // by Bucks Bunny
      stock: toExpr(3),
    )
    .execute();

// Decrease stock for 'Vegan Dining', return update stock
final updatedStock = await db.books
    .where((b) => b.title.equals(toExpr('Vegan Dining')))
    .update((b, set) => set(
          stock: b.stock - toExpr(1),
        ))
    .returning((b) => (b.stock,))
    .executeAndFetch();
check(updatedStock).deepEquals([2]);

// Delete all books by Bucks Bunny
await db.books
    .where((b) => b.authorId.equals(toExpr(authorId)))
    .delete()
    .execute();

We can also query the database in complex manners as follows:

// Lookup author by id
final author = await db.authors.byKey(authorId).fetch();
if (author == null) {
  throw Exception('Author not found!');
}
check(author.name).equals('Bucks Bunny');

// Lookup book and associated author in one query
final (book, authorOfBook) = await db.books
    // Filtering using a .where clause with a typed expression
    .where((b) => b.title.equals(toExpr('Vegan Dining')))
    // Projection to select Expr<book> and Expr<Author> using a subquery
    .select((b) => (b, b.author))
    .first // only get the first result
    .fetchOrNulls();
if (book == null || authorOfBook == null) {
  throw Exception('Book or author not found');
}
check(book.title).equals('Vegan Dining');
check(authorOfBook.name).equals('Bucks Bunny');

// We can also query for books with more than 5 in stock and get the title
// and stock of each book.
final titleAndStock = await db.books
    .where((Expr<Book> b) => b.stock > toExpr(5))
    .select((b) => (b.title, b.stock))
    .fetch();
check(titleAndStock).unorderedEquals([
  // title, stock
  ('Are Bunnies Unhealthy?', 10),
  ('Hiding Eggs for dummies', 12),
  ('Vegetarian Dining', 42),
]);

// We can also join books and authors, group by author sum how many books we
// have in stock by author.
final stockByAuthor = await db.books
    .join(db.authors)
    .usingAuthor()
    .groupBy((b, a) => (a,))
    .aggregate((agg) => agg.sum((b, a) => b.stock))
    .select((a, totalStock) => (a.name, totalStock))
    .fetch();
check(stockByAuthor).unorderedEquals([
  // name, totalStock
  ('Easter Bunny', 22),
  ('Bucks Bunny', 45),
]);

// We can also compute this with subqueries using the @Reference annotation
final stockByAuthorUsingSubquery = await db.authors
    .select((a) => (
          a.name,
          a.books.select((b) => (b.stock,)).sum(),
        ))
    .fetch();
check(stockByAuthorUsingSubquery).unorderedEquals([
  // name, totalStock
  ('Easter Bunny', 22),
  ('Bucks Bunny', 45),
]);

In general .where can be used to filter results, you can build complex expressions using differnet extension methods for Expr<String>, Expr<int>, Expr<DateTime>, etc. making it easy to compare values correctly, and hard (if not impossible) to make syntax errors in SQL!

The .select can be used to create custom projections, by returning a positional record of Expr objects. Where the type of the objects are carried into the .fetch() method ensuring that Expr<String> becomes a String when fetched. Here nullability is preserved, so you don't have to do null checks for columns (or expressions) that can't be null!

package:typed_sql has many more features and tutorial style documentation that demonstrates most of the features by example. In practice, you'll hopefully find that most features are discoverable through auto-completion.

Status #

package:typed_sql is still under active development, breaking changes will occur going forward as features are both added and removed.