json_serializable.dart icon indicating copy to clipboard operation
json_serializable.dart copied to clipboard

Use dart json_serializable to map an object field to JSON flat keys (one to many mapping)

Open luissalgadofreire opened this issue 2 years ago • 2 comments

I have a dart object that includes a field of type Money, which itself is composed of amount and currency:

@JsonSerializable()
class Account {

  final String id;
  final String type;
  final String subtype;
  final String origin;
  final String name;
  final String status;
  final String currency;
  final Money balance; <== value object
  ...
}

Money looks something like this:

class Money {
  final int amount;
  final String currency;

  const Money(this.amount, this.currency);
  ...
}

The above is to be mapped for use by sqflite, therefore the target JSON must be a flat JSON, like:

{
  "id": String,
  "type": String,
  "subtype": String,
  "origin": String,
  "name": String,
  "status": String,
  "currency": String,
  "balanceAmount": int;      <== value object
  "balanceCurrency": String; <== value object
}

I understand I can use JsonKey.readValue to extract a composite object from the entire JSON object prior to decoding.

But how can I do the opposite? Grab the two values from the Money instance and map them to balanceAmount and balanceCurrency?

Nothing in the search I did on json_serializable api docs, GitHub issues, or StackOverflow appears to respond to this in particular: how to map one field to two (or more) target keys?

luissalgadofreire avatar Dec 21 '21 00:12 luissalgadofreire

Maybe json_seriazable doesn't have this functionality yet for now. So in manual approach, it could be like this

@JsonSeriazable()
class Account {
  // properties...

  factory Account.fromJson(Map<String, dynamic> json) => _$AccountFromJson(json);

  Map<String, dynamic> toJson() => _$AccountToJson(this);

  // add this method
  Map<String, dynamic> toFlatJson() {
    final currentJson = toJson();
    final newJson = {...currentJson};

    // flattening 'balance' value
    currentJson['balance']!.forEach((k, v) {
      newJson[k] = v;
    });

    // remove 'balance' value from newJson
    newJson.remove('balance');

    return newJson;
  }
}

Note:

Well, I didn't test this yet. So you might manipulate this code snippet based on your own problems.

mbaguszulmi avatar Dec 23 '21 03:12 mbaguszulmi

At the moment I also have not found any other way than to use readValue and change the toJson method myself. I'll show how this can be done using freezed:

|-- freezed 2.5.2
|   |-- source_gen 1.5.0
|-- freezed_annotation 2.4.1

|-- json_annotation 4.9.0
|-- json_serializable 6.8.0

The models look like this (Pay attention to Account.toJson and the passJson function, which simply passes raw json):

Object passJson(Map json, _) => json;

@freezed
class Account with _$Account {
  @JsonSerializable(explicitToJson: true)
  const factory Account({
    @JsonKey(name: 'status') required String status,
    @JsonKey(name: '_balance', readValue: passJson) required Money balance,
  }) = _Account;

  factory Account.fromJson(Map<String, dynamic> json) =>
      _$AccountFromJson(json);

  const Account._();

  @override
  Map<String, dynamic> toJson() => toJson()
    ..addAll(balance.toJson())
    ..remove('_balance');

  Map<String, dynamic> toFlatJson() => toJson()
    ..addAll(balance.toJson())
    ..remove('_balance');
}

@freezed
class Money with _$Money {
  const factory Money({
    @JsonKey(name: 'balanceCurrency') required String currency,
    @JsonKey(name: 'balanceAmount') required int amount,
  }) = _Money;

  factory Money.fromJson(Map<String, dynamic> json) => _$MoneyFromJson(json);
}

Then the generated files look like this:

_$AccountImpl _$$AccountImplFromJson(Map<String, dynamic> json) =>
    _$AccountImpl(
      status: json['status'] as String,
      balance:
          Money.fromJson(passJson(json, '_balance') as Map<String, dynamic>),
    );

Map<String, dynamic> _$$AccountImplToJson(_$AccountImpl instance) =>
    <String, dynamic>{
      'status': instance.status,
      '_balance': instance.balance.toJson(),
    };

_$MoneyImpl _$$MoneyImplFromJson(Map<String, dynamic> json) => _$MoneyImpl(
      currency: json['balanceCurrency'] as String,
      amount: (json['balanceAmount'] as num).toInt(),
    );

Map<String, dynamic> _$$MoneyImplToJson(_$MoneyImpl instance) =>
    <String, dynamic>{
      'balanceCurrency': instance.currency,
      'balanceAmount': instance.amount,
    };

This is still not as convenient as some kind of “magic short annotation”, but it allows you to complete the task using flat json.


Update

Unfortunately, the method above doesn't work because the toJson method doesn't work as expected (the overridden toJson method does not run our code):

So I added const Account._(); to the example above and defined toFlatJson. Then:

main() {
  final json = {
    'status': 'receipted',
    'balanceCurrency': '1.0',
    'balanceAmount': 500,
  };

  final account = Account.fromJson(json);
  print(account.toJson()); // {status: receipted, _balance: {balanceCurrency: 1.0, balanceAmount: 500}}
  print(account.toFlatJson()); // {status: receipted, balanceCurrency: 1.0, balanceAmount: 500}
}

PackRuble avatar May 02 '24 06:05 PackRuble