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
12 changes: 11 additions & 1 deletion packages/ack/lib/src/schemas/object_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ final class ObjectSchema extends AckSchema<JsonMap, JsonMap>
with FluentSchema<JsonMap, JsonMap, ObjectSchema> {
final Map<String, AnyAckSchema> properties;
final bool additionalProperties;
@internal
final Map<String, Object?> encodeOnlyDefaults;

ObjectSchema(
Map<String, AnyAckSchema>? properties, {
this.additionalProperties = false,
this.encodeOnlyDefaults = const {},
super.isNullable,
super.isOptional,
super.description,
Expand Down Expand Up @@ -184,7 +187,10 @@ final class ObjectSchema extends AckSchema<JsonMap, JsonMap>
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(
Expand Down Expand Up @@ -315,6 +321,8 @@ final class ObjectSchema extends AckSchema<JsonMap, JsonMap>
continue;
}
propertyValue = defaultResult.getOrNull();
} else if (encodeOnlyDefaults.containsKey(key)) {
propertyValue = encodeOnlyDefaults[key];
} else {
continue;
}
Expand Down Expand Up @@ -362,6 +370,7 @@ final class ObjectSchema extends AckSchema<JsonMap, JsonMap>
ObjectSchema copyWith({
Map<String, AnyAckSchema>? properties,
bool? additionalProperties,
Map<String, Object?>? encodeOnlyDefaults,
bool? isNullable,
bool? isOptional,
String? description,
Expand All @@ -371,6 +380,7 @@ final class ObjectSchema extends AckSchema<JsonMap, JsonMap>
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,
Expand Down
17 changes: 16 additions & 1 deletion packages/ack/lib/src/utils/discriminated_branch_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -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.
Expand All @@ -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);
}
Expand Down
187 changes: 187 additions & 0 deletions packages/ack/test/schemas/discriminated_object_schema_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -165,6 +177,181 @@ void main() {
},
);

test(
'encodes a map-runtime branch codec that omits the discriminator',
() {
final codecSchema = Ack.discriminated<Map<String, Object?>>(
discriminatorKey: 'type',
schemas: {
'cat': Ack.object({'name': Ack.string()})
.codec<Map<String, Object?>>(
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<Map<String, Object?>>(
discriminatorKey: 'type',
schemas: {
'cat': Ack.object({'name': Ack.string()})
.codec<Map<String, Object?>>(
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<Map<String, Object?>>(
discriminatorKey: 'type',
schemas: {
'cat': Ack.object({'name': Ack.string()})
.passthrough()
.codec<Map<String, Object?>>(
decode: (data) => Map<String, Object?>.from(data),
encode: (cat) => Map<String, Object?>.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<Object>(
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<Map<String, Object?>>(
discriminatorKey: 'type',
schemas: {
'cat': Ack.object({'name': Ack.string()})
.codec<Map<String, Object?>>(
decode: (data) => {'name': data['name']!},
encode: (cat) => {'name': cat['name']},
)
.withDefault(const {'name': 'Default'}),
},
);
final codecDefaultSchema = Ack.discriminated<Map<String, Object?>>(
discriminatorKey: 'type',
schemas: {
'cat': Ack.object({'name': Ack.string()})
.withDefault(const {'name': 'Default'})
.codec<Map<String, Object?>>(
decode: (data) => {'name': data['name']!},
encode: (cat) => {'name': cat['name']},
),
},
);
final defaultOnlySchema = Ack.discriminated<Map<String, Object?>>(
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<ObjectRequiredPropertiesConstraint>(),
);
},
);

test(
'plain branch still rejects encode input missing the discriminator',
() {
final plainSchema = Ack.discriminated<Map<String, Object?>>(
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<ObjectRequiredPropertiesConstraint>(),
);
},
);

group('Encode error messages (union-owned)', () {
test(
'fails with a required-property constraint when type is missing',
Expand Down
Loading