Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ jobs:
with:
flutter-version: "stable"
run-dcm: true
melos-commands: |
cd packages/ack
dart test --platform chrome \
test/schemas/numeric_platform_parity_test.dart \
test/schemas/extensions/numeric_extensions_test.dart \
test/schemas/extensions/list_schema_extensions_test.dart \
test/helpers_test.dart
12 changes: 11 additions & 1 deletion packages/ack/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@
* Snapshot factory collections so a caller mutating the passed list or map can no
longer corrupt a constructed schema.
* Correct behavior-based schema and deep-collection equality.
* Normalize losslessly representable integer and double inputs consistently on
Dart VM and JavaScript.
* Reject the native minimum integer from `.safe()` without overflowing `abs()`.
* Treat mathematically equal JSON numbers, such as `1` and `1.0`, as equal in
deep comparisons and `uniqueItems` validation.

### Behavior changes

No public API changed (verified with `dart_apitool` against 1.0.1); the following
now reject inputs that previously passed or misbehaved silently.
validation and serialization behavior changed.

* Validate numeric `multipleOf`, IPv6, and RFC 3339 date-time values strictly.
Announced leap seconds are preserved by `Ack.string().datetime()` but rejected
Expand All @@ -34,6 +39,11 @@ now reject inputs that previously passed or misbehaved silently.
snapshot or golden tests of exported schemas.)*
* `parse()` throws `AckException` — instead of the raw callback error — when a
constraint or refinement throws.
* `Ack.integer()` accepts integral doubles and returns an `int`, while
`Ack.double()` accepts exactly representable integers and returns a `double`.
Lossy native numeric conversions are rejected; use `Ack.integer().safe()` to
require JavaScript's portable integer range. `uniqueItems` now reports `1`
and `1.0` as duplicates, matching JSON Schema semantics.

## 1.0.1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ List<T>? _findDuplicates<T>(List<T> value) {
if (value.isEmpty) return null;

// Always use hash-based deep equality for consistency with deepEquals.
// The primitive path was removed because it used == (which treats 1 == 1.0
// as true) while deepEquals treats different runtimeTypes as not equal.
final groupsByHash = <int, List<_DuplicateGroup<T>>>{};
final groupsInOrder = <_DuplicateGroup<T>>[];

