Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store and send documents modified offline #12

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 142 additions & 36 deletions lib/src/adapters/persistent_json_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:sembast/sembast_io.dart';
import 'package:sembast/sembast_memory.dart';

import 'package:flutter_rest_data/src/db_adapters/json_api.dart';
import 'package:uuid/uuid.dart';

import '../exceptions.dart';

Expand All @@ -29,6 +30,7 @@ class PersistentJsonApiAdapter extends JsonApiAdapter {

void setOnline() {
isOnline = true;
_trySendPersistedRequests();
}

void setOffline() {
Expand Down Expand Up @@ -62,23 +64,16 @@ class PersistentJsonApiAdapter extends JsonApiAdapter {

Future<void> dropStores() => _dbFactory.deleteDatabase(_dbName);

// TODO
// - when connection gets back:
// - invoke super.save() on each doc in the 'added' store (endpoint can be computed from type)
// - invoke super.delete() on each doc in the 'removed' store (endpoint can be computed from type)

@override
Future<JsonApiDocument> fetch(String endpoint, String id) async {
JsonApiDocument? doc;
if (isOnline) {
doc = await super.fetch(endpoint, id);
await storePutOne(endpoint, doc);
} else {
doc = id.contains('added')
? (await findAdded(id))
: (await storeGetOne(endpoint, id));
doc = await storeGetOne(endpoint, id);
}
return doc!;
return doc;
}

@override
Expand Down Expand Up @@ -126,24 +121,102 @@ class PersistentJsonApiAdapter extends JsonApiAdapter {
doc = await super.save(endpoint, document);
await storePutOne(endpoint, doc);
} else {
// TODO Handle Update case
doc = document;
int id = await storeAdd(endpoint, doc);
doc.id = 'added:$id';
cache(endpoint, doc);
doc = await _persistWhileOffline(endpoint, document);
}
return doc;
}

Future<JsonApiDocument> _persistWhileOffline(
String endpoint, JsonApiDocument document) async {
final isAdding =
document.id == null || await findAdded(document.id!) != null;
if (isAdding) {
final id = await storeAdd(endpoint, document);
document.id = id;
} else {
await storeUpdate(endpoint, document);
}
cache(endpoint, document);
return document;
}

Future<void> _trySendPersistedRequests() async {
await _trySendPersistedDeleteRequests();
await _trySendPersistedAddRequests();
await _trySendPersistedUpdateRequests();
}

Future<void> _trySendPersistedDeleteRequests() async {
final removedStore = openRemovedStore();
final allRemoved = await removedStore.find(database);
for (var removed in allRemoved) {
final id = removed.key;
final endpoint = removed.value['endpoint'].toString();
try {
await super.performDelete(
endpoint,
// TODO: only id needed to delete, allow in dart-rest-data
JsonApiDocument(id, null, {}, null),
);
removedStore.record(id).delete(database);
} on HttpStatusException catch (_) {
// no-op
}
}
}

Future<void> _trySendPersistedUpdateRequests() async {
final updatedStore = openUpdatedStore();
final allUpdated = await updatedStore.find(database);
for (var updated in allUpdated) {
final id = updated.key;
final endpoint = updated.value['endpoint'].toString();
final doc = await storeGetOne(endpoint, id);
final store = openStringKeyStore(endpoint);
try {
final updatedDoc = await super.save(endpoint, doc);
updatedStore.record(id).delete(database);
store.record(doc.id!).update(database, _storeAdapter.toMap(updatedDoc));
} on HttpStatusException catch (_) {
// no-op
}
}
}

Future<void> _trySendPersistedAddRequests() async {
final addedStore = openAddedStore();
final allAdded = await addedStore.find(database);
for (var added in allAdded) {
final id = added.key;
final endpoint = added.value['endpoint'].toString();
final store = openStringKeyStore(endpoint);
final doc = await storeGetOne(endpoint, id);
doc.id = null;
try {
final updatedDoc = await super.save(endpoint, doc);
addedStore.record(id).delete(database);
store.record(id).delete(database);
store
.record(updatedDoc.id!)
.add(database, _storeAdapter.toMap(updatedDoc));
} on HttpStatusException catch (_) {
// no-op
}
}
}

@override
Future<void> performDelete(String endpoint, JsonApiDocument doc) async {
if (isOnline) {
return super.performDelete(endpoint, doc);
} else {
var removedStore = openStringKeyStore('removed');
await removedStore
.record('$endpoint:${doc.id}')
.put(database, _storeAdapter.toMap(doc));
var addedStore = openAddedStore();
var addedRecord = addedStore.record(doc.id!);
if (await addedRecord.exists(database)) {
await addedRecord.delete(database);
return;
}
await storeDelete(endpoint, doc);
await openStringKeyStore(endpoint).record(doc.id!).delete(database);
}
}
Expand Down Expand Up @@ -193,42 +266,75 @@ class PersistentJsonApiAdapter extends JsonApiAdapter {
});
}

