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

### Fixed

* Expose the shared `deepUnmodifiableCopy` helper for generated immutable
constructors. It snapshots nested collections without validating data or
changing opaque noncollection objects.

## 2.0.0-alpha.1

### Breaking
Expand Down
6 changes: 5 additions & 1 deletion packages/ack/lib/ack.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ export 'src/common_types.dart' show JsonMap;
export 'src/models/ack_model_adapter.dart';
export 'src/models/json_maybe.dart';
export 'src/utils/collection_utils.dart'
show deepEquals, deepHashCode, deepUnmodifiableJsonMap;
show
deepEquals,
deepHashCode,
deepUnmodifiableCopy,
deepUnmodifiableJsonMap;
// Constraints
export 'src/constraints/constraint.dart';
export 'src/constraints/duration_constraint.dart';
Expand Down
3 changes: 1 addition & 2 deletions packages/ack/lib/src/schemas/any_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ final class AnySchema extends AckSchema<Object, Object>
final validated = result.getOrNull();
if (validated == null) return result;

// Same deep, unmodifiable copy that schema defaults use.
return SchemaResult.ok(cloneDefault(validated)!);
return SchemaResult.ok(deepUnmodifiableCopy(validated)!);
}

@override
Expand Down
14 changes: 5 additions & 9 deletions packages/ack/lib/src/schemas/default_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,15 @@ final class DefaultSchema<Boundary extends Object, Runtime extends Object>
@internal
SchemaResult<Runtime> resolveDefaultWithContext(SchemaContext context) {
// Defaults are runtime values, not boundary values, so validate via the
// runtime path. `cloneDefault` returns unmodifiable collection copies when
// it can; mutable collection defaults are rejected if the inner schema
// would otherwise return the original reference.
final cloned = cloneDefault(defaultValue);
// runtime path. Reject collection defaults when neither the snapshot nor
// the inner schema can detach the value while preserving its runtime type.
final cloned = deepUnmodifiableCopy(defaultValue);
final clonedSafely = cloned is Runtime && !identical(cloned, defaultValue);
final effective = (cloned is Runtime) ? cloned : defaultValue;
final effective = cloned is Runtime ? cloned : defaultValue;
final result = inner.validateRuntimeWithContext(effective, context);
if (result.isOk &&
!clonedSafely &&
_isCollectionDefault(defaultValue) &&
(defaultValue is List || defaultValue is Map || defaultValue is Set) &&
identical(result.getOrNull(), defaultValue)) {
return SchemaResult.fail(
SchemaValidationError(
Expand Down Expand Up @@ -157,6 +156,3 @@ final class DefaultSchema<Boundary extends Object, Runtime extends Object>
int get hashCode =>
Object.hash(inner, defaultValue, isNullable, isOptional, description);
}

bool _isCollectionDefault(Object value) =>
value is List || value is Map || value is Set;
21 changes: 12 additions & 9 deletions packages/ack/lib/src/utils/collection_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,34 @@ import '../common_types.dart';
/// Nested maps, lists, and sets are copied without validating their contents.
JsonMap deepUnmodifiableJsonMap(JsonMap value) =>
Map<String, Object?>.unmodifiable(
value.map((key, item) => MapEntry(key, _deepUnmodifiableCopy(item))),
value.map((key, item) => MapEntry(key, deepUnmodifiableCopy(item))),
);

/// Deeply snapshots a schema default without exposing it from ACK's public API.
Object? cloneDefault(Object? value) => _deepUnmodifiableCopy(value);

Object? _deepUnmodifiableCopy(Object? value) {
/// Returns a detached, recursively unmodifiable snapshot of [value].
///
/// Maps retain their keys while their values are copied. Lists and sets are
/// copied recursively. Other objects, including opaque codec values, retain
/// their identity. This does not validate data; schemas still validate it at
/// parse and encode boundaries. Inputs must be acyclic.
Object? deepUnmodifiableCopy(Object? value) {
if (value is Map) {
if (value.keys.every((key) => key is String)) {
return Map<String, Object?>.unmodifiable(
value.map(
(key, item) => MapEntry(key as String, _deepUnmodifiableCopy(item)),
(key, item) => MapEntry(key as String, deepUnmodifiableCopy(item)),
),
);
}

return Map<Object?, Object?>.unmodifiable(
value.map((key, item) => MapEntry(key, _deepUnmodifiableCopy(item))),
value.map((key, item) => MapEntry(key, deepUnmodifiableCopy(item))),
);
}
if (value is List) {
return List<Object?>.unmodifiable(value.map(_deepUnmodifiableCopy));
return List<Object?>.unmodifiable(value.map(deepUnmodifiableCopy));
}
if (value is Set) {
return Set<Object?>.unmodifiable(value.map(_deepUnmodifiableCopy));
return Set<Object?>.unmodifiable(value.map(deepUnmodifiableCopy));
}

return value;
Expand Down
38 changes: 38 additions & 0 deletions packages/ack/test/utils/collection_utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,44 @@ import 'package:ack/ack.dart';
import 'package:test/test.dart';

void main() {
test('deepUnmodifiableCopy accepts null and preserves opaque leaves', () {
final opaque = Object();
final timestamp = DateTime.utc(2026, 10, 26);
expect(deepUnmodifiableCopy(null), isNull);
expect(deepUnmodifiableCopy(opaque), same(opaque));
expect(deepUnmodifiableCopy(timestamp), same(timestamp));
expect(deepUnmodifiableCopy(double.infinity), double.infinity);
});

test(
'deepUnmodifiableCopy snapshots arbitrary map keys, lists, and sets',
() {
final opaque = Object();
final nested = <Object?>[opaque, null];
final map = <Object?, Object?>{1: nested};
final set = <Object?>{map};
final source = <Object?>[map, set];
final snapshot = deepUnmodifiableCopy(source)! as List<Object?>;
nested.clear();
map[2] = 'later';
set.clear();
source.clear();

final copiedMap = snapshot.first! as Map<Object?, Object?>;
expect(copiedMap.keys, [1]);
final copiedList = copiedMap[1]! as List<Object?>;
expect(copiedList.first, same(opaque));
expect(copiedList.last, isNull);
final copiedSet = snapshot.last! as Set<Object?>;
expect(copiedSet, hasLength(1));
expect(deepEquals(copiedSet.single, copiedMap), isTrue);
expect(() => snapshot.clear(), throwsUnsupportedError);
expect(() => copiedMap.clear(), throwsUnsupportedError);
expect(() => copiedList.clear(), throwsUnsupportedError);
expect(() => copiedSet.clear(), throwsUnsupportedError);
},
);

test('deepUnmodifiableJsonMap snapshots nested maps, lists, and sets', () {
final nestedMap = <String, Object?>{'enabled': true};
final nestedList = <Object?>[nestedMap];
Expand Down
20 changes: 20 additions & 0 deletions packages/ack_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
## Unreleased

### Fixed

* Allow built-in and contextual Dart keywords such as `required` as generated
field, discriminator, and capture-member names without changing their JSON
keys. Reserved words such as `class` remain rejected. Member validation now
follows the analyzer's keyword classification rather than a fixed word list.
`dynamic` remains rejected because it shadows the type in generated JSON
methods, not because it is a reserved Dart word.
* Snapshot nested JSON collections held as `Object` or `Object?` in
schema-first model constructors and `copyWith`. Reuse Ack's shared deep
copier; constructors still do not validate data. Opaque codec runtime values
retain their identity and container types. Use `Ack.preserveBoundary(...)`
for heterogeneous JSON fields; native JSON unions are inferred only within
that explicit wire view.
* Preserve native codec boundary types when inferring `Ack.preserveBoundary`
fields, including dates, datetimes, URIs, durations, enums, explicit codec
inputs and statically resolvable nonrecursive lazy schemas.

## 2.0.0-alpha.1

### Breaking
Expand Down
20 changes: 20 additions & 0 deletions packages/ack_generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,26 @@ void main() {
}
```

### Heterogeneous JSON fields

Use `Ack.preserveBoundary(...)` for a generated field that stores wire JSON
rather than a codec's decoded runtime values. For example:

```dart
final answerValues = Ack.preserveBoundary(Ack.map(Ack.anyOf([
Ack.string(), Ack.number(), Ack.list(Ack.string()),
])));

@Schemable()
final answerSchema = Ack.object({'values': answerValues});
```

The constructor snapshots nested JSON collections without validating them.
Parsing and encoding still enforce the original schema. Opaque codec runtime
values retain their identity and runtime container types. Statically
unresolvable or opaque JSON input schemas are rejected instead of inferred.
`Ack.anyOf(...)` model roots remain unsupported.

### Custom schema resolution

A field of custom type `T` (including inside `List<T>`, `Set<T>`, `Map<String, T>`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1411,9 +1411,16 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase {
Element element, {
required String memberKind,
}) {
if (name == 'dynamic') {
throw InvalidGenerationSource(
'${element.name}.$name shadows the dynamic type used by generated '
'JSON methods.',
element: element,
);
}
if (name.startsWith('_') ||
!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(name) ||
isDartKeyword(name)) {
isDartReservedWord(name)) {
throw InvalidGenerationSource(
'${element.name}.$name is not a valid public $memberKind.',
element: element,
Expand Down
10 changes: 7 additions & 3 deletions packages/ack_generator/lib/src/analyzer/schema_first_names.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import '../utils/string_literal.dart';

/// Member names every schema-first model generates, so a field cannot use
/// them.
/// Names that conflict with generated APIs or their unqualified type references.
const ackSchemaFirstReservedMembers = {
r'$ack',
// Generated JSON signatures use the unqualified dynamic type.
'dynamic',
'parse',
'safeParse',
'fromJson',
Expand All @@ -30,9 +31,12 @@ String? ackSchemaFirstFieldNameProblem(
return "cannot start with '_' (private Dart member).";
}
if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(key) ||
isDartKeyword(key)) {
isDartReservedWord(key)) {
return 'cannot be represented as a Dart field name.';
}
if (key == 'dynamic') {
return 'shadows the dynamic type used by generated JSON methods.';
}
if (ackSchemaFirstReservedMembers.contains(key)) {
return 'conflicts with generated/Object member "$key".';
}
Expand Down
Loading
Loading