Adapter file not generated when uniquely identified field is a class and no primitive
devj3ns opened this issue · comments
Hey folks, first, thanks a lot for creating and open-sourcing this project, I really like it so far!
In my OfflineFirstWithRestModel
, I have a field with a custom UUID
class for validating UUIDs. This class simply stores the UUID
as a string inside a value
field. So what I have done in the model is:
@Sqlite(
unique: true,
columnType: Column.varchar,
fromGenerator: 'UUID.fromUniqueString(%DATA_PROPERTY%)',
toGenerator: '%INSTANCE_PROPERTY%.value')
@Rest(
fromGenerator: 'UUID.fromUniqueString(%DATA_PROPERTY%)',
toGenerator: '%INSTANCE_PROPERTY%.value')
final UUID id;
Unfortunately, with this field present in my model, the adapter g.dart
file won't be generated. When I change the id
s type to a String
, it gets correctly generated.
When the adapter is built with the id
of type String
and I change the id
to be a UUID
instead, the to/fromRest/Sqlite methods of the adapter are correctly altered, but the id
field is missing inside the fieldsToSqliteColumns
map.
Hi @devj3ns . This is a tricky part of Brick. What you can do here is use an OfflineFirstSerdes
. This should also save you the copy and paste of the custom generators.
class UUID extends OfflineFirstSerdes<String, String> {
final String value;
UUID(this.value);
factory UUID.fromRest(String? data) {
if (data == null || data.isEmpty) return null;
return UUID(data!)
}
factory UUID.fromSqlite(String data) => UUID.fromRest(data);
toRest() => value;
toSqlite() => value;
}
Then, in your model code, you can just use:
@Sqlite(unique: true)
final UUID id;
That's exactly what I was looking/hoping for, thanks!
With this the generation mostly works, but in the fieldsToSqliteColumns
Map in the adapter the type of the id
SQLite column is set to UUID
instead of String
which results in the following exception:
Invalid argument 399895f6-a34b-4be9-bfea-c2e2bec12de6 with type UUID. Only num, String and Uint8List are supported.
Using columnType: Column.varchar
and rerunning the build_runner
does not change the adapter.
@devj3ns Can you provide more of that stack trace? The fieldsToSqliteColumns
isn't a map to the primitives that SQLite uses, it's more a map to how to retrieve that type from SQLite. Maybe it's a serdes, maybe it's an association.
Based on what you've provided, that seems like an error that's coming from your UUID package, but I'm not sure.
Ah okay, sure!
Here is the full stack trace:
I/flutter (23420): upsert sqlite
I/flutter (23420): *** WARNING ***
I/flutter (23420):
I/flutter (23420): Invalid argument 399895f6-a34b-4be9-bfea-c2e2bec12de6 with type UUID.
I/flutter (23420): Only num, String and Uint8List are supported. See https://github.com/tekartik/sqflite/blob/master/sqflite/doc/supported_types.md for details
I/flutter (23420):
I/flutter (23420): This will throw an exception in the future. For now it is displayed once per type.
I/flutter (23420):
I/flutter (23420):
E/flutter (23420): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Invalid argument: Instance of 'UUID'
E/flutter (23420): #0 StandardMessageCodec.writeValue (package:flutter/src/services/message_codecs.dart:464:7)
E/flutter (23420): #1 StandardMessageCodec.writeValue (package:flutter/src/services/message_codecs.dart:454:9)
E/flutter (23420): #2 StandardMessageCodec.writeValue.<anonymous closure> (package:flutter/src/services/message_codecs.dart:461:9)
E/flutter (23420): #3 _LinkedHashMapMixin.forEach (dart:collection-patch/compact_hash.dart:633:13)
E/flutter (23420): #4 StandardMessageCodec.writeValue (package:flutter/src/services/message_codecs.dart:459:13)
E/flutter (23420): #5 StandardMethodCodec.encodeMethodCall (package:flutter/src/services/message_codecs.dart:602:18)
E/flutter (23420): #6 MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:323:34)
E/flutter (23420): #7 MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:507:12)
E/flutter (23420): #8 invokeMethod (package:sqflite/src/sqflite_impl.dart:16:19)
E/flutter (23420): #9 SqfliteDatabaseFactoryImpl.invokeMethod (package:sqflite/src/factory_impl.dart:49:18)
E/flutter (23420): #10 SqfliteDatabaseMixin.invokeMethod (package:sqflite_common/src/database_mixin.dart:402:22)
E/flutter (23420): #11 SqfliteDatabaseMixin.safeInvokeMethod.<anonymous closure> (package:sqflite_common/src/database_mixin.dart:393:27)
E/flutter (23420): #12 wrapDatabaseException (package:sqflite/src/exception_impl.dart:7:32)
E/flutter (23420): #13 SqfliteDatabaseFactoryImpl.wrapDatabaseException (package:sqflite/src/factory_impl.dart:44:12)
E/flutter (23420): #14 SqfliteDatabaseMixin.safeAction (package:sqflite_common/src/database_mixin.dart:397:15)
E/flutter (23420): #15 SqfliteDatabaseMixin.safeInvokeMethod (package:sqflite_common/src/database_mixin.dart:393:7)
E/flutter (23420): #16 SqfliteDatabaseMixin.txnRawQuery.<anonymous closure> (package:sqflite_common/src/database_mixin.dart:586:36)
E/flutter (23420): #17 SqfliteDatabaseMixin.txnSynchronized (package:sqflite_common/src/database_mixin.dart:485:28)
E/flutter (23420): #18 SqfliteDatabaseMixin.txnRawQuery (package:sqflite_common/src/database_mixin.dart:585:12)
E/flutter (23420): #19 SqfliteDatabaseExecutorMixin._rawQuery (package:sqflite_common/src/database_mixin.dart:129:15)
E/flutter (23420): #20 SqfliteDatabaseExecutorMixin.rawQuery (package:sqflite_common/src/database_mixin.dart:123:12)
E/flutter (23420): #21 ProjectAdapter.primaryKeyByUniqueColumns (package:tragwerk_app/brick/adapters/project_adapter.g.dart:173:36)
E/flutter (23420): #22 SqliteProvider.upsert.<anonymous closure>.<anonymous closure> (package:brick_sqlite/src/sqlite_provider.dart:286:50)
E/flutter (23420): #23 SqfliteDatabaseMixinExt._txnTransaction (package:sqflite_common/src/database_mixin.dart:337:28)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #24 BasicLock.synchronized (package:synchronized/src/basic_lock.dart:33:16)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #25 SqfliteDatabaseMixin.txnSynchronized (package:sqflite_common/src/database_mixin.dart:517:14)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #26 SqliteProvider.upsert.<anonymous closure> (package:brick_sqlite/src/sqlite_provider.dart:285:14)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #27 ReentrantLock.synchronized.<anonymous closure> (package:synchronized/src/reentrant_lock.dart:37:18)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #28 BasicLock.synchronized (package:synchronized/src/basic_lock.dart:33:16)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #29 SqliteProvider.upsert (package:brick_sqlite/src/sqlite_provider.dart:284:16)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #30 Future.wait.<anonymous closure> (dart:async/future.dart:518:21)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #31 OfflineFirstRepository.storeRemoteResults (package:brick_offline_first/src/offline_first_repository.dart:463:21)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #32 OfflineFirstRepository.hydrate (package:brick_offline_first/src/offline_first_repository.dart:434:34)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420): #33 OfflineFirstWithRestRepository.hydrate (package:brick_offline_first_with_rest/src/offline_first_with_rest_repository.dart:181:14)
E/flutter (23420): <asynchronous suspension>
E/flutter (23420):
Here is the generated adapter:
// GENERATED CODE DO NOT EDIT
part of '../brick.g.dart';
Future<Project> _$ProjectFromRest(Map<String, dynamic> data,
{required RestProvider provider,
OfflineFirstWithRestRepository? repository}) async {
return Project(
id: UUID.fromRest(data['id']),
identifier: data['identifier'] as String,
status: ProjectStatus.values.byName(data['status']),
buildingAddress: data['building_address'] as String,
buildingCity: data['building_city'] as String,
buildingZipCode: data['building_zip_code'] as int,
clientFirstName: data['client_first_name'] as String,
clientLastName: data['client_last_name'] as String,
clientCompany: data['client_company'] as String?,
clientEmail: data['client_email'] as String,
clientPhone: data['client_phone'] as String,
clientMobilePhone: data['client_mobile_phone'] as String?);
}
Future<Map<String, dynamic>> _$ProjectToRest(Project instance,
{required RestProvider provider,
OfflineFirstWithRestRepository? repository}) async {
return {
'id': instance.id.toRest(),
'identifier': instance.identifier,
'status': instance.status.name,
'building_address': instance.buildingAddress,
'building_city': instance.buildingCity,
'building_zip_code': instance.buildingZipCode,
'client_first_name': instance.clientFirstName,
'client_last_name': instance.clientLastName,
'client_company': instance.clientCompany,
'client_email': instance.clientEmail,
'client_phone': instance.clientPhone,
'client_mobile_phone': instance.clientMobilePhone
};
}
Future<Project> _$ProjectFromSqlite(Map<String, dynamic> data,
{required SqliteProvider provider,
OfflineFirstWithRestRepository? repository}) async {
return Project(
id: UUID.fromSqlite(data['id'] as String),
identifier: data['identifier'] as String,
status: ProjectStatus.values.byName(data['status'] as String),
buildingAddress: data['building_address'] as String,
buildingCity: data['building_city'] as String,
buildingZipCode: data['building_zip_code'] as int,
clientFirstName: data['client_first_name'] as String,
clientLastName: data['client_last_name'] as String,
clientCompany: data['client_company'] == null
? null
: data['client_company'] as String?,
clientEmail: data['client_email'] as String,
clientPhone: data['client_phone'] as String,
clientMobilePhone: data['client_mobile_phone'] == null
? null
: data['client_mobile_phone'] as String?)
..primaryKey = data['_brick_id'] as int;
}
Future<Map<String, dynamic>> _$ProjectToSqlite(Project instance,
{required SqliteProvider provider,
OfflineFirstWithRestRepository? repository}) async {
return {
'id': instance.id.toSqlite(),
'identifier': instance.identifier,
'status': instance.status.name,
'building_address': instance.buildingAddress,
'building_city': instance.buildingCity,
'building_zip_code': instance.buildingZipCode,
'client_first_name': instance.clientFirstName,
'client_last_name': instance.clientLastName,
'client_company': instance.clientCompany,
'client_email': instance.clientEmail,
'client_phone': instance.clientPhone,
'client_mobile_phone': instance.clientMobilePhone
};
}
/// Construct a [Project]
class ProjectAdapter extends OfflineFirstWithRestAdapter<Project> {
ProjectAdapter();
@override
final restRequest = ProjectRequestTransformer.new;
@override
final Map<String, RuntimeSqliteColumnDefinition> fieldsToSqliteColumns = {
'primaryKey': const RuntimeSqliteColumnDefinition(
association: false,
columnName: '_brick_id',
iterable: false,
type: int,
),
'id': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'id',
iterable: false,
type: UUID,
),
'identifier': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'identifier',
iterable: false,
type: String,
),
'status': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'status',
iterable: false,
type: ProjectStatus,
),
'buildingAddress': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'building_address',
iterable: false,
type: String,
),
'buildingCity': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'building_city',
iterable: false,
type: String,
),
'buildingZipCode': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'building_zip_code',
iterable: false,
type: int,
),
'clientFirstName': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'client_first_name',
iterable: false,
type: String,
),
'clientLastName': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'client_last_name',
iterable: false,
type: String,
),
'clientCompany': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'client_company',
iterable: false,
type: String,
),
'clientEmail': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'client_email',
iterable: false,
type: String,
),
'clientPhone': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'client_phone',
iterable: false,
type: String,
),
'clientMobilePhone': const RuntimeSqliteColumnDefinition(
association: false,
columnName: 'client_mobile_phone',
iterable: false,
type: String,
)
};
@override
Future<int?> primaryKeyByUniqueColumns(
Project instance, DatabaseExecutor executor) async {
final results = await executor.rawQuery('''
SELECT * FROM `Project` WHERE id = ? OR identifier = ? LIMIT 1''',
[instance.id, instance.identifier]);
// SQFlite returns [{}] when no results are found
if (results.isEmpty || (results.length == 1 && results.first.isEmpty)) {
return null;
}
return results.first['_brick_id'] as int;
}
@override
final String tableName = 'Project';
@override
Future<Project> fromRest(Map<String, dynamic> input,
{required provider,
covariant OfflineFirstWithRestRepository? repository}) async =>
await _$ProjectFromRest(input,
provider: provider, repository: repository);
@override
Future<Map<String, dynamic>> toRest(Project input,
{required provider,
covariant OfflineFirstWithRestRepository? repository}) async =>
await _$ProjectToRest(input, provider: provider, repository: repository);
@override
Future<Project> fromSqlite(Map<String, dynamic> input,
{required provider,
covariant OfflineFirstWithRestRepository? repository}) async =>
await _$ProjectFromSqlite(input,
provider: provider, repository: repository);
@override
Future<Map<String, dynamic>> toSqlite(Project input,
{required provider,
covariant OfflineFirstWithRestRepository? repository}) async =>
await _$ProjectToSqlite(input,
provider: provider, repository: repository);
}
Thats the UUID class
class UUID extends OfflineFirstSerdes<String, String> with EquatableMixin {
factory UUID.fromSqlite(String data) => UUID._(data);
factory UUID.fromRest(String data) => UUID._(data);
UUID._(this.value);
factory UUID() {
return UUID._(const Uuid().v4());
}
factory UUID.fromUniqueString(String uniqueIdStr) {
if (uniqueIdStr.isEmpty) {
throw ArgumentError('Passed empty string to UUID.fromUniqueString()');
}
return UUID._(uniqueIdStr);
}
final String value;
@override
toRest() => value;
@override
toSqlite() => value;
@override
List<Object?> get props => [value];
@override
String toString() => value;
}
@devj3ns Thanks for formatting these in drawers. Makes it easier.
What about your migration? What's the migration that was generated for Project
or more specifically the migration that was generated for the addition of id
? I'm wondering if the migration was generated incorrectly.
Also, for what it's worth, there may be a simpler way to do this:
Project({
String? id
}) : this.id = id ?? const Uuid().v4()
@Sqlite(unique: true, index: true)
final String id;
Yeah, that's true, maybe I might just remove my UUID class and use the code snippet you provided.
For the change from final String id;
to final UUID id;
on the model there was no new migration generated.
That's the initial and only migration file
// GENERATED CODE EDIT WITH CAUTION
// THIS FILE **WILL NOT** BE REGENERATED
// This file should be version controlled and can be manually edited.
part of 'schema.g.dart';
// While migrations are intelligently created, the difference between some commands, such as
// DropTable vs. RenameTable, cannot be determined. For this reason, please review migrations after
// they are created to ensure the correct inference was made.
// The migration version must **always** mirror the file name
const List<MigrationCommand> _migration_20240508065905_up = [
InsertTable('Project'),
InsertColumn('id', Column.varchar, onTable: 'Project', unique: true),
InsertColumn('identifier', Column.varchar, onTable: 'Project', unique: true),
InsertColumn('status', Column.integer, onTable: 'Project'),
InsertColumn('building_address', Column.varchar, onTable: 'Project'),
InsertColumn('building_city', Column.varchar, onTable: 'Project'),
InsertColumn('building_zip_code', Column.integer, onTable: 'Project'),
InsertColumn('client_first_name', Column.varchar, onTable: 'Project'),
InsertColumn('client_last_name', Column.varchar, onTable: 'Project'),
InsertColumn('client_company', Column.varchar, onTable: 'Project'),
InsertColumn('client_email', Column.varchar, onTable: 'Project'),
InsertColumn('client_phone', Column.varchar, onTable: 'Project'),
InsertColumn('client_mobile_phone', Column.varchar, onTable: 'Project')
];
const List<MigrationCommand> _migration_20240508065905_down = [
DropTable('Project'),
DropColumn('id', onTable: 'Project'),
DropColumn('identifier', onTable: 'Project'),
DropColumn('status', onTable: 'Project'),
DropColumn('building_address', onTable: 'Project'),
DropColumn('building_city', onTable: 'Project'),
DropColumn('building_zip_code', onTable: 'Project'),
DropColumn('client_first_name', onTable: 'Project'),
DropColumn('client_last_name', onTable: 'Project'),
DropColumn('client_company', onTable: 'Project'),
DropColumn('client_email', onTable: 'Project'),
DropColumn('client_phone', onTable: 'Project'),
DropColumn('client_mobile_phone', onTable: 'Project')
];
//
// DO NOT EDIT BELOW THIS LINE
//
@Migratable(
version: '20240508065905',
up: _migration_20240508065905_up,
down: _migration_20240508065905_down,
)
class Migration20240508065905 extends Migration {
const Migration20240508065905()
: super(
version: 20240508065905,
up: _migration_20240508065905_up,
down: _migration_20240508065905_down,
);
}
@devj3ns Sorry just want to followup on a few things.
- Did the UUID-as-serdes work? Please remove all annotations from the field in the model on any of the
UUID
fields except@Sqlite(index: true, unique: true)
- Would you end up going the
final String id
route instead of doing the custom Serdes?
@devj3ns Thanks for creating that. With a small test and repository I was able to look at the stack trace.
// lib/brick/repository.dart
import 'package:brick_offline_first_with_rest/brick_offline_first_with_rest.dart';
import 'package:brick_rest/brick_rest.dart';
import 'package:brick_sqlite/brick_sqlite.dart';
import 'package:brick_test/brick/brick.g.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart' show databaseFactoryFfi;
import 'package:brick_test/brick/db/schema.g.dart';
export 'package:brick_core/query.dart' show And, Or, Query, QueryAction, Where, WherePhrase;
class Repository extends OfflineFirstWithRestRepository {
Repository({required super.restProvider, required super.sqliteProvider})
: super(
migrations: migrations,
offlineQueueManager: RestRequestSqliteCacheManager(
'brick_offline_queue.sqlite',
databaseFactory: databaseFactoryFfi,
),
);
}
// test/widget_test.dart
import 'package:brick_offline_first_with_rest/testing.dart';
import 'package:brick_rest/brick_rest.dart';
import 'package:brick_sqlite/brick_sqlite.dart';
import 'package:brick_test/brick/brick.g.dart';
import 'package:brick_test/brick/models/project.model.dart';
import 'package:brick_test/brick/repository.dart';
import 'package:brick_test/brick/utils/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
sqfliteFfiInit();
group("MySqliteProvider", () {
late Repository repository;
setUpAll(() async {
repository = Repository(
restProvider: RestProvider(
'http://0.0.0.0:3000',
modelDictionary: restModelDictionary,
client: StubOfflineFirstWithRest(
baseEndpoint: 'http://0.0.0.0:3000',
responses: [
StubOfflineFirstRestResponse('{"name":"Bob"', endpoint: 'users'),
],
).client,
),
sqliteProvider: SqliteProvider(
inMemoryDatabasePath,
databaseFactory: databaseFactoryFfi,
modelDictionary: sqliteModelDictionary,
),
);
await repository.initialize();
});
test('uuid insert', () async {
await repository.upsert<Project>(Project(name: 'my-project', id: UUID('1234')));
});
});
}
This shows exactly where the error is coming from - primaryKeyByUniqueColumns
.
final results = await executor.rawQuery('''
SELECT * FROM `Project` WHERE id = ? LIMIT 1''', [instance.id]);
The Brick architecture always assumed that uniquely identified fields would be single value - a string, an int, any sort of primitive - and never assumed the value would be more complex like a class. Architecturally, I'd still advise that you go the final String id
route to avoid future maintainers from adding complexity within this class.
However, this is something that Brick should at least natively support and perhaps discourage in the @Sqlite(unique)
comments.
It's past my bedtime. This is a relatively quick fix that I will address this week.
Great, you found the root of the problem. A fix for this would be awesome, but no hurries👍
Thanks a lot for your time, I truly appreciate it!
@devj3ns This has been released in brick_offline_first_build
3.2.0. Please update your code to that version (and make sure dependency brick_sqlite_generators
is upgraded to 3.1.0).
If the fix works, please close this issue
@devj3ns You're right. Please try _build 3.1.0.
@tshedor I just tested it and it works 🎉. Thank you, I appreciate it 👍