Skip to content

Commit

Permalink
resolve reduced delete payload; pr feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
tshedor committed Oct 29, 2024
1 parent 5bf2f4b commit a0a879d
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,9 @@ abstract class OfflineFirstRepository<RepositoryModel extends OfflineFirstModel>
subscriptions.clear();
}

/// Listen for streaming changes when the [sqliteProvider].
/// This method utilizes [remoteProvider]'s [get]. For example, whenever new
/// data is acquired from remote, or data is upserted locally, or data is
/// deleted locally, the stream will be notified with a local fetch of [query].
/// Listen for streaming changes when the [sqliteProvider] is invoked. For example,
/// whenever new data is acquired from remote, or data is upserted locally, or
/// data is deleted locally, the stream will be notified with a local fetch of [query].
///
/// [get] is invoked on the [memoryCacheProvider] and [sqliteProvider] following an [upsert]
/// invocation. For more, see [notifySubscriptionsWithLocalData].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,33 @@ abstract class OfflineFirstWithSupabaseRepository
await offlineRequestQueue.client.requestManager.migrate();
}

/// Supabase's realtime payload only returns unique columns;
/// the instance must be discovered from these values so it
/// can be deleted by all providers.
@protected
@visibleForOverriding
@visibleForTesting
Query queryFromSupabaseDeletePayload(
Map<String, dynamic> payload, {
required Map<String, RuntimeSupabaseColumnDefinition> supabaseDefinitions,
}) {
final fieldsWithValues = payload.entries.fold(<String, dynamic>{}, (acc, entry) {
for (final f in supabaseDefinitions.entries) {
if (f.value.columnName == entry.key) {
acc[f.key] = entry.value;
break;
}
}

return acc;
});

return Query(
where: fieldsWithValues.entries.map((entry) => Where.exact(entry.key, entry.value)).toList(),
providerArgs: {'limit': 1},
);
}

@protected
@visibleForTesting
@visibleForOverriding
Expand Down Expand Up @@ -195,12 +222,12 @@ abstract class OfflineFirstWithSupabaseRepository
///
/// See [subscribe] for reactivity without using realtime.
///
/// `eventType` is the triggering remote event.
/// [eventType] is the triggering remote event.
///
/// `policy` determines how data is fetched (local or remote). When `.localOnly`,
/// [policy] determines how data is fetched (local or remote). When [OfflineFirstGetPolicy.localOnly],
/// Supabase channels will not be used.
///
/// `query` is an optional query to filter the data. The query **must be** one level -
/// [query] is an optional query to filter the data. The query **must be** one level -
/// `Query.where('user', Query.exact('name', 'Tom'))` is invalid but `Query.where('name', 'Tom')`
/// is valid. The [Compare] operator is limited to a [PostgresChangeFilterType] equivalent.
/// See [_compareToFilterParam] for a precise breakdown.
Expand Down Expand Up @@ -247,46 +274,37 @@ abstract class OfflineFirstWithSupabaseRepository
memoryCacheProvider.delete<TModel>(deletableModel, repository: this);
}

case PostgresChangeEvent.insert:
// Convert Supabase JSON to Brick model instance
final instance = await adapter.fromSupabase(
payload.newRecord,
provider: remoteProvider,
repository: this,
case PostgresChangeEvent.delete:
final query = queryFromSupabaseDeletePayload(
payload.oldRecord,
supabaseDefinitions: adapter.fieldsToSupabaseColumns,
);
// Save to local SQLite database for offline access
await sqliteProvider.upsert<TModel>(instance as TModel, repository: this);
// Update memory cache for fast retrieval
memoryCacheProvider.upsert<TModel>(instance, repository: this);

case PostgresChangeEvent.update:
// Convert Supabase JSON to Brick model instance
if (query.where?.isEmpty ?? true) return;

final results = await get<TModel>(
query: query,
policy: OfflineFirstGetPolicy.localOnly,
seedOnly: true,
);
if (results.isEmpty) return;

await sqliteProvider.delete<TModel>(results.first, repository: this);
memoryCacheProvider.delete<TModel>(results.first, repository: this);

case PostgresChangeEvent.insert || PostgresChangeEvent.update:
final instance = await adapter.fromSupabase(
payload.newRecord,
provider: remoteProvider,
repository: this,
);
// Save to local SQLite database for offline access

await sqliteProvider.upsert<TModel>(instance as TModel, repository: this);
// Update memory cache for fast retrieval
memoryCacheProvider.upsert<TModel>(instance, repository: this);

case PostgresChangeEvent.delete:
// Convert Supabase JSON to Brick model instance
final instance = await adapter.fromSupabase(
payload.oldRecord,
provider: remoteProvider,
repository: this,
);
final primaryKey =
await sqliteProvider.primaryKeyByUniqueColumns<TModel>(instance as TModel);
instance.primaryKey = primaryKey;
await sqliteProvider.delete<TModel>(instance, repository: this);
memoryCacheProvider.delete<TModel>(instance, repository: this);
}