Future<int> storeAdd(String endpoint, JsonApiDocument doc) async {
var store = openIntKeyStore('added');
int id = await store.add(database, _storeAdapter.toMap(doc));
return id;
Future<String> storeAdd(String endpoint, JsonApiDocument doc) async {
final tempId = doc.id ?? Uuid().v1();
doc.id = tempId;
storePutOne(endpoint, doc);

var store = openAddedStore();
await store.record(tempId).delete(database);
await store.record(tempId).add(database, {'endpoint': endpoint});
return tempId;
}

Future<void> storeUpdate(String endpoint, JsonApiDocument doc) async {
storePutOne(endpoint, doc);

var store = openUpdatedStore();
await store.record(doc.id!).delete(database);
await store.record(doc.id!).add(database, {'endpoint': endpoint});
}

Future<void> storeDelete(String endpoint, JsonApiDocument doc) async {
storePutOne(endpoint, doc);

var store = openRemovedStore();
await store.record(doc.id!).delete(database);
await store.record(doc.id!).add(database, {'endpoint': endpoint});
}

Future<Iterable<JsonApiDocument>> addedByEndpoint(String endpoint) async {
var store = openIntKeyStore('added');
var docs = await store.find(
var addedStore = openAddedStore();

var addedIds = (await addedStore.findKeys(
database,
finder: Finder(filter: Filter.matches('endpoint', endpoint)),
);
return docs.map((e) => _storeAdapter.fromMap(e.value)).toList();
finder: Finder(filter: Filter.equals('endpoint', endpoint)),
));

var store = openStringKeyStore(endpoint);
var list = (await store.records(addedIds).get(database))
.where((e) => e != null)
.map((e) => _storeAdapter.fromMap(e!))
.toList();

return list;
}

Future<JsonApiDocument?> findAdded(String id) async {
var key = int.parse(id.replaceAll('added:', ''));
var store = openIntKeyStore('added');
var map = await store.record(key).get(database);
return map == null ? null : _storeAdapter.fromMap(map);
var store = openAddedStore();
var added = await store.record(id).get(database);
if (added == null) return null;
return storeGetOne(added['endpoint'].toString(), id);
}

Future<JsonApiManyDocument> findAllPersisted(String endpoint) async {
var store = openStringKeyStore(endpoint);
final docs = (await store.find(database, finder: Finder()))
.map((e) => _storeAdapter.fromMap(e.value))
.toList();
docs.addAll(await addedByEndpoint(endpoint));
return JsonApiManyDocument(docs);
}

StoreRef<String, Map<String, Object?>> openAddedStore() =>
openStringKeyStore('added');

StoreRef<String, Map<String, Object?>> openRemovedStore() =>
openStringKeyStore('removed');

StoreRef<String, Map<String, Object?>> openUpdatedStore() =>
openStringKeyStore('updated');

StoreRef<String, Map<String, Object?>> openStringKeyStore(String name) {
return stringMapStoreFactory.store(name);
}

StoreRef<int, Map<String, Object?>> openIntKeyStore(String name) {
return intMapStoreFactory.store(name);
}
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies:
sembast: ^3.1.1
path_provider: ^2.0.5
rest_data: ^1.1.0
uuid: ^3.0.5
flutter:
sdk: flutter

Expand Down
61 changes: 58 additions & 3 deletions test/flutter_rest_data_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,76 @@ void main() {
expect(allIds.toSet().containsAll(returnedIds), isTrue);
});

test('performDelete() moves document to "removed" store', () async {
test(
'performDelete() places document id to "removed" store and removed document from store',
() async {
await adapter.performDelete('docs', doc1);
var remaining = await adapter.findAll('docs');
expect(remaining.length, docs.length - 1);

var removed = await adapter
.openStringKeyStore('removed')
.openRemovedStore()
.find(adapter.database, finder: sembast.Finder());
expect(removed.length, 1);
});

test('save() places new document id to "added" store', () async {
var newDoc = createJsonApiDocument(null);
newDoc = await adapter.save('docs', newDoc);
var remaining = await adapter.findAll('docs');
expect(remaining.length, docs.length + 1);

var addedStore = adapter.openAddedStore();
var added = await addedStore.find(adapter.database);
expect(added.length, 1);

// second save should not duplicate data
newDoc.setAttribute('attribute_one', 'another_value');
newDoc = await adapter.save('docs', newDoc);

remaining = await adapter.findAll('docs');
expect(remaining.length, docs.length + 1);

added = await addedStore.find(adapter.database);
expect(added.length, 1);
});

test('save() places new document ids in correct order', () async {
var newDoc1 = createJsonApiDocument(null);
var newDoc2 = createJsonApiDocument(null);
var newDoc3 = createJsonApiDocument(null);
newDoc1 = await adapter.save('docs', newDoc1);
newDoc2 = await adapter.save('docs', newDoc2);
newDoc3 = await adapter.save('docs', newDoc3);
var remaining = await adapter.findAll('docs');
expect(remaining.length, docs.length + 3);

var added = await adapter
.openAddedStore()
.find(adapter.database, finder: sembast.Finder());
expect(added.length, 3);
expect(added[0].key, newDoc1.id);
expect(added[1].key, newDoc2.id);
expect(added[2].key, newDoc3.id);
});

test('save() places existing document id to "updated" store', () async {
final existingDoc = doc2;
existingDoc.setAttribute('attribute_one', 'new_value');
await adapter.save('docs', existingDoc);
var remaining = await adapter.findAll('docs');
expect(remaining.length, docs.length);

var added = await adapter
.openUpdatedStore()
.find(adapter.database, finder: sembast.Finder());
expect(added.length, 1);
});
});
});
}

JsonApiDocument createJsonApiDocument(String id) =>
JsonApiDocument createJsonApiDocument(String? id) =>
JsonApiDocument(id, 'model_type_1', {
'attribute_one': 'value_one',
'attribute_two': 'value_two',
Expand Down