initial commit with cache support & example
This commit is contained in:
+362
@@ -0,0 +1,362 @@
|
||||
import 'package:gql_exec/gql_exec.dart';
|
||||
import 'package:gql/language.dart';
|
||||
import 'package:fl_query/fl_query.dart' show Fragment;
|
||||
import 'package:fl_query/src/utilities/helpers.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
const String rawOperationKey = 'rawOperationKey';
|
||||
|
||||
class TestCase {
|
||||
TestCase({
|
||||
required this.data,
|
||||
required String operation,
|
||||
Map<String, dynamic> variables = const <String, dynamic>{},
|
||||
this.normalizedEntities,
|
||||
}) : request = Request(
|
||||
operation: Operation(document: parseString(operation)),
|
||||
variables: variables,
|
||||
context: Context(),
|
||||
);
|
||||
|
||||
Request request;
|
||||
|
||||
/// data to write to cache
|
||||
Map<String, dynamic> data;
|
||||
|
||||
/// entities to inspect the store for, if any
|
||||
List<Map<String, dynamic>>? normalizedEntities;
|
||||
}
|
||||
|
||||
final basicTest = TestCase(
|
||||
operation: r'''{
|
||||
a {
|
||||
__typename
|
||||
id
|
||||
# union
|
||||
list {
|
||||
__typename
|
||||
value
|
||||
... on Item { id }
|
||||
}
|
||||
b {
|
||||
__typename
|
||||
id
|
||||
c {
|
||||
__typename
|
||||
id,
|
||||
cField
|
||||
}
|
||||
bField { field }
|
||||
},
|
||||
d {
|
||||
id,
|
||||
dField {field}
|
||||
}
|
||||
aField { field }
|
||||
}
|
||||
}''',
|
||||
data: {
|
||||
'a': {
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
'list': [
|
||||
{'__typename': 'Num', 'value': 1},
|
||||
{'__typename': 'Num', 'value': 2},
|
||||
{'__typename': 'Num', 'value': 3},
|
||||
{'__typename': 'Item', 'id': 4, 'value': 4}
|
||||
],
|
||||
'b': {
|
||||
'__typename': 'B',
|
||||
'id': 5,
|
||||
'c': {
|
||||
'__typename': 'C',
|
||||
'id': 6,
|
||||
'cField': 'value',
|
||||
},
|
||||
'bField': {'field': true}
|
||||
},
|
||||
'd': {
|
||||
'id': 9,
|
||||
'dField': {'field': true}
|
||||
},
|
||||
'aField': {'field': false}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/// https://github.com/gql-dart/gql/blob/master/links/gql_http_link/test/multipart_upload_test.dart
|
||||
final fileVarsTest = TestCase(
|
||||
data: {
|
||||
"multipleUpload": [
|
||||
{
|
||||
"id": "r1odc4PAz",
|
||||
"filename": "sample_upload.jpg",
|
||||
"mimetype": "image/jpeg",
|
||||
"path": "./uploads/r1odc4PAz-sample_upload.jpg"
|
||||
},
|
||||
{
|
||||
"id": "5Ea18qlMur",
|
||||
"filename": "sample_upload.txt",
|
||||
"mimetype": "text/plain",
|
||||
"path": "./uploads/5Ea18qlMur-sample_upload.txt"
|
||||
}
|
||||
],
|
||||
},
|
||||
operation: r"""
|
||||
mutation($files: [Upload!]!) {
|
||||
multipleUpload(files: $files) {
|
||||
id
|
||||
filename
|
||||
mimetype
|
||||
path
|
||||
}
|
||||
}
|
||||
""",
|
||||
variables: {
|
||||
'files': [
|
||||
http.MultipartFile.fromBytes(
|
||||
"",
|
||||
[0, 1, 254, 255],
|
||||
filename: "sample_upload.jpg",
|
||||
contentType: MediaType("image", "jpeg"),
|
||||
),
|
||||
http.MultipartFile.fromString(
|
||||
"",
|
||||
"just plain text",
|
||||
filename: "sample_upload.txt",
|
||||
contentType: MediaType("text", "plain"),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final originalCValue = <String, dynamic>{
|
||||
'__typename': 'C',
|
||||
'id': 6,
|
||||
'cField': 'value',
|
||||
};
|
||||
final originalCFragment = Fragment(
|
||||
document: parseString(
|
||||
r'''
|
||||
fragment partialC on C {
|
||||
__typename
|
||||
id
|
||||
cField
|
||||
}
|
||||
''',
|
||||
),
|
||||
);
|
||||
|
||||
final updatedCFragment = Fragment(
|
||||
document: parseString(
|
||||
r'''
|
||||
fragment partialC on C {
|
||||
__typename
|
||||
id
|
||||
new
|
||||
cField
|
||||
}
|
||||
''',
|
||||
),
|
||||
);
|
||||
|
||||
final updatedCValue = <String, dynamic>{
|
||||
'__typename': 'C',
|
||||
'id': 6,
|
||||
'new': 'field',
|
||||
'cField': 'changed value',
|
||||
};
|
||||
|
||||
final Map? updatedCBasicTestData = deeplyMergeLeft([
|
||||
basicTest.data,
|
||||
{
|
||||
'a': {
|
||||
'b': {
|
||||
'c': {
|
||||
'__typename': 'C',
|
||||
'id': 6,
|
||||
'cField': 'changed value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
final basicTestSubsetAValue = TestCase(
|
||||
operation: r'''{
|
||||
a {
|
||||
__typename
|
||||
id
|
||||
list {
|
||||
__typename
|
||||
value
|
||||
... on Item { id }
|
||||
}
|
||||
d { id }
|
||||
}
|
||||
}''',
|
||||
data: {
|
||||
'a': {
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
'list': [
|
||||
{'__typename': 'Num', 'value': 5},
|
||||
{'__typename': 'Num', 'value': 6},
|
||||
{'__typename': 'Num', 'value': 7},
|
||||
{
|
||||
'__typename': 'Item',
|
||||
'id': 8,
|
||||
'value': 8,
|
||||
}
|
||||
],
|
||||
'd': {
|
||||
'id': 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
getUpdatedSubsetOperationData({withUpdatedC = false}) => {
|
||||
'a': {
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
'list': basicTestSubsetAValue.data['a']['list'],
|
||||
'b': {
|
||||
'__typename': 'B',
|
||||
'id': 5,
|
||||
'c': {
|
||||
'__typename': 'C',
|
||||
'id': 6,
|
||||
'cField': '${withUpdatedC ? "changed " : ""}value',
|
||||
},
|
||||
'bField': {'field': true}
|
||||
},
|
||||
'd': {
|
||||
'id': 10,
|
||||
'dField': {'field': true}
|
||||
},
|
||||
'aField': {'field': false}
|
||||
},
|
||||
};
|
||||
|
||||
final cyclicalTest = TestCase(operation: r'''{
|
||||
a {
|
||||
__typename
|
||||
id
|
||||
b {
|
||||
__typename
|
||||
id
|
||||
as {
|
||||
__typename
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}''', data: {
|
||||
'a': {
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
'b': {
|
||||
'__typename': 'B',
|
||||
'id': 5,
|
||||
'as': [
|
||||
{
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}, normalizedEntities: [
|
||||
{
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
'b': {r"$ref": 'B:5'}
|
||||
},
|
||||
{
|
||||
'__typename': 'B',
|
||||
'id': 5,
|
||||
'as': [
|
||||
{r"$ref": 'A:1'}
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Map<String, dynamic> get cyclicalObjOperationData {
|
||||
Map<String, dynamic> a;
|
||||
Map<String, dynamic> b;
|
||||
a = {
|
||||
'__typename': 'A',
|
||||
'id': 1,
|
||||
};
|
||||
b = {
|
||||
'__typename': 'B',
|
||||
'id': 5,
|
||||
'as': [a]
|
||||
};
|
||||
a['b'] = b;
|
||||
return {'a': a};
|
||||
}
|
||||
|
||||
final typelessTest = TestCase(
|
||||
operation: r'''{
|
||||
a {
|
||||
# union
|
||||
list {
|
||||
#__typename
|
||||
value
|
||||
#... on Item { id }
|
||||
}
|
||||
b {
|
||||
id
|
||||
c {
|
||||
id,
|
||||
cField
|
||||
}
|
||||
bField { field }
|
||||
},
|
||||
d {
|
||||
id,
|
||||
dField {field}
|
||||
}
|
||||
aField { field }
|
||||
}
|
||||
}''',
|
||||
data: {
|
||||
'a': {
|
||||
'list': [
|
||||
{
|
||||
//'__typename': 'Num',
|
||||
'value': 1,
|
||||
},
|
||||
{
|
||||
//'__typename': 'Num',
|
||||
'value': 2,
|
||||
},
|
||||
{
|
||||
//'__typename': 'Num',
|
||||
'value': 3,
|
||||
},
|
||||
{
|
||||
//'__typename': 'Item',
|
||||
//'id': 4,
|
||||
'value': 4,
|
||||
}
|
||||
],
|
||||
'b': {
|
||||
'id': 5,
|
||||
'c': {
|
||||
'id': 6,
|
||||
'cField': 'value',
|
||||
},
|
||||
'bField': {'field': true}
|
||||
},
|
||||
'd': {
|
||||
'id': 9,
|
||||
'dField': {'field': true}
|
||||
},
|
||||
'aField': {'field': false}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'package:fl_query/src/cache/_normalizing_data_proxy.dart';
|
||||
import 'package:normalize/normalize.dart' show PartialDataException;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'package:fl_query/src/cache/cache.dart';
|
||||
|
||||
import '../helpers.dart';
|
||||
import './cache_data.dart';
|
||||
|
||||
typedef CacheTransaction = JSONDataProxy Function(JSONDataProxy proxy);
|
||||
|
||||
void main() {
|
||||
if (debuggingUnexpectedTestFailures) {
|
||||
print(
|
||||
'DEBUGGING UNEXPECTED TEST FAILURES: $debuggingUnexpectedTestFailures.\n'
|
||||
'RUNNING TESTS WITH returnPartialData SET TO TRUE.\n',
|
||||
);
|
||||
}
|
||||
|
||||
group('Normalizes writes', () {
|
||||
late QueryCache cache;
|
||||
setUp(() {
|
||||
cache = getTestCache();
|
||||
});
|
||||
test('.writeQuery .readQuery round trip', () {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
expect(
|
||||
cache.readQuery(basicTest.request),
|
||||
equals(basicTest.data),
|
||||
);
|
||||
});
|
||||
|
||||
test('typeless .writeQuery .readQuery round trip', () {
|
||||
cache.writeQuery(typelessTest.request, data: typelessTest.data);
|
||||
expect(
|
||||
cache.readQuery(typelessTest.request),
|
||||
equals(typelessTest.data),
|
||||
);
|
||||
});
|
||||
|
||||
test('typeless custom dataIdFromObject', () {
|
||||
cache.writeQuery(typelessTest.request, data: typelessTest.data);
|
||||
expect(
|
||||
cache.readQuery(typelessTest.request),
|
||||
equals(typelessTest.data),
|
||||
);
|
||||
});
|
||||
|
||||
test('.writeQuery should fail on missing fields', () {
|
||||
expect(
|
||||
() => cache.writeQuery(basicTest.request, data: <String, dynamic>{
|
||||
...basicTest.data,
|
||||
'a': <String, dynamic>{
|
||||
...basicTest.data['a'],
|
||||
'b': <String, dynamic>{
|
||||
'id': 5,
|
||||
}
|
||||
},
|
||||
}),
|
||||
throwsA(isA<PartialDataException>().having(
|
||||
(e) => e.path,
|
||||
'An accurate path to the first missing subfield',
|
||||
['a', 'b', '__typename'],
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
test('updating nested normalized fragment changes top level operation', () {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
final idFields = {
|
||||
'__typename': updatedCValue['__typename'],
|
||||
'id': updatedCValue['id'],
|
||||
};
|
||||
cache.writeFragment(
|
||||
updatedCFragment.asRequest(
|
||||
idFields: idFields,
|
||||
),
|
||||
data: updatedCValue,
|
||||
);
|
||||
|
||||
expect(
|
||||
cache.readQuery(basicTest.request),
|
||||
equals(updatedCBasicTestData),
|
||||
);
|
||||
|
||||
expect(
|
||||
cache.readFragment(
|
||||
updatedCFragment.asRequest(
|
||||
idFields: idFields,
|
||||
),
|
||||
),
|
||||
updatedCValue,
|
||||
);
|
||||
});
|
||||
|
||||
test('updating subset query only partially overrides superset query', () {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
|
||||
cache.writeQuery(
|
||||
basicTestSubsetAValue.request,
|
||||
data: basicTestSubsetAValue.data,
|
||||
);
|
||||
expect(
|
||||
cache.readQuery(basicTest.request),
|
||||
equals(getUpdatedSubsetOperationData()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('Handles cyclical references', () {
|
||||
final QueryCache cache = getTestCache();
|
||||
test('lazily reads cyclical references', () {
|
||||
cache.writeQuery(cyclicalTest.request, data: cyclicalTest.data);
|
||||
for (final normalized in cyclicalTest.normalizedEntities!) {
|
||||
final dataId = "${normalized['__typename']}:${normalized['id']}";
|
||||
expect(cache.readNormalized(dataId), equals(normalized));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('Handles Object/pointer self-references/cycles', () {
|
||||
final QueryCache cache = getTestCache();
|
||||
test('correctly reads cyclical references', () {
|
||||
cyclicalTest.data = cyclicalObjOperationData;
|
||||
cache.writeQuery(cyclicalTest.request, data: cyclicalTest.data);
|
||||
for (final normalized in cyclicalTest.normalizedEntities!) {
|
||||
final dataId = "${normalized['__typename']}:${normalized['id']}";
|
||||
expect(cache.readNormalized(dataId), equals(normalized));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'.recordOptimisticTransaction',
|
||||
() {
|
||||
late QueryCache cache;
|
||||
|
||||
setUp(() {
|
||||
cache = getTestCache();
|
||||
});
|
||||
|
||||
test(
|
||||
'OptimisticCache.readQuery and .readFragment pass through',
|
||||
() {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
cache.broadcastRequested = false;
|
||||
cache.recordOptimisticTransaction(
|
||||
(proxy) {
|
||||
expect(
|
||||
proxy.readQuery(basicTest.request),
|
||||
equals(basicTest.data),
|
||||
);
|
||||
|
||||
final idFields = {
|
||||
'__typename': originalCValue['__typename'],
|
||||
'id': originalCValue['id'],
|
||||
};
|
||||
|
||||
expect(
|
||||
proxy.readFragment(originalCFragment.asRequest(
|
||||
idFields: idFields,
|
||||
)),
|
||||
originalCValue,
|
||||
);
|
||||
|
||||
expect(
|
||||
(proxy as NormalizingDataProxy).broadcastRequested,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
return proxy;
|
||||
},
|
||||
'1',
|
||||
);
|
||||
|
||||
// no edits
|
||||
expect(cache.broadcastRequested, isFalse);
|
||||
expect(cache.optimisticPatches.first.id, equals('1'));
|
||||
expect(cache.optimisticPatches.first.data, equals({}));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'.writeQuery, .readQuery(optimistic: true) round trip',
|
||||
() {
|
||||
cache.recordOptimisticTransaction(
|
||||
(proxy) => proxy
|
||||
..writeQuery(
|
||||
basicTest.request,
|
||||
data: basicTest.data,
|
||||
),
|
||||
'1',
|
||||
);
|
||||
expect(
|
||||
cache.readQuery(basicTest.request, optimistic: true),
|
||||
equals(basicTest.data),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
recordCFragmentUpdate(QueryCache cache) =>
|
||||
cache.recordOptimisticTransaction(
|
||||
(proxy) => proxy
|
||||
..writeFragment(
|
||||
updatedCFragment.asRequest(idFields: {
|
||||
'__typename': updatedCValue['__typename'],
|
||||
'id': updatedCValue['id'],
|
||||
}),
|
||||
data: updatedCValue,
|
||||
),
|
||||
'2',
|
||||
);
|
||||
|
||||
test(
|
||||
'updating nested normalized fragment changes top level operation',
|
||||
() {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
recordCFragmentUpdate(cache);
|
||||
expect(
|
||||
cache.readQuery(basicTest.request),
|
||||
equals(updatedCBasicTestData),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
recordBasicSubsetData(QueryCache cache) =>
|
||||
cache.recordOptimisticTransaction(
|
||||
(proxy) => proxy
|
||||
..writeQuery(
|
||||
basicTestSubsetAValue.request,
|
||||
data: basicTestSubsetAValue.data,
|
||||
),
|
||||
'3',
|
||||
);
|
||||
test(
|
||||
'updating subset query partially overrides superset query',
|
||||
() {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
recordCFragmentUpdate(cache);
|
||||
recordBasicSubsetData(cache);
|
||||
expect(
|
||||
cache.readQuery(basicTest.request, optimistic: true),
|
||||
equals(getUpdatedSubsetOperationData(withUpdatedC: true)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'.removeOptimisticPatch results in data from lower layers on readQuery',
|
||||
() {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
recordCFragmentUpdate(cache);
|
||||
recordBasicSubsetData(cache);
|
||||
cache.removeOptimisticPatch('2');
|
||||
cache.removeOptimisticPatch('3');
|
||||
expect(
|
||||
cache.readQuery(basicTest.request, optimistic: true),
|
||||
equals(basicTest.data),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
group('Handles MultipartFile variables', () {
|
||||
late QueryCache cache;
|
||||
setUp(() {
|
||||
cache = getTestCache();
|
||||
});
|
||||
test('.writeQuery .readQuery round trip', () {
|
||||
cache.writeQuery(fileVarsTest.request, data: fileVarsTest.data);
|
||||
expect(
|
||||
cache.readQuery(fileVarsTest.request),
|
||||
equals(fileVarsTest.data),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('custom dataIdFromObject', () {
|
||||
/// Uses a `/` instead of the default `:`
|
||||
String? customDataIdFromObject(Object object) {
|
||||
if (object is Map<String, Object> &&
|
||||
object.containsKey('__typename') &&
|
||||
object.containsKey('id'))
|
||||
return "${object['__typename']}/${object['id']}";
|
||||
return null;
|
||||
}
|
||||
|
||||
late QueryCache cache;
|
||||
setUp(() {
|
||||
cache = QueryCache(
|
||||
dataIdFromObject: customDataIdFromObject,
|
||||
partialDataPolicy: PartialDataCachePolicy.reject,
|
||||
);
|
||||
});
|
||||
|
||||
test('.writeQuery .readQuery round trip', () {
|
||||
cache.writeQuery(basicTest.request, data: basicTest.data);
|
||||
expect(
|
||||
cache.readQuery(basicTest.request),
|
||||
equals(basicTest.data),
|
||||
);
|
||||
});
|
||||
|
||||
test('typeless .writeQuery .readQuery round trip', () {
|
||||
cache.writeQuery(typelessTest.request, data: typelessTest.data);
|
||||
expect(
|
||||
cache.readQuery(typelessTest.request),
|
||||
equals(typelessTest.data),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_query/fl_query.dart';
|
||||
import 'package:fl_query/src/utilities/helpers.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('InMemoryStore', () {
|
||||
final data = {
|
||||
'id': {'key': 'value'},
|
||||
'id2': {'otherKey': false}
|
||||
};
|
||||
test('basic methods', () {
|
||||
final store = InMemoryStore();
|
||||
store.put('id', data['id']);
|
||||
expect(store.get('id'), equals(data['id']));
|
||||
|
||||
store.delete('id');
|
||||
expect(store.data, equals({}));
|
||||
});
|
||||
test('bulk methods', () {
|
||||
final store = InMemoryStore();
|
||||
|
||||
store.putAll(data);
|
||||
|
||||
expect(store.data, equals(data));
|
||||
expect(store.toMap(), equals(data));
|
||||
|
||||
store.reset();
|
||||
|
||||
expect(data['id'], notNull); // no mutations
|
||||
});
|
||||
});
|
||||
|
||||
group('HiveStore', () {
|
||||
final data = {
|
||||
'id': {'key': 'value'},
|
||||
'id2': {'otherKey': false}
|
||||
};
|
||||
final path = './test/cache/test_hive_boxes/';
|
||||
test('basic methods', () async {
|
||||
final store =
|
||||
await HiveStore.open(boxName: 'basic', path: path + 'basic');
|
||||
store.put('id', data['id']);
|
||||
expect(store.get('id'), equals(data['id']));
|
||||
|
||||
store.delete('id');
|
||||
expect(store.toMap(), equals({}));
|
||||
|
||||
await store.box.deleteFromDisk();
|
||||
});
|
||||
test('bulk methods', () async {
|
||||
final store = await HiveStore.open(boxName: 'bulk', path: path + 'bulk');
|
||||
|
||||
store.putAll(data);
|
||||
expect(store.toMap(), equals(data));
|
||||
|
||||
await store.reset();
|
||||
expect(store.toMap(), equals({}));
|
||||
|
||||
expect(data['id'], notNull); // no mutations
|
||||
|
||||
await store.box.deleteFromDisk();
|
||||
});
|
||||
|
||||
test('box rereferencing', () async {
|
||||
final store = await HiveStore.open(path: path);
|
||||
store.putAll(data);
|
||||
|
||||
expect(HiveStore().toMap(), equals(data));
|
||||
|
||||
await store.box.deleteFromDisk();
|
||||
});
|
||||
|
||||
tearDownAll(() async {
|
||||
await Directory(path).delete(recursive: true);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user