diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index 2578826a..ca4cff3f 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -24,10 +24,13 @@ final class ObjectSchema extends AckSchema with FluentSchema { final Map properties; final bool additionalProperties; + @internal + final Map encodeOnlyDefaults; ObjectSchema( Map? properties, { this.additionalProperties = false, + this.encodeOnlyDefaults = const {}, super.isNullable, super.isOptional, super.description, @@ -184,7 +187,10 @@ final class ObjectSchema extends AckSchema final hasValue = mapValue.containsKey(key); if (!hasValue) { - if (schema.isOptional || (isEncode && schema is DefaultSchema)) { + if (schema.isOptional || + (isEncode && + (schema is DefaultSchema || + encodeOnlyDefaults.containsKey(key)))) { continue; } final propertyCtx = context.createChild( @@ -315,6 +321,8 @@ final class ObjectSchema extends AckSchema continue; } propertyValue = defaultResult.getOrNull(); + } else if (encodeOnlyDefaults.containsKey(key)) { + propertyValue = encodeOnlyDefaults[key]; } else { continue; } @@ -362,6 +370,7 @@ final class ObjectSchema extends AckSchema ObjectSchema copyWith({ Map? properties, bool? additionalProperties, + Map? encodeOnlyDefaults, bool? isNullable, bool? isOptional, String? description, @@ -371,6 +380,7 @@ final class ObjectSchema extends AckSchema return ObjectSchema( properties ?? this.properties, additionalProperties: additionalProperties ?? this.additionalProperties, + encodeOnlyDefaults: encodeOnlyDefaults ?? this.encodeOnlyDefaults, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, diff --git a/packages/ack/lib/src/utils/discriminated_branch_utils.dart b/packages/ack/lib/src/utils/discriminated_branch_utils.dart index 157ee7fe..740f25d7 100644 --- a/packages/ack/lib/src/utils/discriminated_branch_utils.dart +++ b/packages/ack/lib/src/utils/discriminated_branch_utils.dart @@ -57,6 +57,7 @@ ObjectSchema effectiveDiscriminatedObjectBranch({ required String discriminatorKey, required String discriminatorValue, required ObjectSchema objectSchema, + bool synthesizeOnEncode = false, }) { final existingDiscriminator = objectSchema.properties[discriminatorKey]; if (existingDiscriminator != null && @@ -76,7 +77,15 @@ ObjectSchema effectiveDiscriminatedObjectBranch({ if (entry.key != discriminatorKey) entry.key: entry.value, }; - return objectSchema.copyWith(properties: properties); + return objectSchema.copyWith( + properties: properties, + encodeOnlyDefaults: synthesizeOnEncode + ? { + ...objectSchema.encodeOnlyDefaults, + discriminatorKey: discriminatorValue, + } + : null, + ); } /// Builds the effective schema for a discriminated-union branch. @@ -92,20 +101,26 @@ AnyAckSchema effectiveDiscriminatedBranch({ required String discriminatorKey, required String discriminatorValue, required AnyAckSchema branchSchema, + bool underCodec = false, }) { if (branchSchema is ObjectSchema) { return effectiveDiscriminatedObjectBranch( discriminatorKey: discriminatorKey, discriminatorValue: discriminatorValue, objectSchema: branchSchema, + synthesizeOnEncode: underCodec, ); } if (branchSchema is WrapperSchema) { + // Synthesize only for the branch-root object when a CodecSchema appears + // above it on the wrapper spine. This recursion follows `.inner` only, so + // nested property objects are unaffected. final effectiveInner = effectiveDiscriminatedBranch( discriminatorKey: discriminatorKey, discriminatorValue: discriminatorValue, branchSchema: branchSchema.inner, + underCodec: underCodec || branchSchema is CodecSchema, ); return branchSchema.copyWithInner(effectiveInner); } diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index 693e1437..5cc64f0a 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -3,6 +3,18 @@ import 'package:ack/src/constraints/pattern_constraint.dart'; import 'package:ack/src/constraints/validators.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; +} + void main() { group('DiscriminatedObjectSchema', () { late ObjectSchema catSchema; @@ -165,6 +177,181 @@ void main() { }, ); + test( + 'encodes a map-runtime branch codec that omits the discriminator', + () { + final codecSchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}) + .codec>( + decode: (data) => {'name': data['name']!}, + encode: (cat) => {'name': cat['name']}, + ), + }, + ); + + final result = codecSchema.safeEncode({'name': 'Mittens'}); + + expect(result.isOk, isTrue); + expect(result.getOrThrow(), {'type': 'cat', 'name': 'Mittens'}); + }, + ); + + test('round-trips through a map-runtime branch codec that omits the ' + 'discriminator', () { + final codecSchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}) + .codec>( + decode: (data) => {'name': data['name']!}, + encode: (cat) => {'name': cat['name']}, + ), + }, + ); + + final parsed = codecSchema.parse({'type': 'cat', 'name': 'Mittens'}); + final encoded = codecSchema.safeEncode(parsed); + + expect(encoded.isOk, isTrue); + expect(encoded.getOrThrow(), {'type': 'cat', 'name': 'Mittens'}); + }); + + test('encodes passthrough extras once for a branch codec that omits the ' + 'discriminator', () { + final codecSchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}) + .passthrough() + .codec>( + decode: (data) => Map.from(data), + encode: (cat) => Map.from(cat), + ), + }, + ); + + final result = codecSchema.safeEncode({ + 'name': 'Mittens', + 'color': 'tabby', + }); + + expect(result.isOk, isTrue); + expect(result.getOrThrow(), { + 'type': 'cat', + 'name': 'Mittens', + 'color': 'tabby', + }); + }); + + test('encodes the matching typed codec branch when codecs omit the ' + 'discriminator', () { + final codecSchema = Ack.discriminated( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (data) => _Cat(data['name']! as String), + encode: (cat) => {'name': cat.name}, + ), + 'dog': Ack.object({'name': Ack.string()}).codec<_Dog>( + decode: (data) => _Dog(data['name']! as String), + encode: (dog) => {'name': dog.name}, + ), + }, + ); + + final result = codecSchema.safeEncode(const _Dog('Spot')); + + expect(result.isOk, isTrue); + expect(result.getOrThrow(), {'type': 'dog', 'name': 'Spot'}); + }); + + test( + 'nested default and codec wrappers synthesize the discriminator only ' + 'under codecs', + () { + final defaultCodecSchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}) + .codec>( + decode: (data) => {'name': data['name']!}, + encode: (cat) => {'name': cat['name']}, + ) + .withDefault(const {'name': 'Default'}), + }, + ); + final codecDefaultSchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}) + .withDefault(const {'name': 'Default'}) + .codec>( + decode: (data) => {'name': data['name']!}, + encode: (cat) => {'name': cat['name']}, + ), + }, + ); + final defaultOnlySchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({ + 'name': Ack.string(), + }).withDefault(const {'name': 'Default'}), + }, + ); + + final defaultCodecResult = defaultCodecSchema.safeEncode({ + 'name': 'Mittens', + }); + final codecDefaultResult = codecDefaultSchema.safeEncode({ + 'name': 'Mittens', + }); + final defaultOnlyResult = defaultOnlySchema.safeEncode({ + 'name': 'Mittens', + }); + + expect(defaultCodecResult.isOk, isTrue); + expect(defaultCodecResult.getOrThrow(), { + 'type': 'cat', + 'name': 'Mittens', + }); + expect(codecDefaultResult.isOk, isTrue); + expect(codecDefaultResult.getOrThrow(), { + 'type': 'cat', + 'name': 'Mittens', + }); + expect(defaultOnlyResult.isFail, isTrue); + final error = defaultOnlyResult.getError() as SchemaConstraintsError; + expect( + error.constraints.first.constraint, + isA(), + ); + }, + ); + + test( + 'plain branch still rejects encode input missing the discriminator', + () { + final plainSchema = Ack.discriminated>( + discriminatorKey: 'type', + schemas: { + 'cat': Ack.object({'name': Ack.string()}), + }, + ); + + final result = plainSchema.safeEncode({'name': 'Mittens'}); + + expect(result.isFail, isTrue); + final error = result.getError() as SchemaConstraintsError; + expect( + error.constraints.first.constraint, + isA(), + ); + }, + ); + group('Encode error messages (union-owned)', () { test( 'fails with a required-property constraint when type is missing',