From 0b8703b0becdadada218c0dbb9ab771181a1a1a9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:31:45 -0400 Subject: [PATCH] fix(generator): preserve effective schema metadata --- packages/ack_generator/CHANGELOG.md | 10 + packages/ack_generator/README.md | 9 + .../analyzer/model_graph_builder_base.dart | 18 - .../analyzer/schema_model_graph_builder.dart | 361 +++++++++++++++--- .../integration/ack_infer_graph_test.dart | 190 +++++++++ .../ack_infer_runtime_build_test.dart | 73 ++++ .../schema_alias_shape_contract_test.dart | 96 +++++ 7 files changed, 678 insertions(+), 79 deletions(-) create mode 100644 packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 4ea69bc7..d3ea1796 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +### Fixed + +* Preserve schema-first presence, nullability, and default-wrapper behavior + through aliases and explicit constant flag overrides. Apply the outermost + strict/passthrough policy to object models and aliases. +* Reject object shape-changing methods that cannot be modeled statically, + instead of generating fields that disagree with the authored schema. + ## 2.0.0-alpha.1 ### Breaking diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index abf20830..9eb8030f 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -243,6 +243,15 @@ Named model references work through direct imports, prefixes, and re-exports. Nested conversion uses each model's public `$ack` adapter so codec runtime values aren't parsed twice. +Schema-first fields retain optional, nullable, and default behavior through +named schema variables and getters. Explicit constant flag overrides such as +`.optional(value: false)` are honored, as is the outermost `.strict()` or +`.passthrough()` object policy. Shape-changing object methods (`extend`, +`partial`, `merge`, `pick`, `omit`, or `copyWith(properties: ...)`) are rejected +when generating an object model; write its final properties in an +`Ack.object({...})` map literal instead. An outer bidirectional codec can still +wrap such a schema as a value model. + ## Generated part Every annotated library declares a single `.g.dart` part: diff --git a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart index 1dc8a126..c460b3ea 100644 --- a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -17,18 +17,12 @@ final class SchemaChain { const SchemaChain({ required this.base, required this.reference, - required this.optional, - required this.nullable, - required this.defaulted, required this.hasTransform, required this.hasCodec, }); final MethodInvocation? base; final Expression? reference; - final bool optional; - final bool nullable; - final bool defaulted; final bool hasTransform; final bool hasCodec; } @@ -677,9 +671,6 @@ abstract base class ModelGraphBuilderBase { return SchemaChain( base: null, reference: expression, - optional: false, - nullable: false, - defaulted: false, hasTransform: false, hasCodec: false, ); @@ -687,16 +678,10 @@ abstract base class ModelGraphBuilderBase { MethodInvocation? current = expression; MethodInvocation? base; Expression? reference; - var optional = false; - var nullable = false; - var defaulted = false; var transform = false; var codec = false; while (current != null) { final name = current.methodName.name; - optional |= name == 'optional'; - nullable |= name == 'nullable'; - defaulted |= name == 'withDefault'; transform |= oneWayTransformMethods.contains(name); codec |= name == 'codec'; if (current.methodName.element is TopLevelFunctionElement || @@ -719,9 +704,6 @@ abstract base class ModelGraphBuilderBase { return SchemaChain( base: base, reference: reference, - optional: optional, - nullable: nullable, - defaulted: defaulted, hasTransform: transform, hasCodec: codec, ); diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 22339390..42a59c7b 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -27,6 +27,37 @@ final class _Declaration { typedef _SchemaTypes = ({AckInferRef boundary, AckInferRef runtime}); +/// The effective flags of a schema expression, including referenced aliases. +/// +/// DefaultSchema cannot clear a nullable/optional flag inherited from its inner +/// schema. Other public wrappers copy the flags but have no such inner floor. +final class _SchemaMetadata { + const _SchemaMetadata({ + this.optional = false, + this.nullable = false, + this.defaulted = false, + this.innerOptional = false, + this.innerNullable = false, + }); + + final bool optional; + final bool nullable; + final bool defaulted; + final bool innerOptional; + final bool innerNullable; + + _SchemaMetadata copyWith({bool? optional, bool? nullable}) => _SchemaMetadata( + optional: (optional ?? this.optional) || innerOptional, + nullable: (nullable ?? this.nullable) || innerNullable, + defaulted: defaulted, + innerOptional: innerOptional, + innerNullable: innerNullable, + ); + + _SchemaMetadata get wrapped => + _SchemaMetadata(optional: optional, nullable: nullable); +} + /// Builds the single normalized graph consumed by Ack model emission. final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { SchemaModelGraphBuilder(super.library); @@ -160,7 +191,17 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { graph.begin(declaration.id); final chain = parseSchemaChain(declaration.expression); _rejectTransform(chain, path, declaration.element); - if (chain.nullable) { + await _rejectObjectShapeChanges( + declaration.expression, + path: path, + context: declaration.element, + ); + final metadata = await _schemaMetadata( + declaration.expression, + path: path, + context: declaration.element, + ); + if (metadata.nullable) { throw InvalidGenerationSource( '$path is a nullable root. Generated Ack models are non-nullable.', element: declaration.element, @@ -268,13 +309,23 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); final source = graph.nodeFor(target.id)!; if (source is AckObjectModelNode) { + final additionalProperties = await _additionalProperties( + declaration.expression, + path: path, + context: declaration.element, + ); return AckObjectModelNode( id: declaration.id, className: declaration.className, fields: source.fields, - unknownPropertyPolicy: source.unknownPropertyPolicy, - captureFieldName: source.captureFieldName, - constructorParameters: source.constructorParameters, + unknownPropertyPolicy: additionalProperties + ? AckUnknownPropertyPolicy.capture + : AckUnknownPropertyPolicy.reject, + captureFieldName: additionalProperties ? 'additionalProperties' : null, + constructorParameters: _schemaFirstConstructorParameters( + source.fields, + capture: additionalProperties, + ), description: source.description, ); } @@ -300,6 +351,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { SchemaChain chain, String path, ) async { + _schemaTypes(declaration.expression, path, declaration.element); final invocation = chain.base!; final arguments = argumentExpressions(invocation.argumentList); if (arguments.isEmpty || arguments.first is! SetOrMapLiteral) { @@ -309,10 +361,10 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } final literal = arguments.first as SetOrMapLiteral; - final additionalProperties = _additionalProperties( + final additionalProperties = await _additionalProperties( declaration.expression, path: path, - element: declaration.element, + context: declaration.element, ); final fields = []; for (final entry in literal.elements) { @@ -335,6 +387,11 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { } final fieldPath = '$path.$jsonKey'; final fieldChain = parseSchemaChain(entry.value); + final fieldMetadata = await _schemaMetadata( + entry.value, + path: fieldPath, + context: declaration.element, + ); _rejectTransform(fieldChain, fieldPath, declaration.element); if (fieldChain.base?.methodName.name == 'object') { throw InvalidGenerationSource( @@ -347,8 +404,8 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { AckFieldNode( dartName: jsonKey, jsonKey: jsonKey, - presence: _fieldPresence(fieldChain), - nullable: fieldChain.nullable, + presence: _fieldPresence(fieldMetadata), + nullable: fieldMetadata.nullable, runtimeRef: await _runtimeRefForSchema( entry.value, path: fieldPath, @@ -380,6 +437,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { SchemaChain chain, String path, ) async { + _schemaTypes(declaration.expression, path, declaration.element); String? discriminatorKey; SetOrMapLiteral? branchesLiteral; for (final argumentNode in chain.base!.argumentList.arguments) { @@ -487,9 +545,16 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { }) async { final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); - if (collectionElement && chain.nullable) { + await _rejectObjectShapeChanges(expression, path: path, context: context); + if (collectionElement && + (await _schemaMetadata( + expression, + path: path, + context: context, + )).nullable) { throw InvalidGenerationSource( - '$path uses nullable collection elements. Ack.list does not support ' + '$path uses nullable collection elements (${expression.toSource()}). ' + 'Ack.list does not support ' 'nullable item schemas.', element: context, todo: @@ -542,7 +607,11 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { depth: depth, ); // Unlike list items, JSON object values may be null. - final nullableValue = await _isNullableSchema(arguments.first); + final nullableValue = (await _schemaMetadata( + arguments.first, + path: '$path{}', + context: context, + )).nullable; return AckMapTypeRef( nullableValue && valueType is! AckNullableTypeRef ? AckNullableTypeRef(valueType) @@ -627,7 +696,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { throughLazy: throughLazy, visited: visited ?? {}, depth: depth, - collectionElement: collectionElement, ); if (followed != null) return followed; } @@ -647,7 +715,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { required bool throughLazy, required Set visited, required int depth, - required bool collectionElement, }) async { final element = referencedElement(reference); if (element == null) return null; @@ -689,35 +756,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { visited: {...visited, canonical}, depth: depth + 1, followedName: element.name, - collectionElement: collectionElement, - ); - } - - /// Whether [expression] marks its schema `.nullable()`, following - /// unannotated variable references such as `Ack.map(nullableLabel)`. - Future _isNullableSchema(Expression expression, {int depth = 0}) async { - final chain = parseSchemaChain(expression); - if (chain.nullable) return true; - final reference = chain.reference; - if (chain.base != null || - reference == null || - depth >= ModelGraphBuilderBase.maxReferenceDepth) { - return false; - } - final element = referencedElement(reference); - if (element == null || - (element is! TopLevelVariableElement && element is! GetterElement)) { - return false; - } - final declaration = propertyDeclaration(element); - final owningLibrary = declaration.library; - if (owningLibrary == null) return false; - final initializer = declarationExpression( - await resolvedLibraryFor(owningLibrary), - declaration, ); - return initializer != null && - await _isNullableSchema(initializer, depth: depth + 1); } Future _lazyType( @@ -904,9 +943,9 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ]; } - AckSchemaFieldPresence _fieldPresence(SchemaChain chain) { - if (chain.defaulted) return AckSchemaFieldPresence.defaulted; - if (chain.optional) return AckSchemaFieldPresence.optional; + AckSchemaFieldPresence _fieldPresence(_SchemaMetadata metadata) { + if (metadata.defaulted) return AckSchemaFieldPresence.defaulted; + if (metadata.optional) return AckSchemaFieldPresence.optional; return AckSchemaFieldPresence.required; } @@ -1122,30 +1161,230 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } - bool _additionalProperties( + Future _rejectObjectShapeChanges( Expression expression, { required String path, - required Element element, - }) { + required Element context, + Set visited = const {}, + }) async { Expression? current = expression; + while (current is ParenthesizedExpression) { + current = current.expression; + } while (current is MethodInvocation) { - if (current.methodName.name == 'passthrough') return true; - if (current.methodName.name == 'object') { - for (final argumentNode in current.argumentList.arguments) { - final argument = namedArgument(argumentNode); - if (argument != null && argument.name == 'additionalProperties') { - final value = constantBool(argument.expression); - if (value != null) return value; - throw InvalidGenerationSource( - '$path additionalProperties must be a bool literal or const ' - 'variable reference.', - element: element, - ); - } + final name = current.methodName.name; + // A value codec owns its runtime shape, so its input need not expose + // statically generated object fields. + if (name == 'codec') return; + if (const {'extend', 'partial', 'merge', 'pick', 'omit'}.contains(name) || + (name == 'copyWith' && + current.argumentList.arguments.any( + (argument) => const { + 'properties', + 'schemas', + 'discriminatorKey', + }.contains(namedArgument(argument)?.name), + ))) { + throw InvalidGenerationSource( + '$path uses .$name(...), which changes the object shape and cannot ' + 'be modeled statically. Declare the final shape in literal ' + 'Ack.object({...}) or Ack.discriminated(...) arguments instead.', + element: context, + ); + } + if (isAckTarget(current.target)) return; + if (current.methodName.element is TopLevelFunctionElement || + isTypeOwnedSchema(current.methodName.element)) { + break; + } + current = current.target; + } + if (current == null) return; + final target = await resolveReferencedTopLevelExpression( + memberElement(current) ?? referencedElement(current), + visited: visited, + ); + if (target == null) return; + await _rejectObjectShapeChanges( + target.expression, + path: '$path(→ ${target.declaration.name})', + context: context, + visited: {...visited, target.declaration.baseElement}, + ); + } + + Future _additionalProperties( + Expression expression, { + required String path, + required Element context, + Set visited = const {}, + }) async { + Expression? current = expression; + while (current is MethodInvocation) { + final name = current.methodName.name; + // The outermost policy wins; an earlier dynamic value is irrelevant + // when strict() or passthrough() replaces it. + if (name == 'strict') return false; + if (name == 'passthrough') return true; + if (name == 'object' || name == 'copyWith') { + for (final argument in current.argumentList.arguments) { + final entry = namedArgument(argument); + if (entry?.name != 'additionalProperties') continue; + if (entry!.expression is NullLiteral && name == 'copyWith') continue; + final value = constantBool(entry.expression); + if (value != null) return value; + throw InvalidGenerationSource( + '$path additionalProperties must be a bool literal or const ' + 'variable reference.', + element: context, + ); } } + if (isAckTarget(current.target)) return false; current = current.target; } - return false; + if (current == null) return false; + if (current is ParenthesizedExpression) { + return _additionalProperties( + current.expression, + path: path, + context: context, + visited: visited, + ); + } + final referenced = referencedElement(current); + final target = await resolveReferencedTopLevelExpression( + referenced, + visited: visited, + ); + if (target == null) return false; + return _additionalProperties( + target.expression, + path: path, + context: context, + visited: {...visited, target.declaration.baseElement}, + ); + } + + Future<_SchemaMetadata> _schemaMetadata( + Expression expression, { + required String path, + required Element context, + Set visited = const {}, + }) async { + if (expression is ParenthesizedExpression) { + return _schemaMetadata( + expression.expression, + path: path, + context: context, + visited: visited, + ); + } + if (expression is MethodInvocation) { + final name = expression.methodName.name; + final arguments = expression.argumentList.arguments; + Expression? named(String key) { + for (final argument in arguments) { + final entry = namedArgument(argument); + if (entry?.name == key) return entry!.expression; + } + return null; + } + + bool flag(Expression value, String option) { + final resolved = constantBool(value); + if (resolved != null) return resolved; + throw InvalidGenerationSource( + '$path $option must be a bool literal or const variable reference.', + element: context, + ); + } + + if (isAckTarget(expression.target)) { + final input = name == 'codec' + ? named('input') + : name == 'preserveBoundary' + ? argumentExpressions(expression.argumentList).firstOrNull + : null; + if (input != null) { + return (await _schemaMetadata( + input, + path: path, + context: context, + visited: visited, + )).wrapped; + } + // Factories, including Ack.lazy, start with their own unset flags. + return const _SchemaMetadata(); + } + + final target = expression.target; + if (target != null && !isTypeOwnedSchema(expression.methodName.element)) { + final inner = await _schemaMetadata( + target, + path: path, + context: context, + visited: visited, + ); + switch (name) { + case 'nullable': + final value = named('value'); + return inner.copyWith( + nullable: value == null ? true : flag(value, 'nullable value'), + ); + case 'optional': + final value = named('value'); + return inner.copyWith( + optional: value == null ? true : flag(value, 'optional value'), + ); + case 'withDefault': + return _SchemaMetadata( + optional: inner.optional, + nullable: inner.nullable, + defaulted: true, + innerOptional: inner.optional, + innerNullable: inner.nullable, + ); + case 'codec' || 'transform': + return inner.wrapped; + case 'copyWith' || 'withRuntimeConfig' || 'extend': + final nullable = named('isNullable'); + final optional = named('isOptional'); + return inner.copyWith( + nullable: nullable == null || nullable is NullLiteral + ? null + : flag(nullable, '$name isNullable'), + optional: optional == null || optional is NullLiteral + ? null + : flag(optional, '$name isOptional'), + ); + default: + return inner; + } + } + } + + final element = memberElement(expression) ?? referencedElement(expression); + final declaration = element == null ? null : propertyDeclaration(element); + if (declaration == null || + visited.contains(declaration.baseElement) || + visited.length >= ModelGraphBuilderBase.maxReferenceDepth) { + // Shape/type resolution owns cycle and depth diagnostics. Do not hide + // its actionable alias trace while resolving only metadata. + return const _SchemaMetadata(); + } + final owningLibrary = declaration.library; + if (owningLibrary == null) return const _SchemaMetadata(); + final initializer = declarationExpression( + await resolvedLibraryFor(owningLibrary), + declaration, + ); + if (initializer == null) return const _SchemaMetadata(); + return _schemaMetadata( + initializer, + path: path, + context: context, + visited: {...visited, declaration.baseElement}, + ); } } diff --git a/packages/ack_generator/test/integration/ack_infer_graph_test.dart b/packages/ack_generator/test/integration/ack_infer_graph_test.dart index 7523e496..10a9743c 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -45,6 +45,196 @@ part 'schema.g.dart'; '''; void main() { + test( + 'follows field presence and nullability through schema aliases', + () async { + await _expectOutput( + ''' +$_head +final optionalName = Ack.string().optional(); +final optionalAlias = optionalName; +StringSchema get nullableName => Ack.string().nullable(); +final defaultName = Ack.string().withDefault('member'); + +@Schemable() +final profileSchema = Ack.object({ + 'name': optionalAlias, + 'nickname': nullableName, + 'role': defaultName, +}); +''', + allOf([ + contains('final String? name;'), + contains('this.name'), + isNot(contains('required this.name')), + contains('final String? nickname;'), + contains('required this.nickname'), + contains('final String role;'), + contains('required this.role'), + ]), + ); + }, + ); + + test('honors explicit false and outermost schema flag overrides', () async { + await _expectOutput( + ''' +$_head +const disabled = false; +final optionalName = Ack.string().optional(); +final nullableName = Ack.string().nullable(); + +@Schemable() +final flagsSchema = Ack.object({ + 'name': optionalName.optional(value: disabled), + 'nickname': nullableName.nullable(value: false), + 'labels': Ack.map(nullableName.nullable(value: false)), + 'items': Ack.list(Ack.string().nullable().nullable(value: false)), + 'otherItems': Ack.list(nullableName.nullable(value: false)), +}); + +@Schemable() +final titleSchema = Ack.string().nullable().nullable(value: false); +''', + allOf([ + contains('required this.name'), + contains('final String name;'), + contains('required this.nickname'), + contains('final String nickname;'), + contains('final Map labels;'), + contains('final List items;'), + contains('final List otherItems;'), + contains('final class Title'), + ]), + ); + }); + + test('preserves wrapper flag and outer default semantics', () async { + await _expectOutput( + ''' +$_head +final optionalDefault = Ack.string().optional().withDefault('member'); +final codec = optionalDefault.codec( + decode: (value) => value, + encode: (value) => value, +); +final nullableDefault = Ack.string().nullable().withDefault('member'); + +@Schemable() +final wrappersSchema = Ack.object({ + 'name': codec, + 'nickname': nullableDefault.nullable(value: false), +}); +''', + allOf([ + contains('final String? name;'), + isNot(contains('required this.name')), + contains('required this.nickname'), + contains('final String? nickname;'), + ]), + ); + }); + + test('rejects nullable roots reached through unannotated aliases', () async { + await _expectFailure( + ''' +$_head +final nullableName = Ack.string().nullable(); +@Schemable() +final titleSchema = nullableName; +''', + ['titleSchema', 'nullable root'], + ); + }); + + test('rejects schema flags that are not statically constant', () async { + await _expectFailure( + ''' +$_head +bool get enabled => true; +@Schemable() +final flagsSchema = Ack.object({'name': Ack.string().optional(value: enabled)}); +''', + ['flagsSchema.name', 'optional', 'bool literal or const'], + ); + }); + + for (final modifier in [ + "extend({'age': Ack.integer()})", + 'partial()', + "merge(Ack.object({'age': Ack.integer()}))", + "pick(['name'])", + "omit(['name'])", + 'copyWith(properties: {})', + ]) { + test('rejects unsupported object shape operation $modifier', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final userSchema = Ack.object({'name': Ack.string()}).$modifier; +''', + ['userSchema', modifier.split('(').first, 'shape', 'literal'], + ); + }); + } + + test('strict and passthrough use the outermost object policy', () async { + await _expectOutput( + ''' +$_head +bool get extras => true; +@Schemable() +final strictSchema = Ack.object({}, additionalProperties: extras).passthrough().strict(); +@Schemable() +final aliasSchema = strictSchema.passthrough(); +''', + allOf([ + contains('Strict()'), + isNot(contains('Strict({')), + contains('Alias({Map additionalProperties'), + ]), + ); + }); + + test('rejects shape-changing discriminated copyWith arguments', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final catSchema = Ack.object({'name': Ack.string()}); +@Schemable() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', schemas: {'cat': catSchema}, +).copyWith(discriminatorKey: 'type'); +''', + ['petSchema', 'copyWith', 'shape', 'literal'], + ); + }); + + test('rejects annotated object results that are no longer schemas', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final userSchema = Ack.object({'name': Ack.string()}).toJsonSchema(); +''', + ['userSchema', 'does not resolve to AckSchema'], + ); + }); + + test('permits shape operations behind an outer value codec', () async { + await _expectOutput(''' +$_head +bool get extras => true; +@Schemable() +final titleSchema = Ack.object({}, additionalProperties: extras).extend({'title': Ack.string()}).codec( + decode: (value) => value['title'] as String, + encode: (value) => {'title': value}, +); +''', allOf([contains('final class Title'), contains('final String value;')])); + }); + test('rejects a codec type the library cannot name', () async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); await readerWriter.testing.loadIsolateSources(); diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index 54b7f5ba..fdcee051 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -203,6 +203,40 @@ final labelsSchema = Ack.object({ 'groups': Ack.map(Ack.list(Ack.string()).nullable()), }); +final optionalLabel = Ack.string().optional(); +final optionalLabelAlias = optionalLabel; +StringSchema get nullableLabelAlias => nullableLabel; +final defaultLabel = Ack.string().withDefault('member'); +final optionalDefaultLabel = optionalLabel.withDefault('member'); +final nullableDefaultLabel = nullableLabel.withDefault('member'); + +@Schemable() +final flagAliasesSchema = Ack.object({ + 'name': optionalLabelAlias, + 'nickname': nullableLabelAlias, + 'role': defaultLabel, + 'forced': optionalLabel.optional(value: false), + 'notNull': nullableLabel.nullable(value: false), + 'codec': optionalDefaultLabel.codec( + decode: (value) => value, + encode: (value) => value, + ), + 'wrapped': nullableDefaultLabel.nullable(value: false), + 'labels': Ack.map(nullableLabel.nullable(value: false)), +}); + +@Schemable() +final strictPolicySchema = Ack.object( + {'name': Ack.string()}, + additionalProperties: true, +).passthrough().strict(); + +@Schemable() +final openPolicySchema = strictPolicySchema.passthrough(); + +@Schemable() +final resetNullableSchema = Ack.string().nullable().nullable(value: false); + final class Counted { Counted(this.value); final String value; @@ -268,6 +302,9 @@ part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); + +final optionalLabel = Ack.string().optional(); +StringSchema get nullableLabel => Ack.string().nullable(); ''', ); File( @@ -286,6 +323,8 @@ part 'person.g.dart'; final personSchema = Ack.object({ 'home': direct.addressSchema, 'history': Ack.list(exported.addressSchema), + 'note': direct.optionalLabel, + 'label': exported.nullableLabel, }); ''', ); @@ -386,12 +425,17 @@ void main() { final person = Person.parse({ 'home': {'city': 'New York'}, + 'label': null, 'history': [ {'city': 'Amsterdam'}, ], }); expect(person.home.city, 'New York'); expect(person.history.single.city, 'Amsterdam'); + expect(person.note, isNull); + expect(person.label, isNull); + expect(person.toJson()['label'], isNull); + expect(person.toJson().containsKey('note'), isFalse); }); test('generated list fields use deep equality, hashing, and copyWith', () { @@ -539,6 +583,35 @@ void main() { expect(labels.toJson(), json); }); + test('aliases and wrapper boundaries preserve field flags', () { + final json = { + 'nickname': null, + 'forced': 'present', + 'notNull': 'present', + 'wrapped': null, + 'labels': {'a': 'x'}, + }; + final value = FlagAliases.parse(json); + expect(value.name, isNull); + expect(value.nickname, isNull); + expect(value.role, 'member'); + expect(value.codec, isNull); + expect(value.wrapped, 'member'); + expect(value.toJson(), {...json, 'role': 'member', 'wrapped': 'member'}); + expect(FlagAliases.parse(value.toJson()), value); + expect(FlagAliases.safeParse({...json, 'notNull': null}).isFail, isTrue); + expect(FlagAliases.safeParse({...json, 'labels': {'a': null}}).isFail, isTrue); + expect(ResetNullable.parse('value').value, 'value'); + expect(ResetNullable.safeParse(null).isFail, isTrue); + }); + + test('strict and passthrough override object policy through aliases', () { + expect(StrictPolicy.safeParse({'name': 'Ada', 'extra': 1}).isFail, isTrue); + final open = OpenPolicy.parse({'name': 'Ada', 'extra': 1}); + expect(open.additionalProperties, {'extra': 1}); + expect(open.toJson(), {'name': 'Ada', 'extra': 1}); + }); + test('union discriminators cannot be spoofed through extras', () { final dog = Dog( friendly: true, diff --git a/packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart b/packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart new file mode 100644 index 00000000..8eda10c5 --- /dev/null +++ b/packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart @@ -0,0 +1,96 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +const _source = r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +@Schemable() +final baseSchema = Ack.object({'name': Ack.string()}); +final changed = (baseSchema.extend({'age': Ack.integer()})); +'''; + +Future _build( + String suffix, + Map outputs, { + void Function(String)? onError, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + {'test_pkg|lib/models.dart': '$_source\n$suffix'}, + generateFor: const {'test_pkg|lib/models.dart'}, + readerWriter: readerWriter, + outputs: outputs, + onLog: (log) { + if (log.level.name == 'SEVERE') onError?.call(log.message); + }, + ); +} + +void main() { + test( + 'rejects shape changes hidden behind unannotated schema aliases', + () async { + final errors = []; + await _build( + r''' +final intermediate = changed; +@Schemable() +final aliasSchema = intermediate; +''', + const {}, + onError: errors.add, + ); + expect(errors, contains(contains('aliasSchema'))); + expect(errors, contains(contains('changed'))); + expect(errors, contains(contains('extend'))); + expect(errors, contains(contains('shape'))); + }, + ); + + test('stops alias shape inspection at an outer value codec', () async { + await _build( + r''' +final wrapped = changed.codec( + decode: (value) => value['age'] as int, + encode: (value) => {'name': 'Ada', 'age': value}, +); +@Schemable() +final aliasSchema = wrapped; +''', + { + 'test_pkg|lib/models.g.dart': decodedMatches( + allOf([contains('final class Alias'), contains('final int value;')]), + ), + }, + ); + }); + + test( + 'inspects type-owned schema calls without self-referencing AST recursion', + () async { + await _build( + r''' +final class Token { + const Token(this.value); + final String value; + static AckSchema schema() => Ack.string().codec( + decode: Token.new, encode: (token) => token.value, + ); +} +@Schemable() +final aliasSchema = Ack.object({'token': Token.schema()}); +''', + { + 'test_pkg|lib/models.g.dart': decodedMatches( + contains('final Token token;'), + ), + }, + ); + }, + ); +}