Skip to content

Option to omit empty fields #26

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 25, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@

* Added support for optional, non-nullable fields.

* Added support for excluding `null` values when generating JSON.

* Eliminated all implicit casts in generated code. These would end up being
runtime checks in most cases.

Expand Down
27 changes: 22 additions & 5 deletions lib/src/json_serializable.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ class JsonSerializable {
final bool createFactory;
final bool createToJson;

const JsonSerializable({bool createFactory: true, bool createToJson: true})
: this.createFactory = createFactory,
this.createToJson = createToJson;
/// Whether the generator should include the this field in the serialized
/// output, even if the value is `null`.
final bool includeIfNull;

const JsonSerializable(
{bool createFactory: true,
bool createToJson: true,
bool includeIfNull: true})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is true the default? Backwards compatibility?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup. It can also lead to some weirdness – like having a completely empty Map returned from toJson – better to make it opt-in.

Also the code generated is a lot uglier.

: this.createFactory = createFactory ?? true,
this.createToJson = createToJson ?? true,
this.includeIfNull = includeIfNull ?? true;
}

/// An annotation used to specify how a field is serialized.
Expand All @@ -27,9 +35,18 @@ class JsonKey {
/// must implement their own `null` validation.
final bool nullable;

/// [true] if the generator should include the this field in the serialized
/// output, even if the value is `null`.
///
/// The default value, `null`, indicates that the behavior should be
/// acquired from the [JsonSerializable.includeIfNull] annotation on the
/// enclosing class.
final bool includeIfNull;

/// Creates a new [JsonKey].
///
/// Only required when the default behavior is not desired.
const JsonKey({this.name, bool nullable: true})
: this.nullable = nullable ?? true;
const JsonKey({this.name, bool nullable: true, bool includeIfNull})
: this.nullable = nullable ?? true,
this.includeIfNull = includeIfNull;
}
118 changes: 93 additions & 25 deletions lib/src/json_serializable_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import 'type_helpers/map_helper.dart';
import 'type_helpers/value_helper.dart';
import 'utils.dart';

// TODO: toJson option to omit null/empty values
class JsonSerializableGenerator
extends GeneratorForAnnotation<JsonSerializable> {
static const _coreHelpers = const [
Expand Down Expand Up @@ -121,36 +120,96 @@ class JsonSerializableGenerator
//
buffer.writeln('abstract class ${prefix}SerializerMixin {');

// write fields
// write copies of the fields - this allows the toJson method to access
// the fields of the target class
fields.forEach((name, field) {
//TODO - handle aliased imports
buffer.writeln(' ${field.type} get $name;');
});

// write toJson method
buffer.writeln(' Map<String, dynamic> toJson() => <String, dynamic>{');

var pairs = <String>[];
fields.forEach((fieldName, field) {
try {
pairs.add("'${_fieldToJsonMapKey(field) ?? fieldName}': "
"${_serialize(field.type, fieldName , _nullable(field))}");
} on UnsupportedTypeError {
throw new InvalidGenerationSourceError(
"Could not generate `toJson` code for `${friendlyNameForElement(field)}`.",
todo: "Make sure all of the types are serializable.");
}
});
buffer.writeln(pairs.join(','));

buffer.writeln(' };');
buffer.writeln(' Map<String, dynamic> toJson() ');
if (fieldsList
.every((e) => _includeIfNull(e, annotation.includeIfNull))) {
// write simple `toJson` method that includes all keys...
_writeToJsonSimple(buffer, fields);
} else {
// At least one field should be excluded if null
_writeToJsonWithNullChecks(buffer, fields, annotation.includeIfNull);
}

// end of the mixin class
buffer.write('}');
}

return buffer.toString();
}

