Skip to main content

spring_boot_starter_data 0.6.2 copy "spring_boot_starter_data: ^0.6.2" to clipboard
spring_boot_starter_data: ^0.6.2 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();
}
0
likes
160
points
--
downloads

Documentation

API reference

Publisher

verified publisherfarum-azula.org

Weekly Downloads

Spring Data for spring_boot_dart: entities, repositories, derived and @Query methods, transactions, optimistic and pessimistic locks; no driver inside.

Repository (GitHub)
View/report issues

Topics

#data #repository #sql #spring

License

Apache-2.0 (license)

Dependencies

spring_boot_dart, spring_boot_starter_logging

More

Packages that depend on spring_boot_starter_data