GetDutchie / brick

An intuitive way to work with persistent data in Dart

Home Page:https://getdutchie.github.io/brick/#/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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 ids 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.

  1. 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)
  2. Would you end up going the final String id route instead of doing the custom Serdes?

@tshedor I would like to use the OfflineFirstSerdes, but after some more testing, the adapter file is still not being generated when using the Serdes field.

I have created a minimal, reproducible example here.

@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

Hey @tshedor, thanks for working on this!

However, brick_offline_first_build 3.2.0 is not available on pub.dev and this feature was added to the 3.1.0 changelog.

@devj3ns You're right. Please try _build 3.1.0.

@tshedor I just tested it and it works 🎉. Thank you, I appreciate it 👍