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
4 changes: 4 additions & 0 deletions packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.0.1

* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.1) for details.

## 1.0.0

* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0) for details.
Expand Down
26 changes: 17 additions & 9 deletions packages/ack/lib/src/ack.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,26 @@ final class Ack {

/// Creates a schema reference that is resolved lazily on first use.
///
/// The [builder] is called once and memoized. Two `Ack.lazy` instances are
/// equal only when their `builder` closure is the same reference -- pulling
/// the closure into a `final` variable lets two calls share equality.
/// The [builder] is called once and memoized. [maxDepth] bounds how many
/// times this lazy schema may recur in its active context chain before
/// parsing, runtime validation, or encoding fails; it defaults to
/// [LazySchema.defaultMaxDepth] and must be at least `1`. Two `Ack.lazy`
/// instances are equal only when their `builder` closure is the same
/// reference -- pulling the closure into a `final` variable lets two calls
/// share equality.
///
/// `toJsonSchema()` and `toSchemaModel()` export lazy references through
/// recursive `definitions`/`$ref` JSON Schema definitions. Bare or wrapped
/// `Ack.lazy` schemas cannot be used as discriminated union branches.
static LazySchema<Boundary, Runtime> lazy<
Boundary extends Object,
Runtime extends Object
>(String name, AckSchema<Boundary, Runtime> Function() builder) {
return LazySchema<Boundary, Runtime>(name, builder);
/// `Ack.lazy` schemas cannot be used as discriminated union branches. The
/// `maxDepth` check is a runtime-only constraint that cannot be expressed
/// through a `$ref`, so exported schema models warn that it was omitted.
static LazySchema<Boundary, Runtime>
lazy<Boundary extends Object, Runtime extends Object>(
String name,
AckSchema<Boundary, Runtime> Function() builder, {
int maxDepth = LazySchema.defaultMaxDepth,
}) {
return LazySchema<Boundary, Runtime>(name, builder, maxDepth: maxDepth);
}

/// Creates a schema for a specific Dart instance type [T], with [T] as
Expand Down
78 changes: 74 additions & 4 deletions packages/ack/lib/src/schemas/lazy_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,43 @@ part of 'schema.dart';
final class LazySchema<Boundary extends Object, Runtime extends Object>
extends AckSchema<Boundary, Runtime>
with FluentSchema<Boundary, Runtime, LazySchema<Boundary, Runtime>> {
/// Default recursion cap applied to `Ack.lazy` schemas that do not
/// specify their own [maxDepth].
static const defaultMaxDepth = 100;

Comment on lines +12 to +15

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed intentional, not a bug: the 100-level cap is now the default for every Ack.lazy schema, with no unlimited/uncapped opt-out — that's a deliberate design decision for this PR (not a regression to fix). Updated the PR description to describe this default-cap behavior accurately instead of the old 'uncapped unless configured' wording.

/// Human-readable name for this deferred schema reference.
final String name;

/// Maximum number of times this lazy schema may appear in its active context
/// chain before parsing, runtime validation, or encoding fails.
final int maxDepth;

final AckSchema<Boundary, Runtime> Function() _builder;

late final AckSchema<Boundary, Runtime> _target = _builder();

LazySchema(
this.name,
this._builder, {
this.maxDepth = defaultMaxDepth,
super.isNullable,
super.isOptional,
super.description,
super.constraints,
super.refinements,
});
}) {
if (maxDepth < 1) {
throw ArgumentError.value(maxDepth, 'maxDepth', 'Must be >= 1.');
}
}

@internal
AckSchema<Boundary, Runtime> get target => _target;

/// Count of runtime-only constraints, including the always-present
/// max-depth check that every `Ack.lazy` schema enforces.
@internal
int get runtimeConstraintCount => _constraints.length;
int get runtimeConstraintCount => _constraints.length + 1;

@internal
int get runtimeRefinementCount => _refinements.length;
Expand All @@ -41,6 +56,9 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>
final nullResult = handleNullInput(value, context);
if (nullResult != null) return nullResult;

final depthResult = _checkMaxDepth<Runtime>(context);
if (depthResult != null) return depthResult;

final result = _target.parseWithContext(value, context);
if (result.isFail) return SchemaResult.fail(result.getError());

Expand All @@ -59,6 +77,9 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>
final nullResult = handleNullInput(value, context);
if (nullResult != null) return nullResult;

final depthResult = _checkMaxDepth<Runtime>(context);
if (depthResult != null) return depthResult;

final result = _target.validateRuntimeWithContext(value, context);
if (result.isFail) return SchemaResult.fail(result.getError());

Expand All @@ -74,23 +95,49 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>
Runtime value,
SchemaContext context,
) {
final depthResult = _checkMaxDepth<Boundary>(context);
if (depthResult != null) return depthResult;

final ownChecked = applyConstraintsAndRefinements(value, context);
if (ownChecked.isFail) return SchemaResult.fail(ownChecked.getError());

return _target.encodeWithContext(ownChecked.getOrThrow()!, context);
}

SchemaResult<T>? _checkMaxDepth<T extends Object>(SchemaContext context) {
var depth = 0;
for (SchemaContext? c = context; c != null; c = c.parent) {
if (identical(c.schema, this)) depth++;
}
if (depth <= maxDepth) return null;

return SchemaResult.fail(
SchemaConstraintsError(
constraints: [
ConstraintError(
constraint: _LazyMaxDepthConstraint(maxDepth),
message: 'Maximum recursion depth ($maxDepth) exceeded.',
context: {'depth': depth, 'maxDepth': maxDepth, 'lazyName': name},
),
],
context: context,
),
);
}

