spring_boot_starter_data 0.6.0
spring_boot_starter_data: ^0.6.0 copied to clipboard
Spring Data for spring_boot_dart: entities, repositories, derived and @Query methods, transactions, optimistic and pessimistic locks; no driver inside.
example/example.dart
// Spring Data in a Spring Boot application: describe the aggregates and the
// repositories, the generator writes the implementations and picks the
// starter up (example.spring.dart, from `dart run build_runner build`).
//
// Run as it is, the application uses SQLite: lib/resources/application.properties
// sets spring.datasource.url, `sqlite3` is a dependency of the package (the
// generator wires its driver), and the migrations in
// lib/resources/db/migration/ are applied at startup, before the first
// repository call. The tests build the context without that file, so
// without a database the same repositories run in the starter's in-memory
// store.
//
// dart run example/example.dart
// dart run example/example.dart --spring.datasource.url=sqlite:file:/tmp/l.db
import 'package:spring_boot_starter_data/spring_boot_starter_data.dart';
import 'example.spring.dart';
// --- Aggregates ------------------------------------------------------------
/// An author; the books refer to it by id.
@Table('authors')
class Author {
Author({required this.name, this.id, this.isni});
@Id()
int? id;
String name;
/// The author's international identifier, when there is one: unique
/// (several authors may have none, as `null` never conflicts). Saving a
/// second author with the same value is a `DuplicateKeyException`.
@Unique()
String? isni;
}
/// A book: one row of the `books` table. `id` is generated by the store (a
/// null `int` id is new), `version` guards against lost updates, the audit
/// properties are filled on save (see [AuditingSetup]).
@Table('books')
class Book {
Book({
required this.title,
required this.year,
this.id,
this.subtitle,
this.inStock = true,
this.author,
this.version,
this.createdAt,
this.modifiedAt,
this.createdBy,
this.modifiedBy,
});
@Id()
int? id;
String title;
int year;
String? subtitle;
bool inStock;
/// Another aggregate, referenced by its id only. The table has a foreign
/// key to `authors`: a book cannot name an author that does not exist,
/// and an author who has books cannot be deleted (the default,
/// `OnDeleteAction.noAction`).
AggregateReference<Author, int>? author;
@Version()
int? version;
@CreatedDate()
DateTime? createdAt;
@LastModifiedDate()
DateTime? modifiedAt;
@CreatedBy()
String? createdBy;
@LastModifiedBy()
String? modifiedBy;
@override
String toString() => 'Book($id, $title, $year)';
}
/// A page marked in a book: the other way to reference an aggregate, a
/// plain id marked [Reference] (so `findByBookId(int id)` works as on any
/// column). Deleting the book deletes its bookmarks: the database does it
/// (`on delete cascade`), as [OnDelete] says; a book has one bookmark per
/// page.
@Table('bookmarks')
@UniqueConstraint(['bookId', 'page'])
class Bookmark {
Bookmark({required this.bookId, required this.page, this.id});
@Id()
int? id;
@Reference(Book)
@OnDelete(OnDeleteAction.cascade)
int bookId;
int page;
}
/// An order aggregate: the root and its children are saved, loaded and
/// deleted together.
@Table('orders')
class PurchaseOrder {
PurchaseOrder({
required this.customer,
this.id,
this.lines = const [],
this.notes = const {},
});
@Id()
int? id;
String customer;
/// Kept in order (a position column in a SQL table).
@MappedCollection()
List<OrderLine> lines;
/// Kept by key.
@MappedCollection()
Map<String, OrderNote> notes;
}
/// A child of [PurchaseOrder]: no id of its own is needed.
@Table('order_lines')
class OrderLine {
OrderLine({required this.sku, required this.quantity});
String sku;
int quantity;
}
/// Another kind of child, in a map.
@Table('order_notes')
class OrderNote {
OrderNote({required this.text});
String text;
}
/// An immutable aggregate whose id the application gives (an IBAN): the
/// `version` (0 means new) lets `save` tell an insert from an update, and
/// `save` returns a copy, since nothing can be set.
@Table('accounts')
class Account {
const Account({required this.iban, required this.owner, this.version = 0});
@Id()
final String iban;
final String owner;
@Version()
final int version;
}
/// A review, kept only in memory (see [ReviewRepository]).
@Table('reviews')
class Review {
Review({required this.text, this.id});
@Id()
int? id;
String text;
}
/// A version that is a time stamp.
@Table('tickets')
class Ticket {
Ticket({required this.subject, this.id, this.stamp});
@Id()
int? id;
String subject;
@Version()
DateTime? stamp;
}
// --- Repositories ----------------------------------------------------------
/// The CRUD methods and the paging ones come from the interfaces; the
/// others are derived from their names (and checked by the build).
abstract interface class BookRepository
implements
ListCrudRepository<Book, int>,
ListPagingAndSortingRepository<Book, int> {
Future<List<Book>> findByTitle(String title);
Future<List<Book>> findByTitleIgnoreCase(String title);
Future<Book?> findOneByTitle(String title);
Future<Book> getByTitle(String title);
Future<List<Book>> findByTitleAndYear(String title, int year);
Future<List<Book>> findByTitleOrYear(String title, int year);
Future<List<Book>> findByYearNot(int year);
Future<List<Book>> findByYearBetween(int from, int to);
Future<List<Book>> findByYearGreaterThan(int year);
Future<List<Book>> findByYearGreaterThanEqual(int year);
Future<List<Book>> findByYearLessThan(int year);
Future<List<Book>> findByYearLessThanEqual(int year);
Future<List<Book>> findByYearIn(List<int> years);
Future<List<Book>> findByYearNotIn(Set<int> years);
Future<List<Book>> findByTitleStartingWith(String prefix);
Future<List<Book>> findByTitleEndingWith(String suffix);
Future<List<Book>> findByTitleContaining(String part);
Future<List<Book>> findByTitleNotContaining(String part);
Future<List<Book>> findByTitleLike(String pattern);
Future<List<Book>> findByTitleNotLike(String pattern);
Future<List<Book>> findByTitleContainingIgnoreCase(String part);
Future<List<Book>> findBySubtitleIsNull();
Future<List<Book>> findBySubtitleIsNotNull();
Future<List<Book>> findByInStockTrue();
Future<List<Book>> findByInStockFalse();
Future<List<Book>> findByAuthor(AggregateReference<Author, int> author);
Future<List<Book>> findByInStockOrderByYearDescTitleAsc(bool inStock);
Future<Set<Book>> findByYear(int year);
Future<Iterable<Book>> findByYearLessThanOrderByYear(int year);
Future<List<Book>> findDistinctByInStock(bool inStock);
Stream<Book> streamByInStock(bool inStock);
// Sorting and paging by parameter.
Future<List<Book>> findByInStock(bool inStock, Sort sort);
Future<Page<Book>> findByYearAfter(int year, Pageable pageable);
Future<Slice<Book>> findByInStockIsTrue(Pageable pageable);
Future<List<Book>> findByInStockEquals(bool inStock, Pageable pageable);
// A row lock: the book is held until the transaction ends (SQLite takes
// the whole database), so the check and the change that follow cannot
// race; only inside a transaction.
@Lock(LockMode.pessimisticWrite)
Future<Book?> findOneByYearAndTitle(int year, String title);
// Top and First.
Future<Book?> findFirstByOrderByYearAsc();
Future<List<Book>> findTop2ByOrderByYearDesc();
Future<Page<Book>> findTop3ByInStock(bool inStock, Pageable pageable);
// Count, exists and delete.
Future<int> countByInStock(bool inStock);
Future<bool> existsByTitle(String title);
Future<int> deleteByYearLessThan(int year);
Future<void> removeByTitle(String title);
Future<List<Book>> deleteByInStock(bool inStock);
}
/// A repository of authors, on the default store: SQLite when the
/// application has a database, memory without.
abstract interface class AuthorRepository
implements ListCrudRepository<Author, int> {
Future<List<Author>> findByNameStartingWith(String prefix);
}
/// A repository that stays in memory beside the database, on a store of its
/// own that the name `catalog` gives it (and a transaction manager of that
/// name for `@Transactional(transactionManager: 'catalog')`).
@UseStore('memory')
@UseDataSource('catalog')
abstract interface class ReviewRepository
implements ListCrudRepository<Review, int> {}
/// The bookmarks, found by the book they mark.
abstract interface class BookmarkRepository
implements ListCrudRepository<Bookmark, int> {
Future<List<Bookmark>> findByBookId(int bookId);
}
/// Ids given by the application, immutable entities.
abstract interface class AccountRepository
implements CrudRepository<Account, String> {
Future<List<Account>> findByOwner(String owner);
}
abstract interface class TicketRepository
implements ListCrudRepository<Ticket, int> {}
/// The aggregate with its children is one unit of work.
abstract interface class PurchaseOrderRepository
implements ListCrudRepository<PurchaseOrder, int> {
Future<List<PurchaseOrder>> findByCustomer(String customer);
}
// --- A SQL-only repository -------------------------------------------------
/// What a join gives: the columns by name (`author_name` into
/// `authorName`). The generator describes the class because a `@Query`
/// returns it.
class BookCard {
const BookCard({
required this.title,
required this.authorName,
required this.year,
});
final String title;
final String authorName;
final int year;
@override
String toString() => 'BookCard($title by $authorName, $year)';
}
/// A repository with a `@Query` join: SQL only, so it exists only when the
/// application has a database (the in-memory store has no joins).
///
/// The build reads the query: a `:name` that is no parameter, a misspelled
/// table or column (`b.titel`, `from bokks`) is an error of the build, not
/// of the first call. `{Book}` and `{Book.author}` are the table and the
/// column of the entity as its mapping names them, so a renamed property
/// cannot leave the SQL behind.
@ConditionalOnProperty('spring.datasource.url')
abstract interface class BookReportRepository
implements DataRepository<Book, int> {
@Query(
'select b.title, a.name as author_name, b.year from {Book} b '
'join {Author} a on a.{Author.id} = b.{Book.author} '
'where b.year >= :year order by b.year, b.title',
)
Future<List<BookCard>> cardsSince(int year);
}
// --- Auditing --------------------------------------------------------------
/// Switches the audit properties of the entities on; the `AuditorAware` bean
/// says who is working.
@Configuration()
@EnableDataAuditing()
class AuditingSetup {}
@Component()
class CurrentUser implements AuditorAware<String> {
@override
String? getCurrentAuditor() => 'librarian';
}
// --- Use -------------------------------------------------------------------
/// A service on the repositories; its transactions are SQL transactions of
/// the application's database (the data source's manager) or, without one,
/// of the in-memory store.
@Service()
class Library {
Library(this.authors, this.books, this.orders, this.bookmarks);
final AuthorRepository authors;
final BookRepository books;
final PurchaseOrderRepository orders;
final BookmarkRepository bookmarks;
@Transactional()
Future<Book> add(String title, int year, {String? author}) async {
final writer = author == null
? null
: await authors.save(Author(name: author));
return await books.save(
Book(
title: title,
year: year,
author: writer == null
? null
: AggregateReference<Author, int>(writer.id!),
),
);
}
/// Removes a book; its bookmarks go with it (`on delete cascade`).
@Transactional()
Future<void> discard(int bookId) => books.deleteById(bookId);
/// Renames a book: the row is locked by the first statement, so two
/// renames of one book run one after the other, and `@Version` still
/// catches a change made outside the lock.
@Transactional()
Future<Book?> rename(int year, String title, String newTitle) async {
final book = await books.findOneByYearAndTitle(year, title);
if (book == null) {
return null;
}
book.title = newTitle;
return await books.save(book);
}
/// Books of a decade, oldest first.
@Transactional(readOnly: true)
Future<List<Book>> decade(int start) async {
final found = await books.findByYearBetween(start, start + 9);
return found..sort((a, b) => a.year.compareTo(b.year));
}
@Transactional()
Future<PurchaseOrder> order(String customer, Map<String, int> quantities) =>
orders.save(
PurchaseOrder(
customer: customer,
lines: [
for (final MapEntry(:key, :value) in quantities.entries)
OrderLine(sku: key, quantity: value),
],
),
);
}
/// Uses the library and logs what it saw; `@Log` gives it the `log` getter.
@Component()
@Log(topic: 'example')
@Order(1)
class LibraryTour implements CommandLineRunner {
LibraryTour(this.library);
final Library library;
@override
Future<void> run(List<String> args) async {
await library.add('Dune', 1965, author: 'Frank Herbert');
await library.add('Neuromancer', 1984, author: 'William Gibson');
final saved = await library.add('Snow Crash', 1992, author: 'Neal');
await library.rename(1992, 'Snow Crash', 'Snow Crash (1992)');
log.info('saved ${saved.title} as #${saved.id}, version ${saved.version}');
final nineties = await library.decade(1990);
log.info('the nineties: ${nineties.map((b) => b.title).join(', ')}');
// A bookmark references its book; deleting the book deletes it too.
final draft = await library.add('Draft', 2001);
await library.bookmarks.save(Bookmark(bookId: draft.id!, page: 42));
await library.discard(draft.id!);
log.info('bookmarks left: ${await library.bookmarks.count()}');
final order = await library.order('Ada', {'dune-hc': 1, 'snow-pb': 2});
final loaded = await library.orders.findById(order.id!);
log.info('order of ${loaded!.customer}: ${loaded.lines.length} lines');
}
}
/// Reads with the `@Query` join, the `JdbcClient` and the view of the
/// repeatable migration; only with a database.
@Component()
@Log(topic: 'example')
@Order(2)
@ConditionalOnProperty('spring.datasource.url')
class ReportTour implements CommandLineRunner {
ReportTour(this.reports, this.jdbc);
final BookReportRepository reports;
final JdbcClient jdbc;
@override
Future<void> run(List<String> args) async {
for (final card in await reports.cardsSince(1980)) {
log.info('$card');
}
final counts = await jdbc
.sql('select name, books from author_book_counts order by name')
.query<String>((row) => '${row['name']}: ${row['books']} book(s)')
.list();
counts.forEach(log.info);
}
}
@SpringBootApplication()
Future<void> main(List<String> args) async {
final context = await springApplication.runAsync(args);
context.close();
}