diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index c336cf58..48148e21 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,8 @@ strict/passthrough policy to object models and aliases. * Reject object shape-changing methods that cannot be modeled statically, instead of generating fields that disagree with the authored schema. +* Resolve same-build scalar, list, and codec model references using their + actual runtime types, and generate valid codecs for nullable nested Sets. * Allow built-in and contextual Dart keywords such as `required` as generated field, discriminator, and capture-member names without changing their JSON diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart index 4f7a270e..0b3c308d 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -826,6 +826,22 @@ final class CustomSchemaResolver { field: field, ); if (target == null) return null; + final schemaType = switch (target) { + TopLevelVariableElement(:final type) => type, + GetterElement(:final returnType) => returnType, + _ => null, + }; + final runtime = schemaType == null + ? null + : _base.ackSchemaRuntimeType(schemaType); + if (runtime == null || _base.containsInvalidType(runtime)) { + throw InvalidGenerationSource( + '${_fieldPath(field)} resolves to generated model "$name", but ' + '${target.name} has no resolvable AckSchema runtime type. Declare ' + 'the schema type explicitly.', + element: field, + ); + } final visibleName = qualifySymbol(prefix, name); result = ( schemaExpression: '$visibleName.\$ack.schema', @@ -835,7 +851,11 @@ final class CustomSchemaResolver { declarationName: target.name!, ), className: name, - runtimeRef: ModelGraphBuilderBase.jsonMapRef, + runtimeRef: _base.inferTypeRef( + runtime, + path: _fieldPath(field), + context: field, + ), importPrefix: prefix, ), setListSchema: null, diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index fa254bda..6c831136 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -70,7 +70,11 @@ final class AckSchemaInference { rejectNullableCollectionElement: rejectNullableCollectionElement, validateMapKey: validateMapKey, ); - return '${_ack('Ack')}.list($itemSchema).codec<${renderType(type)}>(decode: (list) => list.toSet(), encode: (set) => set.toList(growable: false),)'; + final runtimeType = type.element.instantiate( + typeArguments: type.typeArguments, + nullabilitySuffix: NullabilitySuffix.none, + ); + return '${_ack('Ack')}.list($itemSchema).codec<${renderType(runtimeType)}>(decode: (list) => list.toSet(), encode: (set) => set.toList(growable: false),)'; } if (type.isDartCoreMap) { validateMapKey(type); diff --git a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart index f8c81d35..4df7eb74 100644 --- a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart @@ -252,6 +252,76 @@ final class Holder with _$HolderAck { } ''', ); + File( + p.join(temporary.path, 'lib', 'value_models.dart'), + ).writeAsStringSync(r''' +import 'package:ack/ack.dart'; + +part 'value_models.g.dart'; + +final class Token { + const Token(this.value); + final String value; + static int decodes = 0; + static int encodes = 0; + + static Token decode(String value) { + decodes++; + return Token(value); + } + + static String encode(Token token) { + encodes++; + return token.value; + } + + @override + bool operator ==(Object other) => other is Token && other.value == value; + @override + int get hashCode => value.hashCode; +} + +@Schemable() +final scalarSchema = Ack.integer(); +@Schemable() +final stringsSchema = Ack.list(Ack.string()); +@Schemable() +final encodedSchema = Ack.string().codec( + decode: Token.decode, + encode: Token.encode, +); +'''); + File( + p.join(temporary.path, 'lib', 'value_holder.dart'), + ).writeAsStringSync(r''' +import 'package:ack/ack.dart'; +import 'value_models.dart' as values; + +part 'value_holder.g.dart'; + +@Schemable() +final class ValueHolder with _$ValueHolderAck { + const ValueHolder({ + required this.scalar, + required this.strings, + required this.encoded, + required this.history, + required this.unique, + this.patch = const JsonMaybe.absent(), + required this.sets, + required this.nestedSets, + }); + + final values.Scalar scalar; + final values.Strings strings; + final values.Encoded encoded; + final List history; + final Set unique; + final JsonMaybe patch; + final Map?> sets; + final Map?>?> nestedSets; +} +'''); File( p.join(temporary.path, 'lib', 'address_envelope.dart'), ).writeAsStringSync(r''' @@ -284,9 +354,55 @@ import 'package:ack_class_first_nested/itinerary.dart'; import 'package:ack_class_first_nested/order.dart'; import 'package:ack_class_first_nested/parcel.dart'; import 'package:ack_class_first_nested/pet.dart'; +import 'package:ack_class_first_nested/value_holder.dart'; +import 'package:ack_class_first_nested/value_models.dart' as values; +import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { + test('same-build value models and nullable nested Sets round-trip', () { + values.Token.decodes = 0; + values.Token.encodes = 0; + final json = { + 'scalar': 7, + 'strings': ['one', 'two'], + 'encoded': 'token', + 'history': [1, 2], + 'unique': [3, 4], + 'sets': {'present': [1, 2], 'none': null}, + 'nestedSets': { + 'present': {'items': ['a', 'b'], 'none': null}, + 'none': null, + }, + }; + final holder = ValueHolderSchema.parse(json); + expect(holder.scalar.value, 7); + expect(holder.strings.value, ['one', 'two']); + expect(holder.encoded.value.value, 'token'); + expect(holder.history.map((model) => model.value), [1, 2]); + expect(holder.unique.map((model) => model.value), [3, 4]); + expect(holder.patch, const JsonMaybe.absent()); + expect(holder.sets['present'], {1, 2}); + expect(holder.sets['none'], isNull); + expect(holder.nestedSets['present']!['items'], {'a', 'b'}); + expect(holder.nestedSets['present']!['none'], isNull); + expect(holder.nestedSets['none'], isNull); + expect(values.Token.decodes, 1); + expect(holder.toJson(), json); + expect(values.Token.encodes, 1); + expect(ValueHolderSchema.parse(holder.toJson()), holder); + expect(() => holder.strings.value.add('three'), throwsUnsupportedError); + expect(() => holder.sets['present']!.add(3), throwsUnsupportedError); + expect( + () => holder.nestedSets['present']!['items']!.add('c'), + throwsUnsupportedError, + ); + + final patched = ValueHolderSchema.parse({...json, 'patch': 'changed'}); + expect(patched.patch.valueOrNull!.value.value, 'changed'); + expect(patched.toJson()['patch'], 'changed'); + }); + test('nested models parse, encode, and export through facades', () { final order = OrderSchema.parse({ 'shipping': {'city': 'Amsterdam'}, diff --git a/packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart b/packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart new file mode 100644 index 00000000..f76ffb7d --- /dev/null +++ b/packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart @@ -0,0 +1,82 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +Future _expectOutput(String source, Matcher matcher) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + {'test_pkg|lib/models.dart': source}, + generateFor: const {'test_pkg|lib/models.dart'}, + readerWriter: readerWriter, + outputs: {'test_pkg|lib/models.g.dart': decodedMatches(matcher)}, + ); +} + +void main() { + test( + 'same-build value models use their actual schema runtime types', + () async { + await _expectOutput( + r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +final class Token { + const Token(this.value); + final String value; +} + +@Schemable() +final scalarSchema = Ack.integer(); +@Schemable() +final stringsSchema = Ack.list(Ack.string()); +@Schemable() +final encodedSchema = Ack.string().codec( + decode: Token.new, + encode: (token) => token.value, +); + +@Schemable() +final class Holder with _$HolderAck { + const Holder({required this.scalar, required this.strings, required this.encoded}); + final Scalar scalar; + final Strings strings; + final Encoded encoded; +} +''', + allOf([ + contains(r"Scalar.$ack.fromRuntime(value['scalar'] as int)"), + contains( + r"Strings.$ack.fromRuntime(value['strings'] as List)", + ), + contains(r"Encoded.$ack.fromRuntime(value['encoded'] as Token)"), + ]), + ); + }, + ); + + test('nullable nested Sets use non-nullable codec type arguments', () async { + await _expectOutput( + r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +@Schemable() +final class Holder with _$HolderAck { + const Holder({required this.values, required this.nested}); + final Map?> values; + final Map?>?> nested; +} +''', + allOf([ + contains('.codec>('), + contains('.codec>('), + isNot(contains('.codec?>(')), + isNot(contains('.codec?>(')), + ]), + ); + }); +}