@override
LazySchema<Boundary, Runtime> copyWith({
bool? isNullable,
bool? isOptional,
String? description,
List<Constraint<Runtime>>? constraints,
List<Refinement<Runtime>>? refinements,
int? maxDepth,
}) {
return LazySchema(
name,
_builder,
maxDepth: maxDepth ?? this.maxDepth,
isNullable: isNullable ?? this.isNullable,
isOptional: isOptional ?? this.isOptional,
description: description ?? this.description,
Expand All @@ -100,7 +147,11 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>
}

@override
Map<String, Object?> toMap() => {...super.toMap(), 'name': name};
Map<String, Object?> toMap() => {
...super.toMap(),
'name': name,
'maxDepth': maxDepth,
};

@override
bool operator ==(Object other) {
Expand All @@ -109,6 +160,7 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>

return baseFieldsEqual(other) &&
name == other.name &&
maxDepth == other.maxDepth &&
identical(_builder, other._builder);
}

Expand All @@ -117,6 +169,24 @@ final class LazySchema<Boundary extends Object, Runtime extends Object>

@override
int get hashCode {
return Object.hash(baseFieldsHashCode, name, identityHashCode(_builder));
return Object.hash(
baseFieldsHashCode,
name,
maxDepth,
identityHashCode(_builder),
);
}
}

final class _LazyMaxDepthConstraint extends Constraint<Object> {
_LazyMaxDepthConstraint(this.maxDepth)
: super(
constraintKey: 'lazy_max_depth',
description: 'Lazy recursion depth must not exceed $maxDepth.',
);

final int maxDepth;

@override
Map<String, Object?> toMap() => {...super.toMap(), 'maxDepth': maxDepth};
}
2 changes: 1 addition & 1 deletion packages/ack/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: ack
description: A simple validation library for Dart
version: 1.0.0
version: 1.0.1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the PR description was stale from an earlier draft. Updated it to say 1.0.1 (matching pubspec.yaml and CHANGELOG.md, both already correct).

repository: https://github.com/btwld/ack
issue_tracker: https://github.com/btwld/ack/issues
homepage: https://docs.page/btwld/ack
Expand Down
85 changes: 84 additions & 1 deletion packages/ack/test/schemas/lazy_schema_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ void main() {
expect(child.warnings, hasLength(1));
expect(child.warnings.single.code, 'lazy_runtime_checks_not_export_safe');
expect(child.warnings.single.context, {
'constraintCount': 0,
'constraintCount': 1,
'refinementCount': 1,
});
});
Expand Down Expand Up @@ -314,6 +314,89 @@ void main() {
// on top of that, inflating this count. Locks in the fixed call profile.
expect(calls, 15);
});

group('maxDepth', () {
test('allows input at the cap and fails one level deeper', () {
const maxDepth = 3;
late final ObjectSchema categorySchema;
final child = Ack.lazy<JsonMap, JsonMap>(
'Category',
() => categorySchema,
maxDepth: maxDepth,
).nullable().optional();
categorySchema = Ack.object({'name': Ack.string(), 'child': child});

final atLimit = _nestedCategoryJson(maxDepth);
final tooDeep = _nestedCategoryJson(maxDepth + 1);

expect(categorySchema.safeParse(atLimit).isOk, isTrue);
expect(categorySchema.safeParse(tooDeep).isFail, isTrue);
expect(categorySchema.safeEncode(atLimit).isOk, isTrue);
expect(categorySchema.safeEncode(tooDeep).isFail, isTrue);
});

test('defaults maxDepth to LazySchema.defaultMaxDepth', () {
final target = Ack.object({'name': Ack.string()});
final lazy = Ack.lazy<JsonMap, JsonMap>('Category', () => target);

expect(lazy.maxDepth, LazySchema.defaultMaxDepth);
});

test('throws for maxDepth values below 1', () {
final target = Ack.object({'name': Ack.string()});

expect(
() => Ack.lazy<JsonMap, JsonMap>('Category', () => target, maxDepth: 0),
throwsA(isA<ArgumentError>()),
);
expect(
() =>
Ack.lazy<JsonMap, JsonMap>('Category', () => target, maxDepth: -1),
throwsA(isA<ArgumentError>()),
);
});

test('preserves maxDepth through fluent copies', () {
final target = Ack.object({'name': Ack.string()});
final lazy = Ack.lazy<JsonMap, JsonMap>(
'Category',
() => target,
maxDepth: 2,
);

expect(lazy.maxDepth, 2);
expect(lazy.nullable().optional().maxDepth, 2);
});

test('includes maxDepth in equality and hashCode', () {
final target = Ack.object({'name': Ack.string()});
AckSchema<JsonMap, JsonMap> builder() => target;

final uncapped = Ack.lazy<JsonMap, JsonMap>('Category', builder);
final capped = Ack.lazy<JsonMap, JsonMap>(
'Category',
builder,
maxDepth: 2,
);
final matchingCap = Ack.lazy<JsonMap, JsonMap>(
'Category',
builder,
maxDepth: 2,
);

expect(capped, matchingCap);
expect(capped.hashCode, matchingCap.hashCode);
expect(capped, isNot(uncapped));
});
});
}

JsonMap _nestedCategoryJson(int childDepth) {
JsonMap node = {'name': 'node-$childDepth'};
for (var depth = childDepth - 1; depth >= 0; depth--) {
node = {'name': 'node-$depth', 'child': node};
}
return node;
}

final class _TestJsonSchemaKeywordConstraint<T extends Object>
Expand Down
Loading