Add offline support for relationships

This commit is contained in:
Steven Nguyen
2023-06-16 16:35:10 -07:00
parent 3153f928b0
commit 4ecf8173e7
7 changed files with 402 additions and 50 deletions
+43 -12
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -82,6 +83,10 @@ class OfflineCallHandler extends CallHandler
final endpoint = getEndpoint(params);
final offlinePersistency = getOfflinePersistency(params);
if (offlinePersistency) {
params.headers['X-SDK-Offline'] = 'true';
}
while (true) {
try {
// if offline, do offline stuff
@@ -96,19 +101,36 @@ class OfflineCallHandler extends CallHandler
final pathValues = routeMatch?.getPathValues(params.path);
final model = modelPattern.split('/').map((part) {
if (!part.startsWith('{') || !part.endsWith('}')) {
return part;
String replacePlaceholder(
String input, Map<String, String>? pathValues) {
if (!input.startsWith('{') || !input.endsWith('}')) {
return input;
}
return pathValues![part.substring(1, part.length - 1)];
return pathValues![input.substring(1, input.length - 1)]!;
}
final model = modelPattern.split('/').map((part) {
return replacePlaceholder(part, pathValues);
}).join('/');
final keyPattern = routeMatch?.getLabel('offline.key') ?? '';
final key = replacePlaceholder(
keyPattern,
pathValues,
);
final containerKeyPattern =
routeMatch?.getLabel('offline.container-key') ?? '';
final containerKey = replacePlaceholder(
containerKeyPattern,
pathValues,
);
final cacheParams = CacheParams(
model: model,
key: routeMatch?.getLabel('offline.key') as String,
key: key,
responseIdKey: routeMatch?.getLabel('offline.response-key') as String,
responseContainerKey:
routeMatch?.getLabel('offline.container-key') as String,
responseContainerKey: containerKey,
);
final uri = Uri.parse(endpoint + params.path);
@@ -145,15 +167,23 @@ class OfflineCallHandler extends CallHandler
final response = await next.handleCall(params);
// cache stuff
print('cached stuff...');
if (offlinePersistency) {
// cache stuff
print('cached stuff...');
final relationsHeader = response.headers['x-appwrite-relations'];
if (relationsHeader != null) {
final relations = (jsonDecode(relationsHeader) as List)
.cast<Map<String, dynamic>>();
await cacheCollections(relations);
}
cacheResponse(
cacheModel: cacheParams.model,
cacheKey: cacheParams.key,
cacheResponseIdKey: cacheParams.responseIdKey,
request: request,
response: response,
cacheResponseContainerKey: cacheParams.responseContainerKey,
requestMethod: request.method,
responseData: response.data,
);
}
@@ -170,7 +200,8 @@ class OfflineCallHandler extends CallHandler
rethrow;
}
isOnline.value = false;
} catch (e) {
} catch (e, s) {
print(s);
throw AppwriteException(e.toString());
}
}
+4
View File
@@ -374,6 +374,10 @@ class ClientIO extends ClientBase with ClientMixin {
@override
Future<Response> call(CallParams params) async {
while (!_initialized) {
await Future.delayed(Duration(milliseconds: 100));
}
params.headers.addAll(this._headers!);
final response = await _handler.handleCall(
withOfflinePersistency(
+90 -13
View File
@@ -366,45 +366,122 @@ class ClientOfflineMixin {
return completer.future;
}
void cacheRelated({
required Map<String, Object?> document,
}) {
// iterate over each attribute to see if it's nested data
document.entries.forEach((entry) {
if (entry.value is Map) {
final nestedDocument = entry.value as Map<String, Object?>;
final nestedDatabaseId = nestedDocument['\$databaseId'] as String?;
final nestedCollectionId = nestedDocument['\$collectionId'] as String?;
final nestedDocumentId = nestedDocument['\$id'] as String?;
if (nestedDatabaseId == null ||
nestedCollectionId == null ||
nestedDocumentId == null) return;
final nestedModel =
"/databases/$nestedDatabaseId/collections/$nestedCollectionId/documents";
cacheResponse(
cacheModel: nestedModel,
cacheKey: nestedDocumentId,
cacheResponseIdKey: "\$id",
cacheResponseContainerKey: '',
requestMethod: 'GET',
responseData: entry.value,
);
document[entry.key] = {
'\$id': nestedDocumentId,
'\$databaseId': nestedDatabaseId,
'\$collectionId': nestedCollectionId,
};
} else if (entry.value is List &&
entry.value != null &&
(entry.value as List).isNotEmpty) {
final values = (entry.value as List);
if (!(values.first is Map<String, Object?>)) return;
final nestedDocument = values.first;
final nestedDatabaseId = nestedDocument['\$databaseId'] as String?;
final nestedCollectionId = nestedDocument['\$collectionId'] as String?;
if (nestedDatabaseId == null || nestedCollectionId == null) return;
final nestedModel =
"/databases/$nestedDatabaseId/collections/$nestedCollectionId/documents";
cacheResponse(
cacheModel: nestedModel,
cacheKey: '',
cacheResponseIdKey: "\$id",
cacheResponseContainerKey: 'documents',
requestMethod: 'GET',
responseData: {
'documents': entry.value,
},
);
document[entry.key] = values.map((value) {
final nestedDocumentId = value['\$id'] as String?;
return {
'\$id': nestedDocumentId,
'\$databaseId': nestedDatabaseId,
'\$collectionId': nestedCollectionId,
};
}).toList();
}
});
}
Future<void> cacheCollections(List<Map<String, dynamic>> relations) {
final futures = <Future>[];
for (var collection in relations) {
futures.add(_modelData.cacheModel(collection));
}
return Future.wait(futures);
}
void cacheResponse({
required String cacheModel,
required String cacheKey,
required String cacheResponseIdKey,
required http.BaseRequest request,
required Response response,
required String cacheResponseContainerKey,
required String requestMethod,
required dynamic responseData,
}) {
if (cacheModel.isEmpty) return;
switch (request.method) {
switch (requestMethod) {
case 'GET':
final clone = cloneMap(response.data);
final clone = cloneMap(responseData);
if (cacheKey.isNotEmpty) {
cacheRelated(document: clone);
_modelData.upsert(model: cacheModel, data: clone, key: cacheKey);
} else {
clone.forEach((key, value) {
if (key == 'total') return;
_modelData.batchUpsert(
model: cacheModel,
dataList: value as List,
idKey: cacheResponseIdKey,
);
final values = clone[cacheResponseContainerKey] as List;
values.forEach((value) {
cacheRelated(document: value);
});
_modelData.batchUpsert(
model: cacheModel,
dataList: values,
idKey: cacheResponseIdKey,
);
}
break;
case 'POST':
case 'PUT':
case 'PATCH':
Map<String, Object?> clone = cloneMap(response.data);
Map<String, Object?> clone = cloneMap(responseData);
if (cacheKey.isEmpty) {
cacheKey = clone['\$id'] as String;
}
if (cacheModel.endsWith('/prefs')) {
clone = response.data['prefs'];
clone = responseData['prefs'];
}
_modelData.upsert(model: cacheModel, data: clone, key: cacheKey);
break;
case 'DELETE':
if (cacheKey.isNotEmpty) {
// _modelData.get(model: cacheModel, key: cacheKey).then((cachedData) {
// if (cachedData == null) {
// return;
// }
// });
_modelData.delete(model: cacheModel, key: cacheKey);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ final routes = [
"offline": {
"model":
"\/databases\/{databaseId}\/collections\/{collectionId}\/documents",
"key": "{documentId}",
"key": "",
"response-key": "\$id",
"container-key": "",
},
+17 -7
View File
@@ -19,23 +19,33 @@ class CacheSize {
return encoded;
}
Future<void> applyChange(int change) async {
if (change == 0) return;
Future<int?> applyChange(Transaction txn, int change) async {
if (change == 0) return null;
final record = getCacheSizeRecordRef();
final currentSize = await record.get(_db) ?? 0;
await record.put(_db, currentSize + change);
final currentSize = await record.get(txn) ?? 0;
return await record.put(txn, currentSize + change);
}
Future<void> update({
Map<String, dynamic>? oldData,
required RecordRef<String, Map<String, Object?>> recordRef,
required Transaction txn,
Map<String, dynamic>? newData,
}) async {
final oldData = await recordRef.get(txn);
final oldSize = oldData != null ? encode(oldData).length : 0;
final newSize = newData != null ? encode(newData).length : 0;
final change = newSize - oldSize;
await applyChange(change);
final cacheSize = await applyChange(txn, change);
if (change != 0) {
print([
'${recordRef.key}: oldSize: $oldSize',
'newSize: $newSize',
'change: $change',
'cacheSize: $cacheSize',
].join(', '));
}
}
void onChange(void callback(int? currentSize)) {
+246 -16
View File
@@ -1,5 +1,6 @@
import 'package:appwrite/src/offline/services/cache_size.dart';
import 'package:sembast/sembast.dart';
import 'package:sembast/utils/value_utils.dart';
import '../../../appwrite.dart';
import 'accessed_at.dart';
@@ -8,6 +9,9 @@ class ModelData {
final Database _db;
final AccessedAt _accessedAt;
final CacheSize _cacheSize;
final int maxDepth = 3;
final documentModelRegex = RegExp(
r'^/databases/([a-zA-Z0-9\-]*)/collections/([a-zA-Z0-9\-]*)/documents$');
ModelData(this._db)
: _accessedAt = AccessedAt(_db),
@@ -17,15 +21,128 @@ class ModelData {
return stringMapStoreFactory.store(model);
}
Future<void> cacheModel(Map<String, dynamic> collection) {
final store = stringMapStoreFactory.store('collections');
return store
.record("${collection['databaseId']}|${collection['collectionId']}")
.put(_db, collection);
}
Future<Map<String, dynamic>?> get({
required String model,
required String key,
}) async {
final immutableRecord = await _getRecord(model: model, key: key);
if (immutableRecord == null) return null;
final record = cloneMap(immutableRecord);
await _populateRelated(record, 0);
return record;
}
Future<Map<String, dynamic>?> _getRecord({
required String model,
required String key,
}) async {
final store = getModelStore(model);
final recordRef = store.record(key);
return recordRef.get(_db);
}
bool _isNestedDocument(Map<String, dynamic> record) {
return record.containsKey('\$databaseId') &&
record.containsKey('\$collectionId') &&
record.containsKey('\$id');
}
/// Given a record with $databaseId, $collectionId and $id, populate the rest
/// of the attributes from the cache and then populate the related records.
Future<Map<String, dynamic>?> _populateRecord(
Map<String, dynamic>? record, int depth) async {
if (record == null) return record;
if (!_isNestedDocument(record)) return record;
final databaseId = record['\$databaseId'] as String;
final collectionId = record['\$collectionId'] as String;
final documentId = record['\$id'] as String;
final nestedModel =
"/databases/$databaseId/collections/$collectionId/documents";
final cached = await _getRecord(
model: nestedModel,
key: documentId,
);
if (cached == null) return record;
record.addAll(cloneMap(cached));
await _populateRelated(record, depth + 1);
return record;
}
/// Iterate over every attribute of a record and fetch related records from
/// the cache.
Future<void> _populateRelated(Map<String, dynamic>? record, int depth) {
if (record == null) {
return Future.value();
}
// iterate over each attribute and check if it is a relation
final futures = <Future>[];
for (final attribute in record.entries) {
if (attribute.value is Map<String, Object?>) {
final map = attribute.value as Map<String, Object?>;
if (_isNestedDocument(record)) {
if (depth >= maxDepth) {
record[attribute.key] = null;
} else {
final future = _populateRecord(map, depth).then((populated) {
record[attribute.key] = populated;
});
futures.add(future);
}
}
} else if (attribute.value is List) {
final List list = attribute.value as List;
final futureList = <Future<Map<String, dynamic>?>>[];
if (list.isEmpty) continue;
if (depth >= maxDepth &&
list.first is Map<String, Object?> &&
_isNestedDocument(list.first)) {
record[attribute.key] = [];
continue;
}
for (final map in list) {
if (map is! Map<String, Object?>) {
continue;
}
futureList.add(_populateRecord(map, depth));
}
if (futureList.isEmpty) {
continue;
}
final future = Future.wait(futureList).then((populated) {
record[attribute.key] = populated;
});
futures.add(future);
}
}
return Future.wait(futures);
}
Future<Map<String, dynamic>> list({
required String model,
required String cacheResponseContainerKey,
@@ -50,6 +167,7 @@ class ModelData {
final List<Filter> equalFilters = [];
value.forEach((v) {
equalFilters.add(Filter.equals(q.params[0], v));
equalFilters.add(Filter.equals("${q.params[0]}.\$id", v));
});
filters.add(Filter.or(equalFilters));
});
@@ -82,6 +200,30 @@ class ModelData {
filters.add(Filter.matches(q.params[0], r'${q.params[1]}+'));
break;
case 'isNull':
// TODO: Handle this case.
break;
case 'isNotNull':
// TODO: Handle this case.
break;
case 'between':
// TODO: Handle this case.
break;
case 'startsWith':
// TODO: Handle this case.
break;
case 'endsWith':
// TODO: Handle this case.
break;
case 'select':
// TODO: Handle this case.
break;
case 'orderAsc':
sortOrders.add(SortOrder(q.params[0] as String));
break;
@@ -116,19 +258,27 @@ class ModelData {
final records = await store.find(_db, finder: finder);
final count = await store.count(_db, filter: filter);
final list = records.map((record) {
// convert to Map<String, dynamic>
final map = Map<String, dynamic>();
record.value.entries.forEach((entry) {
map[entry.key] = entry.value;
});
return map;
}).toList();
final futures = <Future<Map<String, dynamic>?>>[];
for (final record in list) {
futures.add(_populateRecord(record, 0));
}
final keys = records.map((record) => record.key).toList();
_accessedAt.update(model: store.name, keys: keys);
return {
'total': count,
cacheResponseContainerKey: records.map((record) {
final map = Map<String, dynamic>();
record.value.entries.forEach((entry) {
map[entry.key] = entry.value;
});
return map;
}).toList(),
cacheResponseContainerKey: await Future.wait(futures),
};
}
@@ -137,13 +287,90 @@ class ModelData {
required Map<String, dynamic> data,
required String key,
}) async {
final store = getModelStore(model);
final match = documentModelRegex.firstMatch(model);
if (match?.groupCount == 2) {
// data is a document
final recordRef = store.record(key);
final record = await recordRef.get(_db);
_cacheSize.update(oldData: record, newData: data);
// match starting at 1 since 0 is the full match
final databaseId = match!.group(1)!;
final collectionId = match.group(2)!;
final result = await recordRef.put(_db, data, merge: true);
final collectionStore = getModelStore('collections');
final recordRef = collectionStore.record('$databaseId|$collectionId');
final collection = await recordRef.get(_db);
final attributes = (collection?['attributes'] ?? <Map<String, Object?>>{})
as Map<String, Object?>;
for (final attributeEntry in attributes.entries) {
final key = attributeEntry.key;
final attribute = attributeEntry.value as Map<String, Object?>;
final relatedCollection = attribute['relatedCollection'] as String;
final relationType = attribute['relationType'] as String;
final side = attribute['side'] as String;
if (!data.containsKey(key)) continue;
final nestedModel =
"/databases/$databaseId/collections/$relatedCollection/documents";
if (relationType == 'oneToOne' ||
(relationType == 'oneToMany' && side == 'child') ||
(relationType == 'manyToOne' && side == 'parent')) {
// data[key] is a single document
String documentId = '';
if (data[key] is String) {
// data[key] is a document ID
documentId = data[key] as String;
} else if (data[key] is Map<String, Object?>) {
// data[key] is a nested document
final related = data[key] as Map<String, Object?>;
documentId = (related['\$id'] ?? ID.unique()) as String;
await upsert(model: nestedModel, key: documentId, data: related);
}
data[key] = {
'\$databaseId': databaseId,
'\$collectionId': relatedCollection,
'\$id': documentId
};
} else {
// data[key] is a list of documents
final result = <Map<String, Object?>>[];
final relatedList = data[key] as List;
for (final related in relatedList) {
String documentId = '';
if (related is String) {
// related is a document ID
documentId = related;
} else if (related is Map<String, Object?>) {
// related is a nested document
documentId = (related['\$id'] ?? ID.unique()) as String;
await upsert(model: nestedModel, key: documentId, data: related);
}
result.add({
'\$databaseId': databaseId,
'\$collectionId': relatedCollection,
'\$id': documentId
});
}
data[key] = result;
}
}
}
final result = await _db.transaction((txn) async {
final store = getModelStore(model);
final recordRef = store.record(key);
final oldData = await recordRef.get(txn);
final oldSize = oldData != null ? _cacheSize.encode(oldData).length : 0;
final result = await recordRef.put(txn, data, merge: true);
final newSize = _cacheSize.encode(result).length;
final change = newSize - oldSize;
await _cacheSize.applyChange(txn, change);
return result;
});
await _accessedAt.update(model: model, keys: [key]);
return result;
}
@@ -167,16 +394,19 @@ class ModelData {
Future<void> delete({required String model, required String key}) async {
final store = getModelStore(model);
RecordSnapshot<String, Map<String, Object?>>? record;
final recordRef = store.record(key);
record = await store.record(key).getSnapshot(_db);
record = await recordRef.getSnapshot(_db);
if (record == null) {
return;
}
_cacheSize.update(oldData: record.value);
await record.ref.delete(_db);
await _db.transaction((txn) async {
final oldSize = _cacheSize.encode(record!.value).length;
await _cacheSize.applyChange(txn, oldSize * -1);
await record.ref.delete(_db);
});
await _accessedAt.delete(model: model, key: record.key);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:sembast/sembast.dart';
import 'package:sembast_sqflite/sembast_sqflite.dart';
import 'package:sqflite/sqflite.dart' as sqflite;
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart' hide Database;
class OfflineDatabase {
static final OfflineDatabase instance = OfflineDatabase._internal();