Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
35 changes: 19 additions & 16 deletions packages/ack_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -1383,16 +1396,17 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
required String path,
required Element context,
Set<Element> visited = const {},
bool throughCodecs = false,
}) async {
Expression? current = expression;
while (current is ParenthesizedExpression) {
current = current.expression;
}
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(
Expand All @@ -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(
Expand All @@ -1427,6 +1441,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
path: '$path(→ ${target.declaration.name})',
context: context,
visited: {...visited, target.declaration.baseElement},
throughCodecs: throughCodecs,
);
}

Expand Down
100 changes: 100 additions & 0 deletions packages/ack_generator/test/integration/ack_infer_graph_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String?> copiedValues;'),
contains('final Map<String, String?> configuredValues;'),
contains('final Map<String, String> clearedValues;'),
contains('final List<String> items;'),
contains('final Map<String, String?> 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<String, String?> values;'),
contains('final List<Map<String, String?>> groups;'),
contains('final Map<String, Map<String, String?>> 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(
'''
Expand Down Expand Up @@ -485,6 +558,33 @@ final payloadSchema = ${unsupported.key};
});
}

for (final change in [
"extend({'payload': Ack.instance<List<int>>()})",
"copyWith(properties: {'payload': Ack.instance<List<int>>()})",
]) {
for (final conversion in [
'',
'.codec<Object>(decode: (value) => value, encode: (value) => value as Map<String, Object?>)',
]) {
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<Object>()',
'Ack.anyOf([Ack.string(), Ack.instance<Object>()])',
Expand Down
Loading
Loading