await notifySubscriptionsWithLocalData<TModel>(
subscriptionsByQuery: supabaseRealtimeSubscriptions[TModel]![eventType],
subscriptionsByQuery: supabaseRealtimeSubscriptions[TModel]![eventType]!,
);
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,126 @@ void main() async {
});
});

group('#queryFromSupabaseDeletePayload', () {
test('simple', () {
final payload = {
'id': 1,
};

final supabaseDefinitions = {
'id': RuntimeSupabaseColumnDefinition(columnName: 'id'),
'name': RuntimeSupabaseColumnDefinition(columnName: 'name'),
};

final query = repository.queryFromSupabaseDeletePayload(
payload,
supabaseDefinitions: supabaseDefinitions,
);

expect(query.where, hasLength(1));
expect(query.where!.first.evaluatedField, 'id');
expect(query.where!.first.value, 1);
expect(query.providerArgs, equals({'limit': 1}));
});

test('payload entries not present in supabaseDefinitions', () {
final payload = {
'id': 1,
'unknown_field': 'some value',
};

final supabaseDefinitions = {
'id': RuntimeSupabaseColumnDefinition(columnName: 'id'),
'name': RuntimeSupabaseColumnDefinition(columnName: 'name'),
};

final query = repository.queryFromSupabaseDeletePayload(
payload,
supabaseDefinitions: supabaseDefinitions,
);

expect(query.where, hasLength(1));
expect(query.where!.first.evaluatedField, 'id');
expect(query.where!.first.value, 1);
expect(query.providerArgs, equals({'limit': 1}));
});

test('empty payload', () {
final payload = <String, dynamic>{};
final supabaseDefinitions = {
'id': RuntimeSupabaseColumnDefinition(columnName: 'id'),
};

final query = repository.queryFromSupabaseDeletePayload(
payload,
supabaseDefinitions: supabaseDefinitions,
);

expect(query.where, isEmpty);
});

test('payload with no matching definitions', () {
final payload = {
'unknown_field': 'some value',
};
final supabaseDefinitions = {
'id': RuntimeSupabaseColumnDefinition(columnName: 'id'),
};

final query = repository.queryFromSupabaseDeletePayload(
payload,
supabaseDefinitions: supabaseDefinitions,
);

expect(query.where, isEmpty);
});

test('different column names', () {
final payload = {
'user_id': 1,
};

final supabaseDefinitions = {
'id': RuntimeSupabaseColumnDefinition(columnName: 'user_id'),
'name': RuntimeSupabaseColumnDefinition(columnName: 'full_name'),
};

final query = repository.queryFromSupabaseDeletePayload(
payload,
supabaseDefinitions: supabaseDefinitions,
);

expect(query.where, hasLength(1));
expect(query.where!.first.evaluatedField, 'id');
expect(query.where!.first.value, 1);
expect(query.providerArgs, equals({'limit': 1}));
});

test('multiple columns', () {
final payload = {
'user_id': 1,
'full_name': 'Thomas',
};

final supabaseDefinitions = {
'id': RuntimeSupabaseColumnDefinition(columnName: 'user_id'),
'name': RuntimeSupabaseColumnDefinition(columnName: 'full_name'),
};

final query = repository.queryFromSupabaseDeletePayload(
payload,
supabaseDefinitions: supabaseDefinitions,
);

expect(query.where, hasLength(2));
expect(query.where!.first.evaluatedField, 'id');
expect(query.where!.first.value, 1);
expect(query.where!.last.evaluatedField, 'name');
expect(query.where!.last.value, 'Thomas');
expect(query.providerArgs, equals({'limit': 1}));
});
});

