From d31b48981860bc5dd54c17c70fac4077887802d9 Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 22:29:56 +0300 Subject: [PATCH 1/7] Add declarative JSON model codec and generated output directory --- packages/dogs_core/CHANGELOG.md | 6 + packages/dogs_core/README.md | 43 +- packages/dogs_core/lib/dogs_core.dart | 1 + packages/dogs_core/lib/dogs_models.dart | 4 + packages/dogs_core/lib/src/hooks.dart | 7 +- packages/dogs_core/lib/src/model_codec.dart | 271 ++++++++ packages/dogs_core/test/model_codec_test.dart | 597 ++++++++++++++++++ packages/dogs_generator/CHANGELOG.md | 4 + packages/dogs_generator/README.md | 22 +- .../lib/builders/converter_builder.dart | 15 +- .../builders/converter_source_builder.dart | 49 ++ .../lib/builders/reactor_builder.dart | 14 +- packages/dogs_generator/lib/integrations.dart | 8 +- .../dogs_generator/lib/output_layout.dart | 55 ++ .../test/output_layout_test.dart | 78 +++ 15 files changed, 1164 insertions(+), 10 deletions(-) create mode 100644 packages/dogs_core/lib/dogs_models.dart create mode 100644 packages/dogs_core/lib/src/model_codec.dart create mode 100644 packages/dogs_core/test/model_codec_test.dart create mode 100644 packages/dogs_generator/lib/builders/converter_source_builder.dart create mode 100644 packages/dogs_generator/lib/output_layout.dart create mode 100644 packages/dogs_generator/test/output_layout_test.dart diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index 17d64ecd..7cdd4239 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +- Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. +- Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, and `JsonReadWith` metadata. +- Allow `DefaultValue(onNull: true)` to supply a default for explicit null input; the existing missing-only behavior remains the default. + ## 10.2.0 - **FEAT**: Harder version bump. diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index 3d7eb236..4e9c1351 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -100,6 +100,47 @@ class Person with Dataclass { [Package on pub.dev](https://pub.dev/packages/dogs_core) [Documentation](https://dogs.helight.dev) +## Declarative JSON model mapping + +`DogModelCodec` is an opt-in reader for APIs that need strict Dart casts and +stable request keys. It uses the existing generated structures and does not +change native `DogEngine.fromNative` behavior. Import `dogs_models.dart` to use +its annotations: + +```dart +import 'package:dogs_core/dogs_models.dart'; +import 'dogs.g.dart'; + +@JsonModel(outputKeys: ['id', 'name']) +class City { + @JsonAliases(['legacy_id', 'id']) + final int? id; + + final String? name; + + City({this.id, this.name}); +} + +final models = DogModelCodec( + engine: configureDogs(plugins: [GeneratedModelsPlugin()]), +); +final city = models.fromJson({'legacy_id': 1, 'name': 'Erbil'}); +final request = models.toJson(city); // {'id': 1, 'name': 'Erbil'} +``` + +`@JsonModel` also marks the class for DOGs generation. The codec reads +registered nested models and lists recursively. +`@DefaultValue([], onNull: true)` supplies a fresh typed list for missing or +null input. `@JsonReadValue(value)` fixes a transient constructor field; +`@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; +`@JsonListConvert(JsonConversions.integer)` converts list elements; and +`@JsonReadWith(myReader)` handles a rule needing the full input map. Callback +arguments must be constant function tear-offs for generated metadata. + +`toJson` returns raw constructor values, including nulls and nested model +instances. Use `engine.toNative` for a recursively serialized object graph. +Applications can subclass `DogModelGuard` to customize nested parsing errors. + ## Flutter pub Use our flutter integration to easily create forms for any serializable object, without having @@ -121,4 +162,4 @@ you’ll interact with generated code directly is when using builders, which are the central dogs.g.dart. This design keeps Dogs non-intrusive and future-proof. While part files may be added later for flexibility, -you will never be required to reference generated code from within your model definition files. \ No newline at end of file +you will never be required to reference generated code from within your model definition files. diff --git a/packages/dogs_core/lib/dogs_core.dart b/packages/dogs_core/lib/dogs_core.dart index 8c659216..1dfd8532 100644 --- a/packages/dogs_core/lib/dogs_core.dart +++ b/packages/dogs_core/lib/dogs_core.dart @@ -39,6 +39,7 @@ export "src/graph_value.dart"; export "src/hooks.dart"; export "src/json.dart"; export "src/metadata.dart"; +export "src/model_codec.dart"; export "src/opmodes/factory.dart"; export "src/opmodes/operation.dart"; export "src/projections.dart"; diff --git a/packages/dogs_core/lib/dogs_models.dart b/packages/dogs_core/lib/dogs_models.dart new file mode 100644 index 00000000..f50bfd26 --- /dev/null +++ b/packages/dogs_core/lib/dogs_models.dart @@ -0,0 +1,4 @@ +/// Declarative, strict JSON model mapping using generated DOGs structures. +library; + +export "dogs_core.dart" hide DogLinked, compareTypeHashcodes, isPolymorphicField; diff --git a/packages/dogs_core/lib/src/hooks.dart b/packages/dogs_core/lib/src/hooks.dart index bab16489..f91343e9 100644 --- a/packages/dogs_core/lib/src/hooks.dart +++ b/packages/dogs_core/lib/src/hooks.dart @@ -136,13 +136,16 @@ class DefaultValue extends StructureMetadata with FieldSerializationHook { /// If the default value should be kept even if the field is present in the serialized map. final bool keep; + /// Whether an explicitly null input also receives the default value. + final bool onNull; + /// Creates a new [DefaultValue] with the given [value]. /// The [value] may be any constant dart value or a `dynamic Function()` which will /// be evaluated as the supplier of the default value. /// /// If [keep] is set to `true`, the default value will be kept in the serialized map, /// otherwise it will be removed if the field is equal to the default value. - const DefaultValue(this.value, {this.keep = false}); + const DefaultValue(this.value, {this.keep = false, this.onNull = false}); dynamic _getNativeDefault(NativeStructureFieldContext fieldContext, DogEngine engine) { final provided = value is DefaultValueSupplier ? value() : value; @@ -153,7 +156,7 @@ class DefaultValue extends StructureMetadata with FieldSerializationHook { @override void beforeFieldDeserialization(NativeStructureContext context, NativeStructureFieldContext fieldContext, Map map, DogEngine engine) { - if (!map.containsKey(fieldContext.key)) { + if (!map.containsKey(fieldContext.key) || (onNull && map[fieldContext.key] == null)) { map[fieldContext.key] = _getNativeDefault(fieldContext, engine); } } diff --git a/packages/dogs_core/lib/src/model_codec.dart b/packages/dogs_core/lib/src/model_codec.dart new file mode 100644 index 00000000..8b591d29 --- /dev/null +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -0,0 +1,271 @@ +import "package:dogs_core/dogs_core.dart"; + +/// Declares JSON model rules without changing native DOGs coercion defaults. +class JsonModel extends Structure implements SerializationHook { + /// Fields allowed in [DogModelCodec.toJson], or all fields when null. + final List? outputKeys; + + /// Converts non-null input values to strings before construction. + final bool stringify; + + /// Optional custom factory used when this model is decoded as a child. + final Object Function(Map)? reader; + + /// Declares a serializable model with optional read and write rules. + const JsonModel({this.outputKeys, this.stringify = false, this.reader}) + : super(serializable: true); + + @override + void beforeDeserialization(Map map, DogStructure structure, DogEngine engine) { + final source = Map.from(map); + for (final field in structure.fields) { + final aliases = field.firstAnnotationOf(); + if (aliases != null) map[field.name] = aliases.readValue(source); + } + if (stringify) map.updateAll((key, value) => value?.toString()); + } + + @override + void postSerialization( + dynamic obj, Map map, DogStructure structure, DogEngine engine) { + final keys = outputKeys; + if (keys != null) map.removeWhere((key, value) => !keys.contains(key)); + } +} + +/// Reads the first non-null input key, in declaration order. +class JsonAliases extends StructureMetadata { + /// Accepted input names; the generated field name remains the output name. + final List keys; + + /// Declares alternative input names, including the canonical name if wanted. + const JsonAliases(this.keys); + + /// Returns the first key with a non-null value, or null if none match. + String? readKey(Map json) { + for (final key in keys) { + if (json[key] != null) return key; + } + return null; + } + + /// Reads from the original map so aliases never affect each other. + Object? readValue(Map json) => json[readKey(json)]; +} + +/// Forces a value when decoding, independently of incoming JSON. +class JsonReadValue extends StructureMetadata with FieldSerializationHook { + /// The value supplied to the constructor. + final Object? value; + + /// Keeps transient constructor state independent of backend fields. + const JsonReadValue(this.value); + + @override + void beforeFieldDeserialization(NativeStructureContext context, + NativeStructureFieldContext fieldContext, Map map, DogEngine engine) { + map[fieldContext.key] = value; + } +} + +/// Converts a raw JSON field value before constructor type checking. +class JsonConvert extends StructureMetadata { + /// A constant top-level or static function; also receives null inputs. + final Object? Function(Object?) convert; + + /// Declares a reusable field conversion instead of a per-model map override. + const JsonConvert(this.convert); +} + +/// Converts each element of a JSON list with typed, indexed error handling. +class JsonListConvert extends StructureMetadata { + /// A constant top-level or static function applied to each list element. + final Object? Function(Object?) convert; + + /// Declares an element conversion; combine with [DefaultValue] for defaults. + const JsonListConvert(this.convert); +} + +/// Reads a field using the full original input map for cross-field rules. +class JsonReadWith extends StructureMetadata { + /// A constant top-level or static function receiving the unmodified input. + final Object? Function(Map) read; + + /// Declares a custom rule that depends on more than one backend field. + const JsonReadWith(this.read); +} + +/// Common explicit conversions that are not inferred from Dart field types. +class JsonConversions { + /// Converts a non-null value to its string representation. + static String? stringify(Object? value) => value?.toString(); + + /// Converts booleans to 0/1, retains integers, and defaults other values to 0. + static int flagInt(Object? value) => value is bool + ? (value ? 1 : 0) + : value is int + ? value + : 0; + + /// Truncates a numeric value toward zero; rejects non-numeric values. + static int integer(Object? value) => (value as num).toInt(); +} + +/// Controls nested-object/list diagnostics without adding a codec dependency. +class DogModelGuard { + /// Uses strict JSON container types and reports malformed fields as formats. + const DogModelGuard(); + + /// Parses an optional object, retaining its field name in errors. + T? object(Map json, String key, T Function(Map) parse) { + final value = json[key]; + if (value == null) return null; + try { + return parse(value as Map); + } catch (error) { + throw FormatException('Invalid "$key": expected $T. $error', value); + } + } + + /// Parses an optional list, retaining its field name and element index. + List? list(Map json, String key, T Function(dynamic) parse) { + final value = json[key]; + if (value == null) return null; + if (value is! List) { + throw FormatException('Invalid "$key": expected List<$T>.', value); + } + return [ + for (var index = 0; index < value.length; index++) + element(value[index], "$key[$index]", parse), + ]; + } + + /// Parses one list element, reporting its complete indexed field name. + T element(dynamic value, String key, T Function(dynamic) parse) { + try { + return parse(value); + } catch (error) { + throw FormatException('Invalid "$key": expected $T. $error', value); + } + } +} + +/// Maps backend JSON using generated fields while retaining strict Dart casts. +/// +/// This opt-in codec does not alter [DogEngine.fromNative]. Scalar values are +/// passed to the generated constructor unchanged unless explicitly converted. +/// JSON hooks outside the annotations documented here use native DOGs modes. +class DogModelCodec { + /// The engine containing generated model structures. + final DogEngine engine; + + /// Nested object/list diagnostics, which applications may customize. + final DogModelGuard guard; + + /// Creates a reusable codec; no global state or engine mutation is required. + const DogModelCodec({required this.engine, this.guard = const DogModelGuard()}); + + /// Finds the generated structure, or reports a missing model registration. + DogStructure structure(Type type) { + final result = engine.findStructureByType(type); + if (result == null) throw StateError("Missing generated model: $type"); + return result; + } + + /// Reads JSON without mutating input or implicitly coercing scalar numbers. + /// + /// Explicit [values] override input annotations, including explicit nulls. + /// A model's custom reader is invoked only for children, avoiding recursion + /// when a custom factory itself delegates to this method. + T fromJson(Map json, + {Map values = const {}, bool stringify = false}) => + readStructure(structure(T), json, values: values, stringify: stringify) as T; + + /// Reads one generated structure, recursively decoding registered children. + dynamic readStructure(DogStructure descriptor, Map json, + {Map values = const {}, bool stringify = false}) { + final strings = stringify || (descriptor.firstAnnotationOf()?.stringify ?? false); + final fields = []; + for (final field in descriptor.fields) { + if (values.containsKey(field.name)) { + fields.add(values[field.name]); + continue; + } + final fixed = field.firstAnnotationOf(); + if (fixed != null) { + fields.add(fixed.value); + continue; + } + final readWith = field.firstAnnotationOf(); + if (readWith != null) { + fields.add(readWith.read(json)); + continue; + } + final aliases = field.firstAnnotationOf(); + final value = aliases == null ? json[field.name] : aliases.readValue(json); + final key = aliases?.keys.length == 1 ? aliases!.keys.single : field.name; + final input = {key: value}; + final defaultValue = field.firstAnnotationOf(); + final missing = + aliases == null ? !json.containsKey(field.name) : !aliases.keys.any(json.containsKey); + final useDefault = + defaultValue != null && (missing || (defaultValue.onNull && value == null)); + final converter = field.firstAnnotationOf(); + if (converter != null) { + final provided = useDefault ? defaultValue.value : value; + final supplied = useDefault && provided is DefaultValueSupplier ? provided() : provided; + fields.add(converter.convert(supplied)); + continue; + } + final nested = engine.findStructureByType(field.serial.typeArgument); + final listConverter = field.firstAnnotationOf(); + if (field.iterableKind == IterableKind.list && + (nested != null || listConverter != null || defaultValue != null)) { + fields.add(field.serial.consumeType(() { + final result = guard.list(input, key, (item) { + if (listConverter != null) return listConverter.convert(item) as T; + if (nested != null) { + return readChild(nested, item as Map) as T; + } + return item as T; + }); + if (result != null || !useDefault) return result; + final provided = defaultValue.value; + final supplied = provided is DefaultValueSupplier ? provided() : provided; + return List.from(supplied as Iterable); + })); + continue; + } + if (nested != null) { + fields.add(field.serial.consumeType( + () => guard.object(input, key, (data) => readChild(nested, data) as T))); + continue; + } + final provided = useDefault ? defaultValue.value : value; + final supplied = useDefault && provided is DefaultValueSupplier ? provided() : provided; + fields.add(strings ? supplied?.toString() : supplied); + } + return descriptor.proxy.instantiate(fields); + } + + /// Uses an explicitly declared child factory or the generated structure. + dynamic readChild(DogStructure descriptor, Map json) { + final reader = descriptor.firstAnnotationOf()?.reader; + return reader == null ? readStructure(descriptor, json) : reader(json); + } + + /// Returns raw constructor fields, filtered by [JsonModel.outputKeys]. + /// + /// Nulls and nested instances are retained; use [DogEngine.toNative] when a + /// recursively encoded, JSON-ready object graph is required. + Map toJson(T model) { + final descriptor = structure(T); + final values = descriptor.proxy.getFieldValues(model); + final keys = descriptor.firstAnnotationOf()?.outputKeys; + return { + for (var index = 0; index < descriptor.fields.length; index++) + if (keys == null || keys.contains(descriptor.fields[index].name)) + descriptor.fields[index].name: values[index], + }; + } +} diff --git a/packages/dogs_core/test/model_codec_test.dart b/packages/dogs_core/test/model_codec_test.dart new file mode 100644 index 00000000..f1d4ad81 --- /dev/null +++ b/packages/dogs_core/test/model_codec_test.dart @@ -0,0 +1,597 @@ +import "package:dogs_core/dogs_models.dart"; +import "package:test/test.dart"; + +class AliasValue { + const AliasValue({this.id, this.name, this.echo, this.flag, this.ignored = false}); + + final int? id; + final String? name; + final String? echo; + final bool? flag; + final bool ignored; +} + +class TreeValue { + const TreeValue({this.child, this.children, this.nested}); + + final AliasValue? child; + final List? children; + final TreeValue? nested; +} + +class ConvertedValue { + const ConvertedValue({this.text, required this.flag, this.whole, this.numbers, this.summary}); + + final String? text; + final int flag; + final int? whole; + final List? numbers; + final String? summary; +} + +class NumberValue { + const NumberValue(this.value); + + final int? value; +} + +class TextValue { + const TextValue(this.value); + + final String? value; +} + +class NumberEnvelope { + const NumberEnvelope(this.number); + + final NumberValue? number; +} + +class RecordingGuard extends DogModelGuard { + final objectKeys = []; + final listKeys = []; + + @override + T? object(Map json, String key, T Function(Map) parse) { + objectKeys.add(key); + return super.object(json, key, parse); + } + + @override + List? list(Map json, String key, T Function(dynamic) parse) { + listKeys.add(key); + return super.list(json, key, parse); + } +} + +late DogModelCodec readerCodec; +int childReaderCalls = 0; + +Object readNumberChild(Map json) { + childReaderCalls++; + return readerCodec.fromJson(json, values: {"value": (json["value"] as int) + 10}); +} + +Object? readSummary(Map json) => + "${json.containsKey("text") ? json["text"] : "missing"}:${json["suffix"] ?? ""}"; + +num defaultNumber() => 7.9; + +void registerModel(DogEngine engine, DogStructure structure) { + engine.registerStructure(structure, emitChangeToStream: false); + engine.registerAssociatedConverter(DogStructureConverterImpl(structure), + emitChangeToStream: false); +} + +DogStructure aliasStructure() => DogStructure( + "AliasValue", + StructureConformity.basic, + [ + DogStructureField.int("id", optional: true, annotations: const [ + JsonAliases(["legacy_id", "id"]) + ]), + DogStructureField.string("name", optional: true, annotations: const [ + JsonAliases(["legacy_name", "name"]) + ]), + DogStructureField.string("echo", optional: true, annotations: const [ + JsonAliases(["name"]) + ]), + DogStructureField.bool("flag", optional: true, annotations: const [ + JsonAliases(["legacy_flag", "flag"]) + ]), + DogStructureField.bool("ignored", annotations: const [JsonReadValue(false)]), + ], + const [ + JsonModel(outputKeys: ["id", "name"]) + ], + ObjectFactoryStructureProxy( + (values) => AliasValue( + id: values[0], name: values[1], echo: values[2], flag: values[3], ignored: values[4]), + [ + (value) => value.id, + (value) => value.name, + (value) => value.echo, + (value) => value.flag, + (value) => value.ignored + ], + (value) => [value.id, value.name, value.echo, value.flag, value.ignored], + ), + ); + +DogStructure treeStructure() => DogStructure( + "TreeValue", + StructureConformity.basic, + [ + DogStructureField.create("child", optional: true, annotations: const [ + JsonAliases(["payload"]) + ]), + DogStructureField.create( + "children", + optional: true, + iterable: IterableKind.list, + annotations: const [ + JsonAliases(["children", "old_children"]), + DefaultValue([], onNull: true) + ], + ), + DogStructureField.create("nested", optional: true), + ], + const [JsonModel()], + ObjectFactoryStructureProxy( + (values) => TreeValue(child: values[0], children: values[1], nested: values[2]), + [(value) => value.child, (value) => value.children, (value) => value.nested], + (value) => [value.child, value.children, value.nested], + ), + ); + +DogStructure convertedStructure() => DogStructure( + "ConvertedValue", + StructureConformity.basic, + [ + DogStructureField.string("text", optional: true, annotations: const [ + JsonAliases(["legacy_text", "text"]), + JsonConvert(JsonConversions.stringify) + ]), + DogStructureField.int("flag", annotations: const [JsonConvert(JsonConversions.flagInt)]), + DogStructureField.int("whole", optional: true, annotations: const [ + DefaultValue(0, onNull: true), + JsonConvert(JsonConversions.integer) + ]), + DogStructureField.int("numbers", + optional: true, + iterable: IterableKind.list, + annotations: const [ + DefaultValue([], onNull: true), + JsonListConvert(JsonConversions.integer) + ]), + DogStructureField.string("summary", + optional: true, annotations: const [JsonReadWith(readSummary)]), + ], + const [JsonModel()], + ObjectFactoryStructureProxy( + (values) => ConvertedValue( + text: values[0], + flag: values[1], + whole: values[2], + numbers: values[3], + summary: values[4]), + [ + (value) => value.text, + (value) => value.flag, + (value) => value.whole, + (value) => value.numbers, + (value) => value.summary + ], + (value) => [value.text, value.flag, value.whole, value.numbers, value.summary], + ), + ); + +DogStructure numberStructure( + {List annotations = const [], JsonModel metadata = const JsonModel()}) => + DogStructure( + "NumberValue", + StructureConformity.basic, + [DogStructureField.int("value", optional: true, annotations: annotations)], + [metadata], + ObjectFactoryStructureProxy( + (values) => NumberValue(values[0]), [(value) => value.value], (value) => [value.value]), + ); + +DogStructure textStructure({JsonModel metadata = const JsonModel()}) => + DogStructure( + "TextValue", + StructureConformity.basic, + [DogStructureField.string("value", optional: true)], + [metadata], + ObjectFactoryStructureProxy( + (values) => TextValue(values[0]), [(value) => value.value], (value) => [value.value]), + ); + +DogStructure envelopeStructure() => DogStructure( + "NumberEnvelope", + StructureConformity.basic, + [DogStructureField.create("number", optional: true)], + const [JsonModel()], + ObjectFactoryStructureProxy((values) => NumberEnvelope(values[0]), + [(value) => value.number], (value) => [value.number]), + ); + +({DogEngine engine, DogModelCodec codec}) codecFixture( + {DogModelGuard guard = const DogModelGuard()}) { + final engine = DogEngine(); + registerModel(engine, aliasStructure()); + registerModel(engine, treeStructure()); + registerModel(engine, convertedStructure()); + registerModel(engine, numberStructure()); + registerModel(engine, textStructure()); + registerModel(engine, envelopeStructure()); + return (engine: engine, codec: DogModelCodec(engine: engine, guard: guard)); +} + +void main() { + group("strict model fields", () { + test("missing and explicitly null nullable fields remain null", () { + final (:codec, :engine) = codecFixture(); + for (final input in >[ + {}, + {"id": null, "name": null, "flag": null} + ]) { + final value = codec.fromJson(input); + expect(value.id, isNull); + expect(value.name, isNull); + expect(value.echo, isNull); + expect(value.flag, isNull); + expect(value.ignored, isFalse); + } + expect(engine.findStructureByType(AliasValue), isNotNull); + }); + + test("aliases retain zero, false and empty strings before later values", () { + final (:codec, :engine) = codecFixture(); + final value = codec.fromJson({ + "legacy_id": 0, + "id": 8, + "legacy_name": "", + "name": "later", + "legacy_flag": false, + "flag": true + }); + expect(value.id, 0); + expect(value.name, ""); + expect(value.flag, isFalse); + expect(value.echo, "later"); + expect(codec.engine, same(engine)); + }); + + test("aliases fall back only on null and do not normalize other source fields", () { + final (:codec, :engine) = codecFixture(); + final input = Map.unmodifiable({ + "legacy_id": null, + "id": 3, + "legacy_name": "only legacy", + "legacy_flag": null, + "flag": true, + "ignored": true + }); + final value = codec.fromJson(input); + expect(value.id, 3); + expect(value.name, "only legacy"); + expect(value.echo, isNull); + expect(value.flag, isTrue); + expect(value.ignored, isFalse); + expect(input.containsKey("name"), isFalse); + expect(input["ignored"], isTrue); + expect(codec.engine, same(engine)); + }); + + test("explicit values including null override aliases and fixed metadata", () { + final (:codec, :engine) = codecFixture(); + final value = codec.fromJson( + {"legacy_id": "bad", "legacy_name": "name", "ignored": false}, + values: {"id": null, "name": "override", "ignored": true}); + expect(value.id, isNull); + expect(value.name, "override"); + expect(value.ignored, isTrue); + expect(codec.engine, same(engine)); + }); + + test("scalar numbers and malformed preferred aliases are never coerced", () { + final (:codec, :engine) = codecFixture(); + for (final input in >[ + {"id": "7"}, + {"id": 7.0}, + {"legacy_id": "bad", "id": 7}, + {"legacy_flag": 0, "flag": false} + ]) { + expect(() => codec.fromJson(input), throwsA(isA())); + } + expect(codec.engine, same(engine)); + }); + + test("class and call-level stringification retain null without mutating input", () { + final (:codec, :engine) = codecFixture(); + final input = Map.unmodifiable({"value": false}); + expect(codec.fromJson(input, stringify: true).value, "false"); + registerModel(engine, textStructure(metadata: const JsonModel(stringify: true))); + expect(codec.fromJson({"value": 0}).value, "0"); + expect(codec.fromJson({"value": null}).value, isNull); + expect(input["value"], isFalse); + }); + + test("output filters preserve canonical names and explicit null fields", () { + final (:codec, :engine) = codecFixture(); + expect( + codec.toJson( + const AliasValue(id: 4, name: null, echo: "hidden", flag: true, ignored: true)), + {"id": 4, "name": null}); + expect(codec.engine, same(engine)); + }); + }); + + group("nested model graph", () { + test("objects and lists recursively preserve typed children and immutable input", () { + final (:codec, :engine) = codecFixture(); + final child = Map.unmodifiable({"legacy_id": 1}); + final children = List.unmodifiable([ + Map.unmodifiable({"id": 2}) + ]); + final input = Map.unmodifiable({ + "payload": child, + "children": children, + "nested": Map.unmodifiable({ + "payload": Map.unmodifiable({"id": 3}) + }) + }); + final value = codec.fromJson(input); + expect(value.child?.id, 1); + expect(value.children, isA>()); + expect(value.children?.single.id, 2); + expect(value.nested?.child?.id, 3); + expect(input["payload"], same(child)); + expect(input["children"], same(children)); + expect(input.containsKey("child"), isFalse); + expect(child.containsKey("id"), isFalse); + expect(codec.engine, same(engine)); + }); + + test("missing and null model lists receive independent mutable typed defaults", () { + final (:codec, :engine) = codecFixture(); + final missing = codec.fromJson({}); + final nullable = codec.fromJson({"children": null}); + expect(missing.children, isA>()); + expect(nullable.children, isA>()); + expect(missing.children, isNot(same(nullable.children))); + missing.children!.add(const AliasValue(id: 1)); + expect(nullable.children, isEmpty); + expect(const TreeValue().children, isNull); + expect(codec.engine, same(engine)); + }); + + test("list aliases choose an empty first list before a populated fallback", () { + final (:codec, :engine) = codecFixture(); + final preferred = codec.fromJson({ + "children": [], + "old_children": [ + {"id": 2} + ] + }); + final fallback = codec.fromJson({ + "children": null, + "old_children": [ + {"id": 3} + ] + }); + expect(preferred.children, isEmpty); + expect(fallback.children?.single.id, 3); + expect(codec.engine, same(engine)); + }); + + test("objects keep strict generic map checks and backend diagnostic keys", () { + final (:codec, :engine) = codecFixture(); + for (final value in ["bad", {}, const AliasValue()]) { + expect( + () => codec.fromJson({"payload": value}), + throwsA(isA() + .having((error) => error.message, "backend key", contains("payload")) + .having((error) => error.message, "expected child", contains("AliasValue")))); + } + expect(codec.engine, same(engine)); + }); + + test("malformed list containers and elements retain indexed canonical errors", () { + final (:codec, :engine) = codecFixture(); + expect(() => codec.fromJson({"children": "bad", "old_children": []}), + throwsFormatException); + for (final item in [ + null, + "bad", + {}, + {"id": "bad"} + ]) { + expect( + () => codec.fromJson({ + "old_children": [item] + }), + throwsA(isA() + .having((error) => error.message, "indexed field", contains("children[0]")))); + } + expect(codec.engine, same(engine)); + }); + + test("explicit nested values bypass parsing even when null", () { + final (:codec, :engine) = codecFixture(); + final value = codec.fromJson({"payload": "bad", "children": "bad"}, + values: {"child": null, "children": []}); + expect(value.child, isNull); + expect(value.children, isEmpty); + expect(codec.engine, same(engine)); + }); + + test("a custom guard receives typed object and list dispatch", () { + final guard = RecordingGuard(); + final (:codec, :engine) = codecFixture(guard: guard); + final value = codec.fromJson({ + "payload": {"id": 1} + }); + expect(value.child, isA()); + expect(guard.objectKeys, contains("payload")); + expect(guard.listKeys, contains("children")); + expect(codec.engine, same(engine)); + }); + + test("a child reader can delegate back to the codec without recursion", () { + final (:codec, :engine) = codecFixture(); + registerModel(engine, numberStructure(metadata: const JsonModel(reader: readNumberChild))); + readerCodec = codec; + childReaderCalls = 0; + expect(codec.fromJson({"value": 3}).value, 3); + expect(childReaderCalls, 0); + expect( + codec + .fromJson({ + "number": {"value": 3} + }) + .number + ?.value, + 13); + expect(childReaderCalls, 1); + expect( + codec + .fromJson({"number": "bad"}, + values: {"number": const NumberValue(42)}) + .number + ?.value, + 42); + expect(childReaderCalls, 1); + }); + + test("raw output retains nested object identity rather than recursively serializing", () { + final (:codec, :engine) = codecFixture(); + const child = AliasValue(id: 4); + final children = [child]; + final json = codec.toJson(TreeValue(child: child, children: children)); + expect(json["child"], same(child)); + expect(json["children"], same(children)); + expect(json.containsKey("nested"), isTrue); + expect(json["nested"], isNull); + expect(codec.engine, same(engine)); + }); + }); + + group("declarative conversions and defaults", () { + test("field conversions run after alias selection and retain integer flag values", () { + final (:codec, :engine) = codecFixture(); + final value = codec.fromJson( + {"legacy_text": false, "text": "later", "flag": true, "whole": -3.8}); + expect(value.text, "false"); + expect(value.flag, 1); + expect(value.whole, -3); + for (final entry in {null: 0, false: 0, 5: 5, "bad": 0}.entries) { + expect(codec.fromJson({"flag": entry.key}).flag, entry.value); + } + expect(codec.engine, same(engine)); + }); + + test("list conversions create typed results and fresh defaults", () { + final (:codec, :engine) = codecFixture(); + expect( + codec.fromJson({ + "numbers": [2.9, -3.8, 4] + }).numbers, + [2, -3, 4]); + final first = codec.fromJson({}); + final second = codec.fromJson({"numbers": null}); + expect(first.numbers, isA>()); + first.numbers!.add(7); + expect(second.numbers, isEmpty); + expect(codec.engine, same(engine)); + }); + + test("list conversion failures report the element index", () { + final (:codec, :engine) = codecFixture(); + expect( + () => codec.fromJson({ + "numbers": [1, "bad"] + }), + throwsA(isA() + .having((error) => error.message, "indexed conversion", contains("numbers[1]")))); + expect(() => codec.fromJson({"numbers": "bad"}), throwsFormatException); + expect(codec.engine, same(engine)); + }); + + test("whole-map reads see original immutable keys and explicit overrides win", () { + final (:codec, :engine) = codecFixture(); + final input = Map.unmodifiable({"legacy_text": 7, "suffix": "!"}); + final value = codec.fromJson(input); + expect(value.text, "7"); + expect(value.summary, "missing:!"); + expect(input.containsKey("text"), isFalse); + expect( + codec.fromJson(input, values: {"summary": null, "whole": null}).summary, + isNull); + expect(codec.fromJson(input, values: {"whole": null}).whole, isNull); + expect(codec.engine, same(engine)); + }); + + test("default suppliers are resolved before a field conversion", () { + final (:codec, :engine) = codecFixture(); + registerModel( + engine, + numberStructure(annotations: const [ + DefaultValue(defaultNumber, onNull: true), + JsonConvert(JsonConversions.integer) + ])); + expect(codec.fromJson({}).value, 7); + expect(codec.fromJson({"value": null}).value, 7); + expect(codec.fromJson({"value": 2.9}).value, 2); + }); + + test("missing-only defaults retain explicit null while onNull opts in", () { + final (:codec, :engine) = codecFixture(); + registerModel(engine, numberStructure(annotations: const [DefaultValue(7)])); + expect(codec.fromJson({}).value, 7); + expect(codec.fromJson({"value": null}).value, isNull); + registerModel(engine, numberStructure(annotations: const [DefaultValue(9, onNull: true)])); + expect(codec.fromJson({}).value, 9); + expect(codec.fromJson({"value": null}).value, 9); + expect(codec.fromJson({"value": 0}).value, 0); + }); + }); + + group("engine and native compatibility", () { + test("forked codecs use their own structure metadata without altering the parent", () { + final (:codec, :engine) = codecFixture(); + registerModel(engine, numberStructure(annotations: const [DefaultValue(1)])); + final forked = engine.fork(listen: false); + registerModel(forked, numberStructure(annotations: const [DefaultValue(2)])); + final other = DogModelCodec(engine: forked); + expect(codec.fromJson({}).value, 1); + expect(other.fromJson({}).value, 2); + expect(codec.fromJson({}).value, 1); + expect(other.engine, same(forked)); + }); + + test("native DefaultValue preserves explicit-null behavior unless opted in", () { + final (:codec, :engine) = codecFixture(); + registerModel(engine, numberStructure(annotations: const [DefaultValue(7)])); + NumberValue read(Map json) => + engine.convertObjectFromNative(json, NumberValue) as NumberValue; + expect(read({}).value, 7); + expect(read({"value": null}).value, isNull); + registerModel(engine, numberStructure(annotations: const [DefaultValue(9, onNull: true)])); + engine.reset(); + expect(read({}).value, 9); + expect(read({"value": null}).value, 9); + expect(read({"value": 0}).value, 0); + expect(codec.fromJson({"value": null}).value, 9); + }); + + test("unregistered model types fail with a useful registration error", () { + final codec = DogModelCodec(engine: DogEngine()); + expect( + () => codec.fromJson({}), + throwsA(isA() + .having((error) => error.message, "missing model", contains("NumberValue")))); + }); + }); +} diff --git a/packages/dogs_generator/CHANGELOG.md b/packages/dogs_generator/CHANGELOG.md index c6f2d282..2d17f4f1 100644 --- a/packages/dogs_generator/CHANGELOG.md +++ b/packages/dogs_generator/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- Add the `output_directory` builder option to place converters and the generated registry under `lib/`, preserving source subdirectories. + ## 7.2.0 - **FEAT**: Harder version bump. diff --git a/packages/dogs_generator/README.md b/packages/dogs_generator/README.md index 323cdc0c..5564f727 100644 --- a/packages/dogs_generator/README.md +++ b/packages/dogs_generator/README.md @@ -1 +1,21 @@ -Code generator for the dogs serialization library. \ No newline at end of file +Code generator for the dogs serialization library. + +To put generated Dart files under `lib/generated`, configure all three builders +with the same option in the consuming package's `build.yaml`: + +```yaml +targets: + $default: + builders: + dogs_generator:dogs_descriptors: + options: {output_directory: generated} + dogs_generator:dogs_bindings: + options: {output_directory: generated} + dogs_generator:dogs_reactor: + options: {output_directory: generated} +``` + +For example, `lib/city/city_model.dart` generates +`lib/generated/city/city_model.conv.g.dart`, and the registry is +`lib/generated/dogs.g.dart`. The default layout is unchanged. Converter source +subdirectories are retained to avoid collisions between equal file names. diff --git a/packages/dogs_generator/lib/builders/converter_builder.dart b/packages/dogs_generator/lib/builders/converter_builder.dart index fd69dde6..f98194e1 100644 --- a/packages/dogs_generator/lib/builders/converter_builder.dart +++ b/packages/dogs_generator/lib/builders/converter_builder.dart @@ -10,12 +10,23 @@ import 'package:dogs_generator/settings.dart'; import 'package:lyell_gen/lyell_gen.dart'; import 'package:source_gen/source_gen.dart'; +import '../output_layout.dart'; +import 'converter_source_builder.dart'; + class ConverterBuilder extends DogsAdapter { - ConverterBuilder() : super(archetype: "conv"); + final DogsOutputLayout outputLayout; + + ConverterBuilder({this.outputLayout = const DogsOutputLayout()}) : super(archetype: "conv") { + if (outputLayout.directory != null) { + subjectBuilder = ConverterSourceBuilder(this, outputLayout); + } + } @override Future generateDescriptor(SubjectGenContext context) async { - var binding = context.defaultDescriptor(); + var binding = + SubjectDescriptor(uri: outputLayout.converterAsset(context.step.inputId).uri.toString()); + binding.meta["outputDirectory"] = outputLayout.directory; binding.meta["constructedNames"] = context.matches.map((e) => "${e.displayName}Converter").toList(); return binding; diff --git a/packages/dogs_generator/lib/builders/converter_source_builder.dart b/packages/dogs_generator/lib/builders/converter_source_builder.dart new file mode 100644 index 00000000..ae7d004d --- /dev/null +++ b/packages/dogs_generator/lib/builders/converter_source_builder.dart @@ -0,0 +1,49 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:build/build.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:dogs_core/dogs_core.dart'; +import 'package:lyell_gen/lyell_gen.dart'; +import 'package:source_gen/source_gen.dart'; + +import '../output_layout.dart'; +import 'converter_builder.dart'; + +/// Writes converters directly to their declared destination using public Lyell APIs. +class ConverterSourceBuilder implements Builder { + final ConverterBuilder adapter; + final DogsOutputLayout layout; + + ConverterSourceBuilder(this.adapter, this.layout); + + @override + Map> get buildExtensions => layout.converterBuildExtensions(); + + @override + Future build(BuildStep step) async { + if (!await step.resolver.isLibrary(step.inputId)) return; + await tryInitialize(step); + final library = LibraryReader(await step.inputLibrary); + final matches = library + .annotatedWith(TypeChecker.typeNamed(Serializable)) + .map((annotation) => annotation.element) + .whereType() + .toList(); + if (matches.isEmpty) return; + final context = SubjectGenContext(adapter, library, matches, step); + if (!await adapter.doesOutput(context)) return; + final code = SubjectCodeContext([], StringBuffer()); + await adapter.generateSubject(context, code); + if (code.noGenerate) return; + final imports = createImports( + library: adapter.copySourceImports ? library.element : null, + id: adapter.importSourceFile ? step.inputId : null, + imports: code.additionalImports, + ); + final content = '${code.header}\n$imports\n${code.codeBuffer}\n'; + await step.writeAsString( + layout.converterAsset(step.inputId), + DartFormatter(pageWidth: 200, languageVersion: DartFormatter.latestLanguageVersion) + .format(content), + ); + } +} diff --git a/packages/dogs_generator/lib/builders/reactor_builder.dart b/packages/dogs_generator/lib/builders/reactor_builder.dart index b68eb86c..99b36fd8 100755 --- a/packages/dogs_generator/lib/builders/reactor_builder.dart +++ b/packages/dogs_generator/lib/builders/reactor_builder.dart @@ -5,9 +5,13 @@ import 'package:lyell_gen/lyell_gen.dart'; import 'package:recase/recase.dart'; import '../settings.dart'; +import '../output_layout.dart'; class DogReactorBuilder extends SubjectReactorBuilder { - DogReactorBuilder() : super('dogs', 'dogs.g.dart'); + final DogsOutputLayout outputLayout; + + DogReactorBuilder({this.outputLayout = const DogsOutputLayout()}) + : super('dogs', outputLayout.reactorFileName()); String packageName = "unnamed"; bool isLibrary = false; @@ -28,6 +32,14 @@ class DogReactorBuilder extends SubjectReactorBuilder { List descriptorNames = []; for (var descriptor in descriptors) { + outputLayout.validateDescriptor(descriptor); + if (descriptor.meta.containsKey('outputDirectory') && + !await step.canRead(AssetId.resolve(Uri.parse(descriptor.uri)))) { + throw StateError( + 'Cannot read converter ${descriptor.uri}. Configure output_directory identically for ' + 'dogs_descriptors, dogs_bindings and dogs_reactor.', + ); + } var constructedNames = ((descriptor.meta["constructedNames"] ?? []) as List).cast(); var variableNames = ((descriptor.meta["variableNames"] ?? []) as List).cast(); for (var name in constructedNames) { diff --git a/packages/dogs_generator/lib/integrations.dart b/packages/dogs_generator/lib/integrations.dart index 1d664445..74019d49 100644 --- a/packages/dogs_generator/lib/integrations.dart +++ b/packages/dogs_generator/lib/integrations.dart @@ -3,12 +3,14 @@ import 'package:dogs_generator/adapters/dog_adapter.dart'; import 'package:dogs_generator/builders/converter_builder.dart'; import 'package:dogs_generator/builders/link_builder.dart'; import 'package:dogs_generator/builders/reactor_builder.dart'; +import 'package:dogs_generator/output_layout.dart'; Builder dogsLinking(BuilderOptions options) => LinkBuilder().descriptorBuilder; Builder dogsBindings(BuilderOptions options) => CombinedBuilder([ - ConverterBuilder().descriptorBuilder, + ConverterBuilder(outputLayout: DogsOutputLayout.fromOptions(options)).descriptorBuilder, ]); Builder dogsConverters(BuilderOptions options) => CombinedBuilder([ - ConverterBuilder().subjectBuilder, + ConverterBuilder(outputLayout: DogsOutputLayout.fromOptions(options)).subjectBuilder, ]); -Builder dogsReactor(BuilderOptions options) => DogReactorBuilder(); +Builder dogsReactor(BuilderOptions options) => + DogReactorBuilder(outputLayout: DogsOutputLayout.fromOptions(options)); diff --git a/packages/dogs_generator/lib/output_layout.dart b/packages/dogs_generator/lib/output_layout.dart new file mode 100644 index 00000000..95256442 --- /dev/null +++ b/packages/dogs_generator/lib/output_layout.dart @@ -0,0 +1,55 @@ +import 'package:build/build.dart'; +import 'package:lyell_gen/lyell_gen.dart'; +import 'package:path/path.dart' as path; + +/// Determines generated Dart paths before build_runner creates its asset graph. +class DogsOutputLayout { + final String? directory; + + const DogsOutputLayout({this.directory}); + + factory DogsOutputLayout.fromOptions(BuilderOptions options) { + final value = options.config['output_directory']; + if (value == null) return const DogsOutputLayout(); + if (value is! String || + value.isEmpty || + value.contains('\\') || + value.contains(':') || + path.posix.isAbsolute(value) || + value.split('/').any((segment) => segment.isEmpty || segment == '.' || segment == '..')) { + throw ArgumentError.value( + value, 'output_directory', 'Expected a relative directory below lib'); + } + return DogsOutputLayout(directory: value); + } + + AssetId converterAsset(AssetId input) { + final directory = this.directory; + if (directory == null) return input.changeExtension('.conv.g.dart'); + if (!input.path.startsWith('lib/')) { + throw ArgumentError.value(input.path, 'input', 'Relocated models must be under lib'); + } + return AssetId(input.package, 'lib/$directory/${input.path.substring(4)}') + .changeExtension('.conv.g.dart'); + } + + String reactorFileName() => directory == null ? 'dogs.g.dart' : '$directory/dogs.g.dart'; + + Map> converterBuildExtensions() => { + if (directory == null) + '.dart': ['.conv.g.dart'] + else + '^lib/{{}}.dart': ['lib/$directory/{{}}.conv.g.dart'], + }; + + void validateDescriptor(SubjectDescriptor descriptor) { + if (descriptor.meta.containsKey('outputDirectory') && + descriptor.meta['outputDirectory'] != directory) { + throw StateError( + 'Configure output_directory identically for dogs_descriptors, dogs_bindings and ' + 'dogs_reactor. Converter ${descriptor.uri} uses ' + '${descriptor.meta['outputDirectory']}, but dogs_reactor uses $directory.', + ); + } + } +} diff --git a/packages/dogs_generator/test/output_layout_test.dart b/packages/dogs_generator/test/output_layout_test.dart new file mode 100644 index 00000000..21793276 --- /dev/null +++ b/packages/dogs_generator/test/output_layout_test.dart @@ -0,0 +1,78 @@ +import 'package:build/build.dart'; +import 'package:dogs_generator/integrations.dart'; +import 'package:dogs_generator/output_layout.dart'; +import 'package:lyell_gen/lyell_gen.dart'; +import 'package:test/test.dart'; + +void main() { + group('output_directory', () { + test('leaves the existing converter and reactor layout unchanged by default', () { + const layout = DogsOutputLayout(); + final model = AssetId('app', 'lib/orders/order.dart'); + expect(layout.converterAsset(model).path, 'lib/orders/order.conv.g.dart'); + expect(expectedOutputs(dogsConverters(BuilderOptions.empty), model).single.path, + 'lib/orders/order.conv.g.dart'); + expect( + expectedOutputs(dogsReactor(BuilderOptions.empty), AssetId('app', r'lib/$lib$')) + .single + .path, + 'lib/dogs.g.dart'); + }); + + test('preserves source subdirectories and avoids equal-basename collisions', () { + final options = BuilderOptions({'output_directory': 'generated/models'}); + final layout = DogsOutputLayout.fromOptions(options); + final left = AssetId('app', 'lib/city/model.dart'); + final right = AssetId('app', 'lib/order/model.dart'); + expect(layout.converterAsset(left).path, 'lib/generated/models/city/model.conv.g.dart'); + expect(layout.converterAsset(right).path, 'lib/generated/models/order/model.conv.g.dart'); + expect(expectedOutputs(dogsConverters(options), left).single, layout.converterAsset(left)); + expect(expectedOutputs(dogsConverters(options), right).single, layout.converterAsset(right)); + expect(expectedOutputs(dogsReactor(options), AssetId('app', r'lib/$lib$')).single.path, + 'lib/generated/models/dogs.g.dart'); + }); + + test('keeps cached bindings and custom-link descriptors beside their source assets', () { + final options = BuilderOptions({'output_directory': 'generated'}); + final model = AssetId('app', 'lib/models/order.dart'); + expect( + expectedOutputs(dogsBindings(options), model).single.path, 'lib/models/order.conv.dogs'); + expect( + expectedOutputs(dogsLinking(options), model).single.path, 'lib/models/order.link.dogs'); + }); + + test('rejects directories that escape lib or do not name a relative directory', () { + for (final value in [ + '', + '.', + '..', + '../generated', + 'generated/../other', + '/tmp/generated', + 'generated/', + 'generated//models', + r'generated\models', + 'C:/generated', + 1, + true + ]) { + expect(() => DogsOutputLayout.fromOptions(BuilderOptions({'output_directory': value})), + throwsArgumentError, + reason: '$value'); + } + }); + + test('detects inconsistent binding and reactor settings without changing custom links', () { + const layout = DogsOutputLayout(directory: 'generated'); + final relocated = SubjectDescriptor( + uri: 'package:app/generated/order.conv.g.dart', meta: {'outputDirectory': 'generated'}); + final adjacent = + SubjectDescriptor(uri: 'package:app/order.conv.g.dart', meta: {'outputDirectory': null}); + final linked = SubjectDescriptor(uri: 'package:app/custom.dart'); + expect(() => layout.validateDescriptor(relocated), returnsNormally); + expect(() => layout.validateDescriptor(linked), returnsNormally); + expect(() => layout.validateDescriptor(adjacent), throwsStateError); + expect(() => const DogsOutputLayout().validateDescriptor(relocated), throwsStateError); + }); + }); +} From 7dacef49f9ecb184d11d07a54b37afe096474e24 Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 22:40:09 +0300 Subject: [PATCH 2/7] Default opted-in model lists without field annotations --- packages/dogs_core/CHANGELOG.md | 1 + packages/dogs_core/README.md | 4 ++-- packages/dogs_core/lib/src/model_codec.dart | 20 ++++++++++++------ packages/dogs_core/test/model_codec_test.dart | 21 +++++++++++-------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index 7cdd4239..4db48e02 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -2,6 +2,7 @@ - Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. - Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, and `JsonReadWith` metadata. +- Allow `JsonModel(emptyListsOnNull: true)` to default all list fields on an opted-in model without per-field annotations. - Allow `DefaultValue(onNull: true)` to supply a default for explicit null input; the existing missing-only behavior remains the default. ## 10.2.0 diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index 4e9c1351..f191796f 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -130,8 +130,8 @@ final request = models.toJson(city); // {'id': 1, 'name': 'Erbil'} `@JsonModel` also marks the class for DOGs generation. The codec reads registered nested models and lists recursively. -`@DefaultValue([], onNull: true)` supplies a fresh typed list for missing or -null input. `@JsonReadValue(value)` fixes a transient constructor field; +`@JsonModel(emptyListsOnNull: true)` supplies fresh typed empty lists for +missing or null list fields on that model. `@JsonReadValue(value)` fixes a transient constructor field; `@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; `@JsonListConvert(JsonConversions.integer)` converts list elements; and `@JsonReadWith(myReader)` handles a rule needing the full input map. Callback diff --git a/packages/dogs_core/lib/src/model_codec.dart b/packages/dogs_core/lib/src/model_codec.dart index 8b591d29..74a3df8a 100644 --- a/packages/dogs_core/lib/src/model_codec.dart +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -8,11 +8,15 @@ class JsonModel extends Structure implements SerializationHook { /// Converts non-null input values to strings before construction. final bool stringify; + /// Returns a fresh empty list for missing or null list fields on this model. + final bool emptyListsOnNull; + /// Optional custom factory used when this model is decoded as a child. final Object Function(Map)? reader; /// Declares a serializable model with optional read and write rules. - const JsonModel({this.outputKeys, this.stringify = false, this.reader}) + const JsonModel( + {this.outputKeys, this.stringify = false, this.emptyListsOnNull = false, this.reader}) : super(serializable: true); @override @@ -185,6 +189,7 @@ class DogModelCodec { dynamic readStructure(DogStructure descriptor, Map json, {Map values = const {}, bool stringify = false}) { final strings = stringify || (descriptor.firstAnnotationOf()?.stringify ?? false); + final emptyListsOnNull = descriptor.firstAnnotationOf()?.emptyListsOnNull ?? false; final fields = []; for (final field in descriptor.fields) { if (values.containsKey(field.name)) { @@ -220,7 +225,7 @@ class DogModelCodec { final nested = engine.findStructureByType(field.serial.typeArgument); final listConverter = field.firstAnnotationOf(); if (field.iterableKind == IterableKind.list && - (nested != null || listConverter != null || defaultValue != null)) { + (nested != null || listConverter != null || defaultValue != null || emptyListsOnNull)) { fields.add(field.serial.consumeType(() { final result = guard.list(input, key, (item) { if (listConverter != null) return listConverter.convert(item) as T; @@ -229,10 +234,13 @@ class DogModelCodec { } return item as T; }); - if (result != null || !useDefault) return result; - final provided = defaultValue.value; - final supplied = provided is DefaultValueSupplier ? provided() : provided; - return List.from(supplied as Iterable); + if (result != null) return result; + if (useDefault) { + final provided = defaultValue.value; + final supplied = provided is DefaultValueSupplier ? provided() : provided; + return List.from(supplied as Iterable); + } + return emptyListsOnNull ? [] : null; })); continue; } diff --git a/packages/dogs_core/test/model_codec_test.dart b/packages/dogs_core/test/model_codec_test.dart index f1d4ad81..673c500b 100644 --- a/packages/dogs_core/test/model_codec_test.dart +++ b/packages/dogs_core/test/model_codec_test.dart @@ -118,7 +118,7 @@ DogStructure aliasStructure() => DogStructure( ), ); -DogStructure treeStructure() => DogStructure( +DogStructure treeStructure({bool emptyListsOnNull = true}) => DogStructure( "TreeValue", StructureConformity.basic, [ @@ -130,13 +130,12 @@ DogStructure treeStructure() => DogStructure( optional: true, iterable: IterableKind.list, annotations: const [ - JsonAliases(["children", "old_children"]), - DefaultValue([], onNull: true) + JsonAliases(["children", "old_children"]) ], ), DogStructureField.create("nested", optional: true), ], - const [JsonModel()], + [JsonModel(emptyListsOnNull: emptyListsOnNull)], ObjectFactoryStructureProxy( (values) => TreeValue(child: values[0], children: values[1], nested: values[2]), [(value) => value.child, (value) => value.children, (value) => value.nested], @@ -160,14 +159,11 @@ DogStructure convertedStructure() => DogStructure( (values) => ConvertedValue( text: values[0], @@ -367,6 +363,13 @@ void main() { expect(codec.engine, same(engine)); }); + test("list fields retain null when class-level empty lists are disabled", () { + final (:codec, :engine) = codecFixture(); + registerModel(engine, treeStructure(emptyListsOnNull: false)); + expect(codec.fromJson({}).children, isNull); + expect(codec.fromJson({"children": null}).children, isNull); + }); + test("list aliases choose an empty first list before a populated fallback", () { final (:codec, :engine) = codecFixture(); final preferred = codec.fromJson({ From 3f9f901183987c305886aecac0488d55d9170cfd Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 23:01:56 +0300 Subject: [PATCH 3/7] Prefer top-level JsonConvert tear-offs over JsonConversions statics Expose jsonStringify, jsonFlagInt, and jsonInteger as the primary converter functions for field annotations; keep JsonConversions as aliases. Co-authored-by: Cursor --- packages/dogs_core/CHANGELOG.md | 1 + packages/dogs_core/README.md | 8 +++--- packages/dogs_core/lib/src/model_codec.dart | 27 ++++++++++++++----- packages/dogs_core/test/model_codec_test.dart | 10 +++---- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index 4db48e02..c2a85815 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -2,6 +2,7 @@ - Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. - Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, and `JsonReadWith` metadata. +- Add top-level `jsonStringify`, `jsonFlagInt`, and `jsonInteger` converter functions for `JsonConvert` / `JsonListConvert` tear-offs; `JsonConversions` remains as aliases. - Allow `JsonModel(emptyListsOnNull: true)` to default all list fields on an opted-in model without per-field annotations. - Allow `DefaultValue(onNull: true)` to supply a default for explicit null input; the existing missing-only behavior remains the default. diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index f191796f..84623d45 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -132,10 +132,12 @@ final request = models.toJson(city); // {'id': 1, 'name': 'Erbil'} registered nested models and lists recursively. `@JsonModel(emptyListsOnNull: true)` supplies fresh typed empty lists for missing or null list fields on that model. `@JsonReadValue(value)` fixes a transient constructor field; -`@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; -`@JsonListConvert(JsonConversions.integer)` converts list elements; and +`@JsonConvert(jsonStringify)` coerces mixed scalars to strings; +`@JsonConvert(jsonFlagInt)` converts a boolean flag to an integer; +`@JsonListConvert(jsonInteger)` converts list elements; and `@JsonReadWith(myReader)` handles a rule needing the full input map. Callback -arguments must be constant function tear-offs for generated metadata. +arguments must be constant top-level or static function tear-offs for generated +metadata. `toJson` returns raw constructor values, including nulls and nested model instances. Use `engine.toNative` for a recursively serialized object graph. diff --git a/packages/dogs_core/lib/src/model_codec.dart b/packages/dogs_core/lib/src/model_codec.dart index 74a3df8a..579ba41f 100644 --- a/packages/dogs_core/lib/src/model_codec.dart +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -99,20 +99,33 @@ class JsonReadWith extends StructureMetadata { const JsonReadWith(this.read); } +/// Converts a non-null value to its string representation. +String? jsonStringify(Object? value) => value?.toString(); + +/// Converts booleans to 0/1, retains integers, and defaults other values to 0. +int jsonFlagInt(Object? value) => value is bool + ? (value ? 1 : 0) + : value is int + ? value + : 0; + +/// Truncates a numeric value toward zero; rejects non-numeric values. +int jsonInteger(Object? value) => (value as num).toInt(); + /// Common explicit conversions that are not inferred from Dart field types. +/// +/// Prefer the top-level [jsonStringify], [jsonFlagInt], and [jsonInteger] +/// tear-offs with [JsonConvert] / [JsonListConvert]. These static methods +/// remain as aliases. class JsonConversions { /// Converts a non-null value to its string representation. - static String? stringify(Object? value) => value?.toString(); + static String? stringify(Object? value) => jsonStringify(value); /// Converts booleans to 0/1, retains integers, and defaults other values to 0. - static int flagInt(Object? value) => value is bool - ? (value ? 1 : 0) - : value is int - ? value - : 0; + static int flagInt(Object? value) => jsonFlagInt(value); /// Truncates a numeric value toward zero; rejects non-numeric values. - static int integer(Object? value) => (value as num).toInt(); + static int integer(Object? value) => jsonInteger(value); } /// Controls nested-object/list diagnostics without adding a codec dependency. diff --git a/packages/dogs_core/test/model_codec_test.dart b/packages/dogs_core/test/model_codec_test.dart index 673c500b..a255fb7c 100644 --- a/packages/dogs_core/test/model_codec_test.dart +++ b/packages/dogs_core/test/model_codec_test.dart @@ -149,17 +149,17 @@ DogStructure convertedStructure() => DogStructure({}).value, 7); expect(codec.fromJson({"value": null}).value, 7); From 4e3c5496485d3e8938e2a828b6e9025f864c6b38 Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 23:10:17 +0300 Subject: [PATCH 4/7] Keep JsonConversions as the shared converter class API Drop the short-lived top-level jsonStringify helpers so field annotations use JsonConversions.stringify and peers instead. Co-authored-by: Cursor --- packages/dogs_core/CHANGELOG.md | 2 +- packages/dogs_core/README.md | 6 ++-- packages/dogs_core/lib/src/model_codec.dart | 28 ++++++------------- packages/dogs_core/test/model_codec_test.dart | 10 +++---- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index c2a85815..7dc3f177 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -2,7 +2,7 @@ - Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. - Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, and `JsonReadWith` metadata. -- Add top-level `jsonStringify`, `jsonFlagInt`, and `jsonInteger` converter functions for `JsonConvert` / `JsonListConvert` tear-offs; `JsonConversions` remains as aliases. +- Add `JsonConversions` with reusable static converter tear-offs (`stringify`, `flagInt`, `integer`) for `JsonConvert` / `JsonListConvert`. - Allow `JsonModel(emptyListsOnNull: true)` to default all list fields on an opted-in model without per-field annotations. - Allow `DefaultValue(onNull: true)` to supply a default for explicit null input; the existing missing-only behavior remains the default. diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index 84623d45..282a62c7 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -132,9 +132,9 @@ final request = models.toJson(city); // {'id': 1, 'name': 'Erbil'} registered nested models and lists recursively. `@JsonModel(emptyListsOnNull: true)` supplies fresh typed empty lists for missing or null list fields on that model. `@JsonReadValue(value)` fixes a transient constructor field; -`@JsonConvert(jsonStringify)` coerces mixed scalars to strings; -`@JsonConvert(jsonFlagInt)` converts a boolean flag to an integer; -`@JsonListConvert(jsonInteger)` converts list elements; and +`@JsonConvert(JsonConversions.stringify)` coerces mixed scalars to strings; +`@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; +`@JsonListConvert(JsonConversions.integer)` converts list elements; and `@JsonReadWith(myReader)` handles a rule needing the full input map. Callback arguments must be constant top-level or static function tear-offs for generated metadata. diff --git a/packages/dogs_core/lib/src/model_codec.dart b/packages/dogs_core/lib/src/model_codec.dart index 579ba41f..ff61e3de 100644 --- a/packages/dogs_core/lib/src/model_codec.dart +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -99,33 +99,23 @@ class JsonReadWith extends StructureMetadata { const JsonReadWith(this.read); } -/// Converts a non-null value to its string representation. -String? jsonStringify(Object? value) => value?.toString(); - -/// Converts booleans to 0/1, retains integers, and defaults other values to 0. -int jsonFlagInt(Object? value) => value is bool - ? (value ? 1 : 0) - : value is int - ? value - : 0; - -/// Truncates a numeric value toward zero; rejects non-numeric values. -int jsonInteger(Object? value) => (value as num).toInt(); - /// Common explicit conversions that are not inferred from Dart field types. /// -/// Prefer the top-level [jsonStringify], [jsonFlagInt], and [jsonInteger] -/// tear-offs with [JsonConvert] / [JsonListConvert]. These static methods -/// remain as aliases. +/// Use the static methods as constant tear-offs with [JsonConvert] / +/// [JsonListConvert], for example `@JsonConvert(JsonConversions.stringify)`. class JsonConversions { /// Converts a non-null value to its string representation. - static String? stringify(Object? value) => jsonStringify(value); + static String? stringify(Object? value) => value?.toString(); /// Converts booleans to 0/1, retains integers, and defaults other values to 0. - static int flagInt(Object? value) => jsonFlagInt(value); + static int flagInt(Object? value) => value is bool + ? (value ? 1 : 0) + : value is int + ? value + : 0; /// Truncates a numeric value toward zero; rejects non-numeric values. - static int integer(Object? value) => jsonInteger(value); + static int integer(Object? value) => (value as num).toInt(); } /// Controls nested-object/list diagnostics without adding a codec dependency. diff --git a/packages/dogs_core/test/model_codec_test.dart b/packages/dogs_core/test/model_codec_test.dart index a255fb7c..673c500b 100644 --- a/packages/dogs_core/test/model_codec_test.dart +++ b/packages/dogs_core/test/model_codec_test.dart @@ -149,17 +149,17 @@ DogStructure convertedStructure() => DogStructure({}).value, 7); expect(codec.fromJson({"value": null}).value, 7); From 3c479cb8ab6bccd32ae0c189af4c69489678a0dd Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 23:16:37 +0300 Subject: [PATCH 5/7] Add JsonParser and JsonListParser for nested fromJson maps Field annotations take a fromJson-style map factory, use the generated field name by default, and allow an optional custom key (aliases apply when no key is set). Co-authored-by: Cursor --- packages/dogs_core/CHANGELOG.md | 3 +- packages/dogs_core/README.md | 5 +- packages/dogs_core/lib/src/model_codec.dart | 75 ++++++++- packages/dogs_core/test/model_codec_test.dart | 145 ++++++++++++++++++ 4 files changed, 221 insertions(+), 7 deletions(-) diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index 7dc3f177..31c85ee0 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -1,8 +1,9 @@ ## Unreleased - Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. -- Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, and `JsonReadWith` metadata. +- Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, `JsonParser`, `JsonListParser`, and `JsonReadWith` metadata. - Add `JsonConversions` with reusable static converter tear-offs (`stringify`, `flagInt`, `integer`) for `JsonConvert` / `JsonListConvert`. +- Add `JsonParser` / `JsonListParser` for fromJson-style nested map parsing with a default field key or optional custom `key`. - Allow `JsonModel(emptyListsOnNull: true)` to default all list fields on an opted-in model without per-field annotations. - Allow `DefaultValue(onNull: true)` to supply a default for explicit null input; the existing missing-only behavior remains the default. diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index 282a62c7..c1e10409 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -134,7 +134,10 @@ registered nested models and lists recursively. missing or null list fields on that model. `@JsonReadValue(value)` fixes a transient constructor field; `@JsonConvert(JsonConversions.stringify)` coerces mixed scalars to strings; `@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; -`@JsonListConvert(JsonConversions.integer)` converts list elements; and +`@JsonListConvert(JsonConversions.integer)` converts list elements; +`@JsonParser(Item.fromJson)` parses a nested object map (optional `key:` overrides +the field name; otherwise aliases apply); +`@JsonListParser(Item.fromJson)` does the same for list elements; and `@JsonReadWith(myReader)` handles a rule needing the full input map. Callback arguments must be constant top-level or static function tear-offs for generated metadata. diff --git a/packages/dogs_core/lib/src/model_codec.dart b/packages/dogs_core/lib/src/model_codec.dart index ff61e3de..7c654b09 100644 --- a/packages/dogs_core/lib/src/model_codec.dart +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -90,6 +90,38 @@ class JsonListConvert extends StructureMetadata { const JsonListConvert(this.convert); } +/// Parses a nested JSON object with a fromJson-style factory. +/// +/// Reads [key] from the parent map, or the generated field name when [key] is +/// omitted. When [key] is omitted and [JsonAliases] is present, alias lookup +/// is used instead. +class JsonParser extends StructureMetadata { + /// Constant tear-off receiving the nested object map. + final Object? Function(Map) parse; + + /// Backend key to read; defaults to the generated field name when omitted. + final String? key; + + /// Declares an explicit nested object parse, including unregistered types. + const JsonParser(this.parse, {this.key}); +} + +/// Parses each JSON list element as a nested object map. +/// +/// Reads [key] from the parent map, or the generated field name when [key] is +/// omitted. When [key] is omitted and [JsonAliases] is present, alias lookup +/// is used instead. +class JsonListParser extends StructureMetadata { + /// Constant tear-off receiving each nested object map element. + final Object? Function(Map) parse; + + /// Backend key to read; defaults to the generated field name when omitted. + final String? key; + + /// Declares an explicit nested list parse, including unregistered types. + const JsonListParser(this.parse, {this.key}); +} + /// Reads a field using the full original input map for cross-field rules. class JsonReadWith extends StructureMetadata { /// A constant top-level or static function receiving the unmodified input. @@ -210,12 +242,27 @@ class DogModelCodec { continue; } final aliases = field.firstAnnotationOf(); - final value = aliases == null ? json[field.name] : aliases.readValue(json); - final key = aliases?.keys.length == 1 ? aliases!.keys.single : field.name; + final parser = field.firstAnnotationOf(); + final listParser = field.firstAnnotationOf(); + final customKey = parser?.key ?? listParser?.key; + final Object? value; + final String key; + final bool missing; + if (customKey != null) { + key = customKey; + value = json[customKey]; + missing = !json.containsKey(customKey); + } else if (aliases != null) { + key = aliases.readKey(json) ?? field.name; + value = aliases.readValue(json); + missing = !aliases.keys.any(json.containsKey); + } else { + key = field.name; + value = json[field.name]; + missing = !json.containsKey(field.name); + } final input = {key: value}; final defaultValue = field.firstAnnotationOf(); - final missing = - aliases == null ? !json.containsKey(field.name) : !aliases.keys.any(json.containsKey); final useDefault = defaultValue != null && (missing || (defaultValue.onNull && value == null)); final converter = field.firstAnnotationOf(); @@ -225,12 +272,30 @@ class DogModelCodec { fields.add(converter.convert(supplied)); continue; } + if (parser != null) { + fields.add(field.serial.consumeType(() { + if (useDefault) { + final provided = defaultValue.value; + final supplied = provided is DefaultValueSupplier ? provided() : provided; + return supplied as T?; + } + return guard.object(input, key, (data) => parser.parse(data) as T); + })); + continue; + } final nested = engine.findStructureByType(field.serial.typeArgument); final listConverter = field.firstAnnotationOf(); if (field.iterableKind == IterableKind.list && - (nested != null || listConverter != null || defaultValue != null || emptyListsOnNull)) { + (nested != null || + listConverter != null || + listParser != null || + defaultValue != null || + emptyListsOnNull)) { fields.add(field.serial.consumeType(() { final result = guard.list(input, key, (item) { + if (listParser != null) { + return listParser.parse(item as Map) as T; + } if (listConverter != null) return listConverter.convert(item) as T; if (nested != null) { return readChild(nested, item as Map) as T; diff --git a/packages/dogs_core/test/model_codec_test.dart b/packages/dogs_core/test/model_codec_test.dart index 673c500b..f25f1466 100644 --- a/packages/dogs_core/test/model_codec_test.dart +++ b/packages/dogs_core/test/model_codec_test.dart @@ -47,6 +47,21 @@ class NumberEnvelope { final NumberValue? number; } +class ParsedChild { + const ParsedChild({this.id, this.label}); + + final int? id; + final String? label; +} + +class ParsedHolder { + const ParsedHolder({this.child, this.payload, this.children}); + + final ParsedChild? child; + final ParsedChild? payload; + final List? children; +} + class RecordingGuard extends DogModelGuard { final objectKeys = []; final listKeys = []; @@ -75,6 +90,11 @@ Object readNumberChild(Map json) { Object? readSummary(Map json) => "${json.containsKey("text") ? json["text"] : "missing"}:${json["suffix"] ?? ""}"; +ParsedChild parseChild(Map json) => ParsedChild( + id: json["id"] as int?, + label: json["label"]?.toString(), + ); + num defaultNumber() => 7.9; void registerModel(DogEngine engine, DogStructure structure) { @@ -212,6 +232,34 @@ DogStructure envelopeStructure() => DogStructure [(value) => value.number], (value) => [value.number]), ); +DogStructure parsedStructure() => DogStructure( + "ParsedHolder", + StructureConformity.basic, + [ + DogStructureField.create("child", optional: true, annotations: const [ + JsonParser(parseChild), + ]), + DogStructureField.create("payload", optional: true, annotations: const [ + JsonAliases(["legacy_payload", "payload"]), + JsonParser(parseChild), + ]), + DogStructureField.create( + "children", + optional: true, + iterable: IterableKind.list, + annotations: const [ + JsonListParser(parseChild, key: "items"), + ], + ), + ], + const [JsonModel(emptyListsOnNull: true)], + ObjectFactoryStructureProxy( + (values) => ParsedHolder(child: values[0], payload: values[1], children: values[2]), + [(value) => value.child, (value) => value.payload, (value) => value.children], + (value) => [value.child, value.payload, value.children], + ), + ); + ({DogEngine engine, DogModelCodec codec}) codecFixture( {DogModelGuard guard = const DogModelGuard()}) { final engine = DogEngine(); @@ -221,6 +269,7 @@ DogStructure envelopeStructure() => DogStructure registerModel(engine, numberStructure()); registerModel(engine, textStructure()); registerModel(engine, envelopeStructure()); + registerModel(engine, parsedStructure()); return (engine: engine, codec: DogModelCodec(engine: engine, guard: guard)); } @@ -549,6 +598,102 @@ void main() { expect(codec.fromJson({"value": 2.9}).value, 2); }); + test("JsonParser uses the field name by default and keeps nulls", () { + final (:codec, :engine) = codecFixture(); + final input = Map.unmodifiable({ + "child": Map.unmodifiable({"id": 3, "label": 9}), + }); + final value = codec.fromJson(input); + expect(value.child?.id, 3); + expect(value.child?.label, "9"); + expect(value.payload, isNull); + expect(codec.fromJson({}).child, isNull); + expect(codec.fromJson({"child": null}).child, isNull); + expect(input.containsKey("payload"), isFalse); + expect(codec.engine, same(engine)); + }); + + test("JsonParser custom keys override aliases and report the backend key", () { + final (:codec, :engine) = codecFixture(); + registerModel( + engine, + DogStructure( + "ParsedHolder", + StructureConformity.basic, + [ + DogStructureField.create("child", optional: true, annotations: const [ + JsonAliases(["legacy_child", "child"]), + JsonParser(parseChild, key: "product"), + ]), + DogStructureField.create("payload", optional: true), + DogStructureField.create( + "children", + optional: true, + iterable: IterableKind.list, + ), + ], + const [JsonModel()], + ObjectFactoryStructureProxy( + (values) => ParsedHolder(child: values[0], payload: values[1], children: values[2]), + [(value) => value.child, (value) => value.payload, (value) => value.children], + (value) => [value.child, value.payload, value.children], + ), + )); + expect( + codec.fromJson({ + "legacy_child": {"id": 1}, + "product": {"id": 2, "label": "ok"}, + }).child?.id, + 2); + expect( + () => codec.fromJson({"product": "bad"}), + throwsA(isA() + .having((error) => error.message, "custom key", contains("product")))); + expect(codec.engine, same(engine)); + }); + + test("JsonParser falls back through aliases when no custom key is set", () { + final (:codec, :engine) = codecFixture(); + expect( + codec.fromJson({ + "legacy_payload": {"id": 8, "label": true}, + "payload": {"id": 9}, + }).payload?.label, + "true"); + expect( + codec.fromJson({ + "legacy_payload": null, + "payload": {"id": 9}, + }).payload?.id, + 9); + expect(codec.engine, same(engine)); + }); + + test("JsonListParser reads a custom key and defaults empty lists", () { + final (:codec, :engine) = codecFixture(); + final items = List.unmodifiable([ + Map.unmodifiable({"id": 1, "label": "a"}), + Map.unmodifiable({"id": 2, "label": 4}), + ]); + final input = Map.unmodifiable({"items": items}); + final value = codec.fromJson(input); + expect(value.children?.map((child) => child.id).toList(), [1, 2]); + expect(value.children?.map((child) => child.label).toList(), ["a", "4"]); + expect(codec.fromJson({}).children, isEmpty); + expect(codec.fromJson({"items": null}).children, isEmpty); + expect(input["items"], same(items)); + expect( + () => codec.fromJson({ + "items": [ + {"id": 1}, + "bad" + ] + }), + throwsA(isA() + .having((error) => error.message, "indexed parse", contains("items[1]")))); + expect(codec.engine, same(engine)); + }); + test("missing-only defaults retain explicit null while onNull opts in", () { final (:codec, :engine) = codecFixture(); registerModel(engine, numberStructure(annotations: const [DefaultValue(7)])); From d04ff6a72717419079688f555f494e724eaab269 Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 23:19:58 +0300 Subject: [PATCH 6/7] Document JsonParser tear-off requirements for codegen Factory constructor tear-offs are not retained; use top-level or static parse helpers with JsonParser and JsonListParser. Co-authored-by: Cursor --- packages/dogs_core/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index c1e10409..a389a7b6 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -135,12 +135,12 @@ missing or null list fields on that model. `@JsonReadValue(value)` fixes a trans `@JsonConvert(JsonConversions.stringify)` coerces mixed scalars to strings; `@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; `@JsonListConvert(JsonConversions.integer)` converts list elements; -`@JsonParser(Item.fromJson)` parses a nested object map (optional `key:` overrides +`@JsonParser(Item.parseJson)` parses a nested object map (optional `key:` overrides the field name; otherwise aliases apply); -`@JsonListParser(Item.fromJson)` does the same for list elements; and +`@JsonListParser(Item.parseJson)` does the same for list elements; and `@JsonReadWith(myReader)` handles a rule needing the full input map. Callback arguments must be constant top-level or static function tear-offs for generated -metadata. +metadata (factory constructor tear-offs are not retained by codegen). `toJson` returns raw constructor values, including nulls and nested model instances. Use `engine.toNative` for a recursively serialized object graph. From d7a47b328acdedda260775878e2a5aa11664781e Mon Sep 17 00:00:00 2001 From: gailan Date: Thu, 15 Oct 2026 23:32:43 +0300 Subject: [PATCH 7/7] Unify DogModelCodec field metadata into JsonKey Replace separate alias/convert/parser/read annotations with one JsonKey that carries aliases, name, convert, each, parse, read, and value. Co-authored-by: Cursor --- packages/dogs_core/CHANGELOG.md | 5 +- packages/dogs_core/README.md | 21 +- packages/dogs_core/lib/src/model_codec.dart | 196 ++++++++---------- packages/dogs_core/test/model_codec_test.dart | 45 ++-- 4 files changed, 117 insertions(+), 150 deletions(-) diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index 31c85ee0..1d6d7c69 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -1,9 +1,8 @@ ## Unreleased - Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. -- Add `JsonModel`, `JsonAliases`, `JsonReadValue`, `JsonConvert`, `JsonListConvert`, `JsonParser`, `JsonListParser`, and `JsonReadWith` metadata. -- Add `JsonConversions` with reusable static converter tear-offs (`stringify`, `flagInt`, `integer`) for `JsonConvert` / `JsonListConvert`. -- Add `JsonParser` / `JsonListParser` for fromJson-style nested map parsing with a default field key or optional custom `key`. +- Add unified `JsonKey` field metadata (`aliases`, `name`, `convert`, `each`, `parse`, `read`, `value`) in place of separate alias/convert/parser annotations. +- Add `JsonModel` class metadata and `JsonConversions` static converter tear-offs (`stringify`, `flagInt`, `integer`) for use with `JsonKey`. - Allow `JsonModel(emptyListsOnNull: true)` to default all list fields on an opted-in model without per-field annotations. - Allow `DefaultValue(onNull: true)` to supply a default for explicit null input; the existing missing-only behavior remains the default. diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index a389a7b6..fa5f94a0 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -113,7 +113,7 @@ import 'dogs.g.dart'; @JsonModel(outputKeys: ['id', 'name']) class City { - @JsonAliases(['legacy_id', 'id']) + @JsonKey(aliases: ['legacy_id', 'id']) final int? id; final String? name; @@ -131,16 +131,15 @@ final request = models.toJson(city); // {'id': 1, 'name': 'Erbil'} `@JsonModel` also marks the class for DOGs generation. The codec reads registered nested models and lists recursively. `@JsonModel(emptyListsOnNull: true)` supplies fresh typed empty lists for -missing or null list fields on that model. `@JsonReadValue(value)` fixes a transient constructor field; -`@JsonConvert(JsonConversions.stringify)` coerces mixed scalars to strings; -`@JsonConvert(JsonConversions.flagInt)` converts a boolean flag to an integer; -`@JsonListConvert(JsonConversions.integer)` converts list elements; -`@JsonParser(Item.parseJson)` parses a nested object map (optional `key:` overrides -the field name; otherwise aliases apply); -`@JsonListParser(Item.parseJson)` does the same for list elements; and -`@JsonReadWith(myReader)` handles a rule needing the full input map. Callback -arguments must be constant top-level or static function tear-offs for generated -metadata (factory constructor tear-offs are not retained by codegen). +missing or null list fields on that model. Use one `@JsonKey(...)` per field: +`aliases` for alternate input names, `name` for an explicit input key, +`convert` for whole-value conversion (for example `JsonConversions.stringify` +or `JsonConversions.flagInt`), `each` for list-element conversion, +`parse` for nested object maps (or each list element map), `read` when the +rule needs the full parent map, and `value` to force a decode value +(including explicit `null`). Callback arguments must be constant top-level or +static function tear-offs for generated metadata (factory constructor +tear-offs are not retained by codegen). `toJson` returns raw constructor values, including nulls and nested model instances. Use `engine.toNative` for a recursively serialized object graph. diff --git a/packages/dogs_core/lib/src/model_codec.dart b/packages/dogs_core/lib/src/model_codec.dart index 7c654b09..3656b2d6 100644 --- a/packages/dogs_core/lib/src/model_codec.dart +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -1,5 +1,14 @@ import "package:dogs_core/dogs_core.dart"; +/// Sentinel for an unset [JsonKey.value], so explicit `null` stays expressible. +class JsonKeyUnset { + /// Creates the shared unset sentinel. + const JsonKeyUnset(); +} + +/// Marker used when [JsonKey.value] is omitted. +const jsonKeyUnset = JsonKeyUnset(); + /// Declares JSON model rules without changing native DOGs coercion defaults. class JsonModel extends Structure implements SerializationHook { /// Fields allowed in [DogModelCodec.toJson], or all fields when null. @@ -23,8 +32,9 @@ class JsonModel extends Structure implements SerializationHook { void beforeDeserialization(Map map, DogStructure structure, DogEngine engine) { final source = Map.from(map); for (final field in structure.fields) { - final aliases = field.firstAnnotationOf(); - if (aliases != null) map[field.name] = aliases.readValue(source); + final key = field.firstAnnotationOf(); + final aliases = key?.aliases; + if (aliases != null) map[field.name] = key!.readAliasValue(source); } if (stringify) map.updateAll((key, value) => value?.toString()); } @@ -37,16 +47,52 @@ class JsonModel extends Structure implements SerializationHook { } } -/// Reads the first non-null input key, in declaration order. -class JsonAliases extends StructureMetadata { - /// Accepted input names; the generated field name remains the output name. - final List keys; +/// Unified field JSON rules for [DogModelCodec]. +/// +/// Use one annotation per field instead of separate alias/convert/parser types. +/// [convert] always runs on the whole field value. [each] converts list elements. +/// [parse] reads a nested object map, or each list element map when the field is +/// a list. +class JsonKey extends StructureMetadata with FieldSerializationHook { + /// Alternative input names; the generated field name remains the output name. + final List? aliases; + + /// Explicit input key; defaults to the generated field name when omitted. + final String? name; + + /// Converts the whole field value before construction. + final Object? Function(Object?)? convert; + + /// Converts each list element before construction. + final Object? Function(Object?)? each; + + /// Parses a nested object map, or each list element map for list fields. + final Object? Function(Map)? parse; - /// Declares alternative input names, including the canonical name if wanted. - const JsonAliases(this.keys); + /// Reads using the full parent input map. + final Object? Function(Map)? read; - /// Returns the first key with a non-null value, or null if none match. - String? readKey(Map json) { + /// Forces this decode value when not [jsonKeyUnset], including explicit null. + final Object? value; + + /// Declares field-level JSON rules for aliases, conversion, and nested parses. + const JsonKey({ + this.aliases, + this.name, + this.convert, + this.each, + this.parse, + this.read, + this.value = jsonKeyUnset, + }); + + /// Whether [value] was supplied, including an explicit null. + bool hasFixedValue() => !identical(value, jsonKeyUnset); + + /// Returns the first alias key with a non-null value, or null if none match. + String? readAliasKey(Map json) { + final keys = aliases; + if (keys == null) return null; for (final key in keys) { if (json[key] != null) return key; } @@ -54,87 +100,19 @@ class JsonAliases extends StructureMetadata { } /// Reads from the original map so aliases never affect each other. - Object? readValue(Map json) => json[readKey(json)]; -} - -/// Forces a value when decoding, independently of incoming JSON. -class JsonReadValue extends StructureMetadata with FieldSerializationHook { - /// The value supplied to the constructor. - final Object? value; - - /// Keeps transient constructor state independent of backend fields. - const JsonReadValue(this.value); + Object? readAliasValue(Map json) => json[readAliasKey(json)]; @override void beforeFieldDeserialization(NativeStructureContext context, NativeStructureFieldContext fieldContext, Map map, DogEngine engine) { - map[fieldContext.key] = value; + if (hasFixedValue()) map[fieldContext.key] = value; } } -/// Converts a raw JSON field value before constructor type checking. -class JsonConvert extends StructureMetadata { - /// A constant top-level or static function; also receives null inputs. - final Object? Function(Object?) convert; - - /// Declares a reusable field conversion instead of a per-model map override. - const JsonConvert(this.convert); -} - -/// Converts each element of a JSON list with typed, indexed error handling. -class JsonListConvert extends StructureMetadata { - /// A constant top-level or static function applied to each list element. - final Object? Function(Object?) convert; - - /// Declares an element conversion; combine with [DefaultValue] for defaults. - const JsonListConvert(this.convert); -} - -/// Parses a nested JSON object with a fromJson-style factory. -/// -/// Reads [key] from the parent map, or the generated field name when [key] is -/// omitted. When [key] is omitted and [JsonAliases] is present, alias lookup -/// is used instead. -class JsonParser extends StructureMetadata { - /// Constant tear-off receiving the nested object map. - final Object? Function(Map) parse; - - /// Backend key to read; defaults to the generated field name when omitted. - final String? key; - - /// Declares an explicit nested object parse, including unregistered types. - const JsonParser(this.parse, {this.key}); -} - -/// Parses each JSON list element as a nested object map. -/// -/// Reads [key] from the parent map, or the generated field name when [key] is -/// omitted. When [key] is omitted and [JsonAliases] is present, alias lookup -/// is used instead. -class JsonListParser extends StructureMetadata { - /// Constant tear-off receiving each nested object map element. - final Object? Function(Map) parse; - - /// Backend key to read; defaults to the generated field name when omitted. - final String? key; - - /// Declares an explicit nested list parse, including unregistered types. - const JsonListParser(this.parse, {this.key}); -} - -/// Reads a field using the full original input map for cross-field rules. -class JsonReadWith extends StructureMetadata { - /// A constant top-level or static function receiving the unmodified input. - final Object? Function(Map) read; - - /// Declares a custom rule that depends on more than one backend field. - const JsonReadWith(this.read); -} - /// Common explicit conversions that are not inferred from Dart field types. /// -/// Use the static methods as constant tear-offs with [JsonConvert] / -/// [JsonListConvert], for example `@JsonConvert(JsonConversions.stringify)`. +/// Use the static methods as constant tear-offs with [JsonKey], for example +/// `@JsonKey(convert: JsonConversions.stringify)`. class JsonConversions { /// Converts a non-null value to its string representation. static String? stringify(Object? value) => value?.toString(); @@ -231,31 +209,26 @@ class DogModelCodec { fields.add(values[field.name]); continue; } - final fixed = field.firstAnnotationOf(); - if (fixed != null) { - fields.add(fixed.value); + final keyMeta = field.firstAnnotationOf(); + if (keyMeta != null && keyMeta.hasFixedValue()) { + fields.add(keyMeta.value); continue; } - final readWith = field.firstAnnotationOf(); - if (readWith != null) { - fields.add(readWith.read(json)); + if (keyMeta?.read != null) { + fields.add(keyMeta!.read!(json)); continue; } - final aliases = field.firstAnnotationOf(); - final parser = field.firstAnnotationOf(); - final listParser = field.firstAnnotationOf(); - final customKey = parser?.key ?? listParser?.key; final Object? value; final String key; final bool missing; - if (customKey != null) { - key = customKey; - value = json[customKey]; - missing = !json.containsKey(customKey); - } else if (aliases != null) { - key = aliases.readKey(json) ?? field.name; - value = aliases.readValue(json); - missing = !aliases.keys.any(json.containsKey); + if (keyMeta?.name != null) { + key = keyMeta!.name!; + value = json[key]; + missing = !json.containsKey(key); + } else if (keyMeta?.aliases != null) { + key = keyMeta!.readAliasKey(json) ?? field.name; + value = keyMeta.readAliasValue(json); + missing = !keyMeta.aliases!.any(json.containsKey); } else { key = field.name; value = json[field.name]; @@ -265,38 +238,37 @@ class DogModelCodec { final defaultValue = field.firstAnnotationOf(); final useDefault = defaultValue != null && (missing || (defaultValue.onNull && value == null)); - final converter = field.firstAnnotationOf(); - if (converter != null) { + if (keyMeta?.convert != null) { final provided = useDefault ? defaultValue.value : value; final supplied = useDefault && provided is DefaultValueSupplier ? provided() : provided; - fields.add(converter.convert(supplied)); + fields.add(keyMeta!.convert!(supplied)); continue; } - if (parser != null) { + final isList = field.iterableKind == IterableKind.list; + if (keyMeta?.parse != null && !isList) { fields.add(field.serial.consumeType(() { if (useDefault) { final provided = defaultValue.value; final supplied = provided is DefaultValueSupplier ? provided() : provided; return supplied as T?; } - return guard.object(input, key, (data) => parser.parse(data) as T); + return guard.object(input, key, (data) => keyMeta!.parse!(data) as T); })); continue; } final nested = engine.findStructureByType(field.serial.typeArgument); - final listConverter = field.firstAnnotationOf(); - if (field.iterableKind == IterableKind.list && + if (isList && (nested != null || - listConverter != null || - listParser != null || + keyMeta?.each != null || + keyMeta?.parse != null || defaultValue != null || emptyListsOnNull)) { fields.add(field.serial.consumeType(() { final result = guard.list(input, key, (item) { - if (listParser != null) { - return listParser.parse(item as Map) as T; + if (keyMeta?.parse != null) { + return keyMeta!.parse!(item as Map) as T; } - if (listConverter != null) return listConverter.convert(item) as T; + if (keyMeta?.each != null) return keyMeta!.each!(item) as T; if (nested != null) { return readChild(nested, item as Map) as T; } diff --git a/packages/dogs_core/test/model_codec_test.dart b/packages/dogs_core/test/model_codec_test.dart index f25f1466..6ddff63c 100644 --- a/packages/dogs_core/test/model_codec_test.dart +++ b/packages/dogs_core/test/model_codec_test.dart @@ -108,18 +108,18 @@ DogStructure aliasStructure() => DogStructure( StructureConformity.basic, [ DogStructureField.int("id", optional: true, annotations: const [ - JsonAliases(["legacy_id", "id"]) + JsonKey(aliases: ["legacy_id", "id"]) ]), DogStructureField.string("name", optional: true, annotations: const [ - JsonAliases(["legacy_name", "name"]) + JsonKey(aliases: ["legacy_name", "name"]) ]), DogStructureField.string("echo", optional: true, annotations: const [ - JsonAliases(["name"]) + JsonKey(aliases: ["name"]) ]), DogStructureField.bool("flag", optional: true, annotations: const [ - JsonAliases(["legacy_flag", "flag"]) + JsonKey(aliases: ["legacy_flag", "flag"]) ]), - DogStructureField.bool("ignored", annotations: const [JsonReadValue(false)]), + DogStructureField.bool("ignored", annotations: const [JsonKey(value: false)]), ], const [ JsonModel(outputKeys: ["id", "name"]) @@ -143,14 +143,14 @@ DogStructure treeStructure({bool emptyListsOnNull = true}) => DogStru StructureConformity.basic, [ DogStructureField.create("child", optional: true, annotations: const [ - JsonAliases(["payload"]) + JsonKey(aliases: ["payload"]) ]), DogStructureField.create( "children", optional: true, iterable: IterableKind.list, annotations: const [ - JsonAliases(["children", "old_children"]) + JsonKey(aliases: ["children", "old_children"]) ], ), DogStructureField.create("nested", optional: true), @@ -168,20 +168,19 @@ DogStructure convertedStructure() => DogStructure( @@ -237,18 +236,17 @@ DogStructure parsedStructure() => DogStructure( StructureConformity.basic, [ DogStructureField.create("child", optional: true, annotations: const [ - JsonParser(parseChild), + JsonKey(parse: parseChild), ]), DogStructureField.create("payload", optional: true, annotations: const [ - JsonAliases(["legacy_payload", "payload"]), - JsonParser(parseChild), + JsonKey(aliases: ["legacy_payload", "payload"], parse: parseChild), ]), DogStructureField.create( "children", optional: true, iterable: IterableKind.list, annotations: const [ - JsonListParser(parseChild, key: "items"), + JsonKey(name: "items", parse: parseChild), ], ), ], @@ -591,14 +589,14 @@ void main() { engine, numberStructure(annotations: const [ DefaultValue(defaultNumber, onNull: true), - JsonConvert(JsonConversions.integer) + JsonKey(convert: JsonConversions.integer) ])); expect(codec.fromJson({}).value, 7); expect(codec.fromJson({"value": null}).value, 7); expect(codec.fromJson({"value": 2.9}).value, 2); }); - test("JsonParser uses the field name by default and keeps nulls", () { + test("JsonKey parse uses the field name by default and keeps nulls", () { final (:codec, :engine) = codecFixture(); final input = Map.unmodifiable({ "child": Map.unmodifiable({"id": 3, "label": 9}), @@ -613,7 +611,7 @@ void main() { expect(codec.engine, same(engine)); }); - test("JsonParser custom keys override aliases and report the backend key", () { + test("JsonKey name overrides aliases and reports the backend key", () { final (:codec, :engine) = codecFixture(); registerModel( engine, @@ -622,8 +620,7 @@ void main() { StructureConformity.basic, [ DogStructureField.create("child", optional: true, annotations: const [ - JsonAliases(["legacy_child", "child"]), - JsonParser(parseChild, key: "product"), + JsonKey(aliases: ["legacy_child", "child"], name: "product", parse: parseChild), ]), DogStructureField.create("payload", optional: true), DogStructureField.create( @@ -652,7 +649,7 @@ void main() { expect(codec.engine, same(engine)); }); - test("JsonParser falls back through aliases when no custom key is set", () { + test("JsonKey parse falls back through aliases when no name is set", () { final (:codec, :engine) = codecFixture(); expect( codec.fromJson({ @@ -669,7 +666,7 @@ void main() { expect(codec.engine, same(engine)); }); - test("JsonListParser reads a custom key and defaults empty lists", () { + test("JsonKey list parse reads a custom name and defaults empty lists", () { final (:codec, :engine) = codecFixture(); final items = List.unmodifiable([ Map.unmodifiable({"id": 1, "label": "a"}),