Skip to content
Closed
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
9 changes: 9 additions & 0 deletions packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
## Unreleased

### Breaking

* Preserve programmer `Error` objects and their original stack traces when
codec decoders or one-way transforms throw. Nested schemas and `anyOf`
no longer disguise those defects as validation failures or try a fallback
branch. Recoverable `Exception`s still become `SchemaTransformError`
failures. Decoder callbacks should throw an `Exception`, such as
`FormatException`, to report invalid input rather than a programmer defect.

### Added

* `Ack.fromJsonSchema()` now supports `multipleOf` for `number` and `integer`
Expand Down
3 changes: 3 additions & 0 deletions packages/ack/lib/src/schemas/codec_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ final class CodecSchema<Boundary extends Object, Runtime extends Object>
try {
runtime = _decoder(intermediate);
} catch (e, st) {
if (e is Error) {
Error.throwWithStackTrace(e, st);
}
return SchemaResult.fail(
SchemaTransformError(
message: 'Codec decode failed: ${e.toString()}',
Expand Down
111 changes: 111 additions & 0 deletions packages/ack/test/codecs/codec_error_contract_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'package:ack/ack.dart';
import 'package:test/test.dart';

void main() {
group('codec decoder error contract', () {
test('parse and safeParse propagate the original programmer error', () {
final error = StateError('decoder defect');
final schema = Ack.string().codec<int>(
decode: (_) => throw error,
encode: (value) => value.toString(),
);

expect(() => schema.safeParse('7'), throwsA(same(error)));
expect(() => schema.parse('7'), throwsA(same(error)));
});

test('nested objects, lists, and maps preserve decoder errors', () {
final error = StateError('nested decoder defect');
final schema = Ack.string().codec<int>(
decode: (_) => throw error,
encode: (value) => value.toString(),
);

expect(
() => Ack.object({'value': schema}).safeParse({'value': '7'}),
throwsA(same(error)),
);
expect(() => Ack.list(schema).safeParse(['7']), throwsA(same(error)));
expect(
() => Ack.map(schema).safeParse({'value': '7'}),
throwsA(same(error)),
);
});

test('one-way transforms preserve programmer errors too', () {
final error = StateError('transform defect');
final schema = Ack.string().transform<int>((_) => throw error);

expect(() => schema.safeParse('7'), throwsA(same(error)));
});

test('anyOf does not hide a decoder error with a later branch', () {
final error = StateError('first branch defect');
var fallbackCalled = false;
final broken = Ack.string().codec<int>(
decode: (_) => throw error,
encode: (value) => value.toString(),
);
final fallback = Ack.string().refine((_) {
fallbackCalled = true;
return true;
});

expect(
() => Ack.anyOf([broken, fallback]).safeParse('7'),
throwsA(same(error)),
);
expect(fallbackCalled, isFalse);
});

test('recoverable decoder exceptions remain transform failures', () {
const exception = FormatException('not an integer');
final schema = Ack.string().codec<int>(
decode: (_) => throw exception,
encode: (value) => value.toString(),
);

final result = schema.safeParse('bad');

expect(result.isFail, isTrue);
expect(
result.getError(),
isA<SchemaTransformError>().having(
(error) => error.cause,
'cause',
same(exception),
),
);
});

test('anyOf can still recover from a decoder exception', () {
final schema = Ack.string().codec<int>(
decode: (_) => throw const FormatException('not an integer'),
encode: (value) => value.toString(),
);

expect(Ack.anyOf([schema, Ack.string()]).parse('text'), 'text');
});

test('decoder errors retain their original stack trace', () {
final error = StateError('decoder defect');
final originalStack = StackTrace.fromString('original decoder stack');
final schema = Ack.string().codec<int>(
decode: (_) => Error.throwWithStackTrace(error, originalStack),
encode: (value) => value.toString(),
);
Object? caughtError;
StackTrace? caughtStack;

try {
schema.safeParse('7');
} catch (error, stackTrace) {
caughtError = error;
caughtStack = stackTrace;
}

expect(caughtError, same(error));
expect(caughtStack.toString(), originalStack.toString());
});
});
}
13 changes: 6 additions & 7 deletions packages/ack/test/schemas/encode_error_propagation_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,17 +283,16 @@ void main() {
});
});

group('decoder policy is unchanged', () {
test('a decoder Error still becomes a SchemaTransformError', () {
group('decoder policy matches encoder policy', () {
test('a decoder Error propagates unchanged', () {
final error = StateError('decode boom');
final schema = Ack.string().codec<int>(
decode: (_) => throw StateError('decode boom'),
decode: (_) => throw error,
encode: (value) => value.toString(),
);

final result = schema.safeParse('1');

expect(result.isFail, isTrue);
expect(result.getError(), isA<SchemaTransformError>());
expect(() => schema.safeParse('1'), throwsA(same(error)));
expect(() => schema.parse('1'), throwsA(same(error)));
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,14 @@ void main() {
);

test(
'should error when transformer returns null for non-nullable output',
'should propagate TypeError when a non-nullable transform returns null',
() {
final schema = Ack.string().transform<int>((val) {
return (null as dynamic);
});

final result = schema.safeParse('value');

expect(result.isFail, isTrue);
expect(result.getError(), isA<SchemaTransformError>());
expect(() => schema.safeParse('value'), throwsA(isA<TypeError>()));
expect(() => schema.parse('value'), throwsA(isA<TypeError>()));
},
);

Expand Down
4 changes: 2 additions & 2 deletions packages/ack/test/typed_codecs_characterization_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ void main() {

test('decoder exceptions use codec decode wording', () {
final transformSchema = Ack.string().transform<int>(
(_) => throw StateError('transform decoder failed'),
(_) => throw const FormatException('transform decoder failed'),
);
final transformResult = transformSchema.safeParse('value');

Expand All @@ -291,7 +291,7 @@ void main() {
expect(transformError.message, startsWith('Codec decode failed:'));

final codecSchema = Ack.string().codec<int>(
decode: (_) => throw StateError('codec decoder failed'),
decode: (_) => throw const FormatException('codec decoder failed'),
encode: (value) => value.toString(),
);
final codecResult = codecSchema.safeParse('value');
Expand Down
Loading