Skip to content

Commit 61e7b87

Browse files
authored
Add JsonKey.explicitJsonNullWhenNonNullField for PATCH tri-state JSON (#1574)
- Introduced `JsonKey.explicitJsonNullWhenNonNullField` to support PATCH-style tri-state JSON fields, allowing distinction between omitted keys and explicit `null` values. - Updated changelogs and version constraints in pubspec.yaml files to reflect the new features and dependencies. - Enhanced serialization and deserialization logic to accommodate the new tri-state behavior. This release improves JSON handling capabilities for better API integration.
1 parent 62e16d8 commit 61e7b87

16 files changed

Lines changed: 588 additions & 24 deletions

json_annotation/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 4.12.0
2+
3+
- Add `JsonKey.explicitJsonNullWhenNonNullField` for PATCH-style tri-state JSON
4+
fields (omit key vs explicit `null` vs value).
5+
16
## 4.11.0
27

38
- Add `JsonSerializable.dateTimeUtc` configuration option.

json_annotation/lib/src/json_key.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ class JsonKey {
3434
/// same field, an exception will be thrown during code generation.
3535
final bool? disallowNullValue;
3636

37+
/// Enables PATCH-style tri-state semantics for this field.
38+
///
39+
/// When `true`, generated `toJson` omits the JSON key only when the **Dart**
40+
/// field is `null`, but still writes `"key": null` when the field is
41+
/// non-null and serialization yields JSON `null`.
42+
///
43+
/// Generated `fromJson` uses [Map.containsKey] to distinguish a missing key
44+
/// (typically decoded as Dart `null` on a nullable field) from an explicit
45+
/// JSON `null` (decoded via the field's `fromJson` path with a `null`
46+
/// argument). The field type must be nullable, and any custom [fromJson]
47+
/// function must accept a nullable JSON input for the explicit-`null` case.
48+
///
49+
/// Cannot be combined with [disallowNullValue], [required], [defaultValue],
50+
/// or [readValue]. Setting this flag to `true` overrides [includeIfNull].
51+
final bool? explicitJsonNullWhenNonNullField;
52+
3753
/// A [Function] to use when decoding the associated JSON value to the
3854
/// annotated field.
3955
///
@@ -87,6 +103,9 @@ class JsonKey {
87103
///
88104
/// If both [includeIfNull] and [disallowNullValue] are set to `true` on the
89105
/// same field, an exception will be thrown during code generation.
106+
///
107+
/// If [explicitJsonNullWhenNonNullField] is `true`, this value is ignored
108+
/// because `null` Dart fields are always omitted from the serialized output.
90109
final bool? includeIfNull;
91110

92111
/// Determines whether a field should be included (or excluded) when encoding
@@ -120,6 +139,8 @@ class JsonKey {
120139
/// Note: using this feature does not change any of the subsequent decoding
121140
/// logic for the field. For instance, if the field is of type [DateTime] we
122141
/// expect the function provided here to return a [String].
142+
///
143+
/// Cannot be combined with [explicitJsonNullWhenNonNullField].
123144
final Object? Function(Map, String)? readValue;
124145

125146
/// If `true`, generated code for `fromJson` will verify that the source JSON
@@ -159,6 +180,7 @@ class JsonKey {
159180
@Deprecated('Has no effect') bool? nullable,
160181
this.defaultValue,
161182
this.disallowNullValue,
183+
this.explicitJsonNullWhenNonNullField,
162184
this.fromJson,
163185
@Deprecated(
164186
'Use `includeFromJson` and `includeToJson` with a value of `false` '

json_annotation/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: json_annotation
2-
version: 4.11.0
2+
version: 4.12.0
33
description: >-
44
Classes and helper functions that support JSON code generation via the
55
`json_serializable` package.

json_serializable/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 6.14.0
2+
3+
- Support `JsonKey.explicitJsonNullWhenNonNullField` for PATCH-style tri-state
4+
JSON fields: distinguish omitted keys from explicit `null` in `fromJson` and
5+
emit explicit JSON `null` in `toJson` when the Dart field is non-null.
6+
- Require `json_annotation: '>=4.12.0 <4.13.0'`
7+
18
## 6.13.2
29

310
- Require `analyzer: '>=10.0.0 <14.0.0'`

json_serializable/README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -351,15 +351,15 @@ targets:
351351
[`Enum`]: https://api.dart.dev/dart-core/Enum-class.html
352352
[`int`]: https://api.dart.dev/dart-core/int-class.html
353353
[`Iterable`]: https://api.dart.dev/dart-core/Iterable-class.html
354-
[`JsonConverter`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonConverter-class.html
355-
[`JsonEnum.valueField`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonEnum/valueField.html
356-
[`JsonEnum`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonEnum-class.html
357-
[`JsonKey.fromJson`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonKey/fromJson.html
358-
[`JsonKey.toJson`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonKey/toJson.html
359-
[`JsonKey`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonKey-class.html
360-
[`JsonLiteral`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonLiteral-class.html
361-
[`JsonSerializable`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonSerializable-class.html
362-
[`JsonValue`]: https://pub.dev/documentation/json_annotation/4.11.0/json_annotation/JsonValue-class.html
354+
[`JsonConverter`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonConverter-class.html
355+
[`JsonEnum.valueField`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonEnum/valueField.html
356+
[`JsonEnum`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonEnum-class.html
357+
[`JsonKey.fromJson`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonKey/fromJson.html
358+
[`JsonKey.toJson`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonKey/toJson.html
359+
[`JsonKey`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonKey-class.html
360+
[`JsonLiteral`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonLiteral-class.html
361+
[`JsonSerializable`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonSerializable-class.html
362+
[`JsonValue`]: https://pub.dev/documentation/json_annotation/4.12.0/json_annotation/JsonValue-class.html
363363
[`List`]: https://api.dart.dev/dart-core/List-class.html
364364
[`Map`]: https://api.dart.dev/dart-core/Map-class.html
365365
[`num`]: https://api.dart.dev/dart-core/num-class.html

json_serializable/lib/src/check_dependencies.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const _annotationPkgName = 'json_annotation';
1515
final _supportLanguageRange = VersionConstraint.parse(
1616
supportedLanguageConstraint,
1717
);
18-
final requiredJsonAnnotationMinVersion = Version.parse('4.11.0');
18+
final requiredJsonAnnotationMinVersion = Version.parse('4.12.0');
1919

2020
Future<void> pubspecHasRightVersion(BuildStep buildStep) async {
2121
final segments = buildStep.inputId.pathSegments;

json_serializable/lib/src/decode_helper.dart

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:source_helper/source_helper.dart';
1111
import 'helper_core.dart';
1212
import 'json_literal_generator.dart';
1313
import 'type_helpers/generic_factory_helper.dart';
14+
import 'type_helpers/patch_tri_state_helper.dart';
1415
import 'unsupported_type_error.dart';
1516
import 'utils.dart';
1617

@@ -218,15 +219,38 @@ mixin DecodeHelper implements HelperCore {
218219
final jsonKey = jsonKeyFor(field);
219220
final defaultValue = jsonKey.defaultValue;
220221
final readValueFunc = jsonKey.readValueFunctionName;
221-
222-
String deserialize(String expression) => contextHelper
223-
.deserialize(targetType, expression, defaultValue: defaultValue)
224-
.toString();
222+
final patchTriState = usesExplicitJsonNullWhenNonNullField(jsonKey);
223+
224+
String deserialize(String expression, {bool patchPresentValue = false}) =>
225+
(patchPresentValue
226+
? contextHelper.deserializePresentJsonValue(
227+
targetType,
228+
expression,
229+
defaultValue: defaultValue,
230+
)
231+
: contextHelper.deserialize(
232+
targetType,
233+
expression,
234+
defaultValue: defaultValue,
235+
))
236+
.toString();
225237

226238
String value;
227239
try {
228240
if (config.checked) {
229-
value = deserialize('v');
241+
final deserializeV = deserialize('v');
242+
if (patchTriState) {
243+
validateExplicitJsonNullDeserialize(field, contextHelper, targetType);
244+
final triStateBody = wrapPatchTriStateCheckedConvert(
245+
mapExpression: 'json',
246+
jsonKeyName: jsonKeyName,
247+
absentExpression: 'null',
248+
presentExpression: deserialize('v', patchPresentValue: true),
249+
);
250+
value = triStateBody;
251+
} else {
252+
value = deserializeV;
253+
}
230254
if (!checkedProperty) {
231255
final readValueBit = readValueFunc == null
232256
? ''
@@ -239,11 +263,26 @@ mixin DecodeHelper implements HelperCore {
239263
'should only be true if `_generator.checked` is true.',
240264
);
241265

242-
value = deserialize(
243-
readValueFunc == null
244-
? 'json[$jsonKeyName]'
245-
: '$readValueFunc(json, $jsonKeyName)',
266+
final jsonValueExpression = readValueFunc == null
267+
? 'json[$jsonKeyName]'
268+
: '$readValueFunc(json, $jsonKeyName)';
269+
270+
final deserializeValue = deserialize(
271+
jsonValueExpression,
272+
patchPresentValue: patchTriState,
246273
);
274+
275+
if (patchTriState) {
276+
validateExplicitJsonNullDeserialize(field, contextHelper, targetType);
277+
value = wrapPatchTriStateFromJson(
278+
mapExpression: 'json',
279+
jsonKeyName: jsonKeyName,
280+
absentExpression: 'null',
281+
presentExpression: deserializeValue,
282+
);
283+
} else {
284+
value = deserializeValue;
285+
}
247286
}
248287
} on UnsupportedTypeError catch (e) // ignore: avoid_catching_errors
249288
{

json_serializable/lib/src/encoder_helper.dart

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import 'enum_utils.dart';
1010
import 'helper_core.dart';
1111
import 'type_helpers/generic_factory_helper.dart';
1212
import 'type_helpers/json_converter_helper.dart';
13+
import 'type_helpers/patch_tri_state_helper.dart';
1314
import 'unsupported_type_error.dart';
15+
import 'utils.dart';
1416

1517
mixin EncodeHelper implements HelperCore {
1618
String _fieldAccess(FieldElement field) => '$_toJsonParamName.${field.name!}';
@@ -105,9 +107,16 @@ mixin EncodeHelper implements HelperCore {
105107
..writeln('=> <String, dynamic>{')
106108
..writeAll(
107109
accessibleFields.map((field) {
108-
final access = _fieldAccess(field);
109-
110110
final keyExpression = safeNameAccess(field);
111+
112+
if (usesExplicitJsonNullWhenNonNullField(jsonKeyFor(field))) {
113+
final access = _fieldAccess(field);
114+
final valueExpression = _serializePatchField(field, 'value');
115+
return ' if ($access case final value?) '
116+
'$keyExpression: $valueExpression,\n';
117+
}
118+
119+
final access = _fieldAccess(field);
111120
final valueExpression = _serializeField(field, access);
112121

113122
final maybeQuestion = _canWriteJsonWithoutNullCheck(field) ? '' : '?';
@@ -146,11 +155,27 @@ mixin EncodeHelper implements HelperCore {
146155
}
147156
}
148157

158+
String _serializePatchField(FieldElement field, String accessExpression) {
159+
try {
160+
final type = field.type.promoteNonNullable();
161+
return getHelperContext(
162+
field,
163+
).serialize(type, accessExpression).toString();
164+
} on UnsupportedTypeError catch (e) // ignore: avoid_catching_errors
165+
{
166+
throw createInvalidGenerationError('toJson', field, e);
167+
}
168+
}
169+
149170
/// Returns `true` if the field can be written to JSON 'naively' – meaning
150171
/// we can avoid checking for `null`.
151172
bool _canWriteJsonWithoutNullCheck(FieldElement field) {
152173
final jsonKey = jsonKeyFor(field);
153174

175+
if (usesExplicitJsonNullWhenNonNullField(jsonKey)) {
176+
return true;
177+
}
178+
154179
if (jsonKey.includeIfNull) {
155180
return true;
156181
}

json_serializable/lib/src/json_key_utils.dart

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,16 @@ KeyConfig _from(FieldElement element, ClassConfig classAnnotation) {
285285
includeToJson = includeFromJson = !ignore;
286286
}
287287

288+
final explicitJsonNullWhenNonNullField =
289+
fallbackObjRead('explicitJsonNullWhenNonNullField').literalValue as bool?;
290+
288291
return _populateJsonKey(
289292
classAnnotation,
290293
element,
291294
defaultValue: defaultValue ?? ctorParamDefault,
292295
disallowNullValue:
293296
fallbackObjRead('disallowNullValue').literalValue as bool?,
297+
explicitJsonNullWhenNonNullField: explicitJsonNullWhenNonNullField,
294298
includeIfNull: fallbackObjRead('includeIfNull').literalValue as bool?,
295299
name: fallbackObjRead('name').literalValue as String?,
296300
readValueFunctionName: readValueFunctionName,
@@ -309,6 +313,7 @@ KeyConfig _populateJsonKey(
309313
FieldElement element, {
310314
required String? defaultValue,
311315
bool? disallowNullValue,
316+
bool? explicitJsonNullWhenNonNullField,
312317
bool? includeIfNull,
313318
String? name,
314319
String? readValueFunctionName,
@@ -327,9 +332,48 @@ KeyConfig _populateJsonKey(
327332
}
328333
}
329334

335+
if (explicitJsonNullWhenNonNullField == true) {
336+
if (!element.type.isNullableType) {
337+
throwUnsupported(
338+
element,
339+
'Fields with `explicitJsonNullWhenNonNullField` must be nullable so '
340+
'a missing JSON key can be represented as Dart `null`.',
341+
);
342+
}
343+
if (disallowNullValue == true) {
344+
throwUnsupported(
345+
element,
346+
'Cannot set both `explicitJsonNullWhenNonNullField` and '
347+
'`disallowNullValue` to `true`.',
348+
);
349+
}
350+
if (required == true) {
351+
throwUnsupported(
352+
element,
353+
'Cannot set both `explicitJsonNullWhenNonNullField` and `required` to '
354+
'`true`.',
355+
);
356+
}
357+
if (defaultValue != null) {
358+
throwUnsupported(
359+
element,
360+
'Cannot set `defaultValue` when `explicitJsonNullWhenNonNullField` is '
361+
'`true`.',
362+
);
363+
}
364+
if (readValueFunctionName != null) {
365+
throwUnsupported(
366+
element,
367+
'Cannot set `readValue` when `explicitJsonNullWhenNonNullField` is '
368+
'`true`.',
369+
);
370+
}
371+
}
372+
330373
return KeyConfig(
331374
defaultValue: defaultValue,
332375
disallowNullValue: disallowNullValue ?? false,
376+
explicitJsonNullWhenNonNullField: explicitJsonNullWhenNonNullField ?? false,
333377
includeIfNull: _includeIfNull(
334378
includeIfNull,
335379
disallowNullValue,

json_serializable/lib/src/type_helper_ctx.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,26 @@ class TypeHelperCtx
7575
);
7676
}
7777

78+
/// Like [deserialize], but does not apply nullable short-circuiting for
79+
/// [targetType].
80+
///
81+
/// Used when a JSON key is present (including when its value is JSON `null`)
82+
/// for PATCH tri-state fields, so explicit `null` is passed to `fromJson`.
83+
Object deserializePresentJsonValue(
84+
DartType targetType,
85+
String expression, {
86+
String? defaultValue,
87+
}) {
88+
final value = _run(
89+
targetType,
90+
expression,
91+
(TypeHelper th) =>
92+
th.deserialize(targetType, expression, this, defaultValue != null),
93+
);
94+
95+
return DefaultContainer.deserialize(value, defaultValue: defaultValue);
96+
}
97+
7898
Object _run(
7999
DartType targetType,
80100
String expression,

0 commit comments

Comments
 (0)