From 363ea8c506245682e3eed146d26f97f63246082b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 24 Mar 2026 19:25:36 -0400 Subject: [PATCH 1/6] feat: add ack type transform integration coverage --- .../ack_annotations/lib/src/ack_type.dart | 35 +- .../lib/src/analyzer/schema_ast_analyzer.dart | 769 +++++++++++++++--- .../lib/src/builders/type_builder.dart | 52 +- .../integration/ack_type_transform_test.dart | 264 ++++++ .../test/test_utils/test_assets.dart | 19 + 5 files changed, 961 insertions(+), 178 deletions(-) create mode 100644 packages/ack_generator/test/integration/ack_type_transform_test.dart diff --git a/packages/ack_annotations/lib/src/ack_type.dart b/packages/ack_annotations/lib/src/ack_type.dart index bcf25a3..610904b 100644 --- a/packages/ack_annotations/lib/src/ack_type.dart +++ b/packages/ack_annotations/lib/src/ack_type.dart @@ -139,12 +139,13 @@ import 'package:meta/meta_meta.dart'; /// ## Collections /// /// Lists of primitives return `List`, and lists of nested schemas return -/// `List`: +/// `List`. Transformed element schemas are also supported: /// ```dart /// @ackType /// final blogPostSchema = Ack.object({ /// 'tags': Ack.list(Ack.string()), // List /// 'comments': Ack.list(commentSchema), // List +/// 'links': Ack.list(Ack.uri()), // List /// }); /// ``` /// @@ -163,15 +164,26 @@ import 'package:meta/meta_meta.dart'; /// | `Ack.literal('value')` | `XType(String)` implements String | /// | `Ack.enumString([...])` | `XType(String)` implements String | /// | `Ack.enumValues([...])` | `XType(T)` implements T | +/// | `Ack.uri()` | `XType(Uri)` implements Uri | +/// | `Ack.date()` / `Ack.datetime()` | `XType(DateTime)` implements DateTime | +/// | `Ack.duration()` | `XType(Duration)` implements Duration | +/// | `Ack.().transform(...)` | `XType(T)` implements T when `T` is explicit | /// /// All extension types include `parse()` and `safeParse()` factory methods. +/// `toJson()` returns the validated representation value that the schema +/// produced. For transformed schemas, that means the transformed value +/// (for example `Uri`, `DateTime`, or a custom `T`), not the original wire +/// format. /// /// ## Unsupported Schema Types /// /// The following schema types are not currently supported for `@AckType`: /// - **`Ack.any()`** - Not supported (defeats type safety purpose) /// - **`Ack.anyOf()`** - Not supported (requires union types/sealed classes) -/// - **`Ack.discriminated()`** - Use @AckModel on discriminated classes instead +/// - **Transformed object schemas** - `Ack.object({...}).transform()` and +/// `objectSchema.transform()` are not supported +/// - **Transformed discriminated schemas** - `Ack.discriminated(...).transform()` +/// and `discriminatedSchema.transform()` are not supported /// /// ## Method Chaining Support /// @@ -180,7 +192,7 @@ import 'package:meta/meta_meta.dart'; /// - ⚠️ **`.nullable()`** - Extension type is NOT generated (see Limitations) /// - ✅ **`.withDefault()`** - Supported, provides fallback value /// - ✅ **`.refine()`** - Supported, adds custom validation -/// - ⚠️ **`.transform()`** - NOT recommended (changes output type, breaks extension type contract) +/// - ✅ **`.transform()`** - Supported for non-object schemas when `T` is explicit /// /// ```dart /// @AckType() @@ -191,9 +203,14 @@ import 'package:meta/meta_meta.dart'; /// .min(0) /// .refine((age) => age < 150, message: 'Too old'); // ✅ Works /// -/// // @AckType() -/// // final transformed = Ack.string().transform((s) => s.length); // ❌ Don't use -/// // → Extension type would wrap String, but transform returns int +/// @AckType() +/// final transformedLink = Ack.string() +/// .transform((value) => Uri.parse(value!)); // ✅ Works +/// +/// @AckType() +/// final directUri = Ack.uri(); // ✅ Works +/// +/// final validatedOnly = Ack.string().uri(); // Still represents String /// ``` /// /// ## Limitations @@ -210,7 +227,11 @@ import 'package:meta/meta_meta.dart'; /// - ✅ `Ack.list(Ack.string())` → `List` /// - ⚠️ `Ack.list(Ack.string().nullable())` → `List` (element /// nullability lost; expected `List`) -/// - **Transform modifier**: Not supported (changes output type) +/// - **Explicit transform output required**: use `.transform(...)`, not +/// `.transform(...)`, so the generator can infer the representation type. +/// - **Constraint-only string helpers stay String**: `Ack.string().uri()`, +/// `Ack.string().date()`, and `Ack.string().datetime()` validate format but do +/// not change the generated representation type. /// - **Dart version**: Requires Dart 3.3+ for extension type support /// /// See also: [AckModel], [AckField] diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index ae361ea..91fcc54 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -16,9 +16,19 @@ final _log = Logger('SchemaAstAnalyzer'); typedef _SchemaReference = ({String name, String? prefix}); typedef _ListElementRef = ({ - MethodInvocation? ackBase, + MethodInvocation? invocation, _SchemaReference? schemaRef, }); +typedef _SchemaChainInfo = ({ + MethodInvocation? ackBase, + _SchemaReference? schemaReference, + bool isOptional, + bool isNullable, + bool wasTruncated, + MethodInvocation? transformInvocation, + DartType? transformOutputType, + String? transformOutputTypeString, +}); typedef _SchemaTypeMapping = ({ DartType dartType, @@ -311,110 +321,232 @@ class SchemaAstAnalyzer { Element2 element, { String? customTypeName, }) { - // Walk the method chain to find the base Ack.xxx() call - // E.g., Ack.string().min(8) -> walk back to find Ack.string() - final baseInvocation = _findBaseAckInvocation(invocation); + final chain = _analyzeSchemaChain(invocation); + final baseInvocation = chain.ackBase; + final schemaReference = chain.schemaReference; + + if (schemaReference != null) { + return _parseSchemaReferenceChain( + variableName: variableName, + schemaReference: schemaReference, + element: element, + customTypeName: customTypeName, + isNullable: chain.isNullable, + transformOutputTypeString: _requireTransformOutputType( + chain, + element, + contextLabel: 'Schema "$variableName"', + ), + ); + } if (baseInvocation == null) { throw InvalidGenerationSourceError( - 'Schema must be an Ack.xxx() method call (e.g., Ack.object(), Ack.string())', + 'Schema must be an Ack.xxx() method call (e.g., Ack.object(), Ack.string()) or a schema reference.', element: element, ); } final methodName = baseInvocation.methodName.name; - final isNullable = _hasModifier(invocation, 'nullable'); + final isNullable = chain.isNullable; + final transformOutputTypeString = _requireTransformOutputType( + chain, + element, + contextLabel: 'Schema "$variableName"', + ); + _throwIfUnsupportedTransformedBaseSchema( + schemaMethod: methodName, + transformOutputTypeString: transformOutputTypeString, + element: element, + contextLabel: 'Schema "$variableName"', + ); - // Parse based on schema type + late final ModelInfo model; switch (methodName) { case 'object': - return _parseObjectSchema( + model = _parseObjectSchema( variableName, baseInvocation, - invocation, // Pass original invocation to check for chained methods + invocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'string': - return _parseStringSchema( + model = _parseStringSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'integer': - return _parseIntegerSchema( + model = _parseIntegerSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'double': - return _parseDoubleSchema( + model = _parseDoubleSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'boolean': - return _parseBooleanSchema( + model = _parseBooleanSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'list': - return _parseListSchema( + model = _parseListSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'literal': - return _parseLiteralSchema( + model = _parseLiteralSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'enumString': - return _parseEnumStringSchema( + model = _parseEnumStringSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; case 'enumValues': - return _parseEnumValuesSchema( + model = _parseEnumValuesSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; + case 'uri': + model = _parseRepresentationSchema( + variableName, + element, + representationType: 'Uri', + isNullable: isNullable, + customTypeName: customTypeName, + ); + break; + case 'date': + case 'datetime': + model = _parseRepresentationSchema( + variableName, + element, + representationType: 'DateTime', + isNullable: isNullable, + customTypeName: customTypeName, + ); + break; + case 'duration': + model = _parseRepresentationSchema( + variableName, + element, + representationType: 'Duration', + isNullable: isNullable, + customTypeName: customTypeName, + ); + break; case 'discriminated': - return _parseDiscriminatedSchema( + model = _parseDiscriminatedSchema( variableName, baseInvocation, element, isNullable: isNullable, customTypeName: customTypeName, ); + break; default: throw InvalidGenerationSourceError( 'Unsupported schema type for @AckType: Ack.$methodName(). ' - 'Supported types: object, string, integer, double, boolean, list, literal, enumString, enumValues, discriminated', + 'Supported types: object, string, integer, double, boolean, list, literal, enumString, enumValues, uri, date, datetime, duration, discriminated', element: element, ); } + + if (transformOutputTypeString != null) { + return _withRepresentationType(model, transformOutputTypeString); + } + + return model; + } + + ModelInfo _parseSchemaReferenceChain({ + required String variableName, + required _SchemaReference schemaReference, + required Element2 element, + String? customTypeName, + required bool isNullable, + required String? transformOutputTypeString, + }) { + final resolved = _resolveSchemaReference(schemaReference, element); + if (resolved == null) { + final referenceLabel = _formatSchemaReference(schemaReference); + throw InvalidGenerationSourceError( + 'Could not resolve schema reference "$referenceLabel" for "$variableName".', + element: element, + ); + } + + if (transformOutputTypeString != null) { + _throwIfUnsupportedTransformedReferencedSchema( + resolved: resolved, + element: element, + contextLabel: 'Schema "$variableName"', + ); + } + + final aliasTypeName = _resolveModelClassName( + variableName, + element, + customTypeName: customTypeName, + ); + final sourceModel = resolved.modelInfo; + + return ModelInfo( + className: aliasTypeName, + schemaClassName: variableName, + fields: sourceModel.fields, + additionalProperties: sourceModel.additionalProperties, + additionalPropertiesField: sourceModel.additionalPropertiesField, + discriminatorKey: sourceModel.discriminatorKey, + discriminatorValue: sourceModel.discriminatorValue, + subtypeNames: sourceModel.subtypeNames, + schemaIdentity: + sourceModel.schemaIdentity ?? + _declarationVisitKey(resolved.sourceDeclaration), + discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, + isFromSchemaVariable: true, + representationType: + transformOutputTypeString ?? sourceModel.representationType, + isNullableSchema: isNullable || sourceModel.isNullableSchema, + ); } /// Parses Ack.object() schema @@ -1060,29 +1192,24 @@ class SchemaAstAnalyzer { MethodInvocation invocation, Element2 element, ) { - final schemaReference = _findSchemaVariableBase(invocation); + final chain = _analyzeSchemaChain(invocation); + final schemaReference = chain.schemaReference; if (schemaReference == null) { return null; } - var isOptional = false; - var isNullable = false; - final (chain, _) = _collectMethodChain(invocation); - for (final current in chain) { - final methodName = current.methodName.name; - if (methodName == 'optional') { - isOptional = true; - } else if (methodName == 'nullable') { - isNullable = true; - } - } - return _buildFieldInfoForSchemaReference( fieldName: fieldName, schemaReference: schemaReference, element: element, - isRequired: !isOptional, - isNullable: isNullable, + isRequired: !chain.isOptional, + isNullable: chain.isNullable, + transformedOutputType: chain.transformOutputType, + transformedRepresentationType: _requireTransformOutputType( + chain, + element, + contextLabel: 'Field "$fieldName"', + ), ); } @@ -1092,6 +1219,8 @@ class SchemaAstAnalyzer { required Element2 element, bool isRequired = true, bool isNullable = false, + DartType? transformedOutputType, + String? transformedRepresentationType, }) { final schemaVarName = schemaReference.name; final library = element.library2; @@ -1115,9 +1244,21 @@ class SchemaAstAnalyzer { ); } - final hasTypedReference = resolvedReference.hasAckTypeAnnotation; - final isObjectRepresentation = - resolvedReference.modelInfo.representationType == kMapType; + final hasTransformOverride = transformedRepresentationType != null; + if (hasTransformOverride) { + _throwIfUnsupportedTransformedReferencedSchema( + resolved: resolvedReference, + element: element, + contextLabel: 'Field "$fieldName"', + ); + } + + final representationType = + transformedRepresentationType ?? + resolvedReference.modelInfo.representationType; + final hasTypedReference = + resolvedReference.hasAckTypeAnnotation && !hasTransformOverride; + final isObjectRepresentation = representationType == kMapType; if (isObjectRepresentation && !hasTypedReference) { throw InvalidGenerationSourceError( 'Field "$fieldName" references object schema "$schemaVarName" ' @@ -1128,16 +1269,27 @@ class SchemaAstAnalyzer { ); } - final mappedType = _representationTypeToDartType( - resolvedReference.modelInfo.representationType, - typeProvider, - ); + final mappedType = + transformedOutputType ?? + _representationTypeToDartType(representationType, typeProvider); final typeBaseName = hasTypedReference ? _qualifyTypeBaseName( resolvedReference.modelInfo.className, resolvedReference.importPrefix, ) : null; + final rawDisplayTypeOverride = + !hasTypedReference && + !mappedType.isDartCoreString && + !mappedType.isDartCoreInt && + !mappedType.isDartCoreDouble && + !mappedType.isDartCoreBool && + !mappedType.isDartCoreNum && + !mappedType.isDartCoreList && + !mappedType.isDartCoreMap && + !mappedType.isDartCoreSet + ? representationType + : null; return FieldInfo( name: fieldName, @@ -1147,9 +1299,11 @@ class SchemaAstAnalyzer { isNullable: isNullable, constraints: [], nestedSchemaRef: hasTypedReference ? schemaVarName : null, - displayTypeOverride: hasTypedReference ? '${typeBaseName}Type' : null, + displayTypeOverride: hasTypedReference + ? '${typeBaseName}Type' + : rawDisplayTypeOverride, nestedSchemaCastTypeOverride: hasTypedReference - ? resolvedReference.modelInfo.representationType + ? representationType : null, ); } @@ -1160,39 +1314,22 @@ class SchemaAstAnalyzer { MethodInvocation invocation, Element2 element, ) { - // Walk the method chain to find modifiers and base type - var isOptional = false; - var isNullable = false; - MethodInvocation? current = invocation; - MethodInvocation? baseInvocation; + final chain = _analyzeSchemaChain(invocation); + final baseInvocation = chain.ackBase; - while (current != null) { - final methodName = current.methodName.name; - - if (methodName == 'optional') { - isOptional = true; - } else if (methodName == 'nullable') { - isNullable = true; - } else { - // This might be the base schema type (Ack.string(), etc.) - // Supports prefixed Ack (e.g., ack.Ack.string()) via _isAckTarget - final target = current.target; - if (_isAckTarget(target)) { - baseInvocation = current; - break; - } - } - - // Move to the next method in the chain - final target = current.target; - if (target is MethodInvocation) { - current = target; - } else { - break; + if (baseInvocation == null) { + if (chain.wasTruncated) { + final typeProvider = element.library2!.typeProvider; + return FieldInfo( + name: fieldName, + jsonKey: fieldName, + type: typeProvider.dynamicType, + isRequired: !chain.isOptional, + isNullable: chain.isNullable, + constraints: const [], + ); } - } - if (baseInvocation == null) { throw InvalidGenerationSourceError( 'Could not determine schema type for field "$fieldName"', element: element, @@ -1200,6 +1337,17 @@ class SchemaAstAnalyzer { } final schemaMethod = baseInvocation.methodName.name; + final transformOutputTypeString = _requireTransformOutputType( + chain, + element, + contextLabel: 'Field "$fieldName"', + ); + _throwIfUnsupportedTransformedBaseSchema( + schemaMethod: schemaMethod, + transformOutputTypeString: transformOutputTypeString, + element: element, + contextLabel: 'Field "$fieldName"', + ); if (schemaMethod == 'object') { throw InvalidGenerationSourceError( @@ -1213,7 +1361,7 @@ class SchemaAstAnalyzer { // Map schema type to Dart type (passing full invocation for context) // Also captures schema variable reference and list metadata for typed wrappers. - final mappedType = _mapSchemaTypeToDartType(baseInvocation, element); + final mappedType = _mapSchemaTypeToDartType(invocation, element); String? displayTypeOverride; var collectionElementDisplayTypeOverride = @@ -1231,11 +1379,23 @@ class SchemaAstAnalyzer { name: fieldName, jsonKey: fieldName, type: mappedType.dartType, - isRequired: !isOptional, - isNullable: isNullable, + isRequired: !chain.isOptional, + isNullable: chain.isNullable, constraints: [], listElementSchemaRef: mappedType.listElementSchemaRef, - displayTypeOverride: displayTypeOverride, + displayTypeOverride: + displayTypeOverride ?? + (transformOutputTypeString != null && + !mappedType.dartType.isDartCoreString && + !mappedType.dartType.isDartCoreInt && + !mappedType.dartType.isDartCoreDouble && + !mappedType.dartType.isDartCoreBool && + !mappedType.dartType.isDartCoreNum && + !mappedType.dartType.isDartCoreList && + !mappedType.dartType.isDartCoreMap && + !mappedType.dartType.isDartCoreSet + ? transformOutputTypeString + : null), collectionElementDisplayTypeOverride: collectionElementDisplayTypeOverride, collectionElementCastTypeOverride: mappedType.listElementCastTypeOverride, @@ -1251,12 +1411,53 @@ class SchemaAstAnalyzer { MethodInvocation invocation, Element2 element, ) { - final schemaMethod = invocation.methodName.name; + final chain = _analyzeSchemaChain(invocation); + final schemaReference = chain.schemaReference; + final baseInvocation = chain.ackBase; // We need to get the type provider from the element's library final library = element.library2!; - final typeProvider = library.typeProvider; + final transformOutputTypeString = _requireTransformOutputType( + chain, + element, + contextLabel: 'Schema expression', + ); + + if (schemaReference != null) { + return _resolveSchemaVariableType( + schemaReference, + element, + typeProvider, + transformedOutputType: chain.transformOutputType, + transformedRepresentationType: transformOutputTypeString, + ); + } + + if (baseInvocation == null) { + throw InvalidGenerationSourceError( + 'Could not determine schema type for "${invocation.toSource()}".', + element: element, + ); + } + + final schemaMethod = baseInvocation.methodName.name; + _throwIfUnsupportedTransformedBaseSchema( + schemaMethod: schemaMethod, + transformOutputTypeString: transformOutputTypeString, + element: element, + contextLabel: 'Schema expression', + ); + + if (transformOutputTypeString != null) { + return ( + dartType: chain.transformOutputType ?? typeProvider.dynamicType, + listElementSchemaRef: null, + listElementDisplayTypeOverride: null, + listElementCastTypeOverride: null, + listElementIsCustomType: false, + ); + } switch (schemaMethod) { case 'string': @@ -1294,7 +1495,7 @@ class SchemaAstAnalyzer { case 'list': // Extract element type from Ack.list(elementSchema) argument // This may return a schema variable reference for nested schemas - return _extractListType(invocation, element, typeProvider); + return _extractListType(baseInvocation, element, typeProvider); case 'object': // Nested objects represented as Map // Note: Using dynamicType for analyzer; generated code uses Object? @@ -1319,7 +1520,7 @@ class SchemaAstAnalyzer { ); case 'enumValues': final resolvedType = _resolveEnumValuesType( - invocation, + baseInvocation, library: library, ); if (resolvedType != null) { @@ -1344,6 +1545,31 @@ class SchemaAstAnalyzer { listElementCastTypeOverride: null, listElementIsCustomType: false, ); + case 'uri': + return ( + dartType: _dartCoreType(typeProvider, 'Uri'), + listElementSchemaRef: null, + listElementDisplayTypeOverride: null, + listElementCastTypeOverride: null, + listElementIsCustomType: false, + ); + case 'date': + case 'datetime': + return ( + dartType: _dartCoreType(typeProvider, 'DateTime'), + listElementSchemaRef: null, + listElementDisplayTypeOverride: null, + listElementCastTypeOverride: null, + listElementIsCustomType: false, + ); + case 'duration': + return ( + dartType: _dartCoreType(typeProvider, 'Duration'), + listElementSchemaRef: null, + listElementDisplayTypeOverride: null, + listElementCastTypeOverride: null, + listElementIsCustomType: false, + ); default: throw InvalidGenerationSourceError( 'Unsupported schema method: Ack.$schemaMethod()', @@ -1425,7 +1651,9 @@ class SchemaAstAnalyzer { if (args.isEmpty) return null; final ref = _resolveListElementRef(args.first); - final elementSchema = ref.ackBase; + final elementSchema = ref.invocation == null + ? null + : _analyzeSchemaChain(ref.invocation!).ackBase; if (elementSchema == null || elementSchema.methodName.name != 'enumValues') { return null; @@ -1677,25 +1905,20 @@ class SchemaAstAnalyzer { _ListElementRef _resolveListElementRef(Expression firstArg) { if (firstArg is MethodInvocation) { - final baseInvocation = _findBaseAckInvocation(firstArg); - if (baseInvocation != null) { - return (ackBase: baseInvocation, schemaRef: null); - } - final schemaRef = _findSchemaVariableBase(firstArg); if (schemaRef != null) { - return (ackBase: null, schemaRef: schemaRef); + return (invocation: firstArg, schemaRef: schemaRef); } - return (ackBase: null, schemaRef: null); + return (invocation: firstArg, schemaRef: null); } final schemaRef = _extractSchemaReference(firstArg); if (schemaRef != null) { - return (ackBase: null, schemaRef: schemaRef); + return (invocation: null, schemaRef: schemaRef); } - return (ackBase: null, schemaRef: null); + return (invocation: null, schemaRef: null); } /// Extracts the element type from Ack.list(elementSchema) calls @@ -1725,9 +1948,17 @@ class SchemaAstAnalyzer { final firstArg = args.first; final ref = _resolveListElementRef(firstArg); - if (ref.ackBase != null) { - final methodName = ref.ackBase!.methodName.name; - if (methodName == 'object') { + if (ref.invocation != null) { + final chain = _analyzeSchemaChain(ref.invocation!); + final baseInvocation = chain.ackBase; + final transformOutputTypeString = _requireTransformOutputType( + chain, + element, + contextLabel: 'Ack.list(...) element schema', + ); + + if (baseInvocation != null && + baseInvocation.methodName.name == 'object') { throw InvalidGenerationSourceError( 'Ack.list(Ack.object(...)) uses an anonymous inline object schema. ' 'Strict typed generation requires a named schema reference.', @@ -1737,13 +1968,24 @@ class SchemaAstAnalyzer { ); } - final nestedMapping = _mapSchemaTypeToDartType(ref.ackBase!, element); + if (chain.schemaReference != null) { + return _resolveSchemaVariableType( + chain.schemaReference!, + element, + typeProvider, + transformedOutputType: chain.transformOutputType, + transformedRepresentationType: transformOutputTypeString, + ); + } + + final nestedMapping = _mapSchemaTypeToDartType(ref.invocation!, element); return ( dartType: typeProvider.listType(nestedMapping.dartType), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, + listElementSchemaRef: nestedMapping.listElementSchemaRef, + listElementDisplayTypeOverride: + nestedMapping.listElementDisplayTypeOverride, + listElementCastTypeOverride: nestedMapping.listElementCastTypeOverride, + listElementIsCustomType: nestedMapping.listElementIsCustomType, ); } @@ -1767,8 +2009,10 @@ class SchemaAstAnalyzer { _SchemaTypeMapping _resolveSchemaVariableType( _SchemaReference schemaReference, Element2 element, - TypeProvider typeProvider, - ) { + TypeProvider typeProvider, { + DartType? transformedOutputType, + String? transformedRepresentationType, + }) { final resolved = _resolveSchemaReference(schemaReference, element); if (resolved == null) { throw InvalidGenerationSourceError( @@ -1781,8 +2025,21 @@ class SchemaAstAnalyzer { } final modelInfo = resolved.modelInfo; - final isObjectRepresentation = modelInfo.representationType == kMapType; - if (isObjectRepresentation && !resolved.hasAckTypeAnnotation) { + final hasTransformOverride = transformedRepresentationType != null; + if (hasTransformOverride) { + _throwIfUnsupportedTransformedReferencedSchema( + resolved: resolved, + element: element, + contextLabel: 'Ack.list(${schemaReference.name}) element schema', + ); + } + + final representationType = + transformedRepresentationType ?? modelInfo.representationType; + final hasTypedReference = + resolved.hasAckTypeAnnotation && !hasTransformOverride; + final isObjectRepresentation = representationType == kMapType; + if (isObjectRepresentation && !hasTypedReference) { throw InvalidGenerationSourceError( 'Ack.list(${schemaReference.name}) references object schema ' '"${schemaReference.name}" without @AckType. This would fall back to ' @@ -1793,21 +2050,33 @@ class SchemaAstAnalyzer { ); } - final elementDartType = _representationTypeToDartType( - modelInfo.representationType, - typeProvider, - ); + final elementDartType = + transformedOutputType ?? + _representationTypeToDartType(representationType, typeProvider); - final hasTypedReference = resolved.hasAckTypeAnnotation; final typeBaseName = hasTypedReference ? _qualifyTypeBaseName(modelInfo.className, resolved.importPrefix) : null; + final listElementDisplayTypeOverride = hasTypedReference + ? typeBaseName + : (!elementDartType.isDartCoreString && + !elementDartType.isDartCoreInt && + !elementDartType.isDartCoreDouble && + !elementDartType.isDartCoreBool && + !elementDartType.isDartCoreNum && + !elementDartType.isDartCoreList && + !elementDartType.isDartCoreMap && + !elementDartType.isDartCoreSet + ? representationType + : null); return ( dartType: typeProvider.listType(elementDartType), - listElementSchemaRef: resolved.schemaName, - listElementDisplayTypeOverride: typeBaseName, - listElementCastTypeOverride: modelInfo.representationType, + listElementSchemaRef: hasTypedReference ? resolved.schemaName : null, + listElementDisplayTypeOverride: listElementDisplayTypeOverride, + listElementCastTypeOverride: hasTypedReference + ? representationType + : null, listElementIsCustomType: hasTypedReference, ); } @@ -1821,13 +2090,15 @@ class SchemaAstAnalyzer { /// reference is detected. String _resolveSchemaVariableElementTypeString( _SchemaReference schemaReference, - Element2 element, - ) { + Element2 element, { + String? transformedRepresentationType, + }) { final library = element.library2; // Use library-scoped cache key to prevent collisions across libraries final prefix = schemaReference.prefix ?? ''; + final transformKey = transformedRepresentationType ?? ''; final cacheKey = - '${library?.uri ?? 'unknown'}::$prefix::${schemaReference.name}'; + '${library?.uri ?? 'unknown'}::$prefix::${schemaReference.name}::$transformKey'; final cached = _schemaVariableTypeCache[cacheKey]; if (cached != null) { @@ -1868,8 +2139,14 @@ class SchemaAstAnalyzer { ); } - if (resolved.modelInfo.representationType == kMapType && - !resolved.hasAckTypeAnnotation) { + final representationType = + transformedRepresentationType ?? + resolved.modelInfo.representationType; + final hasTypedReference = + resolved.hasAckTypeAnnotation && + transformedRepresentationType == null; + + if (representationType == kMapType && !hasTypedReference) { throw InvalidGenerationSourceError( 'Ack.list(${schemaReference.name}) references object schema ' '"${schemaReference.name}" without @AckType. This would fall back to ' @@ -1880,7 +2157,7 @@ class SchemaAstAnalyzer { ); } - resolvedType = resolved.modelInfo.representationType; + resolvedType = representationType; return resolvedType; } finally { _schemaVariableTypeStack.remove(cacheKey); @@ -2144,6 +2421,9 @@ class SchemaAstAnalyzer { 'double' => typeProvider.doubleType, 'bool' => typeProvider.boolType, 'num' => typeProvider.numType, + 'Uri' => _dartCoreType(typeProvider, 'Uri'), + 'DateTime' => _dartCoreType(typeProvider, 'DateTime'), + 'Duration' => _dartCoreType(typeProvider, 'Duration'), _ when representationType.startsWith('Map<') => typeProvider.mapType( typeProvider.stringType, typeProvider.dynamicType, @@ -2155,6 +2435,14 @@ class SchemaAstAnalyzer { }; } + DartType _dartCoreType(TypeProvider typeProvider, String typeName) { + final type = _resolveTypeByName( + typeName, + typeProvider.stringType.element3.library2, + ); + return type ?? typeProvider.dynamicType; + } + /// Extracts the identifier name from different expression forms. /// /// Supports simple identifiers, prefixed identifiers (`prefix.name`), @@ -2237,6 +2525,146 @@ class SchemaAstAnalyzer { return (chain, depth >= maxDepth); } + _SchemaChainInfo _analyzeSchemaChain(MethodInvocation invocation) { + final (chain, truncated) = _collectMethodChain(invocation); + MethodInvocation? ackBase; + _SchemaReference? schemaReference; + var isOptional = false; + var isNullable = false; + MethodInvocation? transformInvocation; + DartType? transformOutputType; + String? transformOutputTypeString; + + for (final current in chain) { + final methodName = current.methodName.name; + + if (methodName == 'optional') { + isOptional = true; + } else if (methodName == 'nullable') { + isNullable = true; + } else if (methodName == 'transform' && transformInvocation == null) { + transformInvocation = current; + final typeArgs = current.typeArguments?.arguments; + if (typeArgs != null && typeArgs.isNotEmpty) { + final typeArg = typeArgs.first; + transformOutputType = typeArg.type; + transformOutputTypeString = typeArg.toSource(); + } + } + + final target = current.target; + if (ackBase == null && _isAckTarget(target)) { + ackBase = current; + } + + if (schemaReference == null) { + final reference = _extractSchemaReference(target); + if (reference != null) { + schemaReference = reference; + } + } + } + + if (truncated) { + _log.warning( + 'Schema method chain exceeded max depth of 20. ' + 'Type inference may fall back to dynamic.', + ); + } + + return ( + ackBase: ackBase, + schemaReference: schemaReference, + isOptional: isOptional, + isNullable: isNullable, + wasTruncated: truncated, + transformInvocation: transformInvocation, + transformOutputType: transformOutputType, + transformOutputTypeString: transformOutputTypeString, + ); + } + + String? _requireTransformOutputType( + _SchemaChainInfo chain, + Element2 element, { + required String contextLabel, + }) { + if (chain.transformInvocation == null) { + return null; + } + + final typeName = chain.transformOutputTypeString; + if (typeName != null && typeName.isNotEmpty) { + return typeName; + } + + throw InvalidGenerationSourceError( + '$contextLabel uses .transform(...) without an explicit output type. ' + '@AckType requires .transform(...) so the generated type can be inferred.', + element: element, + todo: + 'Add an explicit type argument, for example .transform((value) => ...).', + ); + } + + void _throwIfUnsupportedTransformedBaseSchema({ + required String schemaMethod, + required String? transformOutputTypeString, + required Element2 element, + required String contextLabel, + }) { + if (transformOutputTypeString == null) { + return; + } + + if (schemaMethod == 'object') { + throw InvalidGenerationSourceError( + '$contextLabel transforms an Ack.object(...) schema. ' + 'Transformed object schemas are not supported by @AckType.', + element: element, + todo: + 'Remove .transform() from the object schema or expose the transformed result through a separate non-object schema.', + ); + } + + if (schemaMethod == 'discriminated') { + throw InvalidGenerationSourceError( + '$contextLabel transforms an Ack.discriminated(...) schema. ' + 'Transformed discriminated schemas are not supported by @AckType.', + element: element, + todo: + 'Remove .transform() from the discriminated schema or expose the transformed result through a separate non-object schema.', + ); + } + } + + void _throwIfUnsupportedTransformedReferencedSchema({ + required _ResolvedSchemaReference resolved, + required Element2 element, + required String contextLabel, + }) { + final modelInfo = resolved.modelInfo; + if (modelInfo.isDiscriminatedBaseDefinition) { + throw InvalidGenerationSourceError( + '$contextLabel transforms referenced discriminated schema ' + '"${resolved.schemaName}". Transformed discriminated schemas are not supported by @AckType.', + element: element, + todo: + 'Remove .transform() from the referenced discriminated schema or expose a separate non-object schema.', + ); + } + + if (modelInfo.representationType == kMapType) { + throw InvalidGenerationSourceError( + '$contextLabel transforms referenced object schema ' + '"${resolved.schemaName}". Transformed object schemas are not supported by @AckType.', + element: element, + todo: + 'Remove .transform() from the referenced object schema or expose a separate non-object schema.', + ); + } + } + /// Walks a method chain to find the base Ack.xxx() invocation. /// /// For `Ack.string().describe('...').optional()`, returns `Ack.string()`. @@ -2484,6 +2912,51 @@ class SchemaAstAnalyzer { ); } + ModelInfo _parseRepresentationSchema( + String variableName, + Element2 element, { + required String representationType, + required bool isNullable, + String? customTypeName, + }) { + final typeName = _resolveModelClassName( + variableName, + element, + customTypeName: customTypeName, + ); + + return ModelInfo( + className: typeName, + schemaClassName: variableName, + fields: const [], + isFromSchemaVariable: true, + representationType: representationType, + isNullableSchema: isNullable, + ); + } + + ModelInfo _withRepresentationType( + ModelInfo model, + String representationType, + ) { + return ModelInfo( + className: model.className, + schemaClassName: model.schemaClassName, + description: model.description, + fields: model.fields, + additionalProperties: model.additionalProperties, + additionalPropertiesField: model.additionalPropertiesField, + discriminatorKey: model.discriminatorKey, + discriminatorValue: model.discriminatorValue, + subtypeNames: model.subtypeNames, + schemaIdentity: model.schemaIdentity, + discriminatedBaseClassName: model.discriminatedBaseClassName, + isFromSchemaVariable: model.isFromSchemaVariable, + representationType: representationType, + isNullableSchema: model.isNullableSchema, + ); + } + /// Extracts the element type as a string representation for top-level list schemas. /// /// This handles: @@ -2508,17 +2981,56 @@ class SchemaAstAnalyzer { final firstArg = args.first; final ref = _resolveListElementRef(firstArg); - if (ref.ackBase != null) { - final methodName = ref.ackBase!.methodName.name; + if (ref.invocation != null) { + final chain = _analyzeSchemaChain(ref.invocation!); + final transformOutputTypeString = _requireTransformOutputType( + chain, + element, + contextLabel: 'Ack.list(...) element schema', + ); + final baseInvocation = chain.ackBase; // Handle nested lists recursively - if (methodName == 'list') { - final nestedType = _extractListElementTypeString(ref.ackBase!, element); + if (baseInvocation?.methodName.name == 'list') { + final nestedType = _extractListElementTypeString( + baseInvocation!, + element, + ); return 'List<$nestedType>'; } + if (chain.schemaReference != null) { + return _resolveSchemaVariableElementTypeString( + chain.schemaReference!, + element, + transformedRepresentationType: transformOutputTypeString, + ); + } + + if (baseInvocation == null) { + final rawExpression = firstArg.toSource(); + throw InvalidGenerationSourceError( + 'Could not statically resolve Ack.list($rawExpression) element type.', + element: element, + todo: + 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', + ); + } + + final methodName = baseInvocation.methodName.name; + _throwIfUnsupportedTransformedBaseSchema( + schemaMethod: methodName, + transformOutputTypeString: transformOutputTypeString, + element: element, + contextLabel: 'Ack.list(...) element schema', + ); + + if (transformOutputTypeString != null) { + return transformOutputTypeString; + } + if (methodName == 'enumValues') { - return _extractEnumTypeNameFromInvocation(ref.ackBase!) ?? 'dynamic'; + return _extractEnumTypeNameFromInvocation(baseInvocation) ?? 'dynamic'; } if (methodName == 'object') { @@ -2647,16 +3159,6 @@ class SchemaAstAnalyzer { ); } - bool _hasModifier(MethodInvocation invocation, String modifierName) { - final (chain, _) = _collectMethodChain(invocation); - for (final current in chain) { - if (current.methodName.name == modifierName) { - return true; - } - } - return false; - } - /// Maps Ack schema method names to Dart type strings /// /// Used for generating string representations of types in list element contexts. @@ -2667,6 +3169,9 @@ class SchemaAstAnalyzer { 'integer' => 'int', 'double' => 'double', 'boolean' => 'bool', + 'uri' => 'Uri', + 'date' || 'datetime' => 'DateTime', + 'duration' => 'Duration', 'object' => kMapType, 'list' => 'List', _ => 'dynamic', diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart index 330b29c..4776237 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -728,8 +728,8 @@ ${cases.join(',\n')}, return "_data['$key'] as Map"; } - // Primitives - if (field.isPrimitive) { + // Primitive and already-validated core value types. + if (field.isPrimitive || _isSpecialType(field.type)) { return "_data['$key'] as $baseType"; } @@ -738,12 +738,8 @@ ${cases.join(',\n')}, return "_data['$key'] as $baseType"; } - // Special types need conversion from raw data - // - DateTime: stored as ISO 8601 string, needs DateTime.parse() - // - Uri: stored as string, needs Uri.parse() - // - Duration: stored as int (milliseconds), needs Duration(milliseconds:) - if (_isSpecialType(field.type)) { - return _buildSpecialTypeGetter(field, key, nullable: false); + if (field.displayTypeOverride != null) { + return "_data['$key'] as $baseType"; } // Lists @@ -787,14 +783,14 @@ ${cases.join(',\n')}, return "_data['$key'] != null ? $nonNullPart : null"; } - // For primitives and enums, nullable cast works - if (field.isPrimitive || field.isEnum) { + // For primitives, enums, and already-validated core value types, + // nullable cast works directly on the validated map payload. + if (field.isPrimitive || field.isEnum || _isSpecialType(field.type)) { return "_data['$key'] as $baseType?"; } - // Special types need conversion with null check - if (_isSpecialType(field.type)) { - return _buildSpecialTypeGetter(field, key, nullable: true); + if (field.displayTypeOverride != null) { + return "_data['$key'] as $baseType?"; } // For complex types, check null first @@ -807,32 +803,6 @@ ${cases.join(',\n')}, return "_data['$key'] != null ? $nonNullPart : null"; } - /// Builds getter code for special types (DateTime, Uri, Duration). - /// - /// These types require conversion from their JSON representation: - /// - DateTime: ISO 8601 string -> DateTime.parse() - /// - Uri: string -> Uri.parse() - /// - Duration: int (milliseconds) -> Duration(milliseconds:) - String _buildSpecialTypeGetter( - FieldInfo field, - String key, { - required bool nullable, - }) { - final typeName = field.type.element3?.name3; - - final conversion = switch (typeName) { - 'DateTime' => "DateTime.parse(_data['$key'] as String)", - 'Uri' => "Uri.parse(_data['$key'] as String)", - 'Duration' => "Duration(milliseconds: _data['$key'] as int)", - _ => throw StateError( - 'Unsupported special type: $typeName. ' - 'Callers must gate with _isSpecialType before calling this method.', - ), - }; - - return nullable ? "_data['$key'] != null ? $conversion : null" : conversion; - } - String _buildListGetter(FieldInfo field, _ModelLookups lookups, String key) => _buildCollectionGetter(field, lookups, key, isSet: false); @@ -987,6 +957,10 @@ ${cases.join(',\n')}, return 'Object?'; } + if (field.displayTypeOverride != null) { + return field.displayTypeOverride!; + } + // Nested schema if (field.isNestedSchema && _hasAckType(field, lookups)) { final baseType = field.type.getDisplayString(withNullability: false); diff --git a/packages/ack_generator/test/integration/ack_type_transform_test.dart b/packages/ack_generator/test/integration/ack_type_transform_test.dart new file mode 100644 index 0000000..23c0b67 --- /dev/null +++ b/packages/ack_generator/test/integration/ack_type_transform_test.dart @@ -0,0 +1,264 @@ +import 'package:ack_generator/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_assets.dart'; + +void main() { + group('@AckType transform support', () { + test( + 'supports top-level transformed schemas and direct transformed factories', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); + +@AckType() +final aliasColorSchema = colorSchema.transform((value) => value!); + +@AckType() +final uriSchema = Ack.uri(); + +@AckType() +final dateSchema = Ack.date(); + +@AckType() +final datetimeSchema = Ack.datetime(); + +@AckType() +final durationSchema = Ack.duration(); + +@AckType() +final validatedStringSchema = Ack.string().uri(); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('extension type ColorType(Color _value)'), + contains('extension type AliasColorType(Color _value)'), + contains('extension type UriType(Uri _value)'), + contains('extension type DateType(DateTime _value)'), + contains('extension type DatetimeType(DateTime _value)'), + contains('extension type DurationType(Duration _value)'), + contains('extension type ValidatedStringType(String _value)'), + contains('Color toJson() => _value;'), + contains('Uri toJson() => _value;'), + contains('DateTime toJson() => _value;'), + contains('Duration toJson() => _value;'), + ]), + ), + }, + ); + }, + ); + + test('supports nested transformed fields, refs, and list elements', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); + +final baseColorSchema = Ack.string(); + +@AckType() +final profileSchema = Ack.object({ + 'homepage': Ack.uri(), + 'birthday': Ack.date(), + 'lastLogin': Ack.datetime().optional().nullable(), + 'timeout': Ack.duration(), + 'favoriteColor': Ack.string().transform((value) => Color(value!)), + 'accent': colorSchema, + 'colors': Ack.list(colorSchema), + 'customColors': Ack.list( + baseColorSchema.transform((value) => Color(value!)), + ), +}); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('extension type ColorType(Color _value)'), + contains( + 'extension type ProfileType(Map _data)', + ), + contains('Uri get homepage => _data[\'homepage\'] as Uri'), + contains( + 'DateTime get birthday => _data[\'birthday\'] as DateTime', + ), + contains( + 'DateTime? get lastLogin => _data[\'lastLogin\'] as DateTime?', + ), + contains( + 'Duration get timeout => _data[\'timeout\'] as Duration', + ), + contains( + 'Color get favoriteColor => _data[\'favoriteColor\'] as Color', + ), + contains('ColorType get accent'), + contains("ColorType(_data['accent'] as Color)"), + contains('List get colors'), + contains('ColorType(e as Color)'), + contains('List get customColors'), + contains('_\$ackListCast(_data[\'customColors\'])'), + isNot(contains('Uri.parse(')), + isNot(contains('DateTime.parse(')), + isNot(contains('Duration(milliseconds:')), + ]), + ), + }, + ); + }); + + test('supports transformed getter schemas', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +AckSchema get baseColorSchema => Ack.string(); + +@AckType() +AckSchema get colorSchema => + baseColorSchema.transform((value) => Color(value!)); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('extension type ColorType(Color _value)'), + contains('return colorSchema.parseAs('), + ]), + ), + }, + ); + }); + + test('supports top-level list schema with chained modifiers', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final uniqueTagsSchema = Ack.list(Ack.string()).unique(); + +@AckType() +final describedTagsSchema = Ack.list(Ack.string()).describe('A list of tags'); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('extension type UniqueTagsType(List _value)'), + contains('extension type DescribedTagsType(List _value)'), + ]), + ), + }, + ); + }); + + test('rejects transform without an explicit output type', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + }, + outputs: {'test_pkg|lib/schema.g.dart': anything}, + ), + throwsA(isA()), + ); + }); + + test('rejects transformed object and discriminated schemas', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await expectLater( + () => testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final objectSchema = Ack.object({ + 'name': Ack.string(), +}).transform((value) => 'name'); + +@AckType() +final discriminatedSchema = Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'user': Ack.string(), + }, +).transform((value) => 'user'); +''', + }, + outputs: {'test_pkg|lib/schema.g.dart': anything}, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/ack_generator/test/test_utils/test_assets.dart b/packages/ack_generator/test/test_utils/test_assets.dart index 8edbee8..6a86a49 100644 --- a/packages/ack_generator/test/test_utils/test_assets.dart +++ b/packages/ack_generator/test/test_utils/test_assets.dart @@ -112,6 +112,10 @@ class Ack { static NumberSchema number() => const NumberSchema(); static BooleanSchema boolean() => const BooleanSchema(); static AnySchema any() => const AnySchema(); + static TransformedSchema uri() => TransformedSchema(const StringSchema()); + static TransformedSchema date() => TransformedSchema(const StringSchema()); + static TransformedSchema datetime() => TransformedSchema(const StringSchema()); + static TransformedSchema duration() => TransformedSchema(const IntegerSchema()); static ListSchema list(AckSchema itemSchema) => ListSchema(itemSchema); static MapSchema map(AckSchema valueSchema) => MapSchema(valueSchema); @@ -141,8 +145,20 @@ class Ack { } abstract class AckSchema { + TransformedSchema transform(R Function(T? value) transformer) => + TransformedSchema(this); Map toJsonSchema(); } +class TransformedSchema extends AckSchema { + final AckSchema schema; + TransformedSchema(this.schema); + TransformedSchema nullable() => this; + TransformedSchema optional() => this; + TransformedSchema describe(String description) => this; + + @override + Map toJsonSchema() => schema.toJsonSchema(); +} class StringSchema extends AckSchema { const StringSchema(); StringSchema email() => this; @@ -150,6 +166,9 @@ class StringSchema extends AckSchema { StringSchema minLength(int length) => this; StringSchema maxLength(int length) => this; StringSchema enumString(List values) => this; + StringSchema uri() => this; + StringSchema date() => this; + StringSchema datetime() => this; StringSchema nullable() => this; StringSchema optional() => this; StringSchema describe(String description) => this; From 856cf24ded35f7f394826424e984c7bd523bc812 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 24 Mar 2026 22:08:46 -0400 Subject: [PATCH 2/6] Finish AckType transform support validation --- example/lib/schema_types_transforms.dart | 38 ++ example/lib/schema_types_transforms.g.dart | 72 +++ .../ack_annotations/lib/src/ack_type.dart | 24 +- .../lib/src/analyzer/schema_ast_analyzer.dart | 536 +++++++++++++----- .../lib/src/builders/type_builder.dart | 12 +- packages/ack_generator/lib/src/generator.dart | 1 + .../lib/src/models/field_info.dart | 7 + .../lib/src/models/model_info.dart | 8 + .../ack_type_cross_file_resolution_test.dart | 427 ++++++++++++++ .../ack_type_discriminated_test.dart | 51 ++ .../integration/ack_type_transform_test.dart | 99 ++++ .../example_folder_build_test.dart | 44 ++ .../test/src/test_utilities.dart | 4 + 13 files changed, 1172 insertions(+), 151 deletions(-) create mode 100644 example/lib/schema_types_transforms.dart create mode 100644 example/lib/schema_types_transforms.g.dart diff --git a/example/lib/schema_types_transforms.dart b/example/lib/schema_types_transforms.dart new file mode 100644 index 0000000..1fab692 --- /dev/null +++ b/example/lib/schema_types_transforms.dart @@ -0,0 +1,38 @@ +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +part 'schema_types_transforms.g.dart'; + +class Color { + final String value; + const Color(this.value); +} + +class TagList { + final List value; + const TagList(this.value); +} + +final baseColorSchema = Ack.string(); + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); + +@AckType() +final profileSchema = Ack.object({ + 'homepage': Ack.uri(), + 'birthday': Ack.date(), + 'lastLogin': Ack.datetime(), + 'timeout': Ack.duration(), + 'links': Ack.list(Ack.uri()), + 'favoriteColor': Ack.string().transform((value) => Color(value!)), + 'slug': Ack.string().transform((value) => value! + '#'), + 'accent': colorSchema, + 'colors': Ack.list(colorSchema), + 'customColors': Ack.list( + baseColorSchema.transform((value) => Color(value!)), + ), + 'tagList': Ack.list( + Ack.string(), + ).transform((value) => TagList(value!)), +}); diff --git a/example/lib/schema_types_transforms.g.dart b/example/lib/schema_types_transforms.g.dart new file mode 100644 index 0000000..7c09482 --- /dev/null +++ b/example/lib/schema_types_transforms.g.dart @@ -0,0 +1,72 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'schema_types_transforms.dart'; + +List _$ackListCast(Object? value) => (value as List).cast(); + +/// Extension type for Color +extension type ColorType(Color _value) implements Color { + static ColorType parse(Object? data) { + return colorSchema.parseAs( + data, + (validated) => ColorType(validated as Color), + ); + } + + static SchemaResult safeParse(Object? data) { + return colorSchema.safeParseAs( + data, + (validated) => ColorType(validated as Color), + ); + } + + Color toJson() => _value; +} + +/// Extension type for Profile +extension type ProfileType(Map _data) + implements Map { + static ProfileType parse(Object? data) { + return profileSchema.parseAs( + data, + (validated) => ProfileType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return profileSchema.safeParseAs( + data, + (validated) => ProfileType(validated as Map), + ); + } + + Map toJson() => _data; + + Uri get homepage => _data['homepage'] as Uri; + + DateTime get birthday => _data['birthday'] as DateTime; + + DateTime get lastLogin => _data['lastLogin'] as DateTime; + + Duration get timeout => _data['timeout'] as Duration; + + List get links => _$ackListCast(_data['links']); + + Color get favoriteColor => _data['favoriteColor'] as Color; + + String get slug => _data['slug'] as String; + + ColorType get accent => ColorType(_data['accent'] as Color); + + List get colors => + (_data['colors'] as List).map((e) => ColorType(e as Color)).toList(); + + List get customColors => _$ackListCast(_data['customColors']); + + TagList get tagList => _data['tagList'] as TagList; +} diff --git a/packages/ack_annotations/lib/src/ack_type.dart b/packages/ack_annotations/lib/src/ack_type.dart index 610904b..5ef4010 100644 --- a/packages/ack_annotations/lib/src/ack_type.dart +++ b/packages/ack_annotations/lib/src/ack_type.dart @@ -112,7 +112,8 @@ import 'package:meta/meta_meta.dart'; /// - `parse(data)` factory for validation + wrapping /// - `safeParse(data)` for error handling /// - `toJson()` for serialization -/// - `copyWith()` for immutable updates +/// - `copyWith()` for immutable updates on object wrappers whose fields can be +/// safely reparsed from their public getter values /// - Value equality (`==`, `hashCode`) /// - `toString()` for debugging /// @@ -155,7 +156,7 @@ import 'package:meta/meta_meta.dart'; /// /// | Schema Type | Generated Extension Type | /// |-------------|--------------------------| -/// | `Ack.object({...})` | `XType(Map)` with field getters, copyWith, toJson | +/// | `Ack.object({...})` | `XType(Map)` with field getters, conditional copyWith, toJson | /// | `Ack.string()` | `XType(String)` implements String | /// | `Ack.integer()` | `XType(int)` implements int | /// | `Ack.double()` | `XType(double)` implements double | @@ -216,12 +217,25 @@ import 'package:meta/meta_meta.dart'; /// ## Limitations /// /// - **Class annotations**: `@AckType` is not supported on classes. -/// - **Cross-file schema references**: Schema references must be in the same file -/// - ✅ Same file: `'address': addressSchema` → getter returns `AddressType` -/// - ❌ Cross-file: `'address': addressSchema` → getter returns `Map` +/// - **Cross-file schema references**: Direct imports, prefixed imports, and +/// re-exported schema refs are supported. +/// - ✅ Direct import: `'address': addressSchema` → getter returns `AddressType` +/// - ✅ Prefixed import: `'address': models.addressSchema` → getter returns +/// `models.AddressType` +/// - ✅ Re-export: `'address': addressSchema` through an export works +/// - Cross-file transformed refs require the transformed representation types +/// to be visible from the consuming library. +/// - Direct-import transformed refs may fail when a representation type name +/// collides with a different visible type in the consuming library. +/// - Source-qualified transformed representation types such as `dep.Color` +/// are not supported across library boundaries. /// - **Nullable schema variables**: Extension types are not generated for schemas /// marked with `.nullable()` because the representation is non-nullable. /// - Use the schema directly for nullable validation. +/// - **Object wrappers with transformed-backed fields**: `copyWith()` is not +/// generated for object wrappers, including discriminated branches, when any +/// field is backed by `Ack.uri()`, `Ack.date()`, `Ack.datetime()`, +/// `Ack.duration()`, or `.transform(...)`. /// - **List element modifiers**: List element nullability from chained /// modifiers may not be fully inferred: /// - ✅ `Ack.list(Ack.string())` → `List` diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index 91fcc54..20d477f 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -37,11 +37,21 @@ typedef _SchemaTypeMapping = ({ String? listElementCastTypeOverride, bool listElementIsCustomType, }); +typedef _ListElementAnalysis = ({ + _SchemaTypeMapping mapping, + String elementRepresentationType, + bool isTransformedRepresentation, +}); +typedef _ResolvedSchemaElement = ({ + Element2 element, + LibraryImport? importDirective, +}); class _ResolvedSchemaReference { final String schemaName; final ModelInfo modelInfo; final String? importPrefix; + final LibraryImport? importDirective; final bool hasAckTypeAnnotation; final Element2 sourceDeclaration; final Uri? sourceLibraryUri; @@ -50,6 +60,7 @@ class _ResolvedSchemaReference { required this.schemaName, required this.modelInfo, required this.importPrefix, + required this.importDirective, required this.hasAckTypeAnnotation, required this.sourceDeclaration, required this.sourceLibraryUri, @@ -310,6 +321,7 @@ class SchemaAstAnalyzer { discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, isFromSchemaVariable: true, representationType: sourceModel.representationType, + isTransformedSchema: sourceModel.isTransformedSchema, isNullableSchema: sourceModel.isNullableSchema, ); } @@ -410,11 +422,23 @@ class SchemaAstAnalyzer { ); break; case 'list': + final typeProvider = element.library2?.typeProvider; + if (typeProvider == null) { + throw InvalidGenerationSourceError( + 'Could not get type provider for library', + element: element, + ); + } + final listElementAnalysis = _analyzeListElement( + baseInvocation, + element, + typeProvider, + ); model = _parseListSchema( variableName, - baseInvocation, element, isNullable: isNullable, + listElementAnalysis: listElementAnalysis, customTypeName: customTypeName, ); break; @@ -451,6 +475,7 @@ class SchemaAstAnalyzer { element, representationType: 'Uri', isNullable: isNullable, + isTransformedSchema: true, customTypeName: customTypeName, ); break; @@ -461,6 +486,7 @@ class SchemaAstAnalyzer { element, representationType: 'DateTime', isNullable: isNullable, + isTransformedSchema: true, customTypeName: customTypeName, ); break; @@ -470,6 +496,7 @@ class SchemaAstAnalyzer { element, representationType: 'Duration', isNullable: isNullable, + isTransformedSchema: true, customTypeName: customTypeName, ); break; @@ -545,6 +572,8 @@ class SchemaAstAnalyzer { isFromSchemaVariable: true, representationType: transformOutputTypeString ?? sourceModel.representationType, + isTransformedSchema: + sourceModel.isTransformedSchema || transformOutputTypeString != null, isNullableSchema: isNullable || sourceModel.isNullableSchema, ); } @@ -1014,6 +1043,7 @@ class SchemaAstAnalyzer { discriminatedBaseClassName: model.discriminatedBaseClassName, isFromSchemaVariable: model.isFromSchemaVariable, representationType: model.representationType, + isTransformedSchema: model.isTransformedSchema, isNullableSchema: model.isNullableSchema, ); } @@ -1256,6 +1286,11 @@ class SchemaAstAnalyzer { final representationType = transformedRepresentationType ?? resolvedReference.modelInfo.representationType; + final visibleRepresentationType = _resolveVisibleRepresentationType( + representationType: representationType, + resolved: resolvedReference, + contextElement: element, + ); final hasTypedReference = resolvedReference.hasAckTypeAnnotation && !hasTransformOverride; final isObjectRepresentation = representationType == kMapType; @@ -1303,8 +1338,11 @@ class SchemaAstAnalyzer { ? '${typeBaseName}Type' : rawDisplayTypeOverride, nestedSchemaCastTypeOverride: hasTypedReference - ? representationType + ? visibleRepresentationType : null, + isTransformedRepresentation: + resolvedReference.modelInfo.isTransformedSchema || + transformedRepresentationType != null, ); } @@ -1327,6 +1365,7 @@ class SchemaAstAnalyzer { isRequired: !chain.isOptional, isNullable: chain.isNullable, constraints: const [], + isTransformedRepresentation: false, ); } @@ -1359,9 +1398,16 @@ class SchemaAstAnalyzer { ); } + final typeProvider = element.library2!.typeProvider; + final listElementAnalysis = + schemaMethod == 'list' && transformOutputTypeString == null + ? _analyzeListElement(baseInvocation, element, typeProvider) + : null; // Map schema type to Dart type (passing full invocation for context) // Also captures schema variable reference and list metadata for typed wrappers. - final mappedType = _mapSchemaTypeToDartType(invocation, element); + final mappedType = + listElementAnalysis?.mapping ?? + _mapSchemaTypeToDartType(invocation, element); String? displayTypeOverride; var collectionElementDisplayTypeOverride = @@ -1400,6 +1446,10 @@ class SchemaAstAnalyzer { collectionElementDisplayTypeOverride, collectionElementCastTypeOverride: mappedType.listElementCastTypeOverride, collectionElementIsCustomType: mappedType.listElementIsCustomType, + isTransformedRepresentation: + transformOutputTypeString != null || + _isBuiltInTransformedMethod(schemaMethod) || + listElementAnalysis?.isTransformedRepresentation == true, ); } @@ -1495,7 +1545,11 @@ class SchemaAstAnalyzer { case 'list': // Extract element type from Ack.list(elementSchema) argument // This may return a schema variable reference for nested schemas - return _extractListType(baseInvocation, element, typeProvider); + return _analyzeListElement( + baseInvocation, + element, + typeProvider, + ).mapping; case 'object': // Nested objects represented as Map // Note: Using dynamicType for analyzer; generated code uses Object? @@ -1921,15 +1975,11 @@ class SchemaAstAnalyzer { return (invocation: null, schemaRef: null); } - /// Extracts the element type from Ack.list(elementSchema) calls + /// Analyzes the element schema used by Ack.list(...). /// - /// Supports method invocations and schema references such as - /// `Ack.list(Ack.string())`, `Ack.list(Ack.string().describe(...))`, - /// `Ack.list(addressSchema)`, or `Ack.list(addressSchema.optional())`. - /// - /// Returns a mapping with the list [DartType] and optional metadata used to - /// build typed list wrappers when the element references another schema. - _SchemaTypeMapping _extractListType( + /// Returns the generated list mapping, the list element representation type + /// string, and whether the element is backed by a transformed schema. + _ListElementAnalysis _analyzeListElement( MethodInvocation listInvocation, Element2 element, TypeProvider typeProvider, @@ -1969,28 +2019,96 @@ class SchemaAstAnalyzer { } if (chain.schemaReference != null) { - return _resolveSchemaVariableType( + final mapping = _resolveSchemaVariableType( chain.schemaReference!, element, typeProvider, transformedOutputType: chain.transformOutputType, transformedRepresentationType: transformOutputTypeString, ); + final resolved = _resolveSchemaReference( + chain.schemaReference!, + element, + ); + return ( + mapping: mapping, + elementRepresentationType: _resolveSchemaVariableElementTypeString( + chain.schemaReference!, + element, + transformedRepresentationType: transformOutputTypeString, + ), + isTransformedRepresentation: + resolved?.modelInfo.isTransformedSchema == true || + transformOutputTypeString != null, + ); + } + + if (baseInvocation == null) { + final rawExpression = firstArg.toSource(); + throw InvalidGenerationSourceError( + 'Could not statically resolve Ack.list($rawExpression) element type.', + element: element, + todo: + 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', + ); } - final nestedMapping = _mapSchemaTypeToDartType(ref.invocation!, element); + final methodName = baseInvocation.methodName.name; + _throwIfUnsupportedTransformedBaseSchema( + schemaMethod: methodName, + transformOutputTypeString: transformOutputTypeString, + element: element, + contextLabel: 'Ack.list(...) element schema', + ); + + if (methodName == 'list') { + final nested = _analyzeListElement( + baseInvocation, + element, + typeProvider, + ); + return ( + mapping: _wrapListElementMapping(nested.mapping, typeProvider), + elementRepresentationType: + transformOutputTypeString ?? + 'List<${nested.elementRepresentationType}>', + isTransformedRepresentation: + transformOutputTypeString != null || + nested.isTransformedRepresentation, + ); + } + + final elementMapping = _mapSchemaTypeToDartType(ref.invocation!, element); + final elementRepresentationType = + transformOutputTypeString ?? + (methodName == 'enumValues' + ? _extractEnumTypeNameFromInvocation(baseInvocation) ?? 'dynamic' + : _mapSchemaMethodToType(methodName)); return ( - dartType: typeProvider.listType(nestedMapping.dartType), - listElementSchemaRef: nestedMapping.listElementSchemaRef, - listElementDisplayTypeOverride: - nestedMapping.listElementDisplayTypeOverride, - listElementCastTypeOverride: nestedMapping.listElementCastTypeOverride, - listElementIsCustomType: nestedMapping.listElementIsCustomType, + mapping: _wrapListElementMapping(elementMapping, typeProvider), + elementRepresentationType: elementRepresentationType, + isTransformedRepresentation: + transformOutputTypeString != null || + _isBuiltInTransformedMethod(methodName), ); } if (ref.schemaRef != null) { - return _resolveSchemaVariableType(ref.schemaRef!, element, typeProvider); + final mapping = _resolveSchemaVariableType( + ref.schemaRef!, + element, + typeProvider, + ); + final resolved = _resolveSchemaReference(ref.schemaRef!, element); + return ( + mapping: mapping, + elementRepresentationType: _resolveSchemaVariableElementTypeString( + ref.schemaRef!, + element, + ), + isTransformedRepresentation: + resolved?.modelInfo.isTransformedSchema ?? false, + ); } final rawExpression = firstArg.toSource(); @@ -2002,6 +2120,20 @@ class SchemaAstAnalyzer { ); } + _SchemaTypeMapping _wrapListElementMapping( + _SchemaTypeMapping elementMapping, + TypeProvider typeProvider, + ) { + return ( + dartType: typeProvider.listType(elementMapping.dartType), + listElementSchemaRef: elementMapping.listElementSchemaRef, + listElementDisplayTypeOverride: + elementMapping.listElementDisplayTypeOverride, + listElementCastTypeOverride: elementMapping.listElementCastTypeOverride, + listElementIsCustomType: elementMapping.listElementIsCustomType, + ); + } + /// Resolves a schema reference to its list element type. /// /// Looks up the schema in local/imported namespaces and returns the @@ -2036,6 +2168,11 @@ class SchemaAstAnalyzer { final representationType = transformedRepresentationType ?? modelInfo.representationType; + final visibleRepresentationType = _resolveVisibleRepresentationType( + representationType: representationType, + resolved: resolved, + contextElement: element, + ); final hasTypedReference = resolved.hasAckTypeAnnotation && !hasTransformOverride; final isObjectRepresentation = representationType == kMapType; @@ -2075,7 +2212,7 @@ class SchemaAstAnalyzer { listElementSchemaRef: hasTypedReference ? resolved.schemaName : null, listElementDisplayTypeOverride: listElementDisplayTypeOverride, listElementCastTypeOverride: hasTypedReference - ? representationType + ? visibleRepresentationType : null, listElementIsCustomType: hasTypedReference, ); @@ -2157,7 +2294,11 @@ class SchemaAstAnalyzer { ); } - resolvedType = representationType; + resolvedType = _resolveVisibleRepresentationType( + representationType: representationType, + resolved: resolved, + contextElement: element, + ); return resolvedType; } finally { _schemaVariableTypeStack.remove(cacheKey); @@ -2198,12 +2339,13 @@ class SchemaAstAnalyzer { var shouldCacheResult = false; try { - final resolvedElement = _resolveSchemaElement(reference, library); - if (resolvedElement == null) { + final resolvedElementMatch = _resolveSchemaElement(reference, library); + if (resolvedElementMatch == null) { shouldCacheResult = true; resolvedReference = null; return null; } + final resolvedElement = resolvedElementMatch.element; TopLevelVariableElement2? schemaVariable; GetterElement? schemaGetter; @@ -2296,6 +2438,7 @@ class SchemaAstAnalyzer { schemaName: schemaName, modelInfo: modelInfo, importPrefix: reference.prefix, + importDirective: resolvedElementMatch.importDirective, hasAckTypeAnnotation: hasAckTypeAnnotation, sourceDeclaration: declarationForMetadata, sourceLibraryUri: declarationForMetadata.library2?.uri, @@ -2320,7 +2463,7 @@ class SchemaAstAnalyzer { } } - Element2? _resolveSchemaElement( + _ResolvedSchemaElement? _resolveSchemaElement( _SchemaReference reference, LibraryElement2 library, ) { @@ -2330,16 +2473,12 @@ class SchemaAstAnalyzer { final prefixName = _elementName(import.prefix2?.element); if (prefixName != reference.prefix) continue; - final importedElement = import.namespace.get2(reference.name); - if (importedElement != null) { - return importedElement; - } - - final exportedElement = import.importedLibrary2?.exportNamespace.get2( + final importedElement = import.namespace.getPrefixed2( + reference.prefix!, reference.name, ); - if (exportedElement != null) { - return exportedElement; + if (importedElement != null) { + return (element: importedElement, importDirective: import); } } @@ -2351,19 +2490,51 @@ class SchemaAstAnalyzer { final scopeResult = library.firstFragment.scope.lookup(reference.name); final scopedElement = scopeResult.getter2; if (scopedElement != null) { - return scopedElement; + return ( + element: scopedElement, + importDirective: _findImportDirectiveForElement( + reference.name, + scopedElement, + library, + ), + ); } for (final import in library.firstFragment.libraryImports2) { final importedElement = import.namespace.get2(reference.name); if (importedElement != null) { - return importedElement; + return (element: importedElement, importDirective: import); } } return null; } + LibraryImport? _findImportDirectiveForElement( + String name, + Element2 element, + LibraryElement2 library, + ) { + for (final import in library.firstFragment.libraryImports2) { + final importedElement = import.namespace.get2(name); + if (_elementsMatch(importedElement, element)) { + return import; + } + } + return null; + } + + bool _elementsMatch(Element2? first, Element2? second) { + if (identical(first, second)) { + return true; + } + if (first == null || second == null) { + return false; + } + return first.library2?.uri == second.library2?.uri && + first.name3 == second.name3; + } + String? _elementName(Element2? element) { final modernName = element?.name3; if (modernName != null && modernName.isNotEmpty) { @@ -2395,6 +2566,177 @@ class SchemaAstAnalyzer { return '$prefix.$baseTypeName'; } + String _resolveVisibleRepresentationType({ + required String representationType, + required _ResolvedSchemaReference resolved, + required Element2 contextElement, + }) { + final contextLibrary = contextElement.library2; + if (contextLibrary == null) { + throw InvalidGenerationSourceError( + 'Could not resolve libraries while qualifying transformed representation ' + 'type "$representationType".', + element: contextElement, + ); + } + + if (resolved.sourceLibraryUri == contextLibrary.uri) { + return representationType; + } + + if (_containsUnsupportedRepresentationSyntax(representationType)) { + throw InvalidGenerationSourceError( + 'Transformed representation type "$representationType" for ' + '"${resolved.schemaName}" uses unsupported syntax for cross-file ' + 'generation.', + element: contextElement, + todo: + 'Use a nominal type with optional nested generics/nullability, or keep the schema in the same library.', + ); + } + + final tokenPattern = RegExp(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*'); + final buffer = StringBuffer(); + var lastIndex = 0; + + for (final match in tokenPattern.allMatches(representationType)) { + buffer.write(representationType.substring(lastIndex, match.start)); + final token = match.group(0)!; + buffer.write( + _resolveVisibleRepresentationToken( + token: token, + resolved: resolved, + contextLibrary: contextLibrary, + fullRepresentationType: representationType, + contextElement: contextElement, + ), + ); + lastIndex = match.end; + } + + buffer.write(representationType.substring(lastIndex)); + return buffer.toString(); + } + + String _resolveVisibleRepresentationToken({ + required String token, + required _ResolvedSchemaReference resolved, + required LibraryElement2 contextLibrary, + required String fullRepresentationType, + required Element2 contextElement, + }) { + if (_isBuiltInRepresentationIdentifier(token)) { + return token; + } + + if (token.contains('.')) { + throw InvalidGenerationSourceError( + 'Transformed representation type "$fullRepresentationType" for ' + '"${resolved.schemaName}" uses a qualified type that cannot be ' + 'referenced across library boundaries.', + element: contextElement, + todo: + 'Use an unqualified exported representation type, import that type directly into the consuming library, or keep the schema in the same library.', + ); + } + + final importNamespaceType = _resolveImportedType(token, resolved); + final contextType = _resolveTypeByName(token, contextLibrary); + final prefix = resolved.importPrefix; + + if (importNamespaceType != null && prefix != null && prefix.isNotEmpty) { + return '$prefix.$token'; + } + + if (importNamespaceType != null) { + if (contextType != null && + _sameResolvedType(importNamespaceType, contextType)) { + return token; + } + + if (contextType != null) { + throw InvalidGenerationSourceError( + 'Transformed representation type "$fullRepresentationType" for ' + '"${resolved.schemaName}" is ambiguous in this library.', + element: contextElement, + todo: + 'Use a prefixed schema import or rename/import the representation type so the generated cast resolves unambiguously.', + ); + } + } + + if (contextType != null) { + return token; + } + + throw InvalidGenerationSourceError( + 'Transformed representation type "$fullRepresentationType" for ' + '"${resolved.schemaName}" is not visible from this library.', + element: contextElement, + todo: + 'Export the representation type from the referenced schema library or import that type directly into this library.', + ); + } + + bool _sameResolvedType(DartType first, DartType second) { + return _resolvedTypeIdentity(first) == _resolvedTypeIdentity(second); + } + + DartType? _resolveImportedType( + String token, + _ResolvedSchemaReference resolved, + ) { + final importDirective = resolved.importDirective; + if (importDirective == null) { + return null; + } + + final prefix = resolved.importPrefix; + final importedElement = prefix != null && prefix.isNotEmpty + ? importDirective.namespace.getPrefixed2(prefix, token) + : importDirective.namespace.get2(token); + return _resolveTypeFromElement(importedElement); + } + + String _resolvedTypeIdentity(DartType type) { + if (type is InterfaceType) { + final element = type.element3; + final libraryUri = element.library2.uri.toString(); + final name = + element.name3 ?? type.getDisplayString(withNullability: false); + return '$libraryUri::$name'; + } + + return type.getDisplayString(withNullability: false); + } + + bool _isBuiltInRepresentationIdentifier(String token) { + return token == 'String' || + token == 'int' || + token == 'double' || + token == 'bool' || + token == 'num' || + token == 'dynamic' || + token == 'Object' || + token == 'Null' || + token == 'Never' || + token == 'void' || + token == 'Uri' || + token == 'DateTime' || + token == 'Duration' || + token == 'List' || + token == 'Set' || + token == 'Map'; + } + + bool _containsUnsupportedRepresentationSyntax(String representationType) { + return representationType.contains('(') || + representationType.contains(')') || + representationType.contains('{') || + representationType.contains('}') || + representationType.contains('=>'); + } + String _schemaReferenceCacheKey( _SchemaReference reference, LibraryElement2 library, @@ -2584,6 +2926,13 @@ class SchemaAstAnalyzer { ); } + bool _isBuiltInTransformedMethod(String methodName) { + return methodName == 'uri' || + methodName == 'date' || + methodName == 'datetime' || + methodName == 'duration'; + } + String? _requireTransformOutputType( _SchemaChainInfo chain, Element2 element, { @@ -2888,9 +3237,9 @@ class SchemaAstAnalyzer { /// - `Ack.list(addressSchema)` → `List>` (schema reference) ModelInfo _parseListSchema( String variableName, - MethodInvocation invocation, Element2 element, { required bool isNullable, + required _ListElementAnalysis listElementAnalysis, String? customTypeName, }) { final typeName = _resolveModelClassName( @@ -2899,15 +3248,14 @@ class SchemaAstAnalyzer { customTypeName: customTypeName, ); - // Extract element type from first argument: Ack.list(elementSchema) - final elementType = _extractListElementTypeString(invocation, element); - return ModelInfo( className: typeName, schemaClassName: variableName, fields: [], isFromSchemaVariable: true, - representationType: 'List<$elementType>', + representationType: + 'List<${listElementAnalysis.elementRepresentationType}>', + isTransformedSchema: listElementAnalysis.isTransformedRepresentation, isNullableSchema: isNullable, ); } @@ -2917,6 +3265,7 @@ class SchemaAstAnalyzer { Element2 element, { required String representationType, required bool isNullable, + bool isTransformedSchema = false, String? customTypeName, }) { final typeName = _resolveModelClassName( @@ -2931,6 +3280,7 @@ class SchemaAstAnalyzer { fields: const [], isFromSchemaVariable: true, representationType: representationType, + isTransformedSchema: isTransformedSchema, isNullableSchema: isNullable, ); } @@ -2953,113 +3303,11 @@ class SchemaAstAnalyzer { discriminatedBaseClassName: model.discriminatedBaseClassName, isFromSchemaVariable: model.isFromSchemaVariable, representationType: representationType, + isTransformedSchema: true, isNullableSchema: model.isNullableSchema, ); } - /// Extracts the element type as a string representation for top-level list schemas. - /// - /// This handles: - /// - Primitive schemas: `Ack.list(Ack.string())` → 'String' - /// - Nested lists: `Ack.list(Ack.list(Ack.int()))` → `List` - /// - Schema references: `Ack.list(addressSchema)` → resolves to the referenced schema's representation type - String _extractListElementTypeString( - MethodInvocation listInvocation, - Element2 element, - ) { - final args = listInvocation.argumentList.arguments; - - if (args.isEmpty) { - throw InvalidGenerationSourceError( - 'Ack.list(...) requires an element schema argument for strict typed generation.', - element: element, - todo: - 'Provide a concrete element schema, e.g. Ack.list(Ack.string()) or Ack.list(namedSchema).', - ); - } - - final firstArg = args.first; - final ref = _resolveListElementRef(firstArg); - - if (ref.invocation != null) { - final chain = _analyzeSchemaChain(ref.invocation!); - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Ack.list(...) element schema', - ); - final baseInvocation = chain.ackBase; - - // Handle nested lists recursively - if (baseInvocation?.methodName.name == 'list') { - final nestedType = _extractListElementTypeString( - baseInvocation!, - element, - ); - return 'List<$nestedType>'; - } - - if (chain.schemaReference != null) { - return _resolveSchemaVariableElementTypeString( - chain.schemaReference!, - element, - transformedRepresentationType: transformOutputTypeString, - ); - } - - if (baseInvocation == null) { - final rawExpression = firstArg.toSource(); - throw InvalidGenerationSourceError( - 'Could not statically resolve Ack.list($rawExpression) element type.', - element: element, - todo: - 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', - ); - } - - final methodName = baseInvocation.methodName.name; - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: methodName, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Ack.list(...) element schema', - ); - - if (transformOutputTypeString != null) { - return transformOutputTypeString; - } - - if (methodName == 'enumValues') { - return _extractEnumTypeNameFromInvocation(baseInvocation) ?? 'dynamic'; - } - - if (methodName == 'object') { - throw InvalidGenerationSourceError( - 'Ack.list(Ack.object(...)) uses an anonymous inline object schema. ' - 'Strict typed generation requires a named schema reference.', - element: element, - todo: - 'Extract the inline object to a top-level @AckType() variable and use Ack.list(namedSchema).', - ); - } - - // Map primitive schema types - return _mapSchemaMethodToType(methodName); - } - - if (ref.schemaRef != null) { - return _resolveSchemaVariableElementTypeString(ref.schemaRef!, element); - } - - final rawExpression = firstArg.toSource(); - throw InvalidGenerationSourceError( - 'Could not statically resolve Ack.list($rawExpression) element type.', - element: element, - todo: - 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', - ); - } - /// Parses Ack.literal() schema /// /// Literal schemas are StringSchema with a literal constraint. diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart index 4776237..48eac64 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -132,7 +132,9 @@ class TypeBuilder { // Only add args and copyWith for object schemas if (isObjectSchema) ...[ if (model.additionalProperties) _buildArgsGetter(model), - if (model.fields.isNotEmpty) _buildCopyWith(model), + if (model.fields.isNotEmpty && + _canGenerateCopyWithForFields(model.fields)) + _buildCopyWith(model), ], ]), ); @@ -316,7 +318,9 @@ class TypeBuilder { // Add regular field getters ..._buildGetters(model, lookups, skipJsonKeys: {discriminatorKey}), if (model.additionalProperties) _buildArgsGetter(model), - if (model.isFromSchemaVariable && nonDiscriminatorFields.isNotEmpty) + if (model.isFromSchemaVariable && + nonDiscriminatorFields.isNotEmpty && + _canGenerateCopyWithForFields(nonDiscriminatorFields)) _buildCopyWithForFields( model, nonDiscriminatorFields, @@ -1166,6 +1170,10 @@ ${cases.join(',\n')}, return _buildCopyWithForFields(model, model.fields); } + bool _canGenerateCopyWithForFields(Iterable fields) { + return fields.every((field) => !field.isTransformedRepresentation); + } + Method _buildCopyWithForFields( ModelInfo model, List fields, { diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart index 689f5bc..9ab840a 100644 --- a/packages/ack_generator/lib/src/generator.dart +++ b/packages/ack_generator/lib/src/generator.dart @@ -547,6 +547,7 @@ class AckSchemaGenerator extends Generator { discriminatedBaseClassName ?? model.discriminatedBaseClassName, isFromSchemaVariable: model.isFromSchemaVariable, representationType: model.representationType, + isTransformedSchema: model.isTransformedSchema, isNullableSchema: model.isNullableSchema, ); } diff --git a/packages/ack_generator/lib/src/models/field_info.dart b/packages/ack_generator/lib/src/models/field_info.dart index 81f44fe..bc2f645 100644 --- a/packages/ack_generator/lib/src/models/field_info.dart +++ b/packages/ack_generator/lib/src/models/field_info.dart @@ -44,6 +44,12 @@ class FieldInfo { /// Optional cast type override for nested schema references. final String? nestedSchemaCastTypeOverride; + /// Whether reparsing this field's getter value would re-run a transform. + /// + /// Used to suppress generated `copyWith()` on object wrappers whose public + /// field values no longer match the raw schema input shape. + final bool isTransformedRepresentation; + const FieldInfo({ required this.name, required this.jsonKey, @@ -59,6 +65,7 @@ class FieldInfo { this.collectionElementCastTypeOverride, this.collectionElementIsCustomType = false, this.nestedSchemaCastTypeOverride, + this.isTransformedRepresentation = false, }); /// Whether this field references another schema model diff --git a/packages/ack_generator/lib/src/models/model_info.dart b/packages/ack_generator/lib/src/models/model_info.dart index 6913b47..61f3568 100644 --- a/packages/ack_generator/lib/src/models/model_info.dart +++ b/packages/ack_generator/lib/src/models/model_info.dart @@ -59,6 +59,13 @@ class ModelInfo { /// Representation type for extension type (e.g., `String`, `int`, `Map`) final String representationType; + /// Whether this schema's validated representation is produced by a transform. + /// + /// This includes built-in transformed helpers such as `Ack.uri()` and + /// custom `.transform(...)` schemas, including aliases that resolve to + /// transformed schemas. + final bool isTransformedSchema; + /// Whether the schema variable is nullable via `.nullable()`. /// /// This only applies to @AckType schema variables (not @AckModel classes). @@ -78,6 +85,7 @@ class ModelInfo { this.discriminatedBaseClassName, this.isFromSchemaVariable = false, this.representationType = kMapType, + this.isTransformedSchema = false, this.isNullableSchema = false, }); } diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index 29044b3..b8cc81a 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -5,6 +5,30 @@ import 'package:test/test.dart'; import '../test_utils/test_assets.dart'; +Future _expectGenerationFailure({ + required Builder builder, + required Map assets, + required String expectedMessage, + Map? expectedOutputs, +}) async { + var sawExpectedError = false; + await testBuilder( + builder, + assets, + outputs: expectedOutputs ?? {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && log.message.contains(expectedMessage)) { + sawExpectedError = true; + } + }, + ); + expect( + sawExpectedError, + isTrue, + reason: 'Expected SEVERE log containing "$expectedMessage"', + ); +} + void main() { group('@AckType cross-file schema references', () { test( @@ -147,6 +171,409 @@ final deckToolArgsSchema = Ack.object({ ); }); + test( + 'resolves transformed schema refs across files and suppresses copyWith', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart'; + +@AckType() +final themeSchema = Ack.object({ + 'accent': colorSchema, + 'colors': Ack.list(colorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( + contains('extension type ColorType(Color _value)'), + ), + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains( + 'extension type ThemeType(Map _data)', + ), + contains('ColorType get accent'), + contains("ColorType(_data['accent'] as Color)"), + contains('List get colors'), + contains('ColorType(e as Color)'), + isNot(contains('copyWith(')), + ]), + ), + }, + ); + }, + ); + + test( + 'resolves transformed schema refs through re-exported schemas and suppresses copyWith', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/palette_schema_exports.dart': ''' +export 'palette_schemas.dart'; +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schema_exports.dart'; + +@AckType() +final themeSchema = Ack.object({ + 'accent': colorSchema, + 'colors': Ack.list(colorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( + contains('extension type ColorType(Color _value)'), + ), + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains( + 'extension type ThemeType(Map _data)', + ), + contains('ColorType get accent'), + contains("ColorType(_data['accent'] as Color)"), + contains('List get colors'), + contains('ColorType(e as Color)'), + isNot(contains('copyWith(')), + ]), + ), + }, + ); + }, + ); + + test( + 'resolves prefixed transformed schema refs across files and suppresses copyWith', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart' as palette; + +@AckType() +final themeSchema = Ack.object({ + 'accent': palette.colorSchema, + 'colors': Ack.list(palette.colorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( + contains('extension type ColorType(Color _value)'), + ), + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains('palette.ColorType get accent'), + contains("palette.ColorType(_data['accent'] as palette.Color)"), + contains('List get colors'), + contains('palette.ColorType(e as palette.Color)'), + isNot(contains('copyWith(')), + ]), + ), + }, + ); + }, + ); + + test( + 'fails for direct-import transformed refs when representation types are not visible', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await _expectGenerationFailure( + builder: builder, + expectedMessage: 'is not visible from this library', + expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, + assets: { + ...allAssets, + 'test_pkg|lib/hidden_types.dart': ''' +class HiddenColor { + final String value; + const HiddenColor(this.value); +} +''', + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +import 'hidden_types.dart'; + +@AckType() +final hiddenColorSchema = Ack.string() + .transform((value) => HiddenColor(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart'; + +@AckType() +final themeSchema = Ack.object({ + 'accent': hiddenColorSchema, +}); +''', + }, + ); + }, + ); + + test( + 'resolves prefixed transformed generic refs when representation types are exported', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +class Box { + final T value; + const Box(this.value); +} + +@AckType() +final boxedColorSchema = + Ack.string().transform>((value) => Box(Color(value!))); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart' as palette; + +class Color { + final String localValue; + const Color(this.localValue); +} + +class Box { + final T localValue; + const Box(this.localValue); +} + +@AckType() +final themeSchema = Ack.object({ + 'accent': palette.boxedColorSchema, + 'colors': Ack.list(palette.boxedColorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( + contains('extension type BoxedColorType(Box _value)'), + ), + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains('palette.BoxedColorType get accent'), + contains( + "palette.BoxedColorType(_data['accent'] as palette.Box)", + ), + contains('List get colors'), + contains( + 'palette.BoxedColorType(e as palette.Box)', + ), + isNot(contains('copyWith(')), + ]), + ), + }, + ); + }, + ); + + test( + 'fails for direct-import transformed refs when representation types collide locally', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await _expectGenerationFailure( + builder: builder, + expectedMessage: 'is ambiguous in this library', + expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, + assets: { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart'; + +class Color { + final String localValue; + const Color(this.localValue); +} + +@AckType() +final themeSchema = Ack.object({ + 'accent': colorSchema, +}); +''', + }, + ); + }, + ); + + test( + 'fails for prefixed transformed refs when representation types are not visible', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await _expectGenerationFailure( + builder: builder, + expectedMessage: 'is not visible from this library', + expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, + assets: { + ...allAssets, + 'test_pkg|lib/hidden_types.dart': ''' +class HiddenColor { + final String value; + const HiddenColor(this.value); +} +''', + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +import 'hidden_types.dart'; + +@AckType() +final hiddenColorSchema = Ack.string() + .transform((value) => HiddenColor(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart' as palette; + +@AckType() +final themeSchema = Ack.object({ + 'accent': palette.hiddenColorSchema, +}); +''', + }, + ); + }, + ); + + test( + 'fails for cross-file transformed refs that use qualified representation types', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await _expectGenerationFailure( + builder: builder, + expectedMessage: + 'uses a qualified type that cannot be referenced across library boundaries', + expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, + assets: { + ...allAssets, + 'test_pkg|lib/hidden_types.dart': ''' +class HiddenColor { + final String value; + const HiddenColor(this.value); +} +''', + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +import 'hidden_types.dart' as dep; + +@AckType() +final hiddenColorSchema = Ack.string() + .transform((value) => dep.HiddenColor(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart' as palette; + +@AckType() +final themeSchema = Ack.object({ + 'accent': palette.hiddenColorSchema, +}); +''', + }, + ); + }, + ); + test('supports @AckType alias schema declarations', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart index 58c6564..c538895 100644 --- a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart +++ b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart @@ -97,6 +97,57 @@ final petSchema = Ack.discriminated( ); }); + test( + 'suppresses copyWith for discriminated branches with transformed fields', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final catSchema = Ack.object({ + 'kind': Ack.literal('cat'), + 'homepage': Ack.uri(), +}); + +@AckType() +final dogSchema = Ack.object({ + 'kind': Ack.literal('dog'), + 'timeout': Ack.duration(), +}); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': catSchema, + 'dog': dogSchema, + }, +); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('Uri get homepage => _data[\'homepage\'] as Uri'), + contains( + 'Duration get timeout => _data[\'timeout\'] as Duration', + ), + isNot(contains('CatType copyWith(')), + isNot(contains('DogType copyWith(')), + ]), + ), + }, + ); + }, + ); + test('fails when a branch is an inline expression', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/integration/ack_type_transform_test.dart b/packages/ack_generator/test/integration/ack_type_transform_test.dart index 23c0b67..7ba3098 100644 --- a/packages/ack_generator/test/integration/ack_type_transform_test.dart +++ b/packages/ack_generator/test/integration/ack_type_transform_test.dart @@ -25,6 +25,11 @@ class Color { const Color(this.value); } +class TagList { + final List value; + const TagList(this.value); +} + @AckType() final colorSchema = Ack.string().transform((value) => Color(value!)); @@ -95,12 +100,16 @@ final profileSchema = Ack.object({ 'birthday': Ack.date(), 'lastLogin': Ack.datetime().optional().nullable(), 'timeout': Ack.duration(), + 'links': Ack.list(Ack.uri()), + 'nestedLinks': Ack.list(Ack.list(Ack.uri())), 'favoriteColor': Ack.string().transform((value) => Color(value!)), + 'slug': Ack.string().transform((value) => value! + '#'), 'accent': colorSchema, 'colors': Ack.list(colorSchema), 'customColors': Ack.list( baseColorSchema.transform((value) => Color(value!)), ), + 'tagList': Ack.list(Ack.string()).transform((value) => TagList(value!)), }); ''', }, @@ -121,15 +130,23 @@ final profileSchema = Ack.object({ contains( 'Duration get timeout => _data[\'timeout\'] as Duration', ), + contains('List get links'), + contains('_\$ackListCast(_data[\'links\'])'), + contains('List> get nestedLinks'), + contains('_\$ackListCast>(_data[\'nestedLinks\'])'), contains( 'Color get favoriteColor => _data[\'favoriteColor\'] as Color', ), + contains('String get slug => _data[\'slug\'] as String'), contains('ColorType get accent'), contains("ColorType(_data['accent'] as Color)"), contains('List get colors'), contains('ColorType(e as Color)'), contains('List get customColors'), contains('_\$ackListCast(_data[\'customColors\'])'), + contains('TagList get tagList => _data[\'tagList\'] as TagList'), + isNot(contains('TagList get tagList => _\$ackListCast')), + isNot(contains('copyWith(')), isNot(contains('Uri.parse(')), isNot(contains('DateTime.parse(')), isNot(contains('Duration(milliseconds:')), @@ -202,6 +219,88 @@ final describedTagsSchema = Ack.list(Ack.string()).describe('A list of tags'); ); }); + test('supports transformed refs through re-exported schemas', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/palette_schema_exports.dart': ''' +export 'palette_schemas.dart'; +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schema_exports.dart'; + +@AckType() +final themeSchema = Ack.object({ + 'accent': colorSchema, + 'colors': Ack.list(colorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( + contains('extension type ColorType(Color _value)'), + ), + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains('ColorType get accent'), + contains("ColorType(_data['accent'] as Color)"), + contains('List get colors'), + contains('ColorType(e as Color)'), + isNot(contains('copyWith(')), + ]), + ), + }, + ); + }); + + test('non-transformed object schemas still generate copyWith', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final profileSchema = Ack.object({ + 'name': Ack.string(), + 'age': Ack.integer(), +}); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains( + 'extension type ProfileType(Map _data)', + ), + contains('ProfileType copyWith({String? name, int? age})'), + ]), + ), + }, + ); + }); + test('rejects transform without an explicit output type', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 79c460d..c1b5c11 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -190,6 +190,50 @@ void main() { reason: 'schema_types_discriminated.g.dart discriminated subtypes should implement PetType and Map', ); + + final transformsFile = File( + p.join(exampleDir.path, 'lib', 'schema_types_transforms.g.dart'), + ); + expect( + transformsFile.existsSync(), + isTrue, + reason: + 'schema_types_transforms.g.dart should be generated in example/lib', + ); + + final transformsContent = await transformsFile.readAsString(); + expect( + transformsContent, + contains('extension type ColorType(Color _value)'), + reason: + 'schema_types_transforms.g.dart should include the transformed Color extension type', + ); + expect( + transformsContent, + contains('extension type ProfileType(Map _data)'), + reason: + 'schema_types_transforms.g.dart should include the transform-backed object wrapper', + ); + expect( + transformsContent, + allOf([ + contains('Uri get homepage'), + contains('DateTime get birthday'), + contains('DateTime get lastLogin'), + contains('Duration get timeout'), + contains('List get links'), + contains('ColorType get accent'), + contains('List get colors'), + contains('List get customColors'), + contains('TagList get tagList'), + isNot(contains('ProfileType copyWith(')), + isNot(contains('Uri.parse(')), + isNot(contains('DateTime.parse(')), + isNot(contains('Duration(milliseconds:')), + ]), + reason: + 'schema_types_transforms.g.dart should emit transformed getters without unsafe reparsing or copyWith', + ); }); test('example folder pub get should succeed', () async { diff --git a/packages/ack_generator/test/src/test_utilities.dart b/packages/ack_generator/test/src/test_utilities.dart index 6aba386..478edbb 100644 --- a/packages/ack_generator/test/src/test_utilities.dart +++ b/packages/ack_generator/test/src/test_utilities.dart @@ -65,6 +65,9 @@ class MockFieldInfo implements FieldInfo { @override final String? nestedSchemaCastTypeOverride; + @override + final bool isTransformedRepresentation; + final String typeName; final String? listItemTypeName; final String? mapKeyTypeName; @@ -94,6 +97,7 @@ class MockFieldInfo implements FieldInfo { this.collectionElementCastTypeOverride, this.collectionElementIsCustomType = false, this.nestedSchemaCastTypeOverride, + this.isTransformedRepresentation = false, }) : jsonKey = name; @override From faf4819bb2b47b0a551012ca5fd8274e2c54208b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 24 Mar 2026 23:03:48 -0400 Subject: [PATCH 3/6] Run acktype transform support updates --- .../lib/src/validation/model_validator.dart | 4 +- .../test/additional_properties_args_test.dart | 166 +++++++++--------- .../test/description_generation_test.dart | 4 +- packages/ack_generator/test/enum_test.dart | 43 ++--- .../integration/ack_type_transform_test.dart | 1 + .../test/integration/nested_model_test.dart | 96 +++++----- 6 files changed, 167 insertions(+), 147 deletions(-) diff --git a/packages/ack_generator/lib/src/validation/model_validator.dart b/packages/ack_generator/lib/src/validation/model_validator.dart index 2079780..bb062ee 100644 --- a/packages/ack_generator/lib/src/validation/model_validator.dart +++ b/packages/ack_generator/lib/src/validation/model_validator.dart @@ -41,7 +41,9 @@ class ModelValidator { for (final field in modelInfo.fields) { if (field.isNestedSchema) { // Use withNullability: false to get type name without '?' suffix - final fieldTypeName = field.type.getDisplayString(withNullability: false); + final fieldTypeName = field.type.getDisplayString( + withNullability: false, + ); if (fieldTypeName == modelInfo.className) { // Direct self-reference is okay if it's nullable if (!field.isNullable) { diff --git a/packages/ack_generator/test/additional_properties_args_test.dart b/packages/ack_generator/test/additional_properties_args_test.dart index 01cdcf4..fc31462 100644 --- a/packages/ack_generator/test/additional_properties_args_test.dart +++ b/packages/ack_generator/test/additional_properties_args_test.dart @@ -7,16 +7,16 @@ import 'test_utils/test_assets.dart'; void main() { group('Additional Properties Args Getter', () { - - test('generates args getter for schema variable with .passthrough()', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + test( + 'generates args getter for schema variable with .passthrough()', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -26,28 +26,29 @@ final userSchema = Ack.object({ 'age': Ack.integer(), }).passthrough(); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name' && e.key != 'age'"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('Map get args =>'), + contains("e.key != 'name' && e.key != 'age'"), + ]), + ), + }, + ); + }, + ); test( - 'generates args getter for schema variable with explicit additionalProperties: true', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + 'generates args getter for schema variable with explicit additionalProperties: true', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -57,27 +58,29 @@ final userSchema = Ack.object({ 'age': Ack.integer(), }, additionalProperties: true); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name' && e.key != 'age'"), - ]), - ), - }, - ); - }); - - test('does not generate args getter for schema variable without additionalProperties', - () async { - final builder = ackGenerator(BuilderOptions.empty); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('Map get args =>'), + contains("e.key != 'name' && e.key != 'age'"), + ]), + ), + }, + ); + }, + ); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + test( + 'does not generate args getter for schema variable without additionalProperties', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -87,43 +90,46 @@ final userSchema = Ack.object({ 'age': Ack.integer(), }); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - isNot(contains('Map get args')), - ), - }, - ); - }); - - test('generates args getter with no conditions when there are no fields', - () async { - final builder = ackGenerator(BuilderOptions.empty); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + isNot(contains('Map get args')), + ), + }, + ); + }, + ); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/empty.dart': ''' + test( + 'generates args getter with no conditions when there are no fields', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/empty.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @AckType() final emptySchema = Ack.object({}, additionalProperties: true); ''', - }, - outputs: { - 'test_pkg|lib/empty.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains('_data'), - // Should not have filter conditions when no fields exist - isNot(contains('where')), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/empty.g.dart': decodedMatches( + allOf([ + contains('Map get args =>'), + contains('_data'), + // Should not have filter conditions when no fields exist + isNot(contains('where')), + ]), + ), + }, + ); + }, + ); test('generates correct filter for single field', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/description_generation_test.dart b/packages/ack_generator/test/description_generation_test.dart index d5128b3..ed45baf 100644 --- a/packages/ack_generator/test/description_generation_test.dart +++ b/packages/ack_generator/test/description_generation_test.dart @@ -302,7 +302,9 @@ class User { contains('final userSchema = Ack.object({'), // Verify annotation description is used, not doc comment contains('Public user ID'), - isNot(contains('Internal identifier used for database operations')), + isNot( + contains('Internal identifier used for database operations'), + ), ]), ), }, diff --git a/packages/ack_generator/test/enum_test.dart b/packages/ack_generator/test/enum_test.dart index c93ce5a..c339811 100644 --- a/packages/ack_generator/test/enum_test.dart +++ b/packages/ack_generator/test/enum_test.dart @@ -7,14 +7,16 @@ import 'test_utils/test_assets.dart'; void main() { group('Enum Support Tests', () { - test('should generate schema for simple enum field using Ack.enumValues', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'should generate schema for simple enum field using Ack.enumValues', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/model.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; enum Status { active, inactive, pending } @@ -27,19 +29,20 @@ class User { User({required this.name, required this.status}); } ''', - }, - outputs: { - 'test_pkg|lib/model.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object('), - contains("'name': Ack.string()"), - // Now uses Ack.enumValues(T.values) instead of enumString - contains("'status': Ack.enumValues(Status.values)"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches( + allOf([ + contains('final userSchema = Ack.object('), + contains("'name': Ack.string()"), + // Now uses Ack.enumValues(T.values) instead of enumString + contains("'status': Ack.enumValues(Status.values)"), + ]), + ), + }, + ); + }, + ); test('should handle nullable enum fields', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/integration/ack_type_transform_test.dart b/packages/ack_generator/test/integration/ack_type_transform_test.dart index 7ba3098..f8254e1 100644 --- a/packages/ack_generator/test/integration/ack_type_transform_test.dart +++ b/packages/ack_generator/test/integration/ack_type_transform_test.dart @@ -145,6 +145,7 @@ final profileSchema = Ack.object({ contains('List get customColors'), contains('_\$ackListCast(_data[\'customColors\'])'), contains('TagList get tagList => _data[\'tagList\'] as TagList'), + contains('Map toJson() => _data;'), isNot(contains('TagList get tagList => _\$ackListCast')), isNot(contains('copyWith(')), isNot(contains('Uri.parse(')), diff --git a/packages/ack_generator/test/integration/nested_model_test.dart b/packages/ack_generator/test/integration/nested_model_test.dart index 99d76f4..53a137f 100644 --- a/packages/ack_generator/test/integration/nested_model_test.dart +++ b/packages/ack_generator/test/integration/nested_model_test.dart @@ -181,14 +181,16 @@ class Company { ); }); - test('Issue #43: @AckType with Ack.list(schemaRef) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'Issue #43: @AckType with Ack.list(schemaRef) generates List getter', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/contact_list.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/contact_list.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -204,34 +206,37 @@ final contactListSchema = Ack.object({ 'addresses': Ack.list(addressSchema), }); ''', - }, - outputs: { - 'test_pkg|lib/contact_list.g.dart': decodedMatches( - allOf([ - // Extension type for Address - contains('extension type AddressType'), + }, + outputs: { + 'test_pkg|lib/contact_list.g.dart': decodedMatches( + allOf([ + // Extension type for Address + contains('extension type AddressType'), - // Extension type for ContactList - contains('extension type ContactListType'), + // Extension type for ContactList + contains('extension type ContactListType'), - // KEY: List getter with .map() and .toList() - contains('List get addresses'), - contains('.map((e) => AddressType(e as Map))'), - contains('.toList()'), - ]), - ), - }, - ); - }); + // KEY: List getter with .map() and .toList() + contains('List get addresses'), + contains('.map((e) => AddressType(e as Map))'), + contains('.toList()'), + ]), + ), + }, + ); + }, + ); - test('Ack.list(schemaRef.optional()) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'Ack.list(schemaRef.optional()) generates List getter', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/contact_list.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/contact_list.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -247,19 +252,20 @@ final contactListSchema = Ack.object({ 'addresses': Ack.list(addressSchema.optional()), }); ''', - }, - outputs: { - 'test_pkg|lib/contact_list.g.dart': decodedMatches( - allOf([ - contains('extension type AddressType'), - contains('extension type ContactListType'), - contains('List get addresses'), - contains('.map((e) => AddressType(e as Map))'), - contains('.toList()'), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/contact_list.g.dart': decodedMatches( + allOf([ + contains('extension type AddressType'), + contains('extension type ContactListType'), + contains('List get addresses'), + contains('.map((e) => AddressType(e as Map))'), + contains('.toList()'), + ]), + ), + }, + ); + }, + ); }); } From cee290ef6f55e183848c0ce3a5a076b75ac539fa Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 24 Mar 2026 23:18:22 -0400 Subject: [PATCH 4/6] fix: complete acktype transform review cleanup --- .../schemas/discriminated_object_schema.dart | 2 +- .../src/utils/discriminated_branch_utils.dart | 4 ++ .../discriminated_object_schema_test.dart | 10 ++++ .../lib/src/analyzer/schema_ast_analyzer.dart | 42 +++++---------- .../test/bugs/schema_variable_bugs_test.dart | 54 +++++++++++-------- .../ack_type_cross_file_resolution_test.dart | 45 ++++++++++++++++ .../integration/ack_type_getter_test.dart | 30 +++++++++++ 7 files changed, 134 insertions(+), 53 deletions(-) diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 187d5a1..b4556f0 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -292,7 +292,7 @@ final class DiscriminatedObjectSchema extends AckSchema @override int get hashCode { - final mapEq = MapEquality>(); + const mapEq = MapEquality(); return Object.hash( baseFieldsHashCode, discriminatorKey, diff --git a/packages/ack/lib/src/utils/discriminated_branch_utils.dart b/packages/ack/lib/src/utils/discriminated_branch_utils.dart index 425c507..0f7ea11 100644 --- a/packages/ack/lib/src/utils/discriminated_branch_utils.dart +++ b/packages/ack/lib/src/utils/discriminated_branch_utils.dart @@ -1,5 +1,9 @@ import '../schemas/schema.dart'; +/// Returns the underlying branch schema by unwrapping any transform layers. +/// +/// Discriminated branches may be wrapped in [TransformedSchema] while still +/// being object-backed at their core. AckSchema unwrapDiscriminatedBranchSchema(AckSchema schema) { var current = schema; while (current is TransformedSchema) { diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index 939f979..068837e 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -40,6 +40,16 @@ void main() { final result = animalSchema.safeParse({'meow': true}); expect(result.isOk, isFalse); }); + + test('fails when schemas map is empty', () { + final emptySchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: const >>{}, + ); + + final result = emptySchema.safeParse({'type': 'cat'}); + expect(result.isOk, isFalse); + }); }); group('Fluent methods', () { diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index 20d477f..f09b13c 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:ack_annotations/ack_annotations.dart'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; @@ -1323,7 +1324,7 @@ class SchemaAstAnalyzer { !mappedType.isDartCoreList && !mappedType.isDartCoreMap && !mappedType.isDartCoreSet - ? representationType + ? visibleRepresentationType : null; return FieldInfo( @@ -1357,15 +1358,12 @@ class SchemaAstAnalyzer { if (baseInvocation == null) { if (chain.wasTruncated) { - final typeProvider = element.library2!.typeProvider; - return FieldInfo( - name: fieldName, - jsonKey: fieldName, - type: typeProvider.dynamicType, - isRequired: !chain.isOptional, - isNullable: chain.isNullable, - constraints: const [], - isTransformedRepresentation: false, + throw InvalidGenerationSourceError( + 'Field "$fieldName" schema method chain exceeded max depth of 20. ' + '@AckType requires statically analyzable schema chains.', + element: element, + todo: + 'Reduce the chaining depth or extract part of the schema into a named variable.', ); } @@ -1884,9 +1882,8 @@ class SchemaAstAnalyzer { ...classElement.allSupertypes.expand((type) => type.element3.fields2), ]; - final field = allFields.cast().firstWhere( - (current) => current?.name3 == memberName, - orElse: () => null, + final field = allFields.firstWhereOrNull( + (current) => current.name3 == memberName, ); if (field != null) { return field.type; @@ -1897,9 +1894,8 @@ class SchemaAstAnalyzer { ...classElement.allSupertypes.expand((type) => type.element3.getters2), ]; - final getter = allGetters.cast().firstWhere( - (current) => current?.name3 == memberName, - orElse: () => null, + final getter = allGetters.firstWhereOrNull( + (current) => current.name3 == memberName, ); return getter?.returnType; } @@ -2204,7 +2200,7 @@ class SchemaAstAnalyzer { !elementDartType.isDartCoreList && !elementDartType.isDartCoreMap && !elementDartType.isDartCoreSet - ? representationType + ? visibleRepresentationType : null); return ( @@ -2365,18 +2361,6 @@ class SchemaAstAnalyzer { schemaGetter = resolvedElement; sourceDeclaration = resolvedElement; } - } else if (resolvedElement is PropertyAccessorElement2) { - if (resolvedElement is GetterElement && resolvedElement.isSynthetic) { - final variable = resolvedElement.variable3; - if (variable is TopLevelVariableElement2) { - schemaVariable = variable; - sourceDeclaration = variable; - } - } else if (resolvedElement is GetterElement && - !resolvedElement.isSynthetic) { - schemaGetter = resolvedElement; - sourceDeclaration = resolvedElement; - } } if (schemaVariable == null && schemaGetter != null) { diff --git a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart b/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart index 7b44450..f61279d 100644 --- a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart +++ b/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart @@ -505,14 +505,16 @@ final chainedSchema = Ack.object({ }); }); - test('handles deeply nested chains without hanging', () async { - // Create a chain with 25 .optional() calls to test depth limits - final deepChain = List.generate(25, (_) => 'optional()').join('.'); + test( + 'throws a clear error when field chains exceed analyzer depth', + () async { + // Create a chain with 25 .optional() calls to test depth limits + final deepChain = List.generate(25, (_) => 'optional()').join('.'); - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' + final assets = { + ...allAssets, + 'test_pkg|lib/schema.dart': + ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -521,25 +523,31 @@ final deepSchema = Ack.object({ 'field': Ack.string().$deepChain, }); ''', - }; + }; - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name3 == 'deepSchema', - ); + await resolveSources(assets, (resolver) async { + final library = await resolver.libraryFor( + AssetId('test_pkg', 'lib/schema.dart'), + ); + final schemaVar = library.topLevelVariables.firstWhere( + (e) => e.name3 == 'deepSchema', + ); - final analyzer = SchemaAstAnalyzer(); + final analyzer = SchemaAstAnalyzer(); - // Should complete without hanging - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - returnsNormally, - ); - }); - }); + expect( + () => analyzer.analyzeSchemaVariable(schemaVar), + throwsA( + predicate( + (error) => + error is InvalidGenerationSourceError && + error.toString().contains('exceeded max depth of 20'), + ), + ), + ); + }); + }, + ); }); group('List elements with method chain modifiers', () { diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index b8cc81a..26abf33 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -333,6 +333,51 @@ final themeSchema = Ack.object({ }, ); + test( + 'resolves prefixed transformed refs without @AckType using visible representation types', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; + +class Color { + final String value; + const Color(this.value); +} + +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette_schemas.dart' as palette; + +@AckType() +final themeSchema = Ack.object({ + 'accent': palette.colorSchema, + 'colors': Ack.list(palette.colorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains('palette.Color get accent'), + contains("_data['accent'] as palette.Color"), + contains('List get colors'), + contains("_\$ackListCast(_data['colors'])"), + ]), + ), + }, + ); + }, + ); + test( 'fails for direct-import transformed refs when representation types are not visible', () async { diff --git a/packages/ack_generator/test/integration/ack_type_getter_test.dart b/packages/ack_generator/test/integration/ack_type_getter_test.dart index 62b72f9..d7f120e 100644 --- a/packages/ack_generator/test/integration/ack_type_getter_test.dart +++ b/packages/ack_generator/test/integration/ack_type_getter_test.dart @@ -152,5 +152,35 @@ ObjectSchema get userSchema => Ack.object({ }, ); }); + + test('documents nullable list element type inference loss', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +ObjectSchema get userSchema => Ack.object({ + 'tags': Ack.list(Ack.string().nullable()), +}); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains( + "List get tags => _\$ackListCast(_data['tags'])", + ), + isNot(contains('List get tags')), + ]), + ), + }, + ); + }); }); } From 97c5f300be06647b1a54960eca584ce526bc7e90 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 24 Mar 2026 23:21:50 -0400 Subject: [PATCH 5/6] test: add direct import transformed ref regression --- .../ack_type_cross_file_resolution_test.dart | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index 26abf33..d132cfd 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -836,6 +836,51 @@ final deckToolArgsSchema = Ack.object({ ); }); + test( + 'resolves direct-import transformed refs without @AckType using visible representation types', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/palette.dart': ''' +import 'package:ack/ack.dart'; + +class Color { + final String value; + const Color(this.value); +} + +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'palette.dart' as palette; + +@AckType() +final themeSchema = Ack.object({ + 'primary': palette.colorSchema, + 'accents': Ack.list(palette.colorSchema), +}); +''', + }, + outputs: { + 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( + allOf([ + contains('palette.Color get primary'), + contains("_data['primary'] as palette.Color"), + contains('List get accents'), + contains("_\$ackListCast(_data['accents'])"), + ]), + ), + }, + ); + }, + ); + test( 'fails when Ack.list(schemaRef) object reference lacks @AckType', () async { From c3dcbffcc2e7f3f336dd5f47695e63da7a863d8d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 24 Mar 2026 23:37:44 -0400 Subject: [PATCH 6/6] fix: improve cross-file transformed type ambiguity detection Replace single-type context lookup with per-import namespace scanning to correctly detect when multiple imports expose the same representation type name, and add regression test for the ambiguous import case. --- .../lib/src/analyzer/schema_ast_analyzer.dart | 68 +++++++++++++++++-- .../ack_type_cross_file_resolution_test.dart | 45 ++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart index f09b13c..9aa1d20 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart @@ -2625,7 +2625,29 @@ class SchemaAstAnalyzer { } final importNamespaceType = _resolveImportedType(token, resolved); - final contextType = _resolveTypeByName(token, contextLibrary); + final scopedElement = contextLibrary.firstFragment.scope + .lookup(token) + .getter2; + final scopedType = _resolveTypeFromElement(scopedElement); + final localContextType = + scopedElement != null && + _findImportDirectiveForElement( + token, + scopedElement, + contextLibrary, + ) == + null + ? scopedType + : null; + final importedContextTypes = _resolveImportedTypesByName( + token, + contextLibrary, + ); + final unqualifiedContextType = + localContextType ?? + (importedContextTypes.length == 1 ? importedContextTypes.single : null); + final hasAmbiguousImportedTypes = + localContextType == null && importedContextTypes.length > 1; final prefix = resolved.importPrefix; if (importNamespaceType != null && prefix != null && prefix.isNotEmpty) { @@ -2633,12 +2655,12 @@ class SchemaAstAnalyzer { } if (importNamespaceType != null) { - if (contextType != null && - _sameResolvedType(importNamespaceType, contextType)) { + if (unqualifiedContextType != null && + _sameResolvedType(importNamespaceType, unqualifiedContextType)) { return token; } - if (contextType != null) { + if (hasAmbiguousImportedTypes || unqualifiedContextType != null) { throw InvalidGenerationSourceError( 'Transformed representation type "$fullRepresentationType" for ' '"${resolved.schemaName}" is ambiguous in this library.', @@ -2649,7 +2671,17 @@ class SchemaAstAnalyzer { } } - if (contextType != null) { + if (hasAmbiguousImportedTypes) { + throw InvalidGenerationSourceError( + 'Transformed representation type "$fullRepresentationType" for ' + '"${resolved.schemaName}" is ambiguous in this library.', + element: contextElement, + todo: + 'Use a prefixed schema import or rename/import the representation type so the generated cast resolves unambiguously.', + ); + } + + if (unqualifiedContextType != null) { return token; } @@ -2666,6 +2698,32 @@ class SchemaAstAnalyzer { return _resolvedTypeIdentity(first) == _resolvedTypeIdentity(second); } + List _resolveImportedTypesByName( + String token, + LibraryElement2 library, + ) { + final normalizedToken = token.trim(); + if (normalizedToken.isEmpty) { + return const []; + } + + final importedTypesByIdentity = {}; + for (final import in library.firstFragment.libraryImports2) { + final importedElement = import.namespace.get2(normalizedToken); + final importedType = _resolveTypeFromElement(importedElement); + if (importedType == null) { + continue; + } + + importedTypesByIdentity.putIfAbsent( + _resolvedTypeIdentity(importedType), + () => importedType, + ); + } + + return importedTypesByIdentity.values.toList(growable: false); + } + DartType? _resolveImportedType( String token, _ResolvedSchemaReference resolved, diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index d132cfd..c48fc8a 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -524,6 +524,51 @@ class Color { const Color(this.localValue); } +@AckType() +final themeSchema = Ack.object({ + 'accent': colorSchema, +}); +''', + }, + ); + }, + ); + + test( + 'fails for direct-import transformed refs when multiple imports expose the representation type', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await _expectGenerationFailure( + builder: builder, + expectedMessage: 'is ambiguous in this library', + expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, + assets: { + ...allAssets, + 'test_pkg|lib/palette_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +class Color { + final String value; + const Color(this.value); +} + +@AckType() +final colorSchema = Ack.string().transform((value) => Color(value!)); +''', + 'test_pkg|lib/alt_color_types.dart': ''' +class Color { + final String localValue; + const Color(this.localValue); +} +''', + 'test_pkg|lib/theme_schemas.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'alt_color_types.dart'; +import 'palette_schemas.dart'; + @AckType() final themeSchema = Ack.object({ 'accent': colorSchema,