diff --git a/mobile-v2/lib/domain/entities/asset_isar.entity.dart b/mobile-v2/lib/domain/entities/asset_isar.entity.dart new file mode 100644 index 0000000000..cb64db76f3 --- /dev/null +++ b/mobile-v2/lib/domain/entities/asset_isar.entity.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/utils/collection_util.dart'; +import 'package:isar/isar.dart'; + +part 'asset_isar.entity.g.dart'; + +@Collection() +class Asset { + Id get isarId => id ?? Isar.autoIncrement; + final int? id; + final String name; + final String hash; + final int? height; + final int? width; + @Enumerated(EnumType.ordinal) + final AssetType type; + final DateTime createdTime; + final DateTime modifiedTime; + final int duration; + + // local only + final String? localId; + + // remote only + final String? remoteId; + final String? livePhotoVideoId; + + bool get isRemote => remoteId != null; + bool get isLocal => localId != null; + bool get isMerged => isRemote && isLocal; + bool get isImage => type == AssetType.image; + + const Asset({ + this.id, + required this.name, + required this.hash, + this.height, + this.width, + required this.type, + required this.createdTime, + required this.modifiedTime, + required this.duration, + this.localId, + this.remoteId, + this.livePhotoVideoId, + }); + + Asset copyWith({ + int? id, + String? name, + String? hash, + int? height, + int? width, + AssetType? type, + DateTime? createdTime, + DateTime? modifiedTime, + int? duration, + ValueGetter? localId, + ValueGetter? remoteId, + String? livePhotoVideoId, + }) { + return Asset( + id: id ?? this.id, + name: name ?? this.name, + hash: hash ?? this.hash, + height: height ?? this.height, + width: width ?? this.width, + type: type ?? this.type, + createdTime: createdTime ?? this.createdTime, + modifiedTime: modifiedTime ?? this.modifiedTime, + duration: duration ?? this.duration, + localId: localId == null ? this.localId : localId(), + remoteId: remoteId == null ? this.remoteId : remoteId(), + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + ); + } + + Asset merge(Asset newAsset) { + final existingAsset = this; + assert(existingAsset.id != null, "Existing asset must be from the db"); + + final oldestCreationTime = + existingAsset.createdTime.isBefore(newAsset.createdTime) + ? existingAsset.createdTime + : newAsset.createdTime; + + if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) { + return newAsset.copyWith( + id: newAsset.id ?? existingAsset.id, + height: newAsset.height ?? existingAsset.height, + width: newAsset.width ?? existingAsset.width, + createdTime: oldestCreationTime, + localId: () => existingAsset.localId ?? newAsset.localId, + remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, + ); + } + + return existingAsset.copyWith( + height: existingAsset.height ?? newAsset.height, + width: existingAsset.width ?? newAsset.width, + createdTime: oldestCreationTime, + localId: () => existingAsset.localId ?? newAsset.localId, + remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, + ); + } + + @override + String toString() => """ +{ + "id": "${id ?? "-"}", + "remoteId": "${remoteId ?? "-"}", + "localId": "${localId ?? "-"}", + "name": "$name", + "hash": "$hash", + "height": ${height ?? "-"}, + "width": ${width ?? "-"}, + "type": "$type", + "createdTime": "$createdTime", + "modifiedTime": "$modifiedTime", + "duration": "$duration", + "livePhotoVideoId": "${livePhotoVideoId ?? "-"}", +}"""; + + @override + bool operator ==(covariant Asset other) { + if (identical(this, other)) return true; + + return other.id == id && + other.name == name && + other.hash == hash && + other.height == height && + other.width == width && + other.type == type && + other.createdTime == createdTime && + other.modifiedTime == modifiedTime && + other.duration == duration && + other.localId == localId && + other.remoteId == remoteId && + other.livePhotoVideoId == livePhotoVideoId; + } + + @override + int get hashCode { + return id.hashCode ^ + name.hashCode ^ + hash.hashCode ^ + height.hashCode ^ + width.hashCode ^ + type.hashCode ^ + createdTime.hashCode ^ + modifiedTime.hashCode ^ + duration.hashCode ^ + localId.hashCode ^ + remoteId.hashCode ^ + livePhotoVideoId.hashCode; + } + + static int compareByHash(Asset a, Asset b) => a.hash.compareTo(b.hash); + + static int compareByLocalId(Asset a, Asset b) => + CollectionUtil.compareToNullable(a.localId, b.localId); +} diff --git a/mobile-v2/lib/domain/interfaces/asset.interface.dart b/mobile-v2/lib/domain/interfaces/asset.interface.dart index 1a2ad3b49b..36b9743292 100644 --- a/mobile-v2/lib/domain/interfaces/asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/asset.interface.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:immich_mobile/domain/models/asset.model.dart'; abstract interface class IAssetRepository { + /// Batch upsert asset + Future upsert(Asset assets); + /// Batch upsert asset Future upsertAll(Iterable assets); diff --git a/mobile-v2/lib/domain/interfaces/renderlist.interface.dart b/mobile-v2/lib/domain/interfaces/renderlist.interface.dart index eebee3cd8e..f840ca8e74 100644 --- a/mobile-v2/lib/domain/interfaces/renderlist.interface.dart +++ b/mobile-v2/lib/domain/interfaces/renderlist.interface.dart @@ -3,4 +3,6 @@ import 'package:immich_mobile/domain/models/render_list.model.dart'; abstract interface class IRenderListRepository { /// Streams the [RenderList] for the main timeline Stream watchAll(); + + Future getAll(); } diff --git a/mobile-v2/lib/domain/repositories/asset.repository.dart b/mobile-v2/lib/domain/repositories/asset.repository.dart index fa2b4abe5c..5416130667 100644 --- a/mobile-v2/lib/domain/repositories/asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/asset.repository.dart @@ -88,6 +88,16 @@ class AssetRepository with LogMixin implements IAssetRepository { Future deleteIds(Iterable ids) async { await _db.asset.deleteWhere((row) => row.id.isIn(ids)); } + + @override + Future upsert(Asset asset) async { + final row = _toEntity(asset); + await _db.asset.insertOne( + row, + onConflict: DoUpdate((_) => row, target: [_db.asset.hash]), + ); + return true; + } } AssetCompanion _toEntity(Asset asset) { @@ -98,8 +108,8 @@ AssetCompanion _toEntity(Asset asset) { height: Value(asset.height), width: Value(asset.width), type: asset.type, - createdTime: asset.createdTime, - modifiedTime: Value(asset.modifiedTime), + createdTime: asset.createdTime.toUtc(), + modifiedTime: Value(asset.modifiedTime.toUtc()), duration: Value(asset.duration), localId: Value(asset.localId), remoteId: Value(asset.remoteId), diff --git a/mobile-v2/lib/domain/repositories/asset_isar.repository.dart b/mobile-v2/lib/domain/repositories/asset_isar.repository.dart new file mode 100644 index 0000000000..68d32e39ec --- /dev/null +++ b/mobile-v2/lib/domain/repositories/asset_isar.repository.dart @@ -0,0 +1,136 @@ +import 'package:immich_mobile/domain/entities/asset_isar.entity.dart' as entity; +import 'package:immich_mobile/domain/interfaces/asset.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:isar/isar.dart'; + +class AssetIsarRepository implements IAssetRepository { + final Isar db; + + const AssetIsarRepository({required this.db}); + + @override + Future deleteAll() async { + await db.writeTxn(() async { + await db.assets.clear(); + }); + return true; + } + + @override + Future deleteIds(Iterable ids) async { + await db.writeTxn(() async { + await db.assets.deleteAll(ids.toList()); + }); + } + + @override + Future> getAll({int? offset, int? limit}) async { + return await db.assets + .where() + .offset(offset ?? 0) + .limit(limit ?? 100) + .findAll() + .then((value) => value.map(_toModel).toList()); + } + + @override + Future> getForHashes(Iterable hashes) async { + return await db.assets + .where() + .filter() + .anyOf(hashes, (asset, hash) => asset.hashEqualTo(hash)) + .findAll() + .then((value) => value.map(_toModel).toList()); + } + + @override + Future> getForLocalIds(Iterable localIds) async { + return await db.assets + .where() + .filter() + .anyOf(localIds, (asset, localId) => asset.localIdEqualTo(localId)) + .findAll() + .then((value) => value.map(_toModel).toList()); + } + + @override + Future> getForRemoteIds(Iterable remoteIds) async { + return await db.assets + .where() + .filter() + .anyOf(remoteIds, (asset, remoteId) => asset.remoteIdEqualTo(remoteId)) + .findAll() + .then((value) => value.map(_toModel).toList()); + } + + @override + Future upsertAll(Iterable assets) async { + await db.writeTxn(() async { + await db.assets.putAll(assets.toEntity()); + }); + return true; + } + + @override + Future upsert(Asset assets) async { + await db.writeTxn(() async { + await db.assets.put(assets.toEntity()); + }); + return true; + } +} + +Asset _toModel(entity.Asset entity) { + return Asset( + id: entity.id, + name: entity.name, + hash: entity.hash, + height: entity.height, + width: entity.width, + type: entity.type, + createdTime: entity.createdTime, + modifiedTime: entity.modifiedTime, + duration: entity.duration, + localId: entity.localId, + remoteId: entity.remoteId, + livePhotoVideoId: entity.livePhotoVideoId, + ); +} + +extension on Asset { + entity.Asset toEntity() { + return entity.Asset( + id: id, + name: name, + hash: hash, + height: height, + width: width, + type: type, + createdTime: createdTime, + modifiedTime: modifiedTime, + duration: duration, + localId: localId, + remoteId: remoteId, + livePhotoVideoId: livePhotoVideoId, + ); + } +} + +extension on Iterable { + List toEntity() { + return map((asset) => entity.Asset( + id: asset.id, + name: asset.name, + hash: asset.hash, + height: asset.height, + width: asset.width, + type: asset.type, + createdTime: asset.createdTime, + modifiedTime: asset.modifiedTime, + duration: asset.duration, + localId: asset.localId, + remoteId: asset.remoteId, + livePhotoVideoId: asset.livePhotoVideoId, + )).toList(); + } +} diff --git a/mobile-v2/lib/domain/repositories/renderlist.repository.dart b/mobile-v2/lib/domain/repositories/renderlist.repository.dart index 3b901f012e..c217f4e2e0 100644 --- a/mobile-v2/lib/domain/repositories/renderlist.repository.dart +++ b/mobile-v2/lib/domain/repositories/renderlist.repository.dart @@ -57,4 +57,44 @@ class RenderListRepository with LogMixin implements IRenderListRepository { return RenderList(elements: elements, modifiedTime: modified); }); } + + @override + Future getAll() async { + final assetCountExp = _db.asset.id.count(); + final createdTimeExp = _db.asset.createdTime; + final modifiedTimeExp = _db.asset.modifiedTime.max(); + final monthYearExp = createdTimeExp.strftime('%m-%Y'); + + final query = _db.asset.selectOnly() + ..addColumns([assetCountExp, createdTimeExp, modifiedTimeExp]) + ..groupBy([monthYearExp]) + ..orderBy([OrderingTerm.desc(createdTimeExp)]); + + int lastAssetOffset = 0; + DateTime recentModifiedTime = DateTime(1); + + final elements = await query.expand((row) { + final createdTime = row.read(createdTimeExp)!; + final assetCount = row.read(assetCountExp)!; + final modifiedTime = row.read(modifiedTimeExp)!; + final assetOffset = lastAssetOffset; + lastAssetOffset += assetCount; + + // Get the recent modifed time. This is used to prevent unnecessary grid updates + if (modifiedTime.isAfter(recentModifiedTime)) { + recentModifiedTime = modifiedTime; + } + + return [ + RenderListMonthHeaderElement(date: createdTime), + RenderListAssetElement( + date: createdTime, + assetCount: assetCount, + assetOffset: assetOffset, + ), + ]; + }).get(); + + return RenderList(elements: elements, modifiedTime: recentModifiedTime); + } } diff --git a/mobile-v2/pubspec.lock b/mobile-v2/pubspec.lock index 82bb4c8fdb..30af1573dc 100644 --- a/mobile-v2/pubspec.lock +++ b/mobile-v2/pubspec.lock @@ -39,7 +39,7 @@ packages: source: hosted version: "2.5.0" async: - dependency: transitive + dependency: "direct main" description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" @@ -62,6 +62,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + benchmarking: + dependency: "direct dev" + description: + name: benchmarking + sha256: "6b7f6955e8b0b6ce0dd4c3bc5611430aac017a74923669ad3687b718e5085c6a" + url: "https://pub.dev" + source: hosted + version: "0.6.1" bloc: dependency: transitive description: @@ -366,6 +374,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + faker: + dependency: "direct dev" + description: + name: faker + sha256: "544c34e9e1d322824156d5a8d451bc1bb778263b892aded24ec7ba77b0706624" + url: "https://pub.dev" + source: hosted + version: "2.2.0" ffi: dependency: transitive description: @@ -593,6 +609,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + isar: + dependency: "direct main" + description: + name: isar + sha256: e17a9555bc7f22ff26568b8c64d019b4ffa2dc6bd4cb1c8d9b269aefd32e53ad + url: "https://pub.isar-community.dev" + source: hosted + version: "3.1.8" + isar_flutter_libs: + dependency: "direct main" + description: + name: isar_flutter_libs + sha256: "78710781e658ce4bff59b3f38c5b2735e899e627f4e926e1221934e77b95231a" + url: "https://pub.isar-community.dev" + source: hosted + version: "3.1.8" + isar_generator: + dependency: "direct dev" + description: + name: isar_generator + sha256: "484e73d3b7e81dbd816852fe0b9497333118a9aeb646fd2d349a62cc8980ffe1" + url: "https://pub.isar-community.dev" + source: hosted + version: "3.1.8" js: dependency: transitive description: @@ -1333,6 +1373,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + xxh3: + dependency: transitive + description: + name: xxh3 + sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" + url: "https://pub.dev" + source: hosted + version: "1.2.0" yaml: dependency: transitive description: diff --git a/mobile-v2/pubspec.yaml b/mobile-v2/pubspec.yaml index 775a298e44..03724452ee 100644 --- a/mobile-v2/pubspec.yaml +++ b/mobile-v2/pubspec.yaml @@ -7,6 +7,8 @@ version: 1.102.0+132 environment: sdk: '>=3.3.3 <4.0.0' +isar_version: &isar_version 3.1.8 # define the version to be used + dependencies: flutter: sdk: flutter @@ -36,7 +38,7 @@ dependencies: logging: ^1.3.0 # Collection Utils collection: ^1.18.0 - async: ^2.12.0 + async: ^2.11.0 # service_locator get_it: ^8.0.0 # Photo Manager @@ -56,6 +58,12 @@ dependencies: cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 skeletonizer: ^1.4.2 + isar: + version: *isar_version + hosted: https://pub.isar-community.dev/ + isar_flutter_libs: # contains Isar Core + version: *isar_version + hosted: https://pub.isar-community.dev/ openapi: path: openapi @@ -82,6 +90,11 @@ dev_dependencies: custom_lint: ^0.6.4 immich_mobile_lint: path: './immich_lint' + benchmarking: ^0.6.1 + faker: ^2.2.0 + isar_generator: + version: *isar_version + hosted: https://pub.isar-community.dev/ flutter: uses-material-design: true diff --git a/mobile-v2/test/benchmark_insert_test.dart b/mobile-v2/test/benchmark_insert_test.dart new file mode 100644 index 0000000000..2daf56740f --- /dev/null +++ b/mobile-v2/test/benchmark_insert_test.dart @@ -0,0 +1,232 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +// ignore: import_rule_drift +import 'package:drift/drift.dart'; +// ignore: import_rule_drift +import 'package:drift/native.dart'; +import 'package:faker/faker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/repositories/asset.repository.dart'; +import 'package:immich_mobile/domain/repositories/asset_isar.repository.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; +// import 'package:isar/isar.dart'; +import 'package:sqlite3/sqlite3.dart'; + +import 'test_utils.dart'; + +List _generateAssets(int count) { + final assets = []; + final faker = Faker(); + for (int i = 0; i < count; i++) { + assets.add(Asset( + id: i, + name: 'Asset $i', + hash: faker.guid.guid(), + height: faker.randomGenerator.integer(1000), + width: faker.randomGenerator.integer(1000), + type: faker.randomGenerator.element(AssetType.values), + createdTime: faker.date.dateTime(), + modifiedTime: faker.date.dateTime(), + duration: faker.randomGenerator.integer(100), + localId: faker.guid.guid(), + remoteId: faker.guid.guid(), + livePhotoVideoId: faker.guid.guid(), + )); + } + return assets; +} + +Future _benchDriftInsertsBatched({ + required String name, + required Iterable> assets, + required DriftDatabaseRepository db, +}) async { + final repo = AssetRepository(db: db); + final sp = Stopwatch()..start(); + for (final chunk in assets) { + await repo.upsertAll(chunk); + } + print('$name - ${sp.elapsed}'); +} + +Future _benchIsarInsertsBatched({ + required String name, + required Iterable> assets, + required Isar db, +}) async { + final repo = AssetIsarRepository(db: db); + final sp = Stopwatch()..start(); + for (final chunk in assets) { + await repo.upsertAll(chunk); + } + print('$name - ${sp.elapsed}'); +} + +Future _benchDriftInsertsNonBatched({ + required String name, + required List assets, + required DriftDatabaseRepository db, +}) async { + final repo = AssetRepository(db: db); + final sp = Stopwatch()..start(); + for (final chunk in assets) { + await repo.upsert(chunk); + } + print('$name - ${sp.elapsed}'); +} + +Future _benchIsarInsertsNonBatched({ + required String name, + required List assets, + required Isar db, +}) async { + final repo = AssetIsarRepository(db: db); + final sp = Stopwatch()..start(); + for (final chunk in assets) { + await repo.upsert(chunk); + } + print('$name - ${sp.elapsed}'); +} + +Future _cleanup(File file) async { + if (await file.exists()) { + await file.delete(); + } +} + +void main() { + late DriftDatabaseRepository drift; + late Isar isar; + + // ignore: avoid-local-functions + Future setup() async { + drift = DriftDatabaseRepository(LazyDatabase(() { + sqlite3.tempDirectory = 'test/'; + return NativeDatabase.createInBackground(File('test/test.sqlite')); + })); + isar = await TestUtils.initIsar(); + } + + // ignore: avoid-local-functions + Future cleanupFiles() async { + await _cleanup(File('test/test.sqlite')); + await _cleanup(File('test/test.sqlite-shm')); + await _cleanup(File('test/test.sqlite-wal')); + await _cleanup(File('test/default.isar')); + } + + // ignore: avoid-local-functions + Future closeDb() async { + await drift.close(); + if (isar.isOpen) { + await isar.close(); + } + } + + test('10K assets', () async { + await cleanupFiles(); + await setup(); + + final assets = _generateAssets(10000); + await _benchDriftInsertsBatched( + name: 'Drift 10K assets batched - 1K slice', + assets: assets.slices(1000), + db: drift, + ); + await _benchIsarInsertsBatched( + name: 'Isar 10K assets batched - 1K slice', + assets: assets.slices(1000), + db: isar, + ); + + await closeDb(); + await cleanupFiles(); + await setup(); + + await _benchDriftInsertsNonBatched( + name: 'Drift 10K assets non-batched', + assets: assets, + db: drift, + ); + await _benchIsarInsertsNonBatched( + name: 'Isar 10K assets non-batched', + assets: assets, + db: isar, + ); + }); + + test( + '100K assets', + () async { + await cleanupFiles(); + await setup(); + + final assets = _generateAssets(100000); + await _benchDriftInsertsBatched( + name: 'Drift 100K assets batched - 10K slice', + assets: assets.slices(10000), + db: drift, + ); + await _benchIsarInsertsBatched( + name: 'Isar 100K assets batched - 10K slice', + assets: assets.slices(10000), + db: isar, + ); + + await closeDb(); + await cleanupFiles(); + await setup(); + + await _benchDriftInsertsNonBatched( + name: 'Drift 100K assets non-batched', + assets: assets, + db: drift, + ); + await _benchIsarInsertsNonBatched( + name: 'Isar 100K assets non-batched', + assets: assets, + db: isar, + ); + }, + timeout: Timeout(Duration(minutes: 5)), + ); + + test( + '1M assets', + () async { + await cleanupFiles(); + await setup(); + + final assets = _generateAssets(1000000); + await _benchDriftInsertsBatched( + name: 'Drift 1M assets batched - 10K slice', + assets: assets.slices(10000), + db: drift, + ); + await _benchIsarInsertsBatched( + name: 'Isar 1M assets batched - 10K slice', + assets: assets.slices(10000), + db: isar, + ); + + await closeDb(); + await cleanupFiles(); + await setup(); + + await _benchDriftInsertsNonBatched( + name: 'Drift 1M assets non-batched', + assets: assets, + db: drift, + ); + await _benchIsarInsertsNonBatched( + name: 'Isar 1M assets non-batched', + assets: assets, + db: isar, + ); + }, + timeout: Timeout(Duration(minutes: 25)), + ); +} diff --git a/mobile-v2/test/benchmark_query_test.dart b/mobile-v2/test/benchmark_query_test.dart new file mode 100644 index 0000000000..370b8f551a --- /dev/null +++ b/mobile-v2/test/benchmark_query_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +// ignore: import_rule_drift +import 'package:benchmarking/benchmarking.dart'; +// ignore: import_rule_drift +import 'package:drift/drift.dart'; +// ignore: import_rule_drift +import 'package:drift/native.dart'; +import 'package:faker/faker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/interfaces/renderlist.interface.dart'; +import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/repositories/asset.repository.dart'; +import 'package:immich_mobile/domain/repositories/database.repository.dart'; +import 'package:immich_mobile/domain/repositories/renderlist.repository.dart'; +import 'package:sqlite3/sqlite3.dart'; + +List _generateAssets(int count) { + final assets = []; + final faker = Faker(); + for (int i = 0; i < count; i++) { + assets.add(Asset( + id: i, + name: 'Asset $i', + hash: faker.guid.guid(), + height: faker.randomGenerator.integer(1000), + width: faker.randomGenerator.integer(1000), + type: faker.randomGenerator.element(AssetType.values), + createdTime: faker.date.dateTime(minYear: 2010, maxYear: 2024), + modifiedTime: faker.date.dateTime(minYear: 2010, maxYear: 2024), + duration: faker.randomGenerator.integer(100), + localId: faker.guid.guid(), + remoteId: faker.guid.guid(), + livePhotoVideoId: faker.guid.guid(), + )); + } + return assets; +} + +Future _driftInserts({ + required List assets, + required DriftDatabaseRepository db, +}) async { + final repo = AssetRepository(db: db); + await repo.upsertAll(assets); +} + +void main() { + late DriftDatabaseRepository drift; + late File file; + + setUp(() { + file = File('test/test.sqlite'); + drift = DriftDatabaseRepository(LazyDatabase(() { + sqlite3.tempDirectory = 'test/'; + return NativeDatabase.createInBackground(file); + })); + }); + + tearDown(() async { + for (final table in drift.allTables) { + await drift.delete(table).go(); + } + await drift.close(); + }); + + group('Generate RenderList', () { + test('10K assets', () async { + List assets = []; + final IRenderListRepository repo = RenderListRepository(db: drift); + final result = await asyncBenchmark( + 'Generate RenderList for 10K assets', + () async { + await repo.getAll(); + }, + setup: () async { + assets = _generateAssets(10000); + await _driftInserts(assets: assets, db: drift); + }, + ); + result.report(units: 10000); + }); + test('Drift 100K assets', () async { + List assets = []; + await _driftInserts(assets: assets, db: drift); + final IRenderListRepository repo = RenderListRepository(db: drift); + final result = await asyncBenchmark( + 'Generate RenderList for 100K assets', + () async { + await repo.getAll(); + }, + setup: () async { + assets = _generateAssets(100000); + await _driftInserts(assets: assets, db: drift); + }, + ); + result.report(units: 100000); + }); + test( + 'Drift 1M assets', + () async { + List assets = []; + final IRenderListRepository repo = RenderListRepository(db: drift); + final result = await asyncBenchmark( + 'Generate RenderList for 1M assets', + () async { + await repo.getAll(); + }, + setup: () async { + assets = _generateAssets(1000000); + await _driftInserts(assets: assets, db: drift); + }, + ); + result.report(units: 1000000); + }, + timeout: Timeout(Duration(minutes: 2)), + ); + }); +} diff --git a/mobile-v2/test/test_utils.dart b/mobile-v2/test/test_utils.dart new file mode 100644 index 0000000000..f6e1ae84bb --- /dev/null +++ b/mobile-v2/test/test_utils.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/entities/asset_isar.entity.dart'; +import 'package:isar/isar.dart'; + +abstract final class TestUtils { + const TestUtils._(); + + /// Downloads Isar binaries (if required) and initializes a new Isar db + static Future initIsar() async { + await Isar.initializeIsarCore(download: true); + + final instance = Isar.getInstance(); + if (instance != null) { + return instance; + } + + final db = await Isar.open( + [AssetSchema], + directory: "test/", + maxSizeMiB: 1024, + inspector: false, + ); + + // Clear and close db on test end + addTearDown(() async { + if (!db.isOpen) { + return; + } + await db.writeTxn(() async => await db.clear()); + await db.close(); + }); + return db; + } +}