diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb959c2..28f7da47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Fixed +* Accept contextual Dart member names and deeply snapshot JSON-owned generated + fields without changing opaque codec values. Preserve effective nullable + overrides when inferring explicit JSON boundaries. * Align wrapper and lazy nullability across parsing, encoding, and JSON Schema export; validate discriminated codec outputs against their runtime branches. * Reject duration inputs that cannot preserve their original milliseconds. diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index efc40f97..fe301be1 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -2,6 +2,9 @@ ### Fixed +* Expose the shared `deepUnmodifiableCopy` helper for generated immutable + constructors. It snapshots nested collections without validating data or + changing opaque noncollection objects. * Honor explicit boundary-wrapper nullability consistently during parse and encode. Preserve non-null restrictions on wrappers and lazy references when exporting nullable primitives, unions, and imported JSON Schema resources. @@ -18,9 +21,6 @@ * Discover selected custom dialects bundled beneath their own active schema keywords, without interpreting inactive keyword payloads or annotation data as schemas. -* Expose the shared `deepUnmodifiableCopy` helper for generated immutable - constructors. It snapshots nested collections without validating data or - changing opaque noncollection objects. ## 2.0.0-alpha.1 diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index f07027a2..988971ed 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,25 @@ ### Fixed +* Reject unproven object-shape changes inside preserved boundaries, including + aliases and fluent codecs, rather than copying opaque values as JSON. +* Preserve native codec boundary types when inferring `Ack.preserveBoundary` + fields, including dates, datetimes, URIs, durations, enums, explicit codec + inputs and statically resolvable nonrecursive lazy schemas. +* Resolve preserved JSON boundary nullability through the same effective + metadata as ordinary fields, including aliases and explicit flag overrides. +* Snapshot nested JSON collections held as `Object` or `Object?` in + schema-first model constructors and `copyWith`. Reuse Ack's shared deep + copier; constructors still do not validate data. Opaque codec runtime values + retain their identity and container types. Use `Ack.preserveBoundary(...)` + for heterogeneous JSON fields; native JSON unions are inferred only within + that explicit wire view. +* Allow built-in and contextual Dart keywords such as `required` as generated + field, discriminator, and capture-member names without changing their JSON + keys. Reserved words such as `class` remain rejected. Member validation now + follows the analyzer's keyword classification rather than a fixed word list. + `dynamic` remains rejected because it shadows the type in generated JSON + methods, not because it is a reserved Dart word. * Preserve schema flags and object policies when `copyWith` no-op `null` arguments are parenthesized. * Preserve schema-first presence, nullability, and default-wrapper behavior @@ -23,22 +42,6 @@ schemas, and substitute nested generic bounds before validating type-owned schema methods. -* Allow built-in and contextual Dart keywords such as `required` as generated - field, discriminator, and capture-member names without changing their JSON - keys. Reserved words such as `class` remain rejected. Member validation now - follows the analyzer's keyword classification rather than a fixed word list. - `dynamic` remains rejected because it shadows the type in generated JSON - methods, not because it is a reserved Dart word. -* Snapshot nested JSON collections held as `Object` or `Object?` in - schema-first model constructors and `copyWith`. Reuse Ack's shared deep - copier; constructors still do not validate data. Opaque codec runtime values - retain their identity and container types. Use `Ack.preserveBoundary(...)` - for heterogeneous JSON fields; native JSON unions are inferred only within - that explicit wire view. -* Preserve native codec boundary types when inferring `Ack.preserveBoundary` - fields, including dates, datetimes, URIs, durations, enums, explicit codec - inputs and statically resolvable nonrecursive lazy schemas. - ## 2.0.0-alpha.1 ### Breaking 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 2af15cb4..e43d59a0 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 @@ -606,7 +606,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { if (arguments.length != 1) { _rejectUnsupportedRoot('preserveBoundary', path, context); } - return _jsonBoundaryRef(arguments.single, path: path, context: context); + return _jsonBoundaryRef(expression, path: path, context: context); case 'anyOf': case 'instance': _rejectUnsupportedRoot(baseName, path, context); @@ -717,7 +717,17 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { visited: visited, depth: depth, ); - if (followed != null) return followed; + if (followed != null) { + if (followed is AckNullableTypeRef && + !(await _schemaMetadata( + expression, + path: path, + context: context, + )).nullable) { + return followed.inner; + } + return followed; + } } } @@ -794,6 +804,12 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { element: context, ); } + await _rejectObjectShapeChanges( + expression, + path: path, + context: context, + throughCodecs: true, + ); final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); final base = chain.base; @@ -854,7 +870,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { if (arguments.length != 1) { return _unsupportedJsonBoundary(path, context); } - var inner = await _jsonBoundaryRef( + final inner = await _jsonBoundaryRef( arguments.single, path: '$path[]', context: context, @@ -927,21 +943,16 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { visited.contains(element.baseElement)) { return _unsupportedJsonBoundary(path, context); } - final declaration = propertyDeclaration(element); - final owningLibrary = declaration.library; - if (owningLibrary == null) { - return _unsupportedJsonBoundary(path, context); - } - final initializer = declarationExpression( - await resolvedLibraryFor(owningLibrary), - declaration, + final target = await resolveReferencedTopLevelExpression( + element, + visited: visited, ); - if (initializer == null) return _unsupportedJsonBoundary(path, context); + if (target == null) return _unsupportedJsonBoundary(path, context); result = await _jsonBoundaryRef( - initializer, + target.expression, path: '$path(→ ${element.name})', context: context, - visited: {...visited, element.baseElement}, + visited: {...visited, target.declaration.baseElement}, depth: depth + 1, ); } @@ -952,6 +963,8 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { path: path, context: context, ); + // An outer flag can clear an alias's nullable type without changing the + // nullability of its nested collection values. final nonNullable = result is AckNullableTypeRef ? result.inner : result; return metadata.nullable ? AckNullableTypeRef(nonNullable) : nonNullable; } @@ -1383,6 +1396,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { required String path, required Element context, Set visited = const {}, + bool throughCodecs = false, }) async { Expression? current = expression; while (current is ParenthesizedExpression) { @@ -1390,9 +1404,9 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { } while (current is MethodInvocation) { 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; + // Runtime models can stop at a value codec. Boundary inference still + // needs to prove the input shape underneath that codec. + if (name == 'codec' && !throughCodecs) return; if (const {'extend', 'partial', 'merge', 'pick', 'omit'}.contains(name) || (name == 'copyWith' && current.argumentList.arguments.any( @@ -1414,7 +1428,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { isTypeOwnedSchema(current.methodName.element)) { break; } - current = current.target; + current = current.target?.unParenthesized; } if (current == null) return; final target = await resolveReferencedTopLevelExpression( @@ -1427,6 +1441,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { path: '$path(→ ${target.declaration.name})', context: context, visited: {...visited, target.declaration.baseElement}, + throughCodecs: throughCodecs, ); } 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 aea3dba8..8ccd1d37 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -135,6 +135,79 @@ final policySchema = Ack.object({}, additionalProperties: true).copyWith( }); } + test('preserved JSON boundaries honor an outer nullable clear', () async { + await _expectOutput(''' +$_head +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'value': Ack.preserveBoundary(Ack.string().nullable()).nullable(value: false), +}); +''', allOf([contains('final String value;'), contains('required this.value')])); + }); + + test('preserved JSON boundaries use effective nested flags', () async { + await _expectOutput( + ''' +$_head +final nullableValue = Ack.string().nullable(); +final nullableAlias = nullableValue; +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'copiedValues': Ack.preserveBoundary(Ack.map(Ack.string().copyWith(isNullable: true))), + 'configuredValues': Ack.preserveBoundary(Ack.map(Ack.string().withRuntimeConfig(isNullable: true))), + 'clearedValues': Ack.preserveBoundary(Ack.map(nullableAlias.nullable(value: false))), + 'items': Ack.preserveBoundary(Ack.list(nullableAlias.nullable(value: false))), + 'outerMap': Ack.preserveBoundary(Ack.map(nullableValue).nullable()).nullable(value: false), +}); +''', + allOf([ + contains('final Map copiedValues;'), + contains('final Map configuredValues;'), + contains('final Map clearedValues;'), + contains('final List items;'), + contains('final Map outerMap;'), + ]), + ); + }); + + test('preserved boundary aliases honor consuming flag overrides', () async { + await _expectOutput( + ''' +$_head +final nullableText = Ack.preserveBoundary(Ack.string().nullable()); +final textAlias = nullableText; +final nullableMap = Ack.preserveBoundary(Ack.map(Ack.string().nullable()).nullable()); +final mapAlias = nullableMap; +@Schemable() +final boundaryAliasesSchema = Ack.object({ + 'text': textAlias.nullable(value: false), + 'values': mapAlias.nullable(value: false), + 'groups': Ack.list(mapAlias.nullable(value: false)), + 'byName': Ack.map(mapAlias.nullable(value: false)), +}); +''', + allOf([ + contains('final String text;'), + contains('final Map values;'), + contains('final List> groups;'), + contains('final Map> byName;'), + ]), + ); + }); + + test('rejects actually nullable preserved boundary list items', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'items': Ack.preserveBoundary(Ack.list(Ack.string().nullable())), +}); +''', + ['boundaryFlagsSchema.items', 'cannot prove a native JSON boundary'], + ); + }); + test('preserves wrapper flag and outer default semantics', () async { await _expectOutput( ''' @@ -485,6 +558,33 @@ final payloadSchema = ${unsupported.key}; }); } + for (final change in [ + "extend({'payload': Ack.instance>()})", + "copyWith(properties: {'payload': Ack.instance>()})", + ]) { + for (final conversion in [ + '', + '.codec(decode: (value) => value, encode: (value) => value as Map)', + ]) { + test( + 'rejects unproven boundary shape changes: $change$conversion', + () async { + await _expectFailure( + ''' +$_head +final alteredBoundary = (Ack.object({}).$change)$conversion; +@Schemable() +final boundaryRecordSchema = Ack.object({ + 'value': Ack.preserveBoundary(alteredBoundary), +}); +''', + ['boundaryRecordSchema.value', change.split('(').first, 'shape'], + ); + }, + ); + } + } + for (final boundary in [ 'Ack.instance()', 'Ack.anyOf([Ack.string(), Ack.instance()])', 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 3049e459..6aa6dd34 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 @@ -294,6 +294,32 @@ final answerRecordSchema = Ack.object({ 'ratio': Ack.preserveBoundary(Ack.double()), }); +final nullableBoundaryValue = Ack.string().nullable(); +final nullableBoundaryAlias = nullableBoundaryValue; + +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'copiedValues': Ack.preserveBoundary(Ack.map(Ack.string().copyWith(isNullable: true))), + 'configuredValues': Ack.preserveBoundary(Ack.map(Ack.string().withRuntimeConfig(isNullable: true))), + 'clearedValues': Ack.preserveBoundary(Ack.map(nullableBoundaryAlias.nullable(value: false))), + 'items': Ack.preserveBoundary(Ack.list(nullableBoundaryAlias.nullable(value: false))), + 'outer': Ack.preserveBoundary(nullableBoundaryValue).nullable(value: false), + 'outerMap': Ack.preserveBoundary(Ack.map(nullableBoundaryValue).nullable()).nullable(value: false), +}); + +final nullableBoundaryText = Ack.preserveBoundary(Ack.string().nullable()); +final boundaryTextAlias = nullableBoundaryText; +final nullableBoundaryMap = Ack.preserveBoundary(Ack.map(Ack.string().nullable()).nullable()); +final boundaryMapAlias = nullableBoundaryMap; + +@Schemable() +final boundaryAliasesSchema = Ack.object({ + 'text': boundaryTextAlias.nullable(value: false), + 'values': boundaryMapAlias.nullable(value: false), + 'groups': Ack.list(boundaryMapAlias.nullable(value: false)), + 'byName': Ack.map(boundaryMapAlias.nullable(value: false)), +}); + enum WireStatus { active, archived } @Schemable() @@ -558,6 +584,60 @@ void main() { expect(AnswerRecord(values: {'invalid': false}, ratio: 0.5).safeToJson().isFail, isTrue); }); + test('preserved boundary metadata agrees with generated runtime fields', () { + final json = { + 'copiedValues': {'a': null}, + 'configuredValues': {'b': null}, + 'clearedValues': {'c': 'present'}, + 'items': ['present'], + 'outer': 'present', + 'outerMap': {'d': null}, + }; + final record = BoundaryFlags.parse(json); + final Map copied = record.copiedValues; + final Map configured = record.configuredValues; + final Map cleared = record.clearedValues; + final List items = record.items; + final String outer = record.outer; + final Map outerMap = record.outerMap; + expect(copied['a'], isNull); + expect(configured['b'], isNull); + expect(cleared['c'], 'present'); + expect(items, ['present']); + expect(outer, 'present'); + expect(outerMap['d'], isNull); + expect(record.toJson(), json); + expect(BoundaryFlags.parse(record.toJson()), record); + expect(BoundaryFlags.safeParse({...json, 'outer': null}).isFail, isTrue); + expect(BoundaryFlags.safeParse({...json, 'outerMap': null}).isFail, isTrue); + expect(BoundaryFlags.safeParse({...json, 'clearedValues': {'c': null}}).isFail, isTrue); + expect(() => record.copiedValues['a'] = 'changed', throwsUnsupportedError); + }); + + test('preserved boundary aliases clear only their outer nullable type', () { + final json = { + 'text': 'present', + 'values': {'a': null}, + 'groups': [{'b': null}], + 'byName': {'first': {'c': null}}, + }; + final record = BoundaryAliases.parse(json); + final String text = record.text; + final Map values = record.values; + final List> groups = record.groups; + final Map> byName = record.byName; + expect(text, 'present'); + expect(values['a'], isNull); + expect(groups.single['b'], isNull); + expect(byName['first']!['c'], isNull); + expect(record.toJson(), json); + expect(BoundaryAliases.parse(record.toJson()), record); + expect(BoundaryAliases.safeParse({...json, 'text': null}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'values': null}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'groups': [null]}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'byName': {'first': null}}).isFail, isTrue); + }); + test('preserved native codecs keep JSON boundary types and validation', () { final json = { 'day': '2026-10-26',