google / built_value.dart

Immutable value types, enum classes, and serialization.
https://pub.dev/packages/built_value
BSD 3-Clause "New" or "Revised" License
870 stars 184 forks source link

Being able to access the FullType hierarchy for a given field #1325

Closed larssn closed 1 month ago

larssn commented 1 month ago

So inside the autogenerated serializer class for a given entity, within the serialize method, the FullTypes are inaccessible.

For example:

 result
      ..add('tickets')
      ..add(serializers.serialize(value,
          specifiedType: const FullType(BuiltMap,
              const [const FullType(String), const FullType(Ticket)])));
    value = object.deletedAt;

I wish I had a way, for a given serializer, to access the specifiedType above.

So in the above case, something like: Customer.serializer.fullTypes['tickets'] which would return: const FullType(BuiltMap, const [const FullType(String), const FullType(Ticket)]).

This would make dynamically serializing specific properties on BuiltValue classes much easier, without having to serialize the entire class.

The specific use case is a comparison method in our case, for detecting if there's a change to persist, or if the value is equal to the initial value:

  final changes = <String, dynamic>{};
  bool get isDirty => changes.isNotEmpty;
  T get initialValue => _value;
  T get value => serializers.deserializeWith(_serializer, _value.toMap()..addAll(changes))!;

  add<R>(String key, R? value) {
    final initial = initialValue.toMap()[key];
    // Doesn't work for complex types.
    if (serializers.serialize(initial, specifiedType: FullType(value.runtimeType)) == value) {
      changes.remove(key);
    } else {
      changes[key] = value;
    }
  }
davidmorgan commented 1 month ago

I understand what you're asking for, but I'm confused about what you want it for.

It looks like you're comparing the serialized form of initial to a deserialized value? Or did you mean serializers.deserialize on the line before changes.remove?

What is toMap doing in this code?

larssn commented 1 month ago

Or did you mean serializers.deserialize on the line before changes.remove?

Yes, I meant deserialize.

toMap resides on a BuiltValue class, in this case Customer: Map<String, dynamic> toMap() => serializers.serializeWith(serializer, this) as Map<String, dynamic>;

Our setup is a mix of built_value, Firestore and Flutter, and since Firestore supports incremental updates (as json Maps), it makes sense to be able to compare your built_value field with a change. If the change is identical with the built_value field, then the change is removed from the json sent to Firestore.

In the above example, we have a complex type: BuiltMap<String, Ticket> tickets.

In settings for customer, a change for tickets can occur:

// newTickets comes out of a UI picker.
final newMap = newTickets == null
    ? null
    : BuiltMap.of(
        Map.fromEntries(newTickets.map((e) => MapEntry(e.id!, (tickets ?? <String, Ticket>{})[e.id] ?? Ticket.empty(e.id!)))));

setState(() {
  // newMap is a BuiltMap<String, Ticket> here, and we add it as a change.
  widget.change.add('tickets', newMap);
});

Since I don't have reflection via Mirrors in Flutter, there's no way I can do a customer['tickets'] == newMap (attempting to compare the BuiltMap<String, Ticket> newMap with the BuiltMap<String, Ticket> tickets field on the customer).

So to work around this limitation, and I first serialize the customer object to a map, and dynamically look up the tickets map: serializers.serializeWith(Customer.serializer, customerInstance)['tickets'].

In order to do a deep comparison with this new Map<String, Map> tickets against the BuiltMap<String, Ticket> newMap I need to dynamically deserialize the field, back to a BuiltMap.

That is what I'm attempting to do, and in order to do so, I need to dynamically access the FullType.

Sorry for the long winded explanation. I hope it makes sense.

And thanks for a stellar plugin! Blows Freezed out of the water.

davidmorgan commented 1 month ago

Thanks for the extra detail :) I think I understood now.

Adding some kind of class/field metadata to the generated output is something I've thought about for a while, but it's never quite seemed important enough to push through.

Adding the FullType for each field sort of makes sense but it's a lot of additional generated code for a fairly narrow use case.

The 'serializers' internal map of builder generators is almost what you need, and serializers would be a reasonable place for a Map<Type, FullType>, but populating that would be both a change to generated code and require updates to currently existing manually written addBuilder, which is awkward.

I think there is one way that might work already, you can write a serializer plugin

class FullTypesSerializerPlugin {
  final Map<Type, FullType> types = {};

  Object? beforeSerialize(Object? object, FullType specifiedType) {
    if (object != null) types[object.runtimeType] ??= specifiedType;
    return object;
  }

  Object? afterSerialize(Object? object, FullType specifiedType) => object;

  Object? beforeDeserialize(Object? object, FullType specifiedType) => object;

  Object? afterDeserialize(Object? object, FullType specifiedType) {
    if (object != null) types[object.runtimeType] ??= specifiedType;
    return object;
  }
}

with this created as a global variable somewhere and installed

  plugin = FullTypesSerializerPlugin();
  serializers = (serializers.toBuilder()..addPlugin(plugin)).build();

any type you deserialize/serialize will end up with all its runtimeType->FullType mappings stored in plugin.types. Does that work? :)

larssn commented 1 month ago

Nice!! That totally works!

Makes the API much simpler to use. Thanks!

Feel free to close the issue, if needed.

davidmorgan commented 1 month ago

Great, glad I could help. Thanks for the positive feedback :)