diff --git a/packages/dogs_core/CHANGELOG.md b/packages/dogs_core/CHANGELOG.md index 17d64ec..1d6d7c6 100644 --- a/packages/dogs_core/CHANGELOG.md +++ b/packages/dogs_core/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased + +- Add an opt-in `DogModelCodec` for strict JSON model reads, typed nested models, input aliases, explicit conversions, and filtered raw maps. +- 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. + ## 10.2.0 - **FEAT**: Harder version bump. diff --git a/packages/dogs_core/README.md b/packages/dogs_core/README.md index 3d7eb23..fa5f94a 100644 --- a/packages/dogs_core/README.md +++ b/packages/dogs_core/README.md @@ -100,6 +100,51 @@ 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 { + @JsonKey(aliases: ['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. +`@JsonModel(emptyListsOnNull: true)` supplies fresh typed empty lists for +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. +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 +166,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 8c65921..1dfd853 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 0000000..f50bfd2 --- /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 bab1648..f91343e 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 0000000..3656b2d --- /dev/null +++ b/packages/dogs_core/lib/src/model_codec.dart @@ -0,0 +1,319 @@ +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. + final List? outputKeys; + + /// 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.emptyListsOnNull = 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 key = field.firstAnnotationOf(); + final aliases = key?.aliases; + if (aliases != null) map[field.name] = key!.readAliasValue(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)); + } +} + +/// 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; + + /// Reads using the full parent input map. + final Object? Function(Map)? read; + + /// 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; + } + return null; + } + + /// Reads from the original map so aliases never affect each other. + Object? readAliasValue(Map json) => json[readAliasKey(json)]; + + @override + void beforeFieldDeserialization(NativeStructureContext context, + NativeStructureFieldContext fieldContext, Map map, DogEngine engine) { + if (hasFixedValue()) map[fieldContext.key] = value; + } +} + +/// Common explicit conversions that are not inferred from Dart field types. +/// +/// 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(); + + /// 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 emptyListsOnNull = descriptor.firstAnnotationOf()?.emptyListsOnNull ?? false; + final fields = []; + for (final field in descriptor.fields) { + if (values.containsKey(field.name)) { + fields.add(values[field.name]); + continue; + } + final keyMeta = field.firstAnnotationOf(); + if (keyMeta != null && keyMeta.hasFixedValue()) { + fields.add(keyMeta.value); + continue; + } + if (keyMeta?.read != null) { + fields.add(keyMeta!.read!(json)); + continue; + } + final Object? value; + final String key; + final bool missing; + 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]; + missing = !json.containsKey(field.name); + } + final input = {key: value}; + final defaultValue = field.firstAnnotationOf(); + final useDefault = + defaultValue != null && (missing || (defaultValue.onNull && value == null)); + if (keyMeta?.convert != null) { + final provided = useDefault ? defaultValue.value : value; + final supplied = useDefault && provided is DefaultValueSupplier ? provided() : provided; + fields.add(keyMeta!.convert!(supplied)); + continue; + } + 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) => keyMeta!.parse!(data) as T); + })); + continue; + } + final nested = engine.findStructureByType(field.serial.typeArgument); + if (isList && + (nested != null || + keyMeta?.each != null || + keyMeta?.parse != null || + defaultValue != null || + emptyListsOnNull)) { + fields.add(field.serial.consumeType(() { + final result = guard.list(input, key, (item) { + if (keyMeta?.parse != null) { + return keyMeta!.parse!(item as Map) as T; + } + if (keyMeta?.each != null) return keyMeta!.each!(item) as T; + if (nested != null) { + return readChild(nested, item as Map) as T; + } + return item as T; + }); + 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; + } + 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 0000000..6ddff63 --- /dev/null +++ b/packages/dogs_core/test/model_codec_test.dart @@ -0,0 +1,742 @@ +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 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 = []; + + @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"] ?? ""}"; + +ParsedChild parseChild(Map json) => ParsedChild( + id: json["id"] as int?, + label: json["label"]?.toString(), + ); + +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 [ + JsonKey(aliases: ["legacy_id", "id"]) + ]), + DogStructureField.string("name", optional: true, annotations: const [ + JsonKey(aliases: ["legacy_name", "name"]) + ]), + DogStructureField.string("echo", optional: true, annotations: const [ + JsonKey(aliases: ["name"]) + ]), + DogStructureField.bool("flag", optional: true, annotations: const [ + JsonKey(aliases: ["legacy_flag", "flag"]) + ]), + DogStructureField.bool("ignored", annotations: const [JsonKey(value: 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({bool emptyListsOnNull = true}) => DogStructure( + "TreeValue", + StructureConformity.basic, + [ + DogStructureField.create("child", optional: true, annotations: const [ + JsonKey(aliases: ["payload"]) + ]), + DogStructureField.create( + "children", + optional: true, + iterable: IterableKind.list, + annotations: const [ + JsonKey(aliases: ["children", "old_children"]) + ], + ), + DogStructureField.create("nested", optional: true), + ], + [JsonModel(emptyListsOnNull: emptyListsOnNull)], + 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 [ + JsonKey(aliases: ["legacy_text", "text"], convert: JsonConversions.stringify), + ]), + DogStructureField.int("flag", annotations: const [JsonKey(convert: JsonConversions.flagInt)]), + DogStructureField.int("whole", optional: true, annotations: const [ + DefaultValue(0, onNull: true), + JsonKey(convert: JsonConversions.integer) + ]), + DogStructureField.int("numbers", + optional: true, + iterable: IterableKind.list, + annotations: const [JsonKey(each: JsonConversions.integer)]), + DogStructureField.string("summary", + optional: true, annotations: const [JsonKey(read: readSummary)]), + ], + const [JsonModel(emptyListsOnNull: true)], + 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]), + ); + +DogStructure parsedStructure() => DogStructure( + "ParsedHolder", + StructureConformity.basic, + [ + DogStructureField.create("child", optional: true, annotations: const [ + JsonKey(parse: parseChild), + ]), + DogStructureField.create("payload", optional: true, annotations: const [ + JsonKey(aliases: ["legacy_payload", "payload"], parse: parseChild), + ]), + DogStructureField.create( + "children", + optional: true, + iterable: IterableKind.list, + annotations: const [ + JsonKey(name: "items", parse: parseChild), + ], + ), + ], + 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(); + registerModel(engine, aliasStructure()); + registerModel(engine, treeStructure()); + registerModel(engine, convertedStructure()); + registerModel(engine, numberStructure()); + registerModel(engine, textStructure()); + registerModel(engine, envelopeStructure()); + registerModel(engine, parsedStructure()); + 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 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({ + "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), + 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("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}), + }); + 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("JsonKey name overrides aliases and reports the backend key", () { + final (:codec, :engine) = codecFixture(); + registerModel( + engine, + DogStructure( + "ParsedHolder", + StructureConformity.basic, + [ + DogStructureField.create("child", optional: true, annotations: const [ + JsonKey(aliases: ["legacy_child", "child"], name: "product", parse: parseChild), + ]), + 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("JsonKey parse falls back through aliases when no name 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("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"}), + 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)])); + 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 c6f2d28..2d17f4f 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 323cdc0..5564f72 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 fd69dde..f98194e 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 0000000..ae7d004 --- /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 b68eb86..99b36fd 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 1d66444..74019d4 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 0000000..9525644 --- /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 0000000..2179327 --- /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); + }); + }); +}