Expand Down Expand Up @@ -89,12 +87,16 @@ List<T>? _findDuplicates<T>(List<T> value) {
int _deepHashCode(Object? value) {
if (value == null) return Object.hash(null, null);

// Equal JSON numbers must share a bucket even when one is represented as an
// int and the other as a double. Equal Dart numbers have equal hash codes.
if (value is num) return Object.hash(num, value);

if (value is! Iterable && value is! Map) {
return Object.hash(value.runtimeType, value);
}

if (value is List) {
var hash = Object.hash(value.runtimeType, value.length);
var hash = Object.hash(List, value.length);
for (final item in value) {
hash = Object.hash(hash, _deepHashCode(item));
}
Expand All @@ -111,7 +113,7 @@ int _deepHashCode(Object? value) {
combined ^= h ^ (h >>> 16);
}

return Object.hash(value.runtimeType, value.length, combined);
return Object.hash(Set, value.length, combined);
}

if (value is Map) {
Expand All @@ -124,11 +126,11 @@ int _deepHashCode(Object? value) {
combined ^= entryHash ^ (entryHash >>> 16);
}

return Object.hash(value.runtimeType, value.length, combined);
return Object.hash(Map, value.length, combined);
}

if (value is Iterable) {
var hash = Object.hash(value.runtimeType, 0);
var hash = Object.hash(Iterable, 0);
for (final item in value) {
hash = Object.hash(hash, _deepHashCode(item));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class NumberSafeIntegerConstraint extends Constraint<int> with Validator<int> {
);

@override
bool isValid(int value) => value.abs() <= maxSafeInteger;
bool isValid(int value) =>
value >= -maxSafeInteger && value <= maxSafeInteger;

@override
String buildMessage(int value) =>
Expand Down
51 changes: 45 additions & 6 deletions packages/ack/lib/src/schemas/num_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ sealed class NumSchema<T extends num> extends AckSchema<T, T> {

// --- IntegerSchema ---

/// Schema for validating integer values.
/// Schema for validating JSON integer values.
///
/// JSON Schema defines an integer as any number with a zero fractional part.
/// Integral [double] inputs are therefore normalized to [int] when conversion
/// is lossless. Add `.safe()` when integers must remain exact on JavaScript.
@immutable
final class IntegerSchema extends NumSchema<int>
with FluentSchema<int, int, IntegerSchema> {
Expand All @@ -56,7 +60,7 @@ final class IntegerSchema extends NumSchema<int>
final nullResult = handleNullInput(value, context);
if (nullResult != null) return nullResult;

if (value is! int) {
if (value is! num || !value.isFinite || value.remainder(1) != 0) {
return SchemaResult.fail(
_buildTypeMismatch(
expectedType: schemaType,
Expand All @@ -66,7 +70,26 @@ final class IntegerSchema extends NumSchema<int>
);
}

return applyConstraintsAndRefinements(value, context);
// JavaScript preserves the sign bit on negative zero even when Dart treats
// the value as an `int`. Normalize it explicitly before constraints and
// refinements observe the value.
if (value == 0) return applyConstraintsAndRefinements(0, context);
if (value is int) return applyConstraintsAndRefinements(value, context);

final normalized = value.toInt();
// Native double-to-int conversion saturates outside the platform int
// range. Compare mathematical integer values through BigInt because num
// equality itself rounds at boundaries such as 2^63.
if (BigInt.from(normalized) != BigInt.from(value)) {
return SchemaResult.fail(
SchemaValidationError(
message: 'Number cannot be represented as a Dart int without loss.',
context: context,
),
);
}

return applyConstraintsAndRefinements(normalized, context);
}

@override
Expand Down Expand Up @@ -103,7 +126,10 @@ final class IntegerSchema extends NumSchema<int>

// --- DoubleSchema ---

/// Schema for validating double values.
/// Schema for validating JSON number values as Dart [double]s.
///
/// JSON Schema's `number` type includes integers. Exactly representable numeric
/// inputs are normalized to [double]; lossy integer conversions are rejected.
@immutable
final class DoubleSchema extends NumSchema<double>
with FluentSchema<double, double, DoubleSchema> {
Expand All @@ -124,7 +150,7 @@ final class DoubleSchema extends NumSchema<double>
final nullResult = handleNullInput(value, context);
if (nullResult != null) return nullResult;

if (value is! double) {
if (value is! num) {
return SchemaResult.fail(
_buildTypeMismatch(
expectedType: schemaType,
Expand All @@ -134,7 +160,20 @@ final class DoubleSchema extends NumSchema<double>
);
}

return applyConstraintsAndRefinements(value, context);
final normalized = value.toDouble();
if (normalized.isFinite &&
value is int &&
BigInt.from(normalized) != BigInt.from(value)) {
return SchemaResult.fail(
SchemaValidationError(
message:
'Integer cannot be represented as a Dart double without loss.',
context: context,
),
);
}

return applyConstraintsAndRefinements(normalized, context);
}

@override
Expand Down
7 changes: 4 additions & 3 deletions packages/ack/lib/src/schemas/schema_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ part of 'schema.dart';
/// Schema type enumeration covering JSON primitives and schema-specific
/// categories.
///
/// ACK primitives are strict. Use codecs or transforms when boundary data
/// needs to be converted into a different runtime shape.
/// Numeric inference follows JSON Schema semantics: any finite number with a
/// zero fractional part is an `integer`, regardless of its Dart representation.
enum SchemaType {
string('string'),
integer('integer'),
Expand Down Expand Up @@ -38,7 +38,8 @@ enum SchemaType {
Enum() => SchemaType.enum_,
String() => SchemaType.string,
bool() => SchemaType.boolean,
int() => SchemaType.integer,
num value when value.isFinite && value.remainder(1) == 0 =>
SchemaType.integer,
num() => SchemaType.number,
_ => null,
};
Expand Down
34 changes: 20 additions & 14 deletions packages/ack/lib/src/utils/collection_utils.dart
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
/// Performs deep equality comparison between two values.
///
/// This function recursively compares:
/// - Primitives (num, String, bool, null) using standard equality
/// - JSON numbers by numeric value (`1` and `1.0` are equal)
/// - Other primitives using standard equality
/// - Lists by comparing each element in order
/// - Maps by comparing keys and values
/// - Sets by comparing elements (order-independent)
///
/// Returns `true` if the values are structurally equal, `false` otherwise.
/// Different types are never equal (e.g., List vs Map, int vs double).
/// Collection categories remain distinct (e.g., a List is not equal to a Set
/// or another Iterable implementation).
bool deepEquals(Object? a, Object? b) {
// Fast path: identical objects or both null
if (identical(a, b)) return true;

// Different types are never equal
if (a.runtimeType != b.runtimeType) return false;
if (a == null || b == null) return false;

// Handle primitives (null, bool, num, String)
// These use standard equality
if (a is! Iterable && a is! Map) {
return a == b;
}
// JSON Schema considers mathematically equal numbers equal regardless of
// whether Dart represents them as int or double. Check this before runtime
// types because dart2js does not preserve that distinction consistently.
if (a is num && b is num) return a == b;

// Handle Lists
if (a is List && b is List) {
if (a is List || b is List) {
if (a is! List || b is! List) return false;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (!deepEquals(a[i], b[i])) return false;
Expand All @@ -32,7 +33,8 @@ bool deepEquals(Object? a, Object? b) {
}

// Handle Sets (order-independent comparison)
if (a is Set && b is Set) {
if (a is Set || b is Set) {
if (a is! Set || b is! Set) return false;
if (a.length != b.length) return false;
final unmatched = b.toList();
for (final itemA in a) {
Expand All @@ -51,7 +53,8 @@ bool deepEquals(Object? a, Object? b) {
}

// Handle Maps
if (a is Map && b is Map) {
if (a is Map || b is Map) {
if (a is! Map || b is! Map) return false;
if (a.length != b.length) return false;
for (final key in a.keys) {
if (!b.containsKey(key)) return false;
Expand All @@ -62,7 +65,8 @@ bool deepEquals(Object? a, Object? b) {
}

// Handle other Iterables (not List or Set)
if (a is Iterable && b is Iterable) {
if (a is Iterable || b is Iterable) {
if (a is! Iterable || b is! Iterable) return false;
final iterA = a.iterator;
final iterB = b.iterator;
while (iterA.moveNext()) {
Expand All @@ -73,7 +77,9 @@ bool deepEquals(Object? a, Object? b) {
return !iterB.moveNext(); // Ensure b has no more elements
}

// Fallback: use standard equality
// Preserve type distinctions for non-numeric scalar values.
if (a.runtimeType != b.runtimeType) return false;

return a == b;
}

Expand Down
45 changes: 45 additions & 0 deletions packages/ack/test/helpers_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,51 @@ void main() {
});
});

group('deepEquals', () {
test('compares JSON numbers by value across Dart numeric types', () {
expect(deepEquals(1, 1.0), isTrue);
expect(deepEquals(1.0, 1), isTrue);
expect(deepEquals(1, 1.5), isFalse);
});

test('compares numeric values recursively in lists and maps', () {
final integers = {
'values': [
1,
2,
{'count': 3},
],
};
final doubles = {
'values': [
1.0,
2.0,
{'count': 3.0},
],
};

expect(deepEquals(integers, doubles), isTrue);
expect(deepEquals(doubles, integers), isTrue);
});

test('preserves collection category and list order semantics', () {
expect(deepEquals([1, 2], [2.0, 1.0]), isFalse);
expect(deepEquals([1], {1.0}), isFalse);
expect(deepEquals([1], Iterable<int>.generate(1, (index) => 1)), isFalse);
});

test('compares sets and other iterables within their categories', () {
expect(deepEquals({1, 2}, {2.0, 1.0}), isTrue);
expect(
deepEquals(
Iterable<int>.generate(2, (index) => index + 1),
Iterable<double>.generate(2, (index) => index + 1.0),
),
isTrue,
);
});
});

group('IterableExtensions', () {
group('duplicates', () {
test('should return duplicate elements', () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ void main() {
test('should validate basic double', () {
final schema = Ack.double();
expect(schema.safeParse(3.14).isOk, isTrue);
expect(schema.safeParse(42).isOk, isFalse);
expect(schema.safeParse(42).isOk, isTrue);
});

test('rejects non-finite doubles by default', () {
Expand Down
Loading
Loading