diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index c23f6fd2..69bdfd7a 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -5,6 +5,9 @@ * 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. +* Validate discriminated codec output against its selected map branch or a + matching typed runtime branch, including required fields and refinements, + without decoding runtime values again or adding encode callback passes. * Expose the shared `deepUnmodifiableCopy` helper for generated immutable constructors. It snapshots nested collections without validating data or changing opaque noncollection objects. diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 862250ec..91cb0aa7 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -334,6 +334,55 @@ final class DiscriminatedObjectSchema Object? value, SchemaContext context, ) { + final checked = _checkRuntimeType(value, context); + if (checked.isFail || value == null) return checked; + final runtime = checked.getOrNull()!; + + final mapRuntime = jsonMapOrNull(runtime); + if (mapRuntime != null) { + final discResult = _validateDiscriminatorKey(mapRuntime, context); + if (discResult.isFail) return SchemaResult.fail(discResult.getError()); + final discValue = discResult.getOrNull()!; + final effective = effectiveBranch(discValue); + final branchContext = context.createChild( + name: 'when $discriminatorKey="$discValue"', + schema: effective, + value: runtime, + pathSegment: '', + ); + final result = effective.validateRuntimeWithContext( + runtime, + branchContext, + ); + if (result.isFail) return SchemaResult.fail(result.getError()); + return applyConstraintsAndRefinements(result.getOrNull()!, context); + } + + final errors = []; + for (final discValue in schemas.keys) { + final effective = effectiveBranch(discValue); + final branchContext = context.createChild( + name: 'when $discriminatorKey="$discValue"', + schema: effective, + value: runtime, + pathSegment: '', + ); + final result = effective.validateRuntimeWithContext( + runtime, + branchContext, + ); + if (result.isOk) { + return applyConstraintsAndRefinements(result.getOrNull()!, context); + } + errors.add(result.getError()); + } + + return SchemaResult.fail( + SchemaNestedError(errors: errors, context: context), + ); + } + + SchemaResult _checkRuntimeType(Object? value, SchemaContext context) { final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; if (value is! T) { @@ -346,13 +395,20 @@ final class DiscriminatedObjectSchema ); } - return applyConstraintsAndRefinements(value, context); + return SchemaResult.ok(value); } @override @protected SchemaResult encodeWithContext(T value, SchemaContext context) { - final validated = validateRuntimeWithContext(value, context); + final checked = _checkRuntimeType(value, context); + if (checked.isFail) return SchemaResult.fail(checked.getError()); + final typed = checked.getOrNull(); + if (typed == null) return SchemaResult.ok(null); + + // Encoding validates each branch below. Repeating the full runtime + // membership check here would execute branch callbacks an extra time. + final validated = applyConstraintsAndRefinements(typed, context); if (validated.isFail) { return SchemaResult.fail(validated.getError()); } diff --git a/packages/ack/test/schemas/discriminated_runtime_validation_test.dart b/packages/ack/test/schemas/discriminated_runtime_validation_test.dart new file mode 100644 index 00000000..6a3b3a36 --- /dev/null +++ b/packages/ack/test/schemas/discriminated_runtime_validation_test.dart @@ -0,0 +1,229 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +final class _Cat { + const _Cat(this.name); + final String name; +} + +final class _Dog { + const _Dog(this.name); + final String name; +} + +enum _Role { member } + +CodecSchema _codecWithOutput( + AckSchema output, + T decoded, +) => Ack.codec( + input: Ack.string(), + decode: (_) => decoded, + encode: (_) => 'wire', + output: output, +); + +Iterable _errorPaths(SchemaError error) sync* { + yield error.path; + if (error is SchemaNestedError) { + for (final child in error.errors) { + yield* _errorPaths(child); + } + } +} + +void main() { + group('discriminated codec output validation', () { + final output = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'a': Ack.object({'count': Ack.integer()}), + }, + ); + final invalidCases = <({String name, JsonMap value, String errorPath})>[ + ( + name: 'wrong property runtime type', + value: {'kind': 'a', 'count': 'bad'}, + errorPath: '#/count', + ), + ( + name: 'unknown discriminator', + value: {'kind': 'unknown', 'count': 1}, + errorPath: '#/kind', + ), + (name: 'missing discriminator', value: {'count': 1}, errorPath: '#/kind'), + ( + name: 'missing required property', + value: {'kind': 'a'}, + errorPath: '#/count', + ), + ]; + for (final testCase in invalidCases) { + test('rejects ${testCase.name} on decode and before encode', () { + var encodeCalls = 0; + final codec = Ack.codec( + input: Ack.string(), + decode: (_) => testCase.value, + encode: (_) { + encodeCalls++; + return 'wire'; + }, + output: output, + ); + for (final result in [ + codec.safeParse('wire'), + codec.safeEncode(testCase.value), + ]) { + expect(result.isFail, isTrue); + expect(_errorPaths(result.getError()), contains(testCase.errorPath)); + } + expect(encodeCalls, 0); + }); + } + + test('validates already-decoded nested codec and enum runtimes', () { + final output = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'a': Ack.object({ + 'createdAt': Ack.datetime(), + 'role': Ack.enumValues(_Role.values), + }), + }, + ); + final runtime = { + 'kind': 'a', + 'createdAt': DateTime.utc(2026, 1, 1), + 'role': _Role.member, + }; + final codec = _codecWithOutput(output, runtime); + expect(codec.parse('wire'), runtime); + expect(codec.encode(runtime), 'wire'); + }); + + test('typed runtimes must satisfy at least one branch runtime schema', () { + final output = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) => _Cat(map['name']! as String), + encode: (cat) => {'name': cat.name}, + output: Ack.instance<_Cat>().refine((cat) => cat.name.isNotEmpty), + ), + 'dog': Ack.object({'name': Ack.string()}).codec<_Dog>( + decode: (map) => _Dog(map['name']! as String), + encode: (dog) => {'name': dog.name}, + ), + }, + ); + for (final invalid in [Object(), const _Cat('')]) { + final codec = _codecWithOutput(output, invalid); + expect(codec.safeParse('wire').isFail, isTrue); + expect(codec.safeEncode(invalid).isFail, isTrue); + } + for (final valid in [const _Cat('Mittens'), const _Dog('Spot')]) { + final codec = _codecWithOutput(output, valid); + expect(codec.parse('wire'), same(valid)); + expect(codec.encode(valid), 'wire'); + } + }); + + test('branch membership checks never execute branch codecs', () { + var decodeCalls = 0; + var encodeCalls = 0; + final output = Ack.discriminated<_Cat>( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) { + decodeCalls++; + return _Cat(map['name']! as String); + }, + encode: (cat) { + encodeCalls++; + return {'name': cat.name}; + }, + ), + }, + ); + const value = _Cat('Mittens'); + final codec = _codecWithOutput(output, value); + expect(codec.parse('wire'), same(value)); + expect(codec.encode(value), 'wire'); + expect(decodeCalls, 0); + expect(encodeCalls, 0); + }); + + test('ambiguous runtime membership remains an encode-only failure', () { + final branch = Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) => _Cat(map['name']! as String), + encode: (cat) => {'name': cat.name}, + ); + final union = Ack.discriminated<_Cat>( + discriminatorKey: 'kind', + schemas: {'a': branch, 'b': branch}, + ); + final parsed = union.parse({'kind': 'a', 'name': 'Mittens'})!; + final codec = _codecWithOutput(union, parsed); + expect(codec.parse('wire'), same(parsed)); + expect(codec.encode(parsed), 'wire'); + expect(union.safeEncode(parsed).isFail, isTrue); + }); + }); + + group('direct discriminated encoding callback counts', () { + test('map branches do not acquire an extra runtime refinement pass', () { + var refinements = 0; + var encoders = 0; + final union = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'a': Ack.object({'count': Ack.integer()}).codec( + decode: (map) => map, + encode: (map) { + encoders++; + return {'count': map['count']}; + }, + output: Ack.instance().refine((_) { + refinements++; + return true; + }), + ), + }, + ); + expect(union.encode({'kind': 'a', 'count': 1}), { + 'kind': 'a', + 'count': 1, + }); + expect(refinements, 1); + expect(encoders, 1); + }); + + test('typed branches retain one membership probe and one encode pass', () { + var refinements = 0; + var encoders = 0; + final union = Ack.discriminated<_Cat>( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) => _Cat(map['name']! as String), + encode: (cat) { + encoders++; + return {'name': cat.name}; + }, + output: Ack.instance<_Cat>().refine((_) { + refinements++; + return true; + }), + ), + }, + ); + expect(union.encode(const _Cat('Mittens')), { + 'kind': 'cat', + 'name': 'Mittens', + }); + expect(refinements, 2); + expect(encoders, 1); + }); + }); +}