Skip to content

Commit dc15344

Browse files
committed
feat(ack): add Ack.lazy for recursive schemas
Introduces LazySchema with a memoized builder for schema graphs that reference themselves (e.g. tree-shaped objects). Discriminated unions reject lazy branches with a lazy-specific error since the discriminator property cannot be statically analyzed through a deferred reference. JSON Schema export raises a clear UnsupportedError until $ref/$defs emission lands.
1 parent faec721 commit dc15344

8 files changed

Lines changed: 304 additions & 4 deletions

File tree

packages/ack/lib/src/ack.dart

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,9 @@ final class Ack {
6767
/// (e.g. adding constraints, copying with new flags). Prefer [enumCodec]
6868
/// when downstream code expects every value-shape to be a `CodecSchema`.
6969
static CodecSchema<String, T> enumCodec<T extends Enum>(List<T> values) =>
70-
enumValues(values).codec<T>(
71-
decode: (value) => value,
72-
encode: (value) => value,
73-
);
70+
enumValues(
71+
values,
72+
).codec<T>(decode: (value) => value, encode: (value) => value);
7473

7574
/// Creates a string schema that only accepts one of the given [values].
7675
static StringSchema enumString(List<String> values) =>
@@ -86,6 +85,23 @@ final class Ack {
8685
/// to accept `null`.
8786
static AnySchema any() => const AnySchema();
8887

88+
/// Creates a schema reference that is resolved lazily on first use.
89+
///
90+
/// The [builder] is called once and memoized. Two `Ack.lazy` instances are
91+
/// equal only when their `builder` closure is the same reference -- pulling
92+
/// the closure into a `final` variable lets two calls share equality.
93+
///
94+
/// `toJsonSchema()` and `toSchemaModel()` currently throw for schema graphs
95+
/// containing `Ack.lazy`; recursive `$defs`/`$ref` export support is tracked
96+
/// separately. `Ack.lazy` also cannot be used directly as a discriminated
97+
/// union branch.
98+
static LazySchema<Boundary, Runtime> lazy<
99+
Boundary extends Object,
100+
Runtime extends Object
101+
>(String name, AckSchema<Boundary, Runtime> Function() builder) {
102+
return LazySchema<Boundary, Runtime>(name, builder);
103+
}
104+
89105
/// Creates a schema for a specific Dart instance type [T], with [T] as
90106
/// both boundary and runtime type.
91107
static InstanceSchema<T> instance<T extends Object>() => InstanceSchema<T>();

packages/ack/lib/src/schema_model/ack_schema_model_builder.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ AckSchemaModel _build(AckSchema schema) {
7373
AnySchema() => _any(schema),
7474
InstanceSchema() => _instance(schema),
7575
DiscriminatedObjectSchema() => _discriminated(schema),
76+
LazySchema() => throw UnsupportedError(
77+
'JSON Schema export of recursive schemas (Ack.lazy) is not supported. '
78+
'Use a non-recursive schema, or wait for \$ref/\$defs export support.',
79+
),
7680
_ => throw UnsupportedError(
7781
'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.',
7882
),

packages/ack/lib/src/schemas/discriminated_object_schema.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ final class DiscriminatedObjectSchema<T extends Object>
4141
for (final entry in schemas.entries) {
4242
final label = entry.key;
4343
final base = unwrapDiscriminatedBranchSchema(entry.value);
44+
if (base is LazySchema) {
45+
throw ArgumentError.value(
46+
entry.value,
47+
'schemas["$label"]',
48+
'Discriminated branches cannot be Ack.lazy(...) - recursive '
49+
'discriminator property references cannot be analyzed.',
50+
);
51+
}
4452
if (base is! ObjectSchema) {
4553
throw ArgumentError.value(
4654
entry.value,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
part of 'schema.dart';
2+
3+
/// Defers resolving another schema until parse, validation, or encode time.
4+
///
5+
/// This enables recursive schema graphs where a child schema needs to refer
6+
/// back to an outer schema that is assigned after construction.
7+
@immutable
8+
final class LazySchema<Boundary extends Object, Runtime extends Object>
9+
extends AckSchema<Boundary, Runtime>
10+
with FluentSchema<Boundary, Runtime, LazySchema<Boundary, Runtime>> {
11+
LazySchema(
12+
this.name,
13+
this._builder, {
14+
super.isNullable,
15+
super.isOptional,
16+
super.description,
17+
super.constraints,
18+
super.refinements,
19+
});
20+
21+
/// Human-readable name for this deferred schema reference.
22+
final String name;
23+
24+
final AckSchema<Boundary, Runtime> Function() _builder;
25+
26+
late final AckSchema<Boundary, Runtime> _target = _builder();
27+
28+
@override
29+
SchemaType get schemaType => SchemaType.lazy;
30+
31+
@override
32+
@protected
33+
SchemaResult<Runtime> parseWithContext(Object? value, SchemaContext context) {
34+
final nullResult = handleNullInput(value, context);
35+
if (nullResult != null) return nullResult;
36+
37+
final result = _target.parseWithContext(value, context);
38+
if (result.isFail) return SchemaResult.fail(result.getError());
39+
40+
final runtime = result.getOrNull();
41+
if (runtime == null) return SchemaResult.ok(null);
42+
return applyConstraintsAndRefinements(runtime, context);
43+
}
44+
45+
@override
46+
@protected
47+
SchemaResult<Runtime> validateRuntimeWithContext(
48+
Object? value,
49+
SchemaContext context,
50+
) {
51+
final nullResult = handleNullInput(value, context);
52+
if (nullResult != null) return nullResult;
53+
54+
final result = _target.validateRuntimeWithContext(value, context);
55+
if (result.isFail) return SchemaResult.fail(result.getError());
56+
57+
final runtime = result.getOrNull();
58+
if (runtime == null) return SchemaResult.ok(null);
59+
return applyConstraintsAndRefinements(runtime, context);
60+
}
61+
62+
@override
63+
@protected
64+
SchemaResult<Boundary> encodeWithContext(
65+
Runtime value,
66+
SchemaContext context,
67+
) {
68+
final validated = validateRuntimeWithContext(value, context);
69+
if (validated.isFail) return SchemaResult.fail(validated.getError());
70+
71+
final runtime = validated.getOrNull();
72+
if (runtime == null) return SchemaResult.ok(null);
73+
return _target.encodeWithContext(runtime, context);
74+
}
75+
76+
@override
77+
LazySchema<Boundary, Runtime> copyWith({
78+
bool? isNullable,
79+
bool? isOptional,
80+
String? description,
81+
List<Constraint<Runtime>>? constraints,
82+
List<Refinement<Runtime>>? refinements,
83+
}) {
84+
return LazySchema<Boundary, Runtime>(
85+
name,
86+
_builder,
87+
isNullable: isNullable ?? this.isNullable,
88+
isOptional: isOptional ?? this.isOptional,
89+
description: description ?? this.description,
90+
constraints: constraints ?? this.constraints,
91+
refinements: refinements ?? this.refinements,
92+
);
93+
}
94+
95+
@override
96+
Map<String, Object?> toMap() => {...super.toMap(), 'name': name};
97+
98+
@override
99+
bool operator ==(Object other) {
100+
if (identical(this, other)) return true;
101+
if (other is! LazySchema<Boundary, Runtime>) return false;
102+
return baseFieldsEqual(other) &&
103+
name == other.name &&
104+
identical(_builder, other._builder);
105+
}
106+
107+
@override
108+
int get hashCode {
109+
return Object.hash(baseFieldsHashCode, name, identityHashCode(_builder));
110+
}
111+
}

packages/ack/lib/src/schemas/schema.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ part 'discriminated_object_schema.dart';
2121
part 'enum_schema.dart';
2222
part 'fluent_schema.dart';
2323
part 'instance_schema.dart';
24+
part 'lazy_schema.dart';
2425
part 'list_schema.dart';
2526
part 'num_schema.dart';
2627
part 'object_schema.dart';

packages/ack/lib/src/schemas/schema_type.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ enum SchemaType {
1616
any('any'),
1717
anyOf('anyOf'),
1818
enum_('enum'),
19+
lazy('lazy'),
1920
discriminated('discriminated');
2021

2122
const SchemaType(this.typeName);

packages/ack/test/schemas/discriminated_object_schema_test.dart

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,64 @@ void main() {
150150
);
151151
});
152152

153+
test('rejects lazy branches', () {
154+
late final ObjectSchema categorySchema;
155+
categorySchema = Ack.object({
156+
'name': Ack.string(),
157+
'children': Ack.list(
158+
Ack.lazy<JsonMap, JsonMap>('Category', () => categorySchema),
159+
),
160+
});
161+
162+
expect(
163+
() => Ack.discriminated<JsonMap>(
164+
discriminatorKey: 'type',
165+
schemas: {
166+
'category': Ack.lazy<JsonMap, JsonMap>(
167+
'Category',
168+
() => categorySchema,
169+
),
170+
},
171+
),
172+
throwsA(
173+
isA<ArgumentError>().having(
174+
(error) => error.message,
175+
'message',
176+
contains('Discriminated branches cannot be Ack.lazy(...)'),
177+
),
178+
),
179+
);
180+
});
181+
182+
test('rejects wrapped lazy branches with the lazy-specific error', () {
183+
late final ObjectSchema categorySchema;
184+
categorySchema = Ack.object({
185+
'name': Ack.string(),
186+
'children': Ack.list(
187+
Ack.lazy<JsonMap, JsonMap>('Category', () => categorySchema),
188+
),
189+
});
190+
191+
expect(
192+
() => Ack.discriminated<JsonMap>(
193+
discriminatorKey: 'type',
194+
schemas: {
195+
'category': Ack.lazy<JsonMap, JsonMap>(
196+
'Category',
197+
() => categorySchema,
198+
).withDefault(const {'name': 'root', 'children': <Object?>[]}),
199+
},
200+
),
201+
throwsA(
202+
isA<ArgumentError>().having(
203+
(error) => error.message,
204+
'message',
205+
contains('Discriminated branches cannot be Ack.lazy(...)'),
206+
),
207+
),
208+
);
209+
});
210+
153211
test('defensively copies schemas', () {
154212
final schemas = {'cat': catSchema};
155213
final schema = Ack.discriminated<Map<String, Object?>>(
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import 'package:ack/ack.dart';
2+
import 'package:test/test.dart';
3+
4+
void main() {
5+
test('parses and encodes a recursive object graph', () {
6+
late final ObjectSchema categorySchema;
7+
categorySchema = Ack.object({
8+
'name': Ack.string(),
9+
'children': Ack.list(
10+
Ack.lazy<JsonMap, JsonMap>('Category', () => categorySchema),
11+
),
12+
});
13+
14+
final json = <String, Object?>{
15+
'name': 'root',
16+
'children': [
17+
{
18+
'name': 'first',
19+
'children': [
20+
{'name': 'leaf', 'children': <Object?>[]},
21+
],
22+
},
23+
],
24+
};
25+
26+
final parsed = categorySchema.parse(json);
27+
expect(parsed, equals(json));
28+
29+
final encoded = categorySchema.encode(parsed);
30+
expect(encoded, equals(json));
31+
expect(categorySchema.encode(categorySchema.parse(json)), equals(json));
32+
});
33+
34+
test('throws a clear error when exporting recursive schemas', () {
35+
late final ObjectSchema categorySchema;
36+
categorySchema = Ack.object({
37+
'name': Ack.string(),
38+
'children': Ack.list(
39+
Ack.lazy<JsonMap, JsonMap>('Category', () => categorySchema),
40+
),
41+
});
42+
43+
const message =
44+
'JSON Schema export of recursive schemas (Ack.lazy) is not supported';
45+
46+
expect(
47+
categorySchema.toSchemaModel,
48+
throwsA(
49+
isA<UnsupportedError>().having(
50+
(error) => error.message,
51+
'message',
52+
contains(message),
53+
),
54+
),
55+
);
56+
expect(
57+
categorySchema.toJsonSchema,
58+
throwsA(
59+
isA<UnsupportedError>().having(
60+
(error) => error.message,
61+
'message',
62+
contains(message),
63+
),
64+
),
65+
);
66+
});
67+
68+
test('memoizes the builder result', () {
69+
var calls = 0;
70+
late final ObjectSchema categorySchema;
71+
final lazy = Ack.lazy<JsonMap, JsonMap>('Category', () {
72+
calls++;
73+
return categorySchema;
74+
});
75+
categorySchema = Ack.object({
76+
'name': Ack.string(),
77+
'children': Ack.list(lazy),
78+
});
79+
80+
final json = <String, Object?>{
81+
'name': 'root',
82+
'children': [
83+
{'name': 'leaf', 'children': <Object?>[]},
84+
],
85+
};
86+
87+
final parsed = categorySchema.parse(json);
88+
expect(parsed, equals(json));
89+
expect(categorySchema.encode(parsed), equals(json));
90+
expect(calls, 1);
91+
});
92+
93+
test('uses closure identity for equality', () {
94+
final target = Ack.object({'name': Ack.string()});
95+
final first = Ack.lazy<JsonMap, JsonMap>('Category', () => target);
96+
final second = Ack.lazy<JsonMap, JsonMap>('Category', () => target);
97+
98+
expect(first, equals(first));
99+
expect(first, isNot(equals(second)));
100+
});
101+
}

0 commit comments

Comments
 (0)