| // Copyright 2019 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:math' as math; |
| |
| import 'package:gcloud/datastore.dart' show Datastore, OrderDirection, DatastoreError; |
| import 'package:gcloud/db.dart'; |
| |
| /// Signature for a callback function that will be notified whenever a |
| /// [FakeQuery] is run. |
| /// |
| /// The `results` argument contains the provisional results of the query (after |
| /// [FakeQuery.limit] and [FakeQuery.offset] have been applied). Callers can |
| /// affect the results of the query by returning a different set of results |
| /// from the callback. |
| /// |
| /// The callback must not return null. |
| typedef QueryCallback<T extends Model<dynamic>> = Iterable<T> Function(Iterable<T> results); |
| |
| /// Signature for a callback function that will be notified whenever `commit()` |
| /// is called, either via [FakeDatastoreDB.commit] or [FakeTransaction.commit]. |
| /// |
| /// The `inserts` and `deletes` arguments represent the prospective mutations. |
| /// Both arguments are immutable. |
| /// |
| /// This callback will be invoked before any mutations are applied, so by |
| /// throwing an exception, callbacks can simulate a failed commit. |
| typedef CommitCallback = void Function(List<Model<dynamic>> inserts, List<Key<dynamic>> deletes); |
| |
| /// A fake datastore database implementation. |
| /// |
| /// This datastore's contents are stored in a single [values] map. Callers can |
| /// set up the map to populate the datastore in a way that works for their |
| /// test. |
| class FakeDatastoreDB implements DatastoreDB { |
| FakeDatastoreDB({ |
| Map<Key<dynamic>, Model<dynamic>>? values, |
| Map<Type, QueryCallback<Model<dynamic>>>? onQuery, |
| this.onCommit, |
| this.commitException = false, |
| }) : values = values ?? <Key<dynamic>, Model<dynamic>>{}, |
| onQuery = onQuery ?? <Type, QueryCallback<Model<dynamic>>>{}; |
| |
| final Map<Key<dynamic>, Model<dynamic>> values; |
| final Map<Type, QueryCallback<Model<dynamic>>> onQuery; |
| CommitCallback? onCommit; |
| // Flag used in tests whether the transaction commit throws exception. |
| bool? commitException; |
| |
| /// Adds a [QueryCallback] to the set of callbacks that will be notified when |
| /// queries are run. |
| /// |
| /// The [callback] argument will replace any existing callback that has been |
| /// specified for type `T`, as only one callback may exist per type. |
| void addOnQuery<T extends Model<dynamic>>(QueryCallback<T> callback) { |
| onQuery[T] = (Iterable<Model<dynamic>> results) { |
| return callback(results.cast<T>()).cast<Model<dynamic>>(); |
| }; |
| } |
| |
| @override |
| Future<dynamic> commit({List<Model<dynamic>>? inserts, List<Key<dynamic>>? deletes}) async { |
| inserts ??= <Model<dynamic>>[]; |
| deletes ??= <Key<dynamic>>[]; |
| if (onCommit != null) { |
| onCommit!(List<Model<dynamic>>.unmodifiable(inserts), List<Key<dynamic>>.unmodifiable(deletes)); |
| } |
| deletes.forEach(values.remove); |
| for (Model<dynamic> model in inserts) { |
| values[model.key] = model; |
| } |
| } |
| |
| @override |
| Datastore get datastore => throw UnimplementedError(); |
| |
| @override |
| Partition get defaultPartition => Partition(null); |
| |
| @override |
| Key<dynamic> get emptyKey => defaultPartition.emptyKey; |
| |
| @override |
| Future<List<T?>> lookup<T extends Model<dynamic>>(List<Key<dynamic>> keys) async { |
| final List<T?> found = <T?>[]; |
| for (Key<dynamic> key in keys) { |
| for (Model<dynamic> model in values.values) { |
| if (model.key.id == key.id) { |
| found.add(model as T?); |
| } |
| } |
| |
| if (found.isEmpty) { |
| throw KeyNotFoundException(key); |
| } |
| } |
| |
| return found; |
| } |
| |
| @override |
| Future<T> lookupValue<T extends Model<dynamic>>(Key<dynamic> key, {T Function()? orElse}) async { |
| final List<T?> values = await lookup(<Key<dynamic>>[key]); |
| T? value = values.single; |
| if (value == null) { |
| if (orElse != null) { |
| value = orElse(); |
| } else { |
| throw KeyNotFoundException(key); |
| } |
| } |
| return value; |
| } |
| |
| @override |
| ModelDB get modelDB => throw UnimplementedError(); |
| |
| @override |
| Partition newPartition(String namespace) => Partition(namespace); |
| |
| @override |
| FakeQuery<T> query<T extends Model<dynamic>>({Partition? partition, Key<dynamic>? ancestorKey}) { |
| List<T> results = values.values.whereType<T>().toList(); |
| if (ancestorKey != null) { |
| results = results.where((T entity) => entity.parentKey == ancestorKey).toList(); |
| } |
| return FakeQuery<T>._(this, results); |
| } |
| |
| @override |
| Future<T> withTransaction<T>(TransactionHandler<T> transactionHandler) { |
| final FakeTransaction transaction = FakeTransaction._(this); |
| return transactionHandler(transaction); |
| } |
| |
| @override |
| Future<T> lookupOrNull<T extends Model<dynamic>>(Key<dynamic> key) { |
| throw UnimplementedError(); |
| } |
| } |
| |
| /// A query that will return all values of type `T` that exist in the |
| /// [FakeDatastoreDB.values] map. |
| /// |
| /// This fake query respects [order], [limit], and [offset]. However, [filter] |
| /// may require local additions here to respect new filters. |
| class FakeQuery<T extends Model<dynamic>> implements Query<T> { |
| FakeQuery._(this.db, this.results); |
| |
| final FakeDatastoreDB db; |
| final List<FakeFilterSpec> filters = <FakeFilterSpec>[]; |
| final List<FakeOrderSpec> orders = <FakeOrderSpec>[]; |
| |
| List<T> results; |
| int start = 0; |
| int count = 100; |
| |
| @override |
| void filter(String filterString, Object? comparisonObject) { |
| // In production, Datastore filters cannot have a space at the end. |
| assert(filterString.trim() == filterString); |
| filters.add(FakeFilterSpec._(filterString, comparisonObject)); |
| } |
| |
| @override |
| void limit(int limit) { |
| assert(limit >= 1); |
| count = limit; |
| } |
| |
| @override |
| void offset(int offset) { |
| assert(offset >= 0); |
| start = offset; |
| } |
| |
| @override |
| void order(String orderString) { |
| if (orderString.startsWith('-')) { |
| orders.add(FakeOrderSpec._(orderString.substring(1), OrderDirection.Decending)); |
| } else { |
| orders.add(FakeOrderSpec._(orderString, OrderDirection.Ascending)); |
| } |
| } |
| |
| @override |
| Stream<T> run() { |
| Iterable<T> resultsView = results; |
| |
| for (FakeFilterSpec filter in filters) { |
| final String filterString = filter.filterString; |
| final Object? value = filter.comparisonObject; |
| if (filterString.contains('branch =') || |
| filterString.contains('head =') || |
| filterString.contains('pr =') || |
| filterString.contains('repository =') || |
| filterString.contains('name =')) { |
| resultsView = resultsView.where((T result) => result.toString().contains(value.toString())); |
| } |
| } |
| resultsView = resultsView.skip(start).take(count); |
| |
| if (db.onQuery.containsKey(T)) { |
| resultsView = db.onQuery[T]!(resultsView).cast<T>(); |
| } |
| return Stream<T>.fromIterable(resultsView); |
| } |
| } |
| |
| class FakeFilterSpec { |
| const FakeFilterSpec._(this.filterString, this.comparisonObject); |
| |
| final String filterString; |
| final Object? comparisonObject; |
| |
| @override |
| String toString() => 'FakeFilterSpec($filterString, $comparisonObject)'; |
| } |
| |
| class FakeOrderSpec { |
| const FakeOrderSpec._(this.fieldName, this.direction); |
| |
| final String fieldName; |
| final OrderDirection direction; |
| } |
| |
| /// A fake datastore transaction. |
| /// |
| /// This class keeps track of [inserts] and [deletes] and updates the parent |
| /// [FakeDatastoreDB] when the transaction is committed. |
| class FakeTransaction implements Transaction { |
| FakeTransaction._(this.db); |
| |
| final Map<Key<dynamic>, Model<dynamic>> inserts = <Key<dynamic>, Model<dynamic>>{}; |
| final Set<Key<dynamic>> deletes = <Key<dynamic>>{}; |
| bool sealed = false; |
| |
| @override |
| final FakeDatastoreDB db; |
| |
| @override |
| Future<dynamic> commit() async { |
| if (db.commitException!) { |
| throw DatastoreError(); |
| } |
| if (sealed) { |
| throw StateError('Transaction sealed'); |
| } |
| if (db.onCommit != null) { |
| db.onCommit!(List<Model<dynamic>>.unmodifiable(inserts.values), List<Key<dynamic>>.unmodifiable(deletes)); |
| } |
| for (MapEntry<Key<dynamic>, Model<dynamic>> entry in inserts.entries) { |
| db.values[entry.key] = entry.value; |
| } |
| deletes.forEach(db.values.remove); |
| sealed = true; |
| } |
| |
| @override |
| Future<List<T>> lookup<T extends Model<dynamic>>(List<Key<dynamic>> keys) async { |
| final List<T> results = <T>[]; |
| for (Key<dynamic> key in keys) { |
| if (deletes.contains(key)) { |
| // results.add(null); |
| } else if (inserts.containsKey(key)) { |
| results.add(inserts[key] as T); |
| } else if (db.values.containsKey(key)) { |
| results.add(db.values[key] as T); |
| } else { |
| // results.add(null); |
| } |
| } |
| return results; |
| } |
| |
| @override |
| Future<T> lookupValue<T extends Model<dynamic>>(Key<dynamic> key, {T Function()? orElse}) async { |
| final List<T?> values = await lookup(<Key<dynamic>>[key]); |
| T? value = values.single; |
| if (value == null) { |
| if (orElse != null) { |
| value = orElse(); |
| } else { |
| throw KeyNotFoundException(key); |
| } |
| } |
| return value; |
| } |
| |
| @override |
| Query<T> query<T extends Model<dynamic>>(Key<dynamic> ancestorKey, {Partition? partition}) { |
| final List<T> queryResults = <T>[ |
| ...inserts.values.whereType<T>(), |
| ...db.values.values.whereType<T>(), |
| ]; |
| deletes.whereType<T>().forEach(queryResults.remove); |
| return FakeQuery<T>._(db, queryResults); |
| } |
| |
| @override |
| void queueMutations({List<Model<dynamic>>? inserts, List<Key<dynamic>>? deletes}) { |
| if (sealed) { |
| throw StateError('Transaction sealed'); |
| } |
| if (inserts != null) { |
| final math.Random random = math.Random(); |
| for (Model<dynamic> insert in inserts) { |
| Key<dynamic> key = insert.key; |
| if (key.id == null) { |
| key = Key<dynamic>(key.parent!, key.type, random.nextInt(math.pow(2, 20).toInt())); |
| } |
| this.inserts[key] = insert; |
| } |
| } |
| if (deletes != null) { |
| this.deletes.addAll(deletes); |
| } |
| } |
| |
| @override |
| Future<dynamic> rollback() async { |
| if (sealed) { |
| throw StateError('Transaction sealed'); |
| } |
| inserts.clear(); |
| deletes.clear(); |
| sealed = true; |
| } |
| |
| @override |
| Future<T> lookupOrNull<T extends Model<dynamic>>(Key<dynamic> key) { |
| throw UnimplementedError(); |
| } |
| } |