Skip to content

Commit 9acdce5

Browse files
committed
feat: throw on invalid config and add support for complex sealed classes
1 parent 4ca71f7 commit 9acdce5

File tree

4 files changed

+313
-7
lines changed

4 files changed

+313
-7
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import 'package:json_annotation/json_annotation.dart';
2+
3+
part 'complex_sealed_class_examples.g.dart';
4+
5+
@JsonSerializable(
6+
unionDiscriminator: 'base_base_base_type',
7+
)
8+
sealed class BaseBaseBaseType {
9+
BaseBaseBaseType();
10+
11+
factory BaseBaseBaseType.fromJson(Map<String, dynamic> json) =>
12+
_$BaseBaseBaseTypeFromJson(json);
13+
14+
Map<String, dynamic> toJson() => _$BaseBaseBaseTypeToJson(this);
15+
}
16+
17+
@JsonSerializable(
18+
unionDiscriminator: 'base_base_type',
19+
)
20+
sealed class BaseBase extends BaseBaseBaseType {
21+
BaseBase();
22+
}
23+
24+
@JsonSerializable(
25+
unionDiscriminator: 'base_type',
26+
)
27+
sealed class Base extends BaseBase {
28+
Base();
29+
}
30+
31+
@JsonSerializable()
32+
class FirstBaseImpl extends Base {
33+
FirstBaseImpl(this.value);
34+
35+
String value;
36+
}
37+
38+
@JsonSerializable()
39+
class SecondBaseImpl extends Base {
40+
SecondBaseImpl(this.value);
41+
42+
String value;
43+
}
44+
45+
@JsonSerializable()
46+
class BaseBaseImpl extends BaseBase {
47+
BaseBaseImpl(this.value);
48+
49+
String value;
50+
}
51+
52+
@JsonSerializable(
53+
createToJson: false,
54+
)
55+
sealed class SecondBase {
56+
SecondBase();
57+
58+
factory SecondBase.fromJson(Map<String, dynamic> json) =>
59+
_$SecondBaseFromJson(json);
60+
}
61+
62+
@JsonSerializable(
63+
createToJson: false,
64+
)
65+
sealed class ThirdBase {
66+
ThirdBase();
67+
68+
factory ThirdBase.fromJson(Map<String, dynamic> json) =>
69+
_$ThirdBaseFromJson(json);
70+
}
71+
72+
@JsonSerializable(
73+
createToJson: false,
74+
)
75+
class ImplAll implements SecondBase, ThirdBase {
76+
ImplAll(this.value);
77+
78+
String value;
79+
}

example/lib/complex_sealed_class_examples.g.dart

Lines changed: 121 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

json_serializable/lib/src/generator_helper.dart

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,57 @@ class GeneratorHelper extends HelperCore with EncodeHelper, DecodeHelper {
4848
);
4949
}
5050

51+
final sealedSuperClassesOrEmpty = sealedSuperClasses(element);
52+
53+
final sealedDiscriminators = sealedSuperClassesOrEmpty
54+
.map((sealedClass) => jsonSerializableConfig(sealedClass, _generator))
55+
.map((config) => config?.unionDiscriminator);
56+
57+
if ((sealedSuperClassesOrEmpty.isNotEmpty || element.isSealed) &&
58+
config.genericArgumentFactories) {
59+
throw InvalidGenerationSourceError(
60+
'The class `${element.displayName}` is annotated '
61+
'with `JsonSerializable` field `genericArgumentFactories: true`. '
62+
'`genericArgumentFactories: true` is not supported for classes '
63+
'that are sealed or have sealed superclasses.',
64+
todo:
65+
'Remove the `genericArgumentFactories` option or '
66+
'remove the `sealed` keyword from the class.',
67+
element: element,
68+
);
69+
}
70+
71+
if (element.isSealed) {
72+
if (sealedDiscriminators.contains(config.unionDiscriminator)) {
73+
throw InvalidGenerationSource(
74+
'Nested sealed classes cannot have the same discriminator.',
75+
todo:
76+
'Rename one of the discriminators with `unionDiscriminator` '
77+
'field in `@JsonSerializable`.',
78+
);
79+
}
80+
sealedClassImplementations(element).forEach((impl) {
81+
final annotationConfig = jsonSerializableConfig(impl, _generator);
82+
83+
if (annotationConfig == null) {
84+
throw InvalidGenerationSourceError(
85+
'The class `${element.displayName}` is sealed but its '
86+
'implementation `${impl.displayName}` is not annotated with '
87+
'`JsonSerializable`.',
88+
todo: 'Add `@JsonSerializable` annotation to ${impl.displayName}.',
89+
);
90+
}
91+
92+
if (annotationConfig.createToJson != config.createToJson) {
93+
throw InvalidGenerationSourceError(
94+
'The class `${element.displayName}` is sealed but its '
95+
'implementation `${impl.displayName}` has a different '
96+
'`createToJson` option than the base class.',
97+
);
98+
}
99+
});
100+
}
101+
51102
final sortedFields = createSortedFieldSet(element);
52103