group('#queryToPostgresChangeFilter', () {
group('returns null', () {
test('for complex queries', () {
Expand All @@ -111,21 +231,19 @@ void main() async {

group('Compare', () {
test('.between', () {
final query =
Query(where: [Where('firstName', value: 'Thomas', compare: Compare.between)]);
final query = Query(where: [Where('firstName').isBetween(1, 2)]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);
expect(filter, isNull);
});

test('.doesNotContain', () {
final query =
Query(where: [Where('firstName', value: 'Thomas', compare: Compare.doesNotContain)]);
final query = Query(where: [Where('firstName').doesNotContain('Thomas')]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);
expect(filter, isNull);
});

test('.exact', () {
final query = Query(where: [Where('firstName', value: 'Thomas', compare: Compare.exact)]);
final query = Query(where: [Where.exact('firstName', 'Thomas')]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

expect(filter!.type, PostgresChangeFilterType.eq);
Expand All @@ -134,8 +252,7 @@ void main() async {
});

test('.greaterThan', () {
final query =
Query(where: [Where('firstName', value: 'Thomas', compare: Compare.greaterThan)]);
final query = Query(where: [Where('firstName').isGreaterThan('Thomas')]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

expect(filter!.type, PostgresChangeFilterType.gt);
Expand All @@ -145,7 +262,7 @@ void main() async {

test('.greaterThanOrEqualTo', () {
final query = Query(
where: [Where('firstName', value: 'Thomas', compare: Compare.greaterThanOrEqualTo)],
where: [Where('firstName').isGreaterThanOrEqualTo('Thomas')],
);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

Expand All @@ -155,8 +272,7 @@ void main() async {
});

test('.lessThan', () {
final query =
Query(where: [Where('firstName', value: 'Thomas', compare: Compare.lessThan)]);
final query = Query(where: [Where('firstName').isLessThan('Thomas')]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

expect(filter!.type, PostgresChangeFilterType.lt);
Expand All @@ -166,7 +282,7 @@ void main() async {

test('.lessThanOrEqualTo', () {
final query = Query(
where: [Where('firstName', value: 'Thomas', compare: Compare.lessThanOrEqualTo)],
where: [Where('firstName').isLessThanOrEqualTo('Thomas')],
);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

Expand All @@ -176,8 +292,7 @@ void main() async {
});

test('.notEqual', () {
final query =
Query(where: [Where('firstName', value: 'Thomas', compare: Compare.notEqual)]);
final query = Query(where: [Where('firstName').isNot('Thomas')]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

expect(filter!.type, PostgresChangeFilterType.neq);
Expand All @@ -186,8 +301,7 @@ void main() async {
});

test('.contains', () {
final query =
Query(where: [Where('firstName', value: 'Thomas', compare: Compare.contains)]);
final query = Query(where: [Where('firstName').contains('Thomas')]);
final filter = repository.queryToPostgresChangeFilter<Customer>(query);

expect(filter!.type, PostgresChangeFilterType.inFilter);
Expand Down
2 changes: 0 additions & 2 deletions packages/brick_sqlite/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
## Unreleased

- Add `#primaryKeyByUniqueColumns` to `SqliteProvider` to expose forward functionality

## 3.1.1

- Expose a generic type for `MemoryCacheProvider` models
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// Used to define types in [SqliteAdapter#sqliteFieldsToColumns]. The build runner package
/// Used to define types in [SqliteAdapter#fieldsToSqliteColumns]. The build runner package
/// extracts types and associations that would've been otherwise inaccessible at runtime.
class RuntimeSqliteColumnDefinition {
/// Whether this column relates to another SqliteModel
Expand Down
8 changes: 0 additions & 8 deletions packages/brick_sqlite/lib/src/sqlite_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,6 @@ class SqliteProvider<CModel extends SqliteModel> implements Provider<CModel> {
}
}

/// Retrieve a primary key by querying its unique columns.
/// Advanced use only.
Future<int?> primaryKeyByUniqueColumns<TModel extends CModel>(TModel instance) async {
final adapter = modelDictionary.adapterFor[TModel]!;
final db = await getDb();
return await adapter.primaryKeyByUniqueColumns(instance, db);
}

/// Fetch results for model with a custom SQL statement.
/// It is recommended to use [get] whenever possible. **Advanced use only**.
Future<List<TModel>> rawGet<TModel extends CModel>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ class SupabaseMockServer {
PostgresChangeEvent? realtimeEvent,
ModelRepository<SupabaseModel>? repository,
}) async {
assert(realtimeEvent != PostgresChangeEvent.all, '.all realtime events are not serialized');
assert(realtimeEvent != PostgresChangeEvent.all, '.all events are not serialized');

final adapter = modelDictionary.adapterFor[TModel]!;
final serialized = await adapter.toSupabase(
Expand All @@ -211,6 +211,15 @@ class SupabaseMockServer {

if (realtimeEvent == null) return serialized;

// Delete records from realtime are strictly unique/indexed fields;
// uniqueness is not tracked by [RuntimeSupabaseColumnDefinition]
// so filtering out associations is the closest simulation of an incomplete payload
if (realtimeEvent == PostgresChangeEvent.delete) {
for (final value in adapter.fieldsToSupabaseColumns.values) {
if (value.association) serialized.remove(value.columnName);
}
}

return {
'ref': null,
'event': 'postgres_changes',
Expand Down

0 comments on commit a0a879d

Please sign in to comment.