Add support for enumString, literal, and enumValues in Ack.object()#70
Add support for enumString, literal, and enumValues in Ack.object()#70leoafarias wants to merge 10 commits intomainfrom
Conversation
… in Ack.object()
These schema types were handled correctly as top-level @AckType schemas
but threw "Unsupported schema method" when used as field values inside
Ack.object({...}). The bug was in _mapSchemaTypeToDartType (field-level
type resolution) and _mapSchemaMethodToType (list element type strings),
which were missing cases for these three schema types.
Fixes:
- _mapSchemaTypeToDartType: add enumString/literal → String, enumValues → resolved enum DartType
- _mapSchemaMethodToType: add enumString/literal → 'String'
- _extractListElementTypeString: handle enumValues with type extraction from invocation
- Add _resolveEnumValuesType helper to resolve enum DartType from library elements
- Add _extractEnumValuesTypeName helper for string-based type resolution in list contexts
- Update test mock Ack class with enumString(), enumValues(), literal() static methods
- Add 6 regression tests covering all field-level enum/literal scenarios
https://claude.ai/code/session_01VF6gNrszmCJwYe1AcWPzez
|
To view this pull requests documentation preview, visit the following URL: Documentation is deployed and generated using docs.page. |
…alues/literal fields - Add 9 golden tests verifying generated extension type getters for enumString, literal, and enumValues fields inside Ack.object() schemas - Add 3 edge case tests for Ack.list(Ack.enumValues<T>()), Ack.list(Ack.literal()), and Ack.enumValues<T>().nullable() without optional() - Refactor duplicate enum type name extraction into shared _extractEnumTypeNameFromInvocation() helper https://claude.ai/code/session_01VF6gNrszmCJwYe1AcWPzez
- Remove pointless _extractEnumValuesTypeName wrapper; call shared helper directly - Strip redundant inline comments that narrate the obvious - Trim over-documented docstrings to match surrounding code style - Remove verbose reason strings from test assertions where the framework output is already clear https://claude.ai/code/session_01VF6gNrszmCJwYe1AcWPzez
There was a problem hiding this comment.
Pull request overview
Adds missing schema-analyzer support so Ack.enumString(), Ack.literal(), and Ack.enumValues<T>() fields inside Ack.object({...}) produce correct Dart field types in generated @AckType extension types.
Changes:
- Extend
SchemaAstAnalyzerto resolveenumString,literal, andenumValuesinvocations to Dart types (including list element type string extraction). - Expand test utilities with mock
Ack.literal/enumString/enumValuesand a minimalEnumSchema<T>for test compilation. - Add new integration tests and regression tests covering these schema patterns (including optional/nullable and nested lists).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart | Adds enum/literal/enumValues handling in type resolution + list element type string logic |
| packages/ack_generator/test/test_utils/test_assets.dart | Adds mock Ack methods and EnumSchema<T> used by generator tests |
| packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart | New integration tests validating generated getters/types |
| packages/ack_generator/test/bugs/schema_variable_bugs_test.dart | New regression tests validating AST analyzer field typing |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| DartType? _resolveEnumValuesType( | ||
| MethodInvocation invocation, | ||
| LibraryElement2 library, | ||
| ) { | ||
| final enumTypeName = _extractEnumTypeNameFromInvocation(invocation); | ||
| if (enumTypeName == null) return null; | ||
|
|
||
| for (final enumElement in library.enums) { | ||
| if (enumElement.name3 == enumTypeName) { | ||
| return enumElement.thisType; | ||
| } | ||
| } | ||
|
|
||
| // Also check classes (for class-based enums) | ||
| for (final classElement in library.classes) { | ||
| if (classElement.name3 == enumTypeName) { | ||
| return classElement.thisType; | ||
| } | ||
| } |
There was a problem hiding this comment.
_resolveEnumValuesType only checks library.enums/library.classes, so enums imported from other libraries (or referenced via prefixed imports / re-exports) won’t resolve and will trigger the fallback path. Since the AST is resolved in these builders, prefer extracting T directly from the invocation’s resolved static type (EnumSchema) or from the type argument’s resolved element/type instead of searching only the current library.
| return 'List<$nestedType>'; | ||
| } | ||
|
|
||
| if (methodName == 'enumValues') { |
There was a problem hiding this comment.
In list element type string extraction, using _extractEnumTypeNameFromInvocation will miss common shapes like alias.UserRole.values (AST is typically a PropertyAccess, not a PrefixedIdentifier) and can again drop prefixes, leading to incorrect List<...> representation strings. Consider reusing the same resolved-type-based approach as enumValues field resolution so list element typing works with prefixed/imported enums too.
| if (methodName == 'enumValues') { | |
| if (methodName == 'enumValues') { | |
| // Prefer resolved static types so we correctly handle shapes like | |
| // `alias.UserRole.values` and preserve import prefixes. | |
| final enumArgs = ref.ackBase!.argumentList.arguments; | |
| if (enumArgs.isNotEmpty) { | |
| final argType = enumArgs.first.staticType; | |
| if (argType is InterfaceType) { | |
| InterfaceType? enumType; | |
| // Typical case: `MyEnum.values` has static type `List<MyEnum>`. | |
| if (argType.isDartCoreList && argType.typeArguments.isNotEmpty) { | |
| final elementType = argType.typeArguments.first; | |
| if (elementType is InterfaceType) { | |
| enumType = elementType; | |
| } | |
| } else { | |
| // Fallback: treat the interface type itself as the enum type. | |
| enumType = argType; | |
| } | |
| final enumElement = enumType?.element2; | |
| if (enumElement is EnumElement2) { | |
| // Use the analyzer's display string to keep prefixes and drop nullability. | |
| return enumType!.getDisplayString(withNullability: false); | |
| } | |
| } | |
| } | |
| // Fall back to the existing AST-based extraction to avoid regressions. |
packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart
Outdated
Show resolved
Hide resolved
packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart
Outdated
Show resolved
Hide resolved
Generated examples verified — build_runner produces identical output, all toJson tests pass, and dart analyze is clean.
…Values Source-based enum type extraction was prioritized over resolved static types in _extractEnumTypeNameFromInvocation, which could produce incorrect generated types when a non-enum class exposes a .values getter (e.g., Ack.enumValues(holder.values) would generate 'holder' instead of the actual enum type).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Prefers resolved static types so imported/re-exported enums and prefixed | ||
| /// references (e.g., `alias.UserRole`) are preserved in generated code. | ||
| /// Falls back to source-based extraction if static typing is unavailable. | ||
| String? _extractEnumTypeNameFromInvocation(MethodInvocation invocation) { | ||
| final resolvedType = _resolveEnumValuesType(invocation); | ||
| if (resolvedType != null) { | ||
| return resolvedType.getDisplayString(withNullability: false); | ||
| } | ||
|
|
||
| final sourceTypeName = _extractEnumTypeNameFromSource(invocation); | ||
| if (sourceTypeName != null) { | ||
| return sourceTypeName; | ||
| } | ||
|
|
There was a problem hiding this comment.
_extractEnumTypeNameFromInvocation() returns the resolved DartType display string when available. DartType.getDisplayString() does not reliably preserve import prefixes (e.g., models.UserRole), but generated code in a part file must use the same qualified name as the source library when the enum is only in scope via a prefixed import. Consider preferring the source-based extraction (toSource() / .values target) when present (or at least when it contains a prefix), and only falling back to resolved types when source extraction is unavailable.
| /// Prefers resolved static types so imported/re-exported enums and prefixed | |
| /// references (e.g., `alias.UserRole`) are preserved in generated code. | |
| /// Falls back to source-based extraction if static typing is unavailable. | |
| String? _extractEnumTypeNameFromInvocation(MethodInvocation invocation) { | |
| final resolvedType = _resolveEnumValuesType(invocation); | |
| if (resolvedType != null) { | |
| return resolvedType.getDisplayString(withNullability: false); | |
| } | |
| final sourceTypeName = _extractEnumTypeNameFromSource(invocation); | |
| if (sourceTypeName != null) { | |
| return sourceTypeName; | |
| } | |
| /// Prefers source-based extraction (`toSource()` / `.values` target) so | |
| /// imported/re-exported enums and prefixed references (e.g., `alias.UserRole`) | |
| /// are preserved in generated code. Falls back to resolved static types when | |
| /// source-based extraction is unavailable. | |
| String? _extractEnumTypeNameFromInvocation(MethodInvocation invocation) { | |
| final sourceTypeName = _extractEnumTypeNameFromSource(invocation); | |
| if (sourceTypeName != null) { | |
| return sourceTypeName; | |
| } | |
| final resolvedType = _resolveEnumValuesType(invocation); | |
| if (resolvedType != null) { | |
| return resolvedType.getDisplayString(withNullability: false); | |
| } |
| final scopeResult = library.firstFragment.scope.lookup(normalizedTypeName); | ||
| final scopeType = _resolveTypeFromElement(scopeResult.getter); | ||
| if (scopeType != null) { | ||
| return scopeType; | ||
| } | ||
|
|
||
| // Try import namespaces directly as a fallback for prefixed names. | ||
| for (final import in library.firstFragment.libraryImports) { | ||
| final importedElement = import.namespace.get2(normalizedTypeName); | ||
| final importedType = _resolveTypeFromElement(importedElement); | ||
| if (importedType != null) { | ||
| return importedType; | ||
| } | ||
| } | ||
|
|
||
| // Last-resort local lookup. | ||
| for (final enumElement in library.enums) { | ||
| if (enumElement.name3 == normalizedTypeName) { | ||
| return enumElement.thisType; | ||
| } | ||
| } | ||
| for (final classElement in library.classes) { | ||
| if (classElement.name3 == normalizedTypeName) { |
There was a problem hiding this comment.
_resolveTypeByName() claims to be a fallback for prefixed names, but scope.lookup(normalizedTypeName) / import.namespace.get2(normalizedTypeName) won't resolve a qualified identifier like models.UserRole because lookups are by simple name. If you intend to support prefixed type names here, split on . and resolve via the matching import prefix namespace (or resolve the prefix element first, then the type).
| final scopeResult = library.firstFragment.scope.lookup(normalizedTypeName); | |
| final scopeType = _resolveTypeFromElement(scopeResult.getter); | |
| if (scopeType != null) { | |
| return scopeType; | |
| } | |
| // Try import namespaces directly as a fallback for prefixed names. | |
| for (final import in library.firstFragment.libraryImports) { | |
| final importedElement = import.namespace.get2(normalizedTypeName); | |
| final importedType = _resolveTypeFromElement(importedElement); | |
| if (importedType != null) { | |
| return importedType; | |
| } | |
| } | |
| // Last-resort local lookup. | |
| for (final enumElement in library.enums) { | |
| if (enumElement.name3 == normalizedTypeName) { | |
| return enumElement.thisType; | |
| } | |
| } | |
| for (final classElement in library.classes) { | |
| if (classElement.name3 == normalizedTypeName) { | |
| // Support both simple and prefixed names, e.g. `UserRole` and `models.UserRole`. | |
| final dotIndex = normalizedTypeName.indexOf('.'); | |
| final String? prefix = | |
| dotIndex == -1 ? null : normalizedTypeName.substring(0, dotIndex); | |
| final String simpleName = dotIndex == -1 | |
| ? normalizedTypeName | |
| : normalizedTypeName.substring(dotIndex + 1); | |
| // For simple names, try resolving in the library scope first. | |
| if (prefix == null) { | |
| final scopeResult = library.firstFragment.scope.lookup(simpleName); | |
| final scopeType = _resolveTypeFromElement(scopeResult.getter); | |
| if (scopeType != null) { | |
| return scopeType; | |
| } | |
| } | |
| // Try import namespaces directly. For prefixed names, only consider | |
| // imports whose prefix matches; for simple names, consider all imports. | |
| for (final import in library.firstFragment.libraryImports) { | |
| if (prefix != null) { | |
| final prefixElement = import.prefix?.element; | |
| if (prefixElement == null || prefixElement.name3 != prefix) { | |
| continue; | |
| } | |
| } | |
| final importedElement = import.namespace.get2(simpleName); | |
| final importedType = _resolveTypeFromElement(importedElement); | |
| if (importedType != null) { | |
| return importedType; | |
| } | |
| } | |
| // Last-resort local lookup by simple name (local types are not prefixed). | |
| for (final enumElement in library.enums) { | |
| if (enumElement.name3 == simpleName) { | |
| return enumElement.thisType; | |
| } | |
| } | |
| for (final classElement in library.classes) { | |
| if (classElement.name3 == simpleName) { |
| _log.warning( | ||
| 'Could not resolve enum type for Ack.enumValues(); falling back to dynamic.', | ||
| ); | ||
| return (typeProvider.dynamicType, null); |
There was a problem hiding this comment.
PR description says enumValues resolution “falls back to String type when the enum cannot be resolved”, but the implementation falls back to dynamic here. Please either update the PR description to match this behavior or change the fallback type to String (if that’s the intended contract).
docs/api-reference/index.mdx
Outdated
| - `Ack.anyOf(List<AckSchema> schemas)`: Creates an `AnyOfSchema` for union types. | ||
| - `Ack.any()`: Creates an `AnySchema` that accepts any value. | ||
| - `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema> schemas})`: Creates a discriminated union schema. | ||
| - `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema<MapValue>> schemas})`: Creates a discriminated union schema. |
There was a problem hiding this comment.
The docs now mention AckSchema<MapValue>, but MapValue isn’t introduced/explained on this page and (from the public exports) may not be importable by consumers without reaching into src/. Consider documenting it (and how users should reference it) or keep the signature in docs as Map<String, AckSchema<Map<String, Object?>>> / Map<String, AckSchema<Map<String, Object?>>> to avoid exposing an internal alias.
| - `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema<MapValue>> schemas})`: Creates a discriminated union schema. | |
| - `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema<Map<String, Object?>>> schemas})`: Creates a discriminated union schema. |
docs/api-reference/index.mdx
Outdated
| Schema for polymorphic validation based on a discriminator field. | ||
|
|
||
| - Created using `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema> schemas})` | ||
| - Created using `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema<MapValue>> schemas})` |
There was a problem hiding this comment.
This reference uses AckSchema<MapValue> but MapValue isn’t explained here and may not be part of the stable public surface (it’s not exported from package:ack/ack.dart). Consider replacing with an explicit Map<String, Object?> type in the docs, or add a short note defining MapValue for readers.
| - Created using `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema<MapValue>> schemas})` | |
| - Created using `Ack.discriminated({required String discriminatorKey, required Map<String, AckSchema<Map<String, Object?>>> schemas})` |
Convert traditional switch/case statements to concise switch expressions in field_analyzer and schema_ast_analyzer for improved readability.
| _log.warning( | ||
| 'Could not resolve enum type for Ack.enumValues(); falling back to dynamic.', | ||
| ); | ||
| return (typeProvider.dynamicType, null); |
There was a problem hiding this comment.
Ack.enumValues(...) inside Ack.object({...}) can lose enum typing for variable/property inputs without an explicit type arg, and this branch falls back to dynamic.
Why this is a bug:
- The fallback path is explicit at
schema_ast_analyzer.dart:618, which then drives generated field typing todynamic/Object?instead of enum type. - This can happen when source extraction cannot derive an enum name from non-
.valuesforms (schema_ast_analyzer.dart:657).
Code references:
| // Enums | ||
| if (field.isEnum) { | ||
| return field.type.getDisplayString(withNullability: false); | ||
| return field.displayTypeOverride ?? |
There was a problem hiding this comment.
displayTypeOverride preserves prefixed enum names for getters, but copyWith still types parameters from raw field.type, which drops the import prefix.
Impact:
- For
Ack.enumValues(models.UserRole.values), generated code can become inconsistent:- getter:
models.UserRole get role(qualified) - copyWith:
copyWith({UserRole? role})(unqualified)
- getter:
- If
UserRoleis only available via prefixed import, the unqualifiedcopyWithparameter type is unresolved.
Code references:
Summary
This PR adds proper type resolution support for
Ack.enumString(),Ack.literal(), andAck.enumValues<T>()when used as fields insideAck.object()schemas. Previously, these schema methods were not properly handled by the schema analyzer, resulting in incorrect or missing type information.Key Changes
Enhanced
SchemaAstAnalyzer._resolveSchemaType(): Added cases forenumString,literal, andenumValuesto properly resolve their Dart typesenumStringandliteralresolve toStringtypeenumValues<T>resolves to the generic enum typeTby extracting it from type arguments or inferring from the argument patternNew
_resolveEnumValuesType()method: Implements two-strategy type resolution forAck.enumValues<T>():Ack.enumValues<UserRole>(...))Ack.enumValues(UserRole.values))Updated
_mapSchemaMethodToType()and_extractEnumValuesTypeName(): Added support for string representation of enum types in list element type resolutionTest coverage: Added comprehensive test suite covering:
enumString()field handlingenumString()with optional/nullable modifiersliteral()field handlingenumValues<T>()with explicit type argumentsenumValues<T>()with optional modifierAck.list(Ack.enumString())patternsTest utilities: Added mock implementations of
literal(),enumString(), andenumValues()methods to the testAckclass, plus anEnumSchema<T>class for proper schema representationImplementation Details
The solution handles both explicit type arguments and inferred types, making it flexible for different coding styles. The enum type resolution gracefully falls back to
Stringtype when the enum cannot be resolved, ensuring safe defaults while maintaining type safety when possible.https://claude.ai/code/session_01VF6gNrszmCJwYe1AcWPzez