53104
// Used to keep track of why a field is ignored. Useful for providing
@@ -113,6 +164,16 @@ class GeneratorHelper extends HelperCore with EncodeHelper, DecodeHelper {
113164
// by `_writeCtor`.
114165
..fold(<String>{}, (Set<String> set, fe) {
115166
final jsonKey = nameAccess(fe);
167+
168+
if (sealedDiscriminators.contains(jsonKey)) {
169+
throw InvalidGenerationSourceError(
170+
'The JSON key "$jsonKey" is conflicting with the discriminator '
171+
'of sealed superclass ',
172+
todo: 'Rename the field or the discriminator.',
173+
element: fe,
174+
);
175+
}
176+
116177
if (!set.add(jsonKey)) {
117178
throw InvalidGenerationSourceError(
118179
'More than one field has the JSON key for name "$jsonKey".',

json_serializable/lib/src/utils.dart

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import 'package:json_annotation/json_annotation.dart';
99
import 'package:source_gen/source_gen.dart';
1010
import 'package:source_helper/source_helper.dart';
1111

12+
import 'settings.dart';
1213
import 'shared_checkers.dart';
1314
import 'type_helpers/config_types.dart';
1415

1516
const _jsonKeyChecker = TypeChecker.fromRuntime(JsonKey);
17+
const _jsonSerializableChecker = TypeChecker.fromRuntime(JsonSerializable);
1618

1719
DartObject? _jsonKeyAnnotation(FieldElement element) =>
1820
_jsonKeyChecker.firstAnnotationOf(element) ??
@@ -167,20 +169,63 @@ ConstructorElement constructorByName(ClassElement classElement, String name) {
167169
return ctor;
168170
}
169171

172+
/// Given a [ClassElement] that is a sealed class, returns all the
173+
/// direct subclasses of the given sealed class, excluding any
174+
/// indirect subclasses (ie. subclasses of subclasses).
175+
///
176+
/// Otherwise, returns an empty iterable.
170177
Iterable<ClassElement> sealedClassImplementations(
171-
ClassElement maybeSealedClass,
178+
ClassElement maybeSealedSuperClass,
172179
) {
173-
if (maybeSealedClass case final sc when sc.isSealed) {
174-
return LibraryReader(sc.library)
175-
.annotatedWith(const TypeChecker.fromRuntime(JsonSerializable))
176-
.map((e) => e.element)
177-
.whereType<ClassElement>()
178-
.where((e) => e.allSupertypes.contains(sc.thisType));
180+
if (maybeSealedSuperClass case final sc when sc.isSealed) {
181+
return LibraryReader(
182+
sc.library,
183+
).allElements.whereType<ClassElement>().where(
184+
(e) => e.interfaces.contains(sc.thisType) || e.supertype?.element == sc,
185+
);
179186
}
180187

181188
return const Iterable<ClassElement>.empty();
182189
}
183190

191+
/// Given a [ClassElement] that is a subclass of sealed classes, returns
192+
/// all of the sealed superclasses, including all indirect superclasses
193+
/// (ie. superclasses of superclasses)
194+
///
195+
/// Otherwise, returns an empty iterable.
196+
Iterable<ClassElement> sealedSuperClasses(
197+
ClassElement maybeSealedImplementation,
198+
) => maybeSealedImplementation.allSupertypes
199+
.map((type) => type.element)
200+
.whereType<ClassElement>()
201+
.where((element) => element.isSealed);
202+
203+
/// Given a [ClassElement] that is annotated with `@JsonSerializable`, returns
204+
/// the annotation config merged with build runner config and defaults.
205+
///
206+
/// Otherwise, returns `null`.
207+
ClassConfig? jsonSerializableConfig(
208+
ClassElement maybeAnnotatedElement,
209+
Settings generator,
210+
) {
211+
final maybeSuperAnnotation = _jsonSerializableChecker.firstAnnotationOfExact(
212+
maybeAnnotatedElement,
213+
throwOnUnresolved: false,
214+
);
215+
216+
if (maybeSuperAnnotation case final superAnnotation?) {
217+
final annotationReader = ConstantReader(superAnnotation);
218+
219+
return mergeConfig(
220+
generator.config,
221+
annotationReader,
222+
classElement: maybeAnnotatedElement,
223+
);
224+
}
225+
226+
return null;
227+
}
228+
184229
/// If [targetType] is an enum, returns the [FieldElement] instances associated
185230
/// with its values.
186231
///

0 commit comments

Comments
 (0)