From d2ec4fabdfac698e7adfd7c15c935b56ad2560af Mon Sep 17 00:00:00 2001 From: Angelo Cassano Date: Tue, 18 Aug 2026 17:47:02 +0200 Subject: [PATCH 1/4] fix(generator): skip nested converter synthesis for ignored targets The auto-resolution loop in StandardMappingMethodAnalyzer computed extraMappingMethod guarded only by `callableMappingMethod == null`. ignoredTargets was read afterwards, when populating Binding.ignored, so the nested mapping method for an ignored target was still analyzed and could throw NoRelationFoundError for code that is never emitted. @Mapping(target: 'nested', ignore: true) now short-circuits the extra mapping method analysis. --- .../standard_mapping_method_analyzer.dart | 7 ++- .../test/golden/src/nested_test_src.dart | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart b/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart index 93c3dd4..eb97abf 100644 --- a/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart +++ b/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart @@ -476,8 +476,11 @@ class StandardBindingsAnalyzer extends Analyzer> { nullable: (targetSubstituted?[targetClassParamName] ?? resolvedTargetParam.type).isNullable, ); + final isIgnored = ignoredTargets.contains(targetClassParamName); final callableMappingMethod = callableMap[targetClassParamName]; - final extraMappingMethod = callableMappingMethod == null + // Ignored targets must not synthesize a nested converter: analyzing + // it would surface binding errors for a mapping that is never emitted. + final extraMappingMethod = callableMappingMethod == null && !isIgnored ? extraMappingMethodAnalyzer.analyze( FieldsAnalyzerContext( mapperAnnotation: context.mapperAnnotation, @@ -495,7 +498,7 @@ class StandardBindingsAnalyzer extends Analyzer> { Binding( source: sourceField, target: targetField, - ignored: ignoredTargets.contains(targetClassParamName), + ignored: isIgnored, forceNonNull: forceNonNullTargets.contains(targetClassParamName), callableMappingMethod: callableMappingMethod, diff --git a/packages/dart_mapper_generator/test/golden/src/nested_test_src.dart b/packages/dart_mapper_generator/test/golden/src/nested_test_src.dart index d78da2e..5625473 100644 --- a/packages/dart_mapper_generator/test/golden/src/nested_test_src.dart +++ b/packages/dart_mapper_generator/test/golden/src/nested_test_src.dart @@ -60,3 +60,51 @@ abstract class InnerMapper { abstract class OuterMapper { OuterTarget toTarget(OuterSource source); } + +// Regression: `ignore: true` on a nested field must suppress synthesis of the +// nested converter. The extra mapping method used to be analyzed anyway, +// throwing NoRelationFoundError for a mapping that is never emitted. + +class IgnoredInnerSource { + final String code; + + IgnoredInnerSource(this.code); +} + +class IgnoredInnerTarget { + final String code; + final String direction; + + IgnoredInnerTarget({required this.code, required this.direction}); +} + +class IgnoredNestedSource { + final String id; + final IgnoredInnerSource? inner; + + IgnoredNestedSource(this.id, this.inner); +} + +class IgnoredNestedTarget { + final String id; + final IgnoredInnerTarget? inner; + + IgnoredNestedTarget({required this.id, this.inner}); +} + +@ShouldGenerate( + r'''class IgnoredNestedMapperImpl extends IgnoredNestedMapper { + IgnoredNestedMapperImpl(); + + @override + IgnoredNestedTarget toTarget(IgnoredNestedSource source) { + return IgnoredNestedTarget(id: source.id, inner: null); + } +}''', + contains: true, +) +@Mapper() +abstract class IgnoredNestedMapper { + @Mapping(target: 'inner', ignore: true) + IgnoredNestedTarget toTarget(IgnoredNestedSource source); +} From e7e91466f2e68bf017cc225a67547abd9962c238 Mon Sep 17 00:00:00 2001 From: Angelo Cassano Date: Tue, 18 Aug 2026 17:47:08 +0200 Subject: [PATCH 2/4] fix(generator): resolve target and non-enum returns in enum mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the null-source branch of the generated switch: - The sentinel check in EnumExpressionFactory was nested inside the isPrimitive branch, so an enum return type emitted the sentinel as an identifier (MyEnum.). - EnumMappingCodeProcessor built the null case as refer(returnType).property(target), assuming the return type is always an enum. With a String return type this produced String.RED, and String. when mapping null to null — the latter fails at build time with an unhelpful parser error from the formatter. The null case now goes through the expression factory, like every other case in the switch, and the sentinel resolves to null for any target type. --- .../factories/enum_expression_factory.dart | 11 ++-- .../enum_mapping_code_processor.dart | 16 +++++- .../src/null_value_source_test_src.dart | 54 +++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/dart_mapper_generator/lib/src/factories/enum_expression_factory.dart b/packages/dart_mapper_generator/lib/src/factories/enum_expression_factory.dart index f8ed952..a4f00d8 100644 --- a/packages/dart_mapper_generator/lib/src/factories/enum_expression_factory.dart +++ b/packages/dart_mapper_generator/lib/src/factories/enum_expression_factory.dart @@ -38,12 +38,15 @@ class EnumExpressionFactory extends ExpressionFactory { @override Expression create(ExpressionContext context) { + // The sentinel is a target value, not an identifier: it must resolve + // to `null` regardless of the target type (String, num, enum, ...). + if (context.origin == FieldOrigin.target && + context.field.name == ValueMapping.nullValue) { + return literalNull; + } + if (context.field.type.isPrimitive) { if (context.origin == FieldOrigin.target) { - if (context.field.name == ValueMapping.nullValue) { - return literalNull; - } - if (context.field.type.isDartCoreInt) { return literal(context.field.name).stringToInt( nullable: context.currentMethod.optionalReturn, diff --git a/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart b/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart index 457557d..2e2fc74 100644 --- a/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart +++ b/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart @@ -23,13 +23,14 @@ * OTHER DEALINGS IN THE SOFTWARE. */ -import 'package:code_builder/code_builder.dart'; +import 'package:code_builder/code_builder.dart' hide Field; import 'package:dart_mapper/dart_mapper.dart'; import 'package:dart_mapper_generator/src/exceptions/unknown_return_type_error.dart'; import 'package:dart_mapper_generator/src/extensions/element.dart'; import 'package:dart_mapper_generator/src/factories/expression_factory.dart'; import 'package:dart_mapper_generator/src/misc/expressions.dart'; import 'package:dart_mapper_generator/src/misc/strings.dart'; +import 'package:dart_mapper_generator/src/models/field/field.dart'; import 'package:dart_mapper_generator/src/models/mapper/mapping/method/bases/bindable_mapping_method.dart'; import 'package:dart_mapper_generator/src/models/mapper/mapping/method/defined_mapping_method.dart'; import 'package:dart_mapper_generator/src/models/mapping_behavior.dart'; @@ -79,7 +80,18 @@ class EnumMappingCodeProcessor extends ComponentProcessor { ( literal(null), method is DefinedMappingMethod && method.nullSourceTarget != null - ? refer(qualifiedEnumName).property(method.nullSourceTarget!) + ? expressionFactory.create( + ExpressionContext( + field: Field.from( + name: method.nullSourceTarget!, + type: method.returnType!, + ), + origin: FieldOrigin.target, + counterpartField: sourceField, + currentMethod: method, + importAliases: context.importAliases, + ), + ) : method.optionalReturn ? literal(null) : throwArgumentErrorNotNull(sourceField.name), diff --git a/packages/dart_mapper_generator/test/golden/src/null_value_source_test_src.dart b/packages/dart_mapper_generator/test/golden/src/null_value_source_test_src.dart index fda637d..7cc91ae 100644 --- a/packages/dart_mapper_generator/test/golden/src/null_value_source_test_src.dart +++ b/packages/dart_mapper_generator/test/golden/src/null_value_source_test_src.dart @@ -62,3 +62,57 @@ abstract class NullValueSourceEnumMapper { @ValueMapping(source: ValueMapping.nullValue, target: 'red') PrimaryTargetColor convertNullable(ExtendedSourceColor? source); } + +// Regression: as target must resolve to `null` regardless of the +// return type. It used to be emitted as an identifier (e.g. `String.`, +// `PrimaryTargetColor.`), producing unparsable code. + +@ShouldGenerate( + r'''null => null,''', + contains: true, +) +@ShouldGenerate( + r'''ExtendedSourceColor.red => PrimaryTargetColor.red,''', + contains: true, +) +@Mapper() +abstract class NullValueToNullEnumMapper { + @ValueMapping(source: ValueMapping.anyRemaining, target: 'blue') + @ValueMapping(source: ValueMapping.nullValue, target: ValueMapping.nullValue) + PrimaryTargetColor? convertNullable(ExtendedSourceColor? source); +} + +// Regression: a non-enum (String) return type must go through the expression +// factory instead of `refer(returnType).property(target)`. + +@ShouldGenerate( + r'''null => null,''', + contains: true, +) +@ShouldGenerate( + r'''ExtendedSourceColor.red => 'RED',''', + contains: true, +) +@Mapper() +abstract class NullValueToNullStringMapper { + @ValueMapping(source: ValueMapping.nullValue, target: ValueMapping.nullValue) + @ValueMapping(source: 'red', target: 'RED') + @ValueMapping(source: 'green', target: 'GREEN') + @ValueMapping(source: 'blue', target: 'BLUE') + @ValueMapping(source: 'yellow', target: 'YELLOW') + String? convertToCode(ExtendedSourceColor? source); +} + +@ShouldGenerate( + r'''null => 'UNKNOWN',''', + contains: true, +) +@Mapper() +abstract class NullValueToStringMapper { + @ValueMapping(source: ValueMapping.nullValue, target: 'UNKNOWN') + @ValueMapping(source: 'red', target: 'RED') + @ValueMapping(source: 'green', target: 'GREEN') + @ValueMapping(source: 'blue', target: 'BLUE') + @ValueMapping(source: 'yellow', target: 'YELLOW') + String convertToCode(ExtendedSourceColor? source); +} From 8aac69dc5a1df5faf4f43e6aa1ddbb2626e9618b Mon Sep 17 00:00:00 2001 From: Angelo Cassano Date: Tue, 18 Aug 2026 17:55:39 +0200 Subject: [PATCH 3/4] fix(generator): render sentinel fallbacks through the expression factory and built the switch fallback as refer(returnType).property(target), which assumes an enum return type. With a String return type this emitted String.UNKNOWN instead of 'UNKNOWN'; with an int return type it emitted int.-1, which fails to parse at build time. The fallback now goes through the same expression factory path as the regular cases and the source case, so the target type decides how the value is rendered. The special case for is no longer needed: the factory resolves the sentinel for any target type. --- .../enum_mapping_code_processor.dart | 43 ++++++++++--------- .../golden/src/enum_defaults_test_src.dart | 38 ++++++++++++++++ 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart b/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart index 2e2fc74..3d09219 100644 --- a/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart +++ b/packages/dart_mapper_generator/lib/src/processors/mapping_code/enum_mapping_code_processor.dart @@ -24,7 +24,6 @@ */ import 'package:code_builder/code_builder.dart' hide Field; -import 'package:dart_mapper/dart_mapper.dart'; import 'package:dart_mapper_generator/src/exceptions/unknown_return_type_error.dart'; import 'package:dart_mapper_generator/src/extensions/element.dart'; import 'package:dart_mapper_generator/src/factories/expression_factory.dart'; @@ -66,10 +65,26 @@ class EnumMappingCodeProcessor extends ComponentProcessor { final safeEnumDisplayName = targetEnum.displayName.replaceAll('\\', '\\\\').replaceAll(r'$', r'\$'); - final qualifiedEnumName = context.resolveType(method.returnType!); final sourceField = method.parameters.first.field; final expressionFactory = expressionStrategyDispatcher.get(method.behavior); + // Sentinel-driven cases ( source, , ) + // carry a target *name* rather than a Binding. Route them through the + // expression factory like every other case, so the target type — enum, + // String, num — decides how the value is rendered. + Expression targetExpression(String targetName) => expressionFactory.create( + ExpressionContext( + field: Field.from( + name: targetName, + type: method.returnType!, + ), + origin: FieldOrigin.target, + counterpartField: sourceField, + currentMethod: method, + importAliases: context.importAliases, + ), + ); + return Block( (b) => b ..addExpression( @@ -80,18 +95,7 @@ class EnumMappingCodeProcessor extends ComponentProcessor { ( literal(null), method is DefinedMappingMethod && method.nullSourceTarget != null - ? expressionFactory.create( - ExpressionContext( - field: Field.from( - name: method.nullSourceTarget!, - type: method.returnType!, - ), - origin: FieldOrigin.target, - counterpartField: sourceField, - currentMethod: method, - importAliases: context.importAliases, - ), - ) + ? targetExpression(method.nullSourceTarget!) : method.optionalReturn ? literal(null) : throwArgumentErrorNotNull(sourceField.name), @@ -123,8 +127,8 @@ class EnumMappingCodeProcessor extends ComponentProcessor { otherwise: _buildOtherwiseExpression( method: method, safeEnumDisplayName: safeEnumDisplayName, - qualifiedEnumName: qualifiedEnumName, sourceFieldName: sourceField.name, + targetExpression: targetExpression, ), ).returned, ), @@ -134,18 +138,15 @@ class EnumMappingCodeProcessor extends ComponentProcessor { Expression _buildOtherwiseExpression({ required BindableMappingMethod method, required String safeEnumDisplayName, - required String qualifiedEnumName, required String sourceFieldName, + required Expression Function(String targetName) targetExpression, }) { if (method is DefinedMappingMethod && method.anyRemainingTarget != null) { - return refer(qualifiedEnumName).property(method.anyRemainingTarget!); + return targetExpression(method.anyRemainingTarget!); } if (method is DefinedMappingMethod && method.anyUnmappedTarget != null) { - if (method.anyUnmappedTarget == ValueMapping.nullValue) { - return literal(null); - } - return refer(qualifiedEnumName).property(method.anyUnmappedTarget!); + return targetExpression(method.anyUnmappedTarget!); } if (method.optionalReturn) { diff --git a/packages/dart_mapper_generator/test/golden/src/enum_defaults_test_src.dart b/packages/dart_mapper_generator/test/golden/src/enum_defaults_test_src.dart index 5c6337a..f7a57c2 100644 --- a/packages/dart_mapper_generator/test/golden/src/enum_defaults_test_src.dart +++ b/packages/dart_mapper_generator/test/golden/src/enum_defaults_test_src.dart @@ -92,3 +92,41 @@ abstract class MutualExclusionEnumMapper { @ValueMapping(source: ValueMapping.anyUnmapped, target: ValueMapping.nullValue) TargetColor convert(SourceColor source); } + +// Regression: the fallback target must be rendered by the expression factory, +// not as `refer(returnType).property(target)`. With a non-enum return type the +// latter emitted `String.UNKNOWN` (parsable but wrong) and `int.-1` (a build +// failure). + +@ShouldGenerate( + r"""_ => 'UNKNOWN',""", + contains: true, +) +@Mapper() +abstract class AnyRemainingToStringMapper { + @ValueMapping(source: ValueMapping.anyRemaining, target: 'UNKNOWN') + @ValueMapping(source: 'red', target: 'RED') + String convert(SourceColor source); +} + +@ShouldGenerate( + r'''_ => int.parse('-1'),''', + contains: true, +) +@Mapper() +abstract class AnyRemainingToIntMapper { + @ValueMapping(source: ValueMapping.anyRemaining, target: '-1') + @ValueMapping(source: 'red', target: '0') + int convert(SourceColor source); +} + +@ShouldGenerate( + r'''_ => null,''', + contains: true, +) +@Mapper() +abstract class AnyUnmappedToStringMapper { + @ValueMapping(source: ValueMapping.anyUnmapped, target: ValueMapping.nullValue) + @ValueMapping(source: 'red', target: 'RED') + String? convert(SourceColor source); +} From ebabee90ef7f26dd4dbfb1a18c99ea8e927b99b0 Mon Sep 17 00:00:00 2001 From: Angelo Cassano Date: Tue, 18 Aug 2026 18:04:53 +0200 Subject: [PATCH 4/4] fix(generator): honour ignore on explicitly mapped and built_value targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ignored-target guard was applied only to the auto-resolution loop of the standard analyzer. Three other binding sites computed extraMappingMethod without it: - StandardMappingMethodAnalyzer, explicit dot-notation mappings. @Mapping(target: 'x', source: 'a.b', ignore: true) still analyzed the nested converter and could throw NoRelationFoundError. - BuiltBindingsAnalyzer, both the dot-notation and the auto-resolution branch. Here the symptom is worse than an error: the synthesized converter took precedence over the ignored flag, so ignore was silently disregarded and the generated builder assigned a converted value instead of null — leaving required target fields unset and failing at runtime rather than at build time. All four sites now share the same guard. --- .../binding/built_bindings_analyzer.dart | 14 ++++-- .../standard_mapping_method_analyzer.dart | 7 ++- .../golden/src/dot_notation_test_src.dart | 50 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/packages/dart_mapper_generator/lib/src/analyzers/binding/built_bindings_analyzer.dart b/packages/dart_mapper_generator/lib/src/analyzers/binding/built_bindings_analyzer.dart index 13d0bd9..c6047a7 100644 --- a/packages/dart_mapper_generator/lib/src/analyzers/binding/built_bindings_analyzer.dart +++ b/packages/dart_mapper_generator/lib/src/analyzers/binding/built_bindings_analyzer.dart @@ -150,8 +150,11 @@ class BuiltBindingsAnalyzer extends Analyzer> { ); } + final isIgnored = ignoredTargets.contains(targetName); final callableMappingMethod = callableMap[targetName]; - final extraMappingMethod = callableMappingMethod == null + // Ignored targets must not synthesize a nested converter: analyzing + // it would surface binding errors for a mapping that is never emitted. + final extraMappingMethod = callableMappingMethod == null && !isIgnored ? extraMappingMethodAnalyzer.analyze( FieldsAnalyzerContext( mapperAnnotation: context.mapperAnnotation, @@ -169,7 +172,7 @@ class BuiltBindingsAnalyzer extends Analyzer> { Binding( source: resolvedField, target: targetField, - ignored: ignoredTargets.contains(targetName), + ignored: isIgnored, forceNonNull: forceNonNullTargets.contains(targetName), callableMappingMethod: callableMappingMethod, extraMappingMethod: extraMappingMethod, @@ -253,8 +256,11 @@ class BuiltBindingsAnalyzer extends Analyzer> { nullable: (targetSubstituted?[targetName] ?? targetGetter.type).isNullable, ); + final isIgnored = ignoredTargets.contains(targetName); final callableMappingMethod = callableMap[targetName]; - final extraMappingMethod = callableMappingMethod == null + // Ignored targets must not synthesize a nested converter: analyzing + // it would surface binding errors for a mapping that is never emitted. + final extraMappingMethod = callableMappingMethod == null && !isIgnored ? extraMappingMethodAnalyzer.analyze( FieldsAnalyzerContext( mapperAnnotation: context.mapperAnnotation, @@ -272,7 +278,7 @@ class BuiltBindingsAnalyzer extends Analyzer> { Binding( source: sourceField, target: targetField, - ignored: ignoredTargets.contains(targetName), + ignored: isIgnored, forceNonNull: forceNonNullTargets.contains(targetName), callableMappingMethod: callableMappingMethod, extraMappingMethod: extraMappingMethod, diff --git a/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart b/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart index eb97abf..8e039de 100644 --- a/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart +++ b/packages/dart_mapper_generator/lib/src/analyzers/binding/standard_mapping_method_analyzer.dart @@ -299,6 +299,7 @@ class StandardBindingsAnalyzer extends Analyzer> { // and the target is non-null, and forceNonNull is not set, throw. final chainHasNullable = accessChain.any((e) => e.$2); final resolvedIsNullable = resolvedField.nullable || chainHasNullable; + final isIgnored = ignoredTargets.contains(targetName); final callableMappingMethod = callableMap[targetName]; if (resolvedIsNullable && !targetField.nullable && @@ -312,7 +313,9 @@ class StandardBindingsAnalyzer extends Analyzer> { element: method, ); } - final extraMappingMethod = callableMappingMethod == null + // Ignored targets must not synthesize a nested converter: analyzing + // it would surface binding errors for a mapping that is never emitted. + final extraMappingMethod = callableMappingMethod == null && !isIgnored ? extraMappingMethodAnalyzer.analyze( FieldsAnalyzerContext( mapperAnnotation: context.mapperAnnotation, @@ -330,7 +333,7 @@ class StandardBindingsAnalyzer extends Analyzer> { Binding( source: resolvedField, target: targetField, - ignored: ignoredTargets.contains(targetName), + ignored: isIgnored, forceNonNull: forceNonNullTargets.contains(targetName), callableMappingMethod: callableMappingMethod, extraMappingMethod: extraMappingMethod, diff --git a/packages/dart_mapper_generator/test/golden/src/dot_notation_test_src.dart b/packages/dart_mapper_generator/test/golden/src/dot_notation_test_src.dart index 8e98200..b59f086 100644 --- a/packages/dart_mapper_generator/test/golden/src/dot_notation_test_src.dart +++ b/packages/dart_mapper_generator/test/golden/src/dot_notation_test_src.dart @@ -168,3 +168,53 @@ abstract class CallableNullableDotNotationMapper { @Mapping(target: 'streetName', source: 'address.street.name', callable: _upperCase) FlatCallableTarget flattenCallable(NullableStreetPerson source); } + +// Regression: `ignore: true` combined with a dot-notation source must suppress +// synthesis of the nested converter. The explicit-mapping branch used to +// analyze the extra mapping method regardless of ignoredTargets, throwing +// NoRelationFoundError for a converter that is never emitted. + +class IgnoredDotInnerSource { + final String code; + + const IgnoredDotInnerSource(this.code); +} + +class IgnoredDotInnerTarget { + final String code; + final String direction; + + const IgnoredDotInnerTarget({required this.code, required this.direction}); +} + +class IgnoredDotWrapper { + final IgnoredDotInnerSource? inner; + + const IgnoredDotWrapper(this.inner); +} + +class IgnoredDotSource { + final String id; + final IgnoredDotWrapper wrapper; + + const IgnoredDotSource(this.id, this.wrapper); +} + +class IgnoredDotTarget { + final String id; + final IgnoredDotInnerTarget? inner; + + const IgnoredDotTarget({required this.id, this.inner}); +} + +@ShouldGenerate( + r'''IgnoredDotTarget map(IgnoredDotSource source) { + return IgnoredDotTarget(id: source.id, inner: null); + }''', + contains: true, +) +@Mapper() +abstract class IgnoredDotNotationMapper { + @Mapping(target: 'inner', source: 'wrapper.inner', ignore: true) + IgnoredDotTarget map(IgnoredDotSource source); +}