diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fec8c9..b5260e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.1.0 + +- changed method signature from `hasCacheItemExpired` to `isCacheItemExpired` +- expose a where API which takes a key and a predicate for more conditional CacheStore filtering + ## 1.0.2 - moved cache utils to its own package diff --git a/lib/cache_manager_plus.dart b/lib/cache_manager_plus.dart index 6b532e8..9c5f79c 100644 --- a/lib/cache_manager_plus.dart +++ b/lib/cache_manager_plus.dart @@ -1,4 +1,3 @@ library; -export 'src/cache/cache.dart'; -export 'src/in_memory_store.dart'; +export 'src/src.dart'; diff --git a/lib/src/cache/cache_item.dart b/lib/src/cache_item.dart similarity index 97% rename from lib/src/cache/cache_item.dart rename to lib/src/cache_item.dart index 71c29d1..9cc20a2 100644 --- a/lib/src/cache/cache_item.dart +++ b/lib/src/cache_item.dart @@ -31,8 +31,6 @@ final class CacheItem { bool get isValid => !isExpired; - bool get isInvalid => !isValid; - String toCacheEntryString() { return jsonEncode({ 'expiry': expiry.toIso8601String(), @@ -79,6 +77,8 @@ final class CacheItem { other.expiry == expiry; } + //coverage:ignore-start @override int get hashCode => key.hashCode ^ data.hashCode ^ expiry.hashCode; + //coverage:ignore-end } diff --git a/lib/src/cache/cache_store.dart b/lib/src/cache_store.dart similarity index 91% rename from lib/src/cache/cache_store.dart rename to lib/src/cache_store.dart index e345525..5ecd7de 100644 --- a/lib/src/cache/cache_store.dart +++ b/lib/src/cache_store.dart @@ -1,3 +1,5 @@ +//coverage:ignore-file + import 'dart:async'; import '../../cache_manager_plus.dart'; @@ -79,4 +81,10 @@ abstract interface class CacheStore { /// /// ``` bool containsKey(String key); + + /// contract requirement for closing the cache store + /// helpful to allow stores be closed safely to prevent memory leaks. + /// When implemented, the client should have to reinitialise the store to read + /// its content + FutureOr close(); } diff --git a/lib/src/in_memory_store.dart b/lib/src/in_memory_store.dart index aa6839d..f4d6005 100644 --- a/lib/src/in_memory_store.dart +++ b/lib/src/in_memory_store.dart @@ -1,28 +1,51 @@ -import 'cache/cache.dart'; +import 'dart:async'; + +import 'cache_item.dart'; +import 'cache_store.dart'; final class InMemoryCacheStore implements CacheStore { InMemoryCacheStore(); - late final Map _store; + Map? _store; @override Future get cacheVersion async { - return int.parse(_store['version'] ?? '-1'); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + return int.parse(_store!['version'] ?? '-1'); } @override Future updateCacheVersion(int version) async { - _store['version'] = version.toString(); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + _store!['version'] = version.toString(); } @override bool containsKey(String key) { - return _store.containsKey(key); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + return _store!.containsKey(key); } @override Future getCacheItem(String key) async { - final item = _store[key]; + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + final item = _store![key]; if (item == null) return null; return CacheItem.fromCacheEntryString(item, key: key); @@ -30,12 +53,17 @@ final class InMemoryCacheStore implements CacheStore { @override Future initialiseStore() async { - _store = {}; + _store ??= {}; } @override Future invalidateCache() async { - return _store.clear(); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + return _store!.clear(); } @override @@ -49,6 +77,11 @@ final class InMemoryCacheStore implements CacheStore { @override Future saveCacheItem(CacheItem item) async { - _store[item.key] = item.toCacheEntryString(); + _store![item.key] = item.toCacheEntryString(); + } + + @override + FutureOr close() { + _store = null; } } diff --git a/lib/src/cache/manager.dart b/lib/src/manager.dart similarity index 86% rename from lib/src/cache/manager.dart rename to lib/src/manager.dart index 3c23ef0..e913fce 100644 --- a/lib/src/cache/manager.dart +++ b/lib/src/manager.dart @@ -40,14 +40,12 @@ final class CacheManager { List stores = const [], bool forceInit = false, }) async { - assert(store != null || stores.isNotEmpty, - 'At least one store must be provided'); - if (store == null && stores.isEmpty) { throw ArgumentError('At least one store must be provided'); } if (forceInit) { + await close(); // safe closes all stores _instance = CacheManager._(); } else { if (_instance != null) { @@ -57,18 +55,40 @@ final class CacheManager { _instance ??= CacheManager._(); } - _instance!._stores = { - if (store != null) store.runtimeType.toString(): store, - for (final store in stores) store.runtimeType.toString(): store, - }; + _instance!._stores = {}; + if (store case final CacheStore s) { + _instance!._stores.putIfAbsent(s.runtimeType.toString(), () => s); + } + for (final store in stores) { + _instance!._stores.putIfAbsent(store.runtimeType.toString(), () => store); + } await Future.wait( instance._stores.values.map((store) => store.initialiseStore()), ); } - static void close() { - _instance = null; + /// closes all stores + static Future close() async { + if (_instance != null) { + await _instance!._stores.values + .map((store) async => await store.close()) + .wait; + _instance = null; + } + } + + /// retrieves all [CacheStore] that matches the [predicate] + Future> where( + String key, bool Function(CacheItem i) predicate) async { + List s = []; + for (final store in _stores.values) { + if (await store.getCacheItem(key) case final CacheItem it) { + if (predicate(it)) s.add(store); + } + } + + return s; } /// retrieves the [CacheStore] version for the [CacheStore] specified by @@ -163,11 +183,11 @@ final class CacheManager { return await invalidateItemInCache(_effectiveStore(S.toString())); } - /// helper method to check if a [CacheItem] has expired/valid. It returns a + /// helper method to check if a [CacheItem] is expired/valid. It returns a /// nullable boolean with null being returned when the item doesn't exist /// in the [CacheStore] specified by the method's generic type [S] /// or the first [CacheStore] in the manager - FutureOr hasCacheItemExpired(String key) async { + FutureOr isCacheItemExpired(String key) async { final item = await _effectiveStore(S.toString()).getCacheItem(key); return item?.isExpired; } diff --git a/lib/src/cache/cache.dart b/lib/src/src.dart similarity index 76% rename from lib/src/cache/cache.dart rename to lib/src/src.dart index 5d133bc..2d08df9 100644 --- a/lib/src/cache/cache.dart +++ b/lib/src/src.dart @@ -1,4 +1,5 @@ //GENERATED BARREL FILE export 'cache_item.dart'; export 'cache_store.dart'; +export 'in_memory_store.dart'; export 'manager.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 0d40182..fb14fba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: cache_manager_plus description: "A Dart package for managing cache with support for multiple storage backends." -version: 1.0.2 +version: 1.1.0 homepage: https://github.com/CoderNamedHendrick/cache_manager repository: https://github.com/CoderNamedHendrick/cache_manager issue_tracker: https://github.com/CoderNamedHendrick/cache_manager/issues @@ -15,8 +15,6 @@ topics: environment: sdk: ^3.0.0 -dependencies: - dev_dependencies: lints: any test: ^1.25.15 diff --git a/test/cache_item_test.dart b/test/cache_item_test.dart new file mode 100644 index 0000000..b9fc4d6 --- /dev/null +++ b/test/cache_item_test.dart @@ -0,0 +1,35 @@ +import 'package:cache_manager_plus/cache_manager_plus.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + group('Cache item test', () { + test('equality comparison', () { + final a = CacheItem.ephemeral(key: 'a', data: 'Data a') + .copyWith(expiry: DateTime(1900)); + final b = CacheItem.ephemeral(key: 'a', data: 'Data a') + .copyWith(expiry: DateTime(1900)); + + expect(a == b, true); + }); + + test('persistence duration update', () { + var a = CacheItem.ephemeral(key: 'a', data: 'data a'); + + expect(a.isExpired, true); + + a = a.copyWith(persistenceDuration: Duration(minutes: 20)); + expect(a.isExpired, false); + + a = a.copyWith( + expiry: DateTime.now().add(Duration(minutes: 10)), + persistenceDuration: Duration(minutes: -20)); + expect(a.isExpired, true); + final expiry = a.expiry; + + a = a.copyWith(); + + expect(a.expiry, expiry); + }); + }); +} diff --git a/test/cache_manager_test.dart b/test/cache_manager_test.dart index f11ad7f..8b00df8 100644 --- a/test/cache_manager_test.dart +++ b/test/cache_manager_test.dart @@ -4,11 +4,27 @@ import 'package:test/test.dart'; void main() { group('Cache test suite', () { late CacheManager manager; - setUp(() { - CacheManager.init(store: InMemoryCacheStore(), forceInit: true); + setUp(() async { + await CacheManager.init(store: InMemoryCacheStore(), forceInit: true); manager = CacheManager.instance; }); + tearDown(() async { + await CacheManager.close(); + }); + + test('not passing any store throws error', () async { + await CacheManager.close(); + + expect( + CacheManager.init(), + throwsA( + isArgumentError.having((p0) => p0.message, 'invalid arguments error', + 'At least one store must be provided'), + ), + ); + }); + test('re-initialisation without forcing throw error', () async { expect( CacheManager.init(store: InMemoryCacheStore()), @@ -22,6 +38,14 @@ void main() { ); }); + test('update cache version', () async { + expect(await manager.cacheVersion(), -1); + + manager.updateCacheVersion(1); + + expect(await manager.cacheVersion(), 1); + }); + test('ephemeral cache items expire immediately', () async { final item = CacheItem.ephemeral( key: 'test-key-1', @@ -74,5 +98,40 @@ void main() { expect(updatedCachedItem?.isExpired, true); expect(updatedCachedItem?.data, {'data': 'look at me'}); }); + + test('close store and reinitialise', () async { + final item = CacheItem.persistent( + key: 'test-key-3', + data: {'data': 'look at me'}, + duration: Duration(seconds: 40), + ); + + CacheManager.instance.set(item); + + var cachedItem = await CacheManager.instance.get('test-key-3'); + expect(cachedItem?.isExpired, false); + + await CacheManager.close(); + + expect( + () async { + await CacheManager.instance.set(item); + }(), + throwsA( + isArgumentError.having( + (p0) => p0.message, + 'state error message', + 'CacheManager not initialized', + ), + ), + ); + + CacheManager.init(store: InMemoryCacheStore()); + + CacheManager.instance.set(item); + + cachedItem = await CacheManager.instance.get('test-key-3'); + expect(cachedItem?.isExpired, false); + }); }); } diff --git a/test/multi_store_manager_test.dart b/test/multi_store_manager_test.dart index 099c326..1ac3478 100644 --- a/test/multi_store_manager_test.dart +++ b/test/multi_store_manager_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:cache_manager_plus/cache_manager_plus.dart'; import 'package:test/test.dart'; @@ -5,14 +7,18 @@ void main() { group('Multi store cache test suite', () { late CacheManager manager; - setUp(() { - CacheManager.init( + setUp(() async { + await CacheManager.init( stores: [InMemoryCacheStore(), TestInMemoryCacheStore()], forceInit: true, ); manager = CacheManager.instance; }); + tearDown(() async { + await CacheManager.close(); + }); + test('verify ephemeral cache item is saved to a single store', () async { final item = CacheItem.ephemeral( key: 'test-key-1', @@ -58,6 +64,29 @@ void main() { (await manager.get('test-key-1')) != null, true); }); + test('verify where returns the correct cache stores', () async { + final item1 = CacheItem.ephemeral( + key: 'test-key-1', + data: 'John', + ); + final item2 = CacheItem.ephemeral( + key: 'test-key-1', + data: 'Doe', + ); + + await manager.set(item1); + await manager.set(item2); + + var stores = + await manager.where('test-key-1', (item) => item.data == 'Doe'); + expect(stores.length, 1); + expect(stores.first, isA()); + + stores = await manager.where('test-key-1', (item) => item.data == 'John'); + expect(stores.length, 1); + expect(stores.first, isA()); + }); + test('verify contains method works correctly across stores', () async { var item = CacheItem.ephemeral( key: 'test-key-1', @@ -178,21 +207,21 @@ void main() { await manager.set(item, all: true); expect( - (await manager.hasCacheItemExpired('test-key-1')), + (await manager.isCacheItemExpired('test-key-1')), false); expect( (await manager - .hasCacheItemExpired('test-key-1')), + .isCacheItemExpired('test-key-1')), false); await manager.invalidateCacheItem('test-key-1'); expect( - (await manager.hasCacheItemExpired('test-key-1')), + (await manager.isCacheItemExpired('test-key-1')), true); expect( (await manager - .hasCacheItemExpired('test-key-1')), + .isCacheItemExpired('test-key-1')), false); }); @@ -207,11 +236,11 @@ void main() { await manager.set(item, all: true); expect( - (await manager.hasCacheItemExpired('test-key-1')), + (await manager.isCacheItemExpired('test-key-1')), false); expect( (await manager - .hasCacheItemExpired('test-key-1')), + .isCacheItemExpired('test-key-1')), false); await manager.invalidateCacheItem('test-key-1'); @@ -249,52 +278,46 @@ void main() { await storeActions.wait; - expect( - await manager.hasCacheItemExpired('test-key-1'), + expect(await manager.isCacheItemExpired('test-key-1'), false); - expect( - await manager.hasCacheItemExpired('test-key-2'), + expect(await manager.isCacheItemExpired('test-key-2'), false); - expect( - await manager.hasCacheItemExpired('test-key-3'), + expect(await manager.isCacheItemExpired('test-key-3'), false); expect( await manager - .hasCacheItemExpired('test-key-1'), + .isCacheItemExpired('test-key-1'), false); expect( await manager - .hasCacheItemExpired('test-key-2'), + .isCacheItemExpired('test-key-2'), false); expect( await manager - .hasCacheItemExpired('test-key-3'), + .isCacheItemExpired('test-key-3'), false); await manager.invalidateCache(); - expect( - await manager.hasCacheItemExpired('test-key-1'), + expect(await manager.isCacheItemExpired('test-key-1'), null); - expect( - await manager.hasCacheItemExpired('test-key-2'), + expect(await manager.isCacheItemExpired('test-key-2'), null); - expect( - await manager.hasCacheItemExpired('test-key-3'), + expect(await manager.isCacheItemExpired('test-key-3'), null); expect( await manager - .hasCacheItemExpired('test-key-1'), + .isCacheItemExpired('test-key-1'), false); expect( await manager - .hasCacheItemExpired('test-key-2'), + .isCacheItemExpired('test-key-2'), false); expect( await manager - .hasCacheItemExpired('test-key-3'), + .isCacheItemExpired('test-key-3'), false); }); @@ -327,52 +350,46 @@ void main() { await storeActions.wait; - expect( - await manager.hasCacheItemExpired('test-key-1'), + expect(await manager.isCacheItemExpired('test-key-1'), false); - expect( - await manager.hasCacheItemExpired('test-key-2'), + expect(await manager.isCacheItemExpired('test-key-2'), false); - expect( - await manager.hasCacheItemExpired('test-key-3'), + expect(await manager.isCacheItemExpired('test-key-3'), false); expect( await manager - .hasCacheItemExpired('test-key-1'), + .isCacheItemExpired('test-key-1'), false); expect( await manager - .hasCacheItemExpired('test-key-2'), + .isCacheItemExpired('test-key-2'), false); expect( await manager - .hasCacheItemExpired('test-key-3'), + .isCacheItemExpired('test-key-3'), false); await manager.invalidateCache(all: true); - expect( - await manager.hasCacheItemExpired('test-key-1'), + expect(await manager.isCacheItemExpired('test-key-1'), null); - expect( - await manager.hasCacheItemExpired('test-key-2'), + expect(await manager.isCacheItemExpired('test-key-2'), null); - expect( - await manager.hasCacheItemExpired('test-key-3'), + expect(await manager.isCacheItemExpired('test-key-3'), null); expect( await manager - .hasCacheItemExpired('test-key-1'), + .isCacheItemExpired('test-key-1'), null); expect( await manager - .hasCacheItemExpired('test-key-2'), + .isCacheItemExpired('test-key-2'), null); expect( await manager - .hasCacheItemExpired('test-key-3'), + .isCacheItemExpired('test-key-3'), null); }); }); @@ -381,26 +398,46 @@ void main() { final class TestInMemoryCacheStore implements CacheStore { TestInMemoryCacheStore(); - late final Map _store; + Map? _store; @override Future get cacheVersion async { - return int.parse(_store['version'] ?? '-1'); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + return int.parse(_store!['version'] ?? '-1'); } @override Future updateCacheVersion(int version) async { - _store['version'] = version.toString(); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + _store!['version'] = version.toString(); } @override bool containsKey(String key) { - return _store.containsKey(key); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + return _store!.containsKey(key); } @override Future getCacheItem(String key) async { - final item = _store[key]; + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + final item = _store![key]; if (item == null) return null; return CacheItem.fromCacheEntryString(item, key: key); @@ -413,7 +450,12 @@ final class TestInMemoryCacheStore implements CacheStore { @override Future invalidateCache() async { - return _store.clear(); + if (_store == null) { + throw StateError( + 'Store not initialised, did you fail to initialise the store?'); + } + + return _store!.clear(); } @override @@ -427,6 +469,11 @@ final class TestInMemoryCacheStore implements CacheStore { @override Future saveCacheItem(CacheItem item) async { - _store[item.key] = item.toCacheEntryString(); + _store![item.key] = item.toCacheEntryString(); + } + + @override + FutureOr close() { + _store = null; } }