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: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
field collide with the generated additional-properties member.
* Preserve round trips for discriminated map codecs with strict output schemas,
including values used as another codec's output, without narrowing permissive
intermediate output policies.
intermediate output policies. Lazy outputs retain deferred resolution and
recursion limits.
* 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.
Expand Down
1 change: 1 addition & 0 deletions packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Accept union-owned discriminators in strict map-backed codec outputs while
preserving declared discriminator rules and rejecting unrelated extra keys.
Already-permissive intermediate outputs retain their policies and values.
Lazy outputs preserve round trips, deferred resolution and recursion limits.
* Expose the shared `deepUnmodifiableCopy` helper for generated immutable
constructors. It snapshots nested collections without validating data or
changing opaque noncollection objects.
Expand Down
5 changes: 5 additions & 0 deletions packages/ack/lib/src/schemas/discriminated_object_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,11 @@ final class DiscriminatedObjectSchema<T extends Object>
optionalDiscriminator: true,
);
}
if (schema is LazySchema) {
return schema._mapTarget(
(target) => _withRuntimeDiscriminator(target, discriminatorValue),
);
}
if (schema is CodecSchema) {
final input = _withRuntimeDiscriminator(
schema.inputSchema,
Expand Down
16 changes: 16 additions & 0 deletions packages/ack/lib/src/schemas/lazy_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>
@internal
AckSchema<Boundary, Runtime> get target => _target;

// Preserve deferred resolution, generic types and recursion identity when
// adapting a lazy codec output for a union-owned discriminator.
LazySchema<Boundary, Runtime> _mapTarget(
AnyAckSchema Function(AnyAckSchema) transform,
) => LazySchema<Boundary, Runtime>._copy(
name,
() => transform(_target) as AckSchema<Boundary, Runtime>,
_recursionToken,
maxDepth: maxDepth,
isNullable: isNullable,
isOptional: isOptional,
description: description,
constraints: constraints,
refinements: refinements,
);

/// Whether the lazy reference's own null policy narrows its target.
@internal
bool get excludesTargetNull => !acceptsNull && _target.acceptsNull;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,107 @@ void main() {
expect(union.encode(union.parse(wire)!), wire);
});

for (final unionOutput in [false, true]) {
test('preserves permissive intermediate codec outputs '
'${unionOutput ? 'inside anyOf' : 'directly'}', () {
test('round-trips lazy object outputs without eagerly resolving them', () {
var builds = 0;
var refinements = 0;
final output =
Ack.lazy<JsonMap, JsonMap>('CatOutput', () {
builds++;
return Ack.object({'name': Ack.string()});
}).refine((map) {
refinements++;
return map['name'] == 'Mittens';
});
final union = Ack.discriminated<JsonMap>(
discriminatorKey: 'kind',
schemas: {
'cat': Ack.object({'name': Ack.string()}).codec<JsonMap>(
decode: (map) => {'name': map['name']},
encode: (map) => {'name': map['name']},
output: output,
),
},
);
union.effectiveBranch('cat');
expect(builds, 0);

final wire = <String, Object?>{'kind': 'cat', 'name': 'Mittens'};
final parsed = union.parse(wire)!;
expect(parsed, wire);
expect(union.encode(parsed), wire);
final codec = _codecWithOutput(union, parsed);
expect(codec.parse('wire'), same(parsed));
expect(codec.encode(parsed), 'wire');
expect(builds, 1);
expect(refinements, 4);
expect(union.safeEncode({...wire, 'extra': true}).isFail, isTrue);
expect(_codecWithOutput(output, wire).safeParse('wire').isFail, isTrue);
});

test('retains recursion limits in lazy codec outputs', () {
late final LazySchema<JsonMap, JsonMap> output;
output = Ack.lazy<JsonMap, JsonMap>(
'RecursiveOutput',
() => output,
maxDepth: 2,
);
final union = Ack.discriminated<JsonMap>(
discriminatorKey: 'kind',
schemas: {
'cat': Ack.object({'name': Ack.string()}).codec<JsonMap>(
decode: (map) => {'name': map['name']},
encode: (map) => {'name': map['name']},
output: output,
),
},
);
final result = union.safeParse({'kind': 'cat', 'name': 'Mittens'});
expect(result.isFail, isTrue);
expect(
result.getError().toString(),
contains('Maximum recursion depth (2)'),
);
});

test('preserves a lazy output codec boundary type without converting', () {
final output = Ack.lazy<String, JsonMap>(
'CatOutput',
() => Ack.string().codec<JsonMap>(
decode: (_) => throw StateError('Output decoder must not run'),
encode: (_) => throw StateError('Output encoder must not run'),
output: Ack.object({'name': Ack.string()}),
),
);
final union = Ack.discriminated<JsonMap>(
discriminatorKey: 'kind',
schemas: {
'cat': Ack.object({'name': Ack.string()}).codec<JsonMap>(
decode: (map) => {'name': map['name']},
encode: (map) => {'name': map['name']},
output: output,
),
},
);
final wire = <String, Object?>{'kind': 'cat', 'name': 'Mittens'};
expect(union.encode(union.parse(wire)!), wire);
});

for (final outputShape in ['directly', 'inside anyOf', 'inside lazy']) {
test('preserves permissive intermediate codec outputs $outputShape', () {
final output = Ack.object({
'name': Ack.string(),
}).passthrough().refine((map) => map['kind'] == 'internal');
final inner = Ack.object({'name': Ack.string()}).codec<Object>(
decode: (map) => {'name': map['name'], 'kind': 'internal'},
encode: (value) => {'name': (value as JsonMap)['name']},
output: unionOutput ? Ack.anyOf([output]) : output,
output: switch (outputShape) {
'inside anyOf' => Ack.anyOf([output]),
'inside lazy' => Ack.lazy<JsonMap, JsonMap>(
'CatOutput',
() => output,
),
_ => output,
},
);
final union = Ack.discriminated<String>(
discriminatorKey: 'kind',
Expand Down
Loading