void _writeToJsonWithNullChecks(StringBuffer buffer,
Map<String, FieldElement> fields, bool includeIfNull) {
buffer.writeln('{');

// TODO(kevmoo) We could write all values up to the null-excluded value
// directly in this literal.
buffer.writeln("var $toJsonMapVarName = <String, dynamic>{};");

buffer.writeln("""void $toJsonMapHelperName(String key, dynamic value) {
if (value != null) {
$toJsonMapVarName[key] = value;
}
}""");

fields.forEach((fieldName, field) {
try {
var safeJsonKeyString =
_safeNameAccess(_fieldToJsonMapKey(field, fieldName));

// If `fieldName` collides with one of the local helpers, prefix
// access with `this.`.
if (fieldName == toJsonMapVarName || fieldName == toJsonMapHelperName) {
fieldName = 'this.$fieldName';
}

if (_includeIfNull(field, includeIfNull)) {
buffer.writeln("$toJsonMapVarName[$safeJsonKeyString] = "
"${_serialize(field.type, fieldName, _nullable(field))};");
} else {
buffer.writeln("$toJsonMapHelperName($safeJsonKeyString, "
"${_serialize(field.type, fieldName, _nullable(field))});");
}
} on UnsupportedTypeError {
throw new InvalidGenerationSourceError(
"Could not generate `toJson` code for `${friendlyNameForElement(
field)}`.",
todo: "Make sure all of the types are serializable.");
}
});

buffer.writeln(r"return $map;");

buffer.writeln('}');
}

void _writeToJsonSimple(
StringBuffer buffer, Map<String, FieldElement> fields) {
buffer.writeln('=> <String, dynamic>{');

var pairs = <String>[];
fields.forEach((fieldName, field) {
try {
pairs.add("'${_fieldToJsonMapKey(field, fieldName)}': "
"${_serialize(field.type, fieldName, _nullable(field))}");
} on UnsupportedTypeError {
throw new InvalidGenerationSourceError(
"Could not generate `toJson` code for `${friendlyNameForElement(
field)}`.",
todo: "Make sure all of the types are serializable.");
}
});
buffer.writeAll(pairs, ', ');

buffer.writeln(' };');
}

/// Returns the set of fields that are not written to via constructors.
Set<FieldElement> _writeFactory(
StringBuffer buffer,
Expand Down Expand Up @@ -263,12 +322,13 @@ class JsonSerializableGenerator

String _deserializeForField(String name, FieldElement field,
{ParameterElement ctorParam}) {
name = _fieldToJsonMapKey(field) ?? name;
name = _fieldToJsonMapKey(field, name);

var targetType = ctorParam?.type ?? field.type;

try {
return _deserialize(targetType, "json['$name']", _nullable(field));
var safeName = _safeNameAccess(name);
return _deserialize(targetType, "json[$safeName]", _nullable(field));
} on UnsupportedTypeError {
throw new InvalidGenerationSourceError(
"Could not generate fromJson code for `${friendlyNameForElement(field)}`.",
Expand All @@ -285,17 +345,24 @@ class JsonSerializableGenerator
throw new UnsupportedTypeError(targetType, expression));
}

String _safeNameAccess(String name) =>
name.contains(r'$') ? "r'$name'" : "'$name'";

/// Returns the JSON map `key` to be used when (de)serializing [field], if any.
///
/// Otherwise, `null`;
String _fieldToJsonMapKey(FieldElement field) => _getJsonKeyReader(field).name;
String _fieldToJsonMapKey(FieldElement field, String ifNull) =>
_jsonKeyFor(field).name ?? ifNull;

/// Returns `true` if the field should be treated as potentially nullable.
///
/// If no [JsonKey] annotation is present on the field, `true` is returned.
bool _nullable(FieldElement field) => _getJsonKeyReader(field).nullable;
bool _nullable(FieldElement field) => _jsonKeyFor(field).nullable;

bool _includeIfNull(FieldElement element, bool parentValue) =>
_jsonKeyFor(element).includeIfNull ?? parentValue;

JsonKey _getJsonKeyReader(FieldElement element) {
JsonKey _jsonKeyFor(FieldElement element) {
var key = _jsonKeyExpando[element];

if (key == null) {
Expand All @@ -309,7 +376,8 @@ JsonKey _getJsonKeyReader(FieldElement element) {
? const JsonKey()
: new JsonKey(
name: obj.getField('name').toStringValue(),
nullable: obj.getField('nullable').toBoolValue());
nullable: obj.getField('nullable').toBoolValue(),
includeIfNull: obj.getField('includeIfNull').toBoolValue());
}

return key;
Expand Down
3 changes: 3 additions & 0 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ String friendlyNameForElement(Element element) {
void log(object) {
stderr.writeln(['***', object, '***'].join('\n'));
}

final toJsonMapVarName = r'$map';
final toJsonMapHelperName = r'$writeNotNull';
31 changes: 30 additions & 1 deletion test/json_serializable_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'dart:async';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/src/string_source.dart';
import 'package:json_serializable/generators.dart';
import 'package:json_serializable/src/utils.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';

Expand Down Expand Up @@ -153,11 +154,25 @@ void main() {
expect(output, contains("'h': height,"));
expect(output, contains("..height = json['h']"));
});

group("includeIfNull", () {
test("some", () async {
var output = await _runForElementNamed('IncludeIfNullAll');
expect(output, isNot(contains(toJsonMapVarName)));
expect(output, isNot(contains(toJsonMapHelperName)));
});

test("all", () async {
var output = await _runForElementNamed('IncludeIfNullOverride');
expect(output, contains("$toJsonMapVarName[\'number\'] = number;"));
expect(output, contains("$toJsonMapHelperName('str', str);"));
});
});
}

const _generator = const JsonSerializableGenerator();

Future<String> _runForElementNamed(String name) async {
Future<String> _runForElementNamed(String name) {
var library = _compUnit.element.library;
var element =
getElementsFromLibraryElement(library).singleWhere((e) => e.name == name);
Expand Down Expand Up @@ -278,4 +293,18 @@ class NoSerializeBadKey {
class NoDeserializeBadKey {
Map<int, DateTime> intDateTimeMap;
}

@JsonSerializable(createFactory: false)
class IncludeIfNullAll {
@JsonKey(includeIfNull: true)
int number;
String str;
}

@JsonSerializable(createFactory: false, includeIfNull: false)
class IncludeIfNullOverride {
@JsonKey(includeIfNull: true)
int number;
String str;
}
''';
40 changes: 31 additions & 9 deletions test/kitchen_sink_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import 'package:test/test.dart';

import 'package:json_serializable/src/utils.dart';

import 'test_files/bathtub.dart';
import 'test_files/kitchen_sink.dart';
import 'test_utils.dart';
Expand All @@ -14,12 +16,17 @@ void main() {
roundTripObject(p, (json) => new KitchenSink.fromJson(json));
}

test('null', () {
test('Fields with `!includeIfNull` should not be included when null', () {
var item = new KitchenSink();
roundTripItem(item);

var expectedDefaultKeys = _expectedOrder.toSet()
..removeAll(_excludeIfNullKeys);

var encoded = item.toJson();
for (var key in _expectedOrder) {

expect(encoded.keys, orderedEquals(expectedDefaultKeys));

for (var key in expectedDefaultKeys) {
expect(encoded, containsPair(key, isNull));
}
});
Expand Down Expand Up @@ -113,7 +120,6 @@ void _sharedTests(

test('empty', () {
var item = ctor();

roundTripSink(item);
});

Expand Down Expand Up @@ -144,16 +150,29 @@ void _sharedTests(
roundTripSink(item);
});

test('json keys should be defined in field/property order', () {
var item = ctor();
test('JSON keys should be defined in field/property order', () {
/// Explicitly setting values from [_excludeIfNullKeys] to ensure
/// they exist for KitchenSink where they are excluded when null
var item = ctor(iterable: [])
..dateTime = new DateTime.now()
..dateTimeList = []
..crazyComplex = []
..$map = {};

var json = item.toJson();

expect(json.keys, orderedEquals(_expectedOrder));
});
}

const _expectedOrder = const [
final _excludeIfNullKeys = [
'dateTime',
'iterable',
'dateTimeList',
'crazyComplex',
toJsonMapVarName
];

final _expectedOrder = [
'dateTime',
'iterable',
'dynamicIterable',
Expand All @@ -169,5 +188,8 @@ const _expectedOrder = const [
'stringStringMap',
'stringIntMap',
'stringDateTimeMap',
'crazyComplex'
'crazyComplex',
toJsonMapVarName,
toJsonMapHelperName,
r'$string'
];
8 changes: 8 additions & 0 deletions test/test_files/bathtub.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,13 @@ class Bathtub extends Object
@JsonKey(nullable: false)
List<Map<String, Map<String, List<List<DateTime>>>>> crazyComplex = [];

// Handle fields with names that collide with helper names
@JsonKey(nullable: false, includeIfNull: false)
Map<String, bool> $map = {};
@JsonKey(nullable: false)
bool $writeNotNull;
@JsonKey(nullable: false, name: r'$string')
String string;

bool operator ==(Object other) => sinkEquals(this, other);
}
Loading