diff --git a/CHANGELOG.md b/CHANGELOG.md index fce147ea..c741069e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ## 2.0.0-alpha.2 +### Breaking + +* Restrict `Ack.preserveBoundary` to JSON-object schemas and return detached, + unmodifiable copies of the input map. Generated preserved map fields infer + JSON numbers as `num`. + ### Fixed * Reject generator aliases whose final passthrough policy makes an inherited @@ -30,6 +36,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline * Resolve same-build value-model and getter references, nullable nested Sets, generated-name collisions, and application-generic nullability correctly. * Substitute nested and recursive type bounds when resolving type-owned schemas. +* Infer native JSON `Ack.anyOf(...)` fields without a wire-view wrapper, and + validate wire views with parse semantics and no encoders. ### Documentation and release preparation diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 48ec9316..0730686d 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -382,10 +382,10 @@ constructors remain responsible for defensively copying direct inputs. **Target**: Public, constructable classes -**Generates**: A public `Schema` facade backed by a private raw -`wireSchema` and typed codec (`_Schema`), plus the `_$ClassAck` mixin -for `toJson`, `safeToJson`, `copyWith`, and deep collection-aware `==` / -`hashCode` / `toString` +**Generates**: A public `Schema` facade backed by a private typed +codec (`_Schema`) and a `wireSchema` that validates like it but +returns the JSON map, plus the `_$ClassAck` mixin for `toJson`, `safeToJson`, +`copyWith`, and deep collection-aware `==` / `hashCode` / `toString` Ack infers schema fields from constructor-backed fields (including Dart 3.12+ primary constructors). Required parameters, nullable types, optional parameters, diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 1aa65379..32530eda 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -197,7 +197,8 @@ Ack generates a library-private `_profileSchema` codec variable and a public - `ProfileSchema.toJsonSchema()` exports the boundary JSON Schema; - `ProfileSchema.toSchemaModel()` exports Ack's canonical schema model; - `ProfileSchema.schema` is the typed model codec (`_profileSchema`) used for composition; -- `ProfileSchema.wireSchema` is the raw structural `Map` schema. +- `ProfileSchema.wireSchema` validates like `schema` and returns the JSON map + instead of the model. Every instantiable `@Schemable` class and implicit sealed-union branch must be declared `final class`, initialize its fields through an unnamed generative diff --git a/example/lib/class_first_models.g.dart b/example/lib/class_first_models.g.dart index a9bbdbda..d3f7d476 100644 --- a/example/lib/class_first_models.g.dart +++ b/example/lib/class_first_models.g.dart @@ -26,7 +26,7 @@ abstract final class CatSchema { /// The typed schema, for composing [Cat] into other schemas. static AckSchema, Cat> get schema => _catSchema; - /// The structural JSON map schema behind [schema]. + /// Validates like [schema] and returns the JSON map instead of the model. static AckSchema, Map> get wireSchema => _catWireSchema; @@ -154,7 +154,7 @@ abstract final class DogSchema { /// The typed schema, for composing [Dog] into other schemas. static AckSchema, Dog> get schema => _dogSchema; - /// The structural JSON map schema behind [schema]. + /// Validates like [schema] and returns the JSON map instead of the model. static AckSchema, Map> get wireSchema => _dogWireSchema; @@ -290,7 +290,7 @@ abstract final class PetSchema { /// The typed schema, for composing [Pet] into other schemas. static AckSchema, Pet> get schema => _petSchema; - /// The structural JSON map schema behind [schema]. + /// Validates like [schema] and returns the JSON map instead of the model. static AckSchema, Map> get wireSchema => _petWireSchema; @@ -362,7 +362,7 @@ abstract final class AccountSchema { /// The typed schema, for composing [Account] into other schemas. static AckSchema, Account> get schema => _accountSchema; - /// The structural JSON map schema behind [schema]. + /// Validates like [schema] and returns the JSON map instead of the model. static AckSchema, Map> get wireSchema => _accountWireSchema; diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 734c676c..13ec2777 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,5 +1,16 @@ ## 2.0.0-alpha.2 +### Breaking + +* Restrict `Ack.preserveBoundary` to JSON-object schemas + (`AckSchema`), such as objects, maps, discriminated unions, and + lazy, default, or codec wrappers over them. `BoundarySchema` no longer takes + a type argument. Compose list wire views as `Ack.list(X.wireSchema)`, and use + a codec's input schema, such as `Ack.string().datetime()`, for a scalar. +* Return a detached, recursively unmodifiable copy of the input map from + `BoundarySchema` parse, runtime validation, and encode instead of the + caller's map instance. + ### Fixed * Accept union-owned discriminators in strict map-backed codec outputs while @@ -25,6 +36,16 @@ * Discover selected custom dialects bundled beneath their own active schema keywords, without interpreting inactive keyword payloads or annotation data as schemas. +* Return the same JSON from `BoundarySchema` for every string-keyed map + container, including `jsonDecode` output, without running an encoder. Nested + codec values keep their wire form, one-way transforms no longer reject valid + input, and wrapped defaults are not injected. +* Validate `BoundarySchema` values with parse semantics during encode and + runtime validation, so encode no longer accepts a value that parse rejects. +* Return a schema that is already a wire view from `Ack.preserveBoundary` + unchanged instead of validating its values twice. +* Export a wrapper's non-null restriction as `allOf: [{not: {type: null}}]` + only when the narrowed schema can still accept `null`. ## 2.0.0-alpha.1 diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 15774f87..2d3f592a 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -109,16 +109,21 @@ final class Ack { /// snapshot of the input. static AnySchema any() => const AnySchema(); - /// Validates with [schema] while returning the original boundary value. + /// Creates a wire view of a JSON object [schema]: it validates like + /// [schema] and returns the JSON map instead of the runtime value. /// - /// Nested codecs still run for validation, but their decoded runtime values - /// are discarded. This keeps wire data composable with a later typed parse. - static BoundarySchema preserveBoundary< - Boundary extends Object, - Runtime extends Object - >(AckSchema schema) { - return BoundarySchema(schema); - } + /// Nested models, codecs, defaults, and refinements still run for + /// validation, but their decoded runtime values are discarded and no encoder + /// runs. Parse and encode return a detached, recursively unmodifiable copy + /// of the input map. This keeps wire data composable with a later typed + /// parse. Put `withDefault` outside the wire view; a default inside it does + /// not apply. A [schema] that is already a wire view is returned unchanged. + static BoundarySchema preserveBoundary( + AckSchema schema, + ) => switch (schema) { + final BoundarySchema wire => wire, + _ => BoundarySchema(schema), + }; /// Creates a schema reference that is resolved lazily on first use. /// diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index ffe2c525..5bb3d165 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -113,7 +113,9 @@ final class _SchemaModelBuilder { var layered = base .withDescription(schema.description ?? base.description) .withNullable(schema.isNullable); - if (schema.excludesInnerNull) layered = _excludeNull(layered); + if (schema.excludesInnerNull && _admitsNull(layered)) { + layered = _excludeNull(layered); + } // `DefaultSchema.constraints` is a passthrough to `inner.constraints`, // which `_build(schema.inner)` already applied. Re-running them here // would emit duplicate warnings (e.g. datetime range under a default). @@ -617,6 +619,21 @@ final class _SchemaModelBuilder { } } +/// Whether [model] can still accept `null` after its own nullable flag. +/// +/// References, imported resources, and extension keywords are opaque here, so +/// they count as accepting `null`. +bool _admitsNull(AckSchemaModel model) { + if (model.nullable || model.extensions.isNotEmpty) return true; + return switch (model) { + AckRefSchemaModel() || AckRawSchemaModel() || AckNullSchemaModel() => true, + AckAnyOfSchemaModel(:final schemas) || + AckOneOfSchemaModel(:final schemas) => schemas.any(_admitsNull), + AckAllOfSchemaModel(:final schemas) => schemas.every(_admitsNull), + _ => false, + }; +} + AckSchemaModel _excludeNull(AckSchemaModel model) => model.withExtensions({ ...model.extensions, 'allOf': [ diff --git a/packages/ack/lib/src/schemas/boundary_schema.dart b/packages/ack/lib/src/schemas/boundary_schema.dart index 8775e33e..c3e8d779 100644 --- a/packages/ack/lib/src/schemas/boundary_schema.dart +++ b/packages/ack/lib/src/schemas/boundary_schema.dart @@ -1,18 +1,26 @@ part of 'schema.dart'; -/// Schema wrapper that validates with [inner] while preserving the original -/// boundary value. +/// Wire view of a JSON object schema: validates with [inner] and returns the +/// JSON instead of the runtime value. /// -/// This is useful when callers need a composable schema for validating wire -/// data without retaining any runtime values produced by nested codecs. +/// Parse, runtime validation, and encode all run the full parse of [inner] +/// (nested models, codecs, defaults, and refinements), never an encoder. Each +/// returns a detached, recursively unmodifiable copy of the validated input +/// map, so the result never depends on the input's container type and never +/// aliases the caller's map. Each wire view copies its whole subtree, so +/// compose wire views inside a plain `Ack.object(...)` rather than inside +/// another wire view. +/// +/// The wrapper's own nullability decides whether `null` is accepted; `null` +/// never reaches [inner]. A default inside [inner] therefore does not apply. +/// Put `withDefault` outside the wrapper instead. @immutable -final class BoundarySchema - extends AckSchema +final class BoundarySchema extends AckSchema with - FluentSchema>, - WrapperSchema> { + FluentSchema, + WrapperSchema { @override - final AckSchema inner; + final AckSchema inner; BoundarySchema( this.inner, { @@ -27,64 +35,67 @@ final class BoundarySchema description: description ?? inner.description, ); - SchemaResult _validateBoundary( + SchemaResult _validateBoundary( Object? value, SchemaContext context, ) { final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; - final result = inner.parseWithContext(value, context); + // Encode leniency (for example, omitted defaults) must not let the wire + // view accept a value that its parse would reject. + final parseContext = context.operation == SchemaOperation.parse + ? context + : context.createChild( + name: context.name, + schema: inner, + value: value, + pathSegment: '', + operation: SchemaOperation.parse, + ); + final result = inner.parseWithContext(value, parseContext); if (result.isFail) return SchemaResult.fail(result.getError()); - final Boundary boundary; - final normalizedMap = jsonMapOrNull(value); - if (value is Boundary) { - boundary = value; - } else if (normalizedMap is Boundary) { - boundary = normalizedMap as Boundary; - } else { - final runtime = result.getOrNull()!; - final encodeContext = context.createChild( - name: context.name, - schema: inner, - value: runtime, - pathSegment: '', - operation: SchemaOperation.encode, + // Copying also canonicalizes any string-keyed map to a JsonMap. + final copy = deepUnmodifiableCopy(value); + if (copy is! JsonMap) { + return SchemaResult.fail( + _buildTypeMismatch( + expectedType: schemaType, + actualValue: value, + context: context, + ), ); - final encoded = inner.encodeWithContext(runtime, encodeContext); - if (encoded.isFail) return SchemaResult.fail(encoded.getError()); - boundary = encoded.getOrNull()!; } - return applyConstraintsAndRefinements(boundary, context); + return applyConstraintsAndRefinements(copy, context); } @override @protected - SchemaResult parseWithContext( + SchemaResult parseWithContext( Object? value, SchemaContext context, ) => _validateBoundary(value, context); @override @protected - SchemaResult validateRuntimeWithContext( + SchemaResult validateRuntimeWithContext( Object? value, SchemaContext context, ) => _validateBoundary(value, context); @override @protected - SchemaResult encodeWithContext( - Boundary value, + SchemaResult encodeWithContext( + JsonMap value, SchemaContext context, ) => _validateBoundary(value, context); @override - BoundarySchema copyWithInner(AnyAckSchema newInner) { - return BoundarySchema( - newInner as AckSchema, + BoundarySchema copyWithInner(AnyAckSchema newInner) { + return BoundarySchema( + newInner as AckSchema, isNullable: isNullable, isOptional: isOptional, description: description, @@ -94,14 +105,14 @@ final class BoundarySchema } @override - BoundarySchema copyWith({ + BoundarySchema copyWith({ bool? isNullable, bool? isOptional, String? description, - List>? constraints, - List>? refinements, + List>? constraints, + List>? refinements, }) { - return BoundarySchema( + return BoundarySchema( inner, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, @@ -114,7 +125,7 @@ final class BoundarySchema @override bool operator ==(Object other) { if (identical(this, other)) return true; - if (other is! BoundarySchema) return false; + if (other is! BoundarySchema) return false; return baseFieldsEqual(other) && inner == other.inner; } diff --git a/packages/ack/test/json_schema_vocabulary_contract_test.dart b/packages/ack/test/json_schema_vocabulary_contract_test.dart index 73a19727..cd3db54b 100644 --- a/packages/ack/test/json_schema_vocabulary_contract_test.dart +++ b/packages/ack/test/json_schema_vocabulary_contract_test.dart @@ -260,9 +260,9 @@ void main() { test('fluent null policies use operators from an active dialect', () { final widened = _import({'type': 'string'}).nullable(); final narrowed = _import({}, vocabularies: []).nullable(value: false); - final wrapped = Ack.preserveBoundary( - _import({}, vocabularies: []), - ).nullable(value: false); + final wrapped = _import({}, vocabularies: []) + .codec(decode: (value) => value, encode: (value) => value) + .nullable(value: false); final defaulted = _import({ 'type': ['string', 'null'], }).withDefault('fallback'); diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index d5514d39..e2a6b9a7 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -453,7 +453,7 @@ void main() { .withDefault('ack') .toSchemaModel(); final boundary = Ack.preserveBoundary( - Ack.string().minLength(2), + Ack.object({'name': Ack.string().minLength(2)}), ).toSchemaModel(); expect(withDefault.toJsonSchema(), { @@ -461,7 +461,14 @@ void main() { 'minLength': 2, 'default': 'ack', }); - expect(boundary.toJsonSchema(), {'type': 'string', 'minLength': 2}); + expect(boundary.toJsonSchema(), { + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'minLength': 2}, + }, + 'required': ['name'], + 'additionalProperties': false, + }); expect(withDefault.warnings, isEmpty); expect(boundary.warnings, isEmpty); }); diff --git a/packages/ack/test/schemas/boundary_schema_test.dart b/packages/ack/test/schemas/boundary_schema_test.dart index 4f56c971..4ff1dae2 100644 --- a/packages/ack/test/schemas/boundary_schema_test.dart +++ b/packages/ack/test/schemas/boundary_schema_test.dart @@ -1,8 +1,57 @@ +import 'dart:convert'; + import 'package:ack/ack.dart'; import 'package:test/test.dart'; enum Status { active, archived } +const _instant = '2026-08-27T12:00:00Z'; + +/// A codec whose output object fills a default, so its runtime validation is +/// stricter under parse than under encode. +final _defaultFillingCodec = Ack.codec>( + input: Ack.string(), + decode: (_) => {}, + encode: (_) => 'x', + output: Ack.object({'n': Ack.integer().withDefault(1)}), +); + +/// Wire views over every JSON-object root kind, with one valid wire value. +final _roots = + <({String name, AckSchema schema, String json})>[ + ( + name: 'object', + schema: Ack.object({ + 'when': Ack.datetime(), + 'tags': Ack.list(Ack.string()), + }), + json: '{"when": "$_instant", "tags": ["a"]}', + ), + ( + name: 'map', + schema: Ack.map(Ack.datetime()), + json: '{"a": "$_instant"}', + ), + ( + name: 'discriminated', + schema: Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'stamp': Ack.object({ + 'kind': Ack.literal('stamp'), + 'when': Ack.datetime(), + }), + }, + ), + json: '{"kind": "stamp", "when": "$_instant"}', + ), + ( + name: 'lazy', + schema: Ack.lazy('Stamp', () => Ack.object({'when': Ack.datetime()})), + json: '{"when": "$_instant"}', + ), + ]; + void main() { group('BoundarySchema', () { final typedSchema = Ack.object({ @@ -11,15 +60,15 @@ void main() { }); final wireSchema = Ack.preserveBoundary(typedSchema); - test('validates codecs without retaining decoded values', () { - final input = { - 'createdAt': '2026-08-27T12:00:00Z', - 'statuses': ['active'], - }; + Map wireValue() => { + 'createdAt': _instant, + 'statuses': ['active'], + }; - final result = wireSchema.parse(input); + test('validates codecs without retaining decoded values', () { + final result = wireSchema.parse(wireValue()); - expect(result, same(input)); + expect(result, wireValue()); expect(result!['createdAt'], isA()); expect(result['statuses'], ['active']); }); @@ -33,29 +82,271 @@ void main() { expect(result.isFail, isTrue); }); - test('canonicalizes compatible dynamic boundary containers', () { - final input = { - 'createdAt': '2026-08-27T12:00:00Z', - 'statuses': ['active'], - }; + test('exports the wrapped boundary contract', () { + expect(wireSchema.toJsonSchema(), typedSchema.toJsonSchema()); + }); + + test('returns the same schema when it already wraps a wire view', () { + expect(Ack.preserveBoundary(wireSchema), same(wireSchema)); + }); + + group('detached results', () { + void expectDetached( + Map Function(Map input) operation, + ) { + final input = wireValue(); + final result = operation(input); + input['createdAt'] = 'not-a-date'; + (input['statuses']! as List).add('archived'); - final result = wireSchema.parse(input); + expect(result, wireValue()); + expect(() => result['createdAt'] = 'x', throwsUnsupportedError); + expect( + () => (result['statuses']! as List).add('archived'), + throwsUnsupportedError, + ); + } + + test('parse returns an unmodifiable copy of the input', () { + expectDetached((input) => wireSchema.parse(input)!); + }); + + test('encode returns an unmodifiable copy of the input', () { + expectDetached((input) => wireSchema.encode(input)!); + }); - expect(result, input); - expect(result, isA>()); + test('runtime validation returns an unmodifiable copy', () { + late Map decoded; + final codec = Ack.codec>( + input: Ack.string(), + decode: (_) => decoded = wireValue(), + encode: jsonEncode, + output: wireSchema, + ); + + final result = codec.parse('ignored')!; + decoded.clear(); + + expect(result, wireValue()); + expect(() => result['createdAt'] = 'x', throwsUnsupportedError); + }); }); - test('encodes validated boundary values unchanged', () { - final input = { - 'createdAt': '2026-08-27T12:00:00Z', - 'statuses': ['archived'], - }; + group('container independence', () { + for (final root in _roots) { + test('${root.name} roots accept every JSON map container alike', () { + final wire = Ack.preserveBoundary(root.schema); + final decoded = jsonDecode(root.json) as Map; + final inputs = [ + Map.of(decoded), + decoded, + Map.of(decoded), + ]; + + for (final input in inputs) { + final result = wire.safeParse(input); + expect(result.isOk, isTrue, reason: '${input.runtimeType}'); + expect(result.getOrNull(), decoded); + expect(wire.safeEncode(decoded).getOrNull(), decoded); + } + final invalid = jsonDecode(root.json.replaceAll(_instant, 'bad')); + expect(wire.safeParse(invalid).isFail, isTrue); + expect(wire.safeEncode(invalid as JsonMap).isFail, isTrue); + }); - expect(wireSchema.encode(input), same(input)); + test('${root.name} roots export the wrapped schema', () { + expect( + Ack.preserveBoundary(root.schema).toJsonSchema(), + root.schema.toJsonSchema(), + ); + }); + } }); - test('exports the wrapped boundary contract', () { - expect(wireSchema.toJsonSchema(), typedSchema.toJsonSchema()); + group('no encoders', () { + test('nested codec strings are returned unchanged', () { + final wire = Ack.preserveBoundary( + Ack.object({'times': Ack.list(Ack.datetime())}), + ); + final input = jsonDecode('{"times": ["$_instant"]}'); + + expect(wire.parse(input), { + 'times': [_instant], + }); + expect(wire.encode(input as JsonMap), { + 'times': [_instant], + }); + }); + + test('map values keep their wire representation', () { + final wire = Ack.preserveBoundary(Ack.map(Ack.datetime())); + final input = jsonDecode('{"a": "$_instant"}'); + + expect(wire.parse(input), {'a': _instant}); + }); + + test('one-way transforms do not change acceptance', () { + int toInt(String? value) => int.parse(value!); + final wires = [ + Ack.preserveBoundary(Ack.map(Ack.string().transform(toInt))), + Ack.preserveBoundary( + Ack.object({'a': Ack.list(Ack.string().transform(toInt))}), + ), + ]; + final inputs = [jsonDecode('{"a": "1"}'), jsonDecode('{"a": ["1"]}')]; + + for (final (index, wire) in wires.indexed) { + final input = inputs[index] as JsonMap; + expect(wire.parse(input), input); + expect(wire.encode(input), input); + } + }); + + test('defaults of the wrapped object are not injected', () { + final wire = Ack.preserveBoundary( + Ack.object({'n': Ack.integer().withDefault(1)}), + ); + + expect(wire.parse({}), isEmpty); + expect(wire.encode({}), isEmpty); + }); + }); + + group('invariants', () { + final values = [ + wireValue(), + {'createdAt': 'not-a-date', 'statuses': []}, + { + 'createdAt': _instant, + 'statuses': ['unknown'], + }, + {'statuses': []}, + {...wireValue(), 'extra': true}, + 'not a map', + [], + ]; + + test('parse agrees with the wrapped schema and keeps the input', () { + for (final value in values) { + final wire = wireSchema.safeParse(value); + expect( + wire.isOk, + typedSchema.safeParse(value).isOk, + reason: '$value', + ); + if (wire.isOk) expect(wire.getOrNull(), value); + } + }); + + test('parse and encode accept the same values', () { + final holder = Ack.object({'wire': wireSchema}); + for (final value in values.whereType()) { + final parsed = wireSchema.safeParse(value).isOk; + expect(wireSchema.safeEncode(value).isOk, parsed, reason: '$value'); + expect(holder.safeParse({'wire': value}).isOk, parsed); + expect(holder.safeEncode({'wire': value}).isOk, parsed); + } + }); + + test('encode does not accept what parse rejects', () { + final typed = Ack.object({'p': _defaultFillingCodec}); + final wire = Ack.preserveBoundary(typed); + final holder = Ack.object({'wire': wire}); + const value = {'p': 'x'}; + + expect(typed.safeParse(value).isFail, isTrue); + expect(wire.safeParse(value).isFail, isTrue); + expect(wire.safeEncode(value).isFail, isTrue); + expect(holder.safeParse({'wire': value}).isFail, isTrue); + expect(holder.safeEncode({'wire': value}).isFail, isTrue); + }); + + test('encode reports parse-side errors at the wrapper path', () { + final holder = Ack.object({'wire': wireSchema}); + final invalid = {...wireValue(), 'createdAt': 'not-a-date'}; + + final parseError = holder.safeParse({'wire': invalid}).getError(); + final encodeError = holder.safeEncode({'wire': invalid}).getError(); + + for (final error in [parseError, encodeError]) { + final nested = (error as SchemaNestedError).errors.single; + expect(nested, isA()); + expect(nested.path, '#/wire'); + expect( + (nested as SchemaNestedError).errors.single.path, + '#/wire/createdAt', + ); + } + }); + + test('encoding a parsed value returns the same JSON', () { + final parsed = wireSchema.parse(wireValue()); + + expect(wireSchema.encode(parsed), wireValue()); + }); + + for (final (name, wire) in [ + ('a non-null wrapper', Ack.preserveBoundary(typedSchema)), + ('a widened wrapper', Ack.preserveBoundary(typedSchema).nullable()), + ('a nullable inner', Ack.preserveBoundary(typedSchema.nullable())), + ( + 'a narrowed nullable inner', + Ack.preserveBoundary(typedSchema.nullable()).nullable(value: false), + ), + ]) { + test('$name agrees on null in every operation and export', () { + final acceptsNull = wire.safeParse(null).isOk; + + expect(wire.safeEncode(null).isOk, acceptsNull); + expect( + Ack.object({'k': wire}).safeParse({'k': null}).isOk, + acceptsNull, + ); + expect( + Ack.object({'k': wire}).safeEncode({'k': null}).isOk, + acceptsNull, + ); + expect( + Ack.fromJsonSchema(wire.toJsonSchema()).safeParse(null).isOk, + acceptsNull, + ); + }); + } + }); + + group('defaults', () { + final inner = Ack.object({'k': Ack.string()}); + + test('a default inside the wrapper does not apply at the root', () { + final wire = Ack.preserveBoundary(inner.withDefault({'k': 'x'})); + + expect(wire.isNullable, isFalse); + expect(wire.safeParse(null).isFail, isTrue); + expect(wire.safeEncode(null).isFail, isTrue); + expect(wire.toJsonSchema(), containsPair('default', {'k': 'x'})); + }); + + test('a default inside the wrapper keeps a property required', () { + final holder = Ack.object({ + 'p': Ack.preserveBoundary(inner.withDefault({'k': 'x'})), + }); + + expect(holder.safeParse({}).isFail, isTrue); + expect(holder.safeParse({'p': null}).isFail, isTrue); + expect(holder.safeEncode({}).isFail, isTrue); + expect(holder.toJsonSchema()['required'], ['p']); + }); + + test('a default outside the wrapper applies', () { + final holder = Ack.object({ + 'p': Ack.preserveBoundary(inner).withDefault({'k': 'x'}), + }); + + expect(holder.parse({}), { + 'p': {'k': 'x'}, + }); + expect(holder.toJsonSchema()['required'], isNull); + }); }); }); } diff --git a/packages/ack/test/schemas/effective_nullability_test.dart b/packages/ack/test/schemas/effective_nullability_test.dart index 3e9864c9..7400195f 100644 --- a/packages/ack/test/schemas/effective_nullability_test.dart +++ b/packages/ack/test/schemas/effective_nullability_test.dart @@ -134,20 +134,25 @@ void main() { }); group('effective nullability — BoundarySchema', () { - test('preserveBoundary over a nullable union encodes null at the root', () { - final schema = Ack.preserveBoundary(_nullableUnion()); - - final encoded = schema.safeEncode(null); - expect( - encoded.isOk, - isTrue, - reason: encoded.isFail ? encoded.getError().toString() : null, - ); - expect(encoded.getOrNull(), isNull); - }); - - test('preserveBoundary over a nullable union encodes null as a field', () { - final schema = Ack.object({'v': Ack.preserveBoundary(_nullableUnion())}); + test( + 'preserveBoundary over a nullable object encodes null at the root', + () { + final schema = Ack.preserveBoundary(Ack.object({}).nullable()); + + final encoded = schema.safeEncode(null); + expect( + encoded.isOk, + isTrue, + reason: encoded.isFail ? encoded.getError().toString() : null, + ); + expect(encoded.getOrNull(), isNull); + }, + ); + + test('preserveBoundary over a nullable object encodes null as a field', () { + final schema = Ack.object({ + 'v': Ack.preserveBoundary(Ack.object({}).nullable()), + }); final encoded = schema.safeEncode({'v': null}); expect( @@ -296,9 +301,9 @@ void main() { ); }); - test('Ack.list rejects preserveBoundary over a nullable union', () { + test('Ack.list rejects preserveBoundary over a nullable object', () { expect( - () => Ack.list(Ack.preserveBoundary(_nullableUnion())), + () => Ack.list(Ack.preserveBoundary(Ack.object({}).nullable())), throwsA( isA().having( (e) => e.message, diff --git a/packages/ack/test/schemas/nullability_export_contract_test.dart b/packages/ack/test/schemas/nullability_export_contract_test.dart index 91010abc..177e2358 100644 --- a/packages/ack/test/schemas/nullability_export_contract_test.dart +++ b/packages/ack/test/schemas/nullability_export_contract_test.dart @@ -6,6 +6,9 @@ AnyOfSchema _nullableUnion() => Ack.anyOf([ Ack.boolean(), ]); +final _record = Ack.object({'name': Ack.string()}); +const _recordSample = {'name': 'ok'}; + AckSchema _nullableImport() => Ack.fromJsonSchema({ 'anyOf': [ {'type': 'string'}, @@ -18,30 +21,27 @@ AckSchema _nullableImport() => Ack.fromJsonSchema({ void main() { final cases = - <({String name, AckSchema schema, bool acceptsNull})>[ + < + ({ + String name, + AckSchema schema, + bool acceptsNull, + Object sample, + }) + >[ ( - name: 'boundary widens a non-null primitive', - schema: Ack.preserveBoundary(Ack.string()).nullable(), + name: 'boundary widens a non-null object', + schema: Ack.preserveBoundary(_record).nullable(), acceptsNull: true, + sample: _recordSample, ), ( - name: 'boundary narrows a nullable primitive', - schema: Ack.preserveBoundary( - Ack.string().nullable(), - ).nullable(value: false), - acceptsNull: false, - ), - ( - name: 'boundary narrows a nested nullable union', - schema: Ack.preserveBoundary(_nullableUnion()).nullable(value: false), - acceptsNull: false, - ), - ( - name: 'boundary narrows imported source nullability', + name: 'boundary narrows a nullable object', schema: Ack.preserveBoundary( - _nullableImport(), + _record.nullable(), ).nullable(value: false), acceptsNull: false, + sample: _recordSample, ), ( name: 'codec narrows a nullable primitive', @@ -50,6 +50,7 @@ void main() { .codec(decode: (value) => value, encode: (value) => value) .nullable(value: false), acceptsNull: false, + sample: 'ok', ), ( name: 'codec narrows a nested nullable union', @@ -57,6 +58,7 @@ void main() { .codec(decode: (value) => value, encode: (value) => value) .nullable(value: false), acceptsNull: false, + sample: 'ok', ), ( name: 'codec narrows imported source nullability', @@ -64,11 +66,13 @@ void main() { .codec(decode: (value) => value, encode: (value) => value) .nullable(value: false), acceptsNull: false, + sample: 'ok', ), ( name: 'lazy uses its non-null flag over a nullable primitive', schema: Ack.lazy('NullableString', () => Ack.string().nullable()), acceptsNull: false, + sample: 'ok', ), ( name: 'lazy narrows a nested nullable union', @@ -77,16 +81,19 @@ void main() { _nullableUnion, ).nullable().nullable(value: false), acceptsNull: false, + sample: 'ok', ), ( name: 'lazy uses its non-null flag over imported source nullability', schema: Ack.lazy('NullableImport', _nullableImport), acceptsNull: false, + sample: 'ok', ), ( name: 'lazy widens a non-null target', schema: Ack.lazy('NonNullString', Ack.string).nullable(), acceptsNull: true, + sample: 'ok', ), ]; @@ -100,8 +107,8 @@ void main() { expect(schema.safeEncode(null).isOk, testCase.acceptsNull); expect(nested.safeParse({'value': null}).isOk, testCase.acceptsNull); expect(nested.safeEncode({'value': null}).isOk, testCase.acceptsNull); - expect(schema.parse('ok'), 'ok'); - expect(schema.encode('ok'), 'ok'); + expect(schema.parse(testCase.sample), testCase.sample); + expect(schema.encode(testCase.sample), testCase.sample); }); for (final draft7 in [false, true]) { @@ -119,8 +126,11 @@ void main() { restoredNested.safeParse({'value': null}).isOk, testCase.acceptsNull, ); - expect(restored.safeParse('ok').isOk, isTrue); - expect(restoredNested.safeParse({'value': 'ok'}).isOk, isTrue); + expect(restored.safeParse(testCase.sample).isOk, isTrue); + expect( + restoredNested.safeParse({'value': testCase.sample}).isOk, + isTrue, + ); }, ); } @@ -128,9 +138,9 @@ void main() { } test('a non-null restriction keeps imported source allOf assertions', () { - final schema = Ack.preserveBoundary( - _nullableImport(), - ).nullable(value: false); + final schema = _nullableImport() + .codec(decode: (value) => value, encode: (value) => value) + .nullable(value: false); for (final exported in [ schema.toJsonSchema(), schema.toJsonSchemaDraft7(), @@ -142,6 +152,63 @@ void main() { } }); + group('non-null restrictions add allOf only while null stays reachable', () { + Object same(Object value) => value; + final cases = <(String, AckSchema, bool)>[ + ( + 'a codec over a nullable primitive', + Ack.string() + .nullable() + .codec(decode: (value) => value, encode: (value) => value) + .nullable(value: false), + false, + ), + ( + 'a boundary over a nullable object', + Ack.preserveBoundary(_record.nullable()).nullable(value: false), + false, + ), + ( + 'a codec over a nested nullable union', + _nullableUnion() + .codec(decode: same, encode: same) + .nullable(value: false), + true, + ), + ( + 'a codec over imported source nullability', + _nullableImport() + .codec(decode: same, encode: same) + .nullable(value: false), + true, + ), + ( + 'a codec over a nullable lazy reference', + Ack.lazy('NullableUnion', _nullableUnion) + .nullable() + .codec(decode: same, encode: same) + .nullable(value: false), + true, + ), + ]; + + for (final (name, schema, needsAllOf) in cases) { + test(name, () { + for (final exported in [ + schema.toJsonSchema(), + schema.toJsonSchemaDraft7(), + ]) { + expect( + exported.containsKey('allOf'), + needsAllOf, + reason: '$exported', + ); + expect(Ack.fromJsonSchema(exported).safeParse(null).isFail, isTrue); + } + }); + } + }); + group('default null semantics', () { test('nullable defaults still substitute only on parse', () { final schema = Ack.string().nullable().withDefault('fallback'); @@ -168,9 +235,9 @@ void main() { 'a default outside a narrowed boundary still substitutes on parse', () { final schema = Ack.preserveBoundary( - _nullableUnion(), - ).nullable(value: false).withDefault('fallback'); - expect(schema.parse(null), 'fallback'); + _record.nullable(), + ).nullable(value: false).withDefault(_recordSample); + expect(schema.parse(null), _recordSample); expect(schema.safeEncode(null).isFail, isTrue); expect( Ack.fromJsonSchema(schema.toJsonSchema()).safeParse(null).isFail, diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 21ab80dc..729d646e 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,5 +1,11 @@ ## 2.0.0-alpha.2 +### Breaking + +* Infer JSON numbers in `Ack.preserveBoundary(Ack.map(...))` field values, + including duration milliseconds, as `num`. A wire value keeps each number as + decoded, and numeric schemas accept both `1` and `1.0`. + ### Changed * Require `ack: ^2.0.0-alpha.2` so consumers receive the corrected runtime @@ -11,17 +17,16 @@ generation, rejecting collisions with the additional-properties member. * Reject unproven object-shape changes inside preserved boundaries, including aliases and fluent codecs, rather than copying opaque values as JSON. -* 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. +* Preserve native codec boundary types when inferring the values of + `Ack.preserveBoundary(Ack.map(...))` fields, including dates, datetimes, + URIs, enums, explicit codec inputs and statically resolvable nonrecursive + lazy schemas. * Resolve preserved JSON boundary nullability through the same effective metadata as ordinary fields, including aliases and explicit flag overrides. * 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. + retain their identity and container types. * 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 @@ -30,6 +35,17 @@ methods, not because it is a reserved Dart word. * Preserve schema flags and object policies when `copyWith` no-op `null` arguments are parenthesized. +* Infer `Ack.anyOf(...)` fields whose branches are all native JSON as JSON + `Object` values, including list items and map values, without an + `Ack.preserveBoundary(...)` wrapper. A branch may itself be an explicit + `Ack.preserveBoundary(...)` wire view. +* Reject an `Ack.anyOf(...)` field with a codec, `Ack.instance()`, model + schema, or nullable branch, naming the field and the offending branch. + `Ack.any()` and `Ack.anyOf(...)` model roots remain unsupported, including + through type-erased aliases. Branches are proven from their static schema + types and statically resolvable initializers. A custom method whose declared + runtime type is `Object` or a JSON collection but that returns a codec cannot + be detected. * Preserve schema-first presence, nullability, and default-wrapper behavior through aliases and explicit constant flag overrides. Apply the outermost strict/passthrough policy to object models and aliases. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index cb7ccbd3..6db21c35 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -175,23 +175,41 @@ 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: +A field whose values are native JSON needs no wrapper. `Ack.any()` and an +`Ack.anyOf(...)` whose branches are all native JSON (strings, numbers, +booleans, `Ack.any()`, inline objects, and lists, maps, unions, or lazy schemas +of those) become `Object` fields, including as list items and map values: ```dart -final answerValues = Ack.preserveBoundary(Ack.map(Ack.anyOf([ +final answerValue = Ack.anyOf([ Ack.string(), Ack.number(), Ack.list(Ack.string()), -]))); +]); @Schemable() -final answerSchema = Ack.object({'values': answerValues}); +final answerSchema = Ack.object({'values': Ack.map(answerValue)}); +// Generates `final Map values;`. ``` +A union branch must not decode its value. Codec branches such as +`Ack.datetime()`, `Ack.enumValues(...)`, and `.codec(...)`, `Ack.instance()`, +model schemas, and nullable branches are rejected; mark the union itself +`.nullable()` instead. Use `Ack.discriminated(...)` for a union of models. +`Ack.anyOf(...)` model roots remain unsupported. Branches are proven from their +static schema types and statically resolvable initializers. A custom method +whose declared runtime type is `Object` or a JSON collection but that returns a +codec cannot be detected. + +`Ack.preserveBoundary(...)` stores the JSON of a codec-bearing object, map, or +discriminated union. It validates with the full typed schema and keeps the +wire value, so a field over a preserved object is a `Map`, and +a field over a preserved `Ack.map(...)` keeps each value's wire type (a JSON +number is a `num`). A preserved object can also be a union branch. Put +`withDefault(...)` outside the wire view; a default inside it does not apply. + 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. +unresolvable or opaque JSON schemas are rejected instead of inferred. ### Custom schema resolution @@ -332,7 +350,8 @@ means a JSON-safe value, not an arbitrary Dart instance: For a hand-written `Account`, class-first generation exposes an `AccountSchema` facade backed by a private `_accountSchema` codec. The facade provides parsing, safe parsing, encoding, JSON Schema/schema-model export, -typed `schema`, and raw `wireSchema`. Instantiable models apply the generated +typed `schema`, and a `wireSchema` that validates like `schema` but returns the +JSON map. Instantiable models apply the generated `_$AccountAck` mixin, which supplies `toJson`, `safeToJson`, a typed `copyWith`, and deep collection-aware equality. Add `static final schema = _accountSchema;` and `static final fromJson = AccountSchema.fromJson;` when the class should diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index bfe0cddc..392673a0 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -1,5 +1,5 @@ import 'package:ack/ack.dart' - show AnyOfSchema, AnySchema, InstanceSchema, MapSchema; + show AnyOfSchema, CodecSchema, EnumSchema, InstanceSchema; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; @@ -70,10 +70,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { static const _reservedMembers = ackSchemaFirstReservedMembers; - static const _anySchemaChecker = TypeChecker.typeNamed( - AnySchema, - inPackage: 'ack', - ); static const _anyOfSchemaChecker = TypeChecker.typeNamed( AnyOfSchema, inPackage: 'ack', @@ -82,10 +78,11 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { InstanceSchema, inPackage: 'ack', ); - static const _mapSchemaChecker = TypeChecker.typeNamed( - MapSchema, - inPackage: 'ack', - ); + static const _decodingSchemaChecker = TypeChecker.any([ + TypeChecker.typeNamed(CodecSchema, inPackage: 'ack'), + TypeChecker.typeNamed(EnumSchema, inPackage: 'ack'), + _instanceSchemaChecker, + ]); final Map _declarationsByElement = {}; final Map _declarationsById = {}; @@ -252,22 +249,8 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { 'codec', 'lazy', }; - // Ack.any() and Ack.map() are valid fields but never model roots, - // whether written inline or reached through an unannotated variable. - final rootType = declaration.expression.staticType; - if (rootType != null && - _anySchemaChecker.isAssignableFromType(rootType)) { - _rejectUnsupportedRoot('any', path, declaration.element); - } - if (rootType != null && - _mapSchemaChecker.isAssignableFromType(rootType)) { - throw InvalidGenerationSource( - '$path uses an Ack.map() root. Generated models need an object or ' - 'value root.', - element: declaration.element, - todo: 'Use Ack.map(...) as a field of an Ack.object(...) model.', - ); - } + // Aliased Ack.map() roots are rejected while following the alias. + if (baseName == 'map') _rejectMapRoot(path, declaration.element); if (baseName != null && !supportedValueRoots.contains(baseName)) { _rejectUnsupportedRoot(baseName, path, declaration.element); } @@ -291,6 +274,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { path: path, context: declaration.element, throughLazy: false, + root: true, ), description: descriptionFromExpression(expression), ); @@ -546,6 +530,8 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { int depth = 0, String? followedName, bool collectionElement = false, + // A model root, or an unannotated alias that a root follows. + bool root = false, }) async { final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); @@ -589,7 +575,8 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { element: context, ); case 'any': - // A JSON-safe value field. Ack.any() roots are rejected in _resolve. + // A JSON-safe value field, never a model root. + if (root) _rejectUnsupportedRoot('any', path, context); return const AckScalarTypeRef('Object', isJsonValue: true); case 'preserveBoundary': final arguments = argumentExpressions(chain.base!.argumentList); @@ -598,9 +585,19 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { } return _jsonBoundaryRef(expression, path: path, context: context); case 'anyOf': + // A JSON value field when every branch is native JSON, like Ack.any(), + // never a model root. + if (root) _rejectUnsupportedRoot('anyOf', path, context); + return _jsonBoundaryRef( + expression, + path: path, + context: context, + nativeUnionPath: path, + ); case 'instance': _rejectUnsupportedRoot(baseName, path, context); case 'map': + if (root) _rejectMapRoot(path, context); final arguments = argumentExpressions(chain.base!.argumentList); if (arguments.isEmpty) { throw InvalidGenerationSource( @@ -706,6 +703,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { throughLazy: throughLazy, visited: visited, depth: depth, + root: root, ); if (followed != null) { if (followed is AckNullableTypeRef && @@ -735,6 +733,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { required bool throughLazy, required Set visited, required int depth, + required bool root, }) async { final element = referencedElement(reference); if (element == null) return null; @@ -776,17 +775,26 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { visited: {...visited, canonical}, depth: depth + 1, followedName: element.name, + root: root, ); } - /// Infers an explicitly preserved JSON boundary without inferring opaque - /// codec runtime values. Reuses the normal source/reference machinery. + /// Infers a field that holds JSON without inferring opaque codec runtime + /// values. Reuses the normal source/reference machinery. + /// + /// By default this proves an explicit `Ack.preserveBoundary(...)` wire + /// value, so codecs count by their JSON input side. When [nativeUnionPath] + /// is set, it instead proves the runtime values of the `Ack.anyOf(...)` + /// field at that path: codecs, codec factories and model schemas are + /// rejected, and an explicit `Ack.preserveBoundary(...)` branch is proven as + /// a wire value again. Future _jsonBoundaryRef( Expression expression, { required String path, required Element context, Set visited = const {}, int depth = 0, + String? nativeUnionPath, }) async { if (depth >= ModelGraphBuilderBase.maxReferenceDepth) { throw InvalidGenerationSource( @@ -802,6 +810,9 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); + if (nativeUnionPath != null && _decodesInChain(expression)) { + return _unsupportedJsonBoundary(path, context, nativeUnionPath); + } final base = chain.base; AckInferRef result; switch (base?.methodName.name) { @@ -819,7 +830,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { 'enumValues': // Native factories own their boundary types; decoded codec types do // not belong in a preserved wire field. - result = _schemaTypes(base!, path, context).boundary; + result = _jsonScalarType(base!, path, context); case 'any': result = const AckScalarTypeRef('Object', isJsonValue: true); case 'codec': @@ -831,7 +842,9 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { break; } } - if (input == null) return _unsupportedJsonBoundary(path, context); + if (input == null) { + return _unsupportedJsonBoundary(path, context, nativeUnionPath); + } result = await _jsonBoundaryRef( input, path: '$path.input', @@ -842,23 +855,26 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { case 'lazy': final arguments = argumentExpressions(base!.argumentList); if (arguments.length < 2 || arguments[1] is! FunctionExpression) { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } final target = functionBodyExpression( (arguments[1] as FunctionExpression).body, ); - if (target == null) return _unsupportedJsonBoundary(path, context); + if (target == null) { + return _unsupportedJsonBoundary(path, context, nativeUnionPath); + } result = await _jsonBoundaryRef( target, path: '$path.lazy', context: context, visited: visited, depth: depth + 1, + nativeUnionPath: nativeUnionPath, ); case 'list' || 'map' || 'preserveBoundary': final arguments = argumentExpressions(base!.argumentList); if (arguments.length != 1) { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } final inner = await _jsonBoundaryRef( arguments.single, @@ -866,10 +882,14 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { context: context, visited: visited, depth: depth + 1, + // An explicit wire view stores JSON even inside a native union. + nativeUnionPath: base.methodName.name == 'preserveBoundary' + ? null + : nativeUnionPath, ); if (base.methodName.name == 'list') { if (inner is AckNullableTypeRef) { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } result = AckListTypeRef(inner); } else if (base.methodName.name == 'map') { @@ -882,7 +902,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { if (arguments.firstOrNull case SetOrMapLiteral(:final elements)) { for (final entry in elements) { if (entry is! MapLiteralEntry) { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } await _jsonBoundaryRef( entry.value, @@ -890,39 +910,50 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { context: context, visited: visited, depth: depth + 1, + nativeUnionPath: nativeUnionPath, ); } result = const AckMapTypeRef( AckNullableTypeRef(AckScalarTypeRef('Object', isJsonValue: true)), ); } else { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } case 'anyOf': final arguments = argumentExpressions(base!.argumentList); if (arguments.firstOrNull case ListLiteral( :final elements, ) when elements.isNotEmpty) { - for (final branch in elements) { + for (final (index, branch) in elements.indexed) { + final branchPath = '$path.anyOf[$index]'; if (branch is! Expression) { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary( + branchPath, + context, + nativeUnionPath, + ); } final inner = await _jsonBoundaryRef( branch, - path: '$path.anyOf', + path: branchPath, context: context, visited: visited, depth: depth + 1, + nativeUnionPath: nativeUnionPath, ); // Nullable alternatives require an explicit union .nullable() so // the field's storage/constructor nullability remains unambiguous. if (inner is AckNullableTypeRef) { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary( + branchPath, + context, + nativeUnionPath, + ); } } result = const AckScalarTypeRef('Object', isJsonValue: true); } else { - return _unsupportedJsonBoundary(path, context); + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } default: final reference = chain.reference; @@ -930,24 +961,29 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { if (element == null || (element is! TopLevelVariableElement && element is! GetterElement) || - visited.contains(element.baseElement)) { - return _unsupportedJsonBoundary(path, context); + visited.contains(element.baseElement) || + // A model schema's runtime value is its model, not JSON. + (nativeUnionPath != null && hasSchemable(element))) { + return _unsupportedJsonBoundary(path, context, nativeUnionPath); } final target = await resolveReferencedTopLevelExpression( element, visited: visited, ); - if (target == null) return _unsupportedJsonBoundary(path, context); + if (target == null) { + return _unsupportedJsonBoundary(path, context, nativeUnionPath); + } result = await _jsonBoundaryRef( target.expression, path: '$path(→ ${element.name})', context: context, visited: {...visited, target.declaration.baseElement}, depth: depth + 1, + nativeUnionPath: nativeUnionPath, ); } - // Codec outputs are intentionally ignored here: preserveBoundary stores - // and validates the original input-side JSON, not decoded runtime objects. + // Codec outputs are intentionally ignored here: a wire value stores and + // validates the original input-side JSON, not decoded runtime objects. final metadata = await _schemaMetadata( expression, path: path, @@ -959,13 +995,79 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { return metadata.nullable ? AckNullableTypeRef(nonNullable) : nonNullable; } - Never _unsupportedJsonBoundary(String path, Element context) => - throw InvalidGenerationSource( - '$path cannot prove a native JSON boundary. Use Ack.preserveBoundary ' - 'with statically resolvable JSON schemas; opaque instance/codec input ' - 'types and nullable union alternatives are not inferred.', - element: context, - ); + /// Whether a schema in [expression]'s fluent chain decodes JSON into a + /// runtime object, judged by static type so that extension methods and + /// wrappers such as `withDefault` cannot hide a codec. + /// + /// A node decodes when it is a codec, enum, or instance schema, or when its + /// `AckSchema` view declares a non-JSON runtime type. + bool _decodesInChain(Expression expression) { + for ( + Expression? node = expression.unParenthesized; + node != null; + node = node is MethodInvocation ? node.target?.unParenthesized : null + ) { + final type = node.staticType; + if (type == null) continue; + if (_decodingSchemaChecker.isAssignableFromType(type)) return true; + final runtime = ackSchemaRuntimeType(type); + if (runtime != null && !_isJsonType(runtime)) return true; + } + return false; + } + + /// Whether [type] holds only JSON: a nullable or non-null `String`, `int`, + /// `double`, `num`, `bool` or `Object`, `dynamic`, or a `List` or + /// string-keyed `Map` of JSON. + bool _isJsonType(DartType type) => switch (type) { + DynamicType() => true, + InterfaceType(isDartCoreList: true, :final typeArguments) => _isJsonType( + typeArguments.single, + ), + InterfaceType(isDartCoreMap: true, :final typeArguments) => + typeArguments.first.isDartCoreString && _isJsonType(typeArguments.last), + InterfaceType() => + type.isDartCoreString || + type.isDartCoreInt || + type.isDartCoreDouble || + type.isDartCoreNum || + type.isDartCoreBool || + type.isDartCoreObject, + _ => false, + }; + + /// The Dart type of a JSON scalar that a wire value keeps as decoded. + /// + /// Numeric schemas accept both `1` and `1.0`, and a wire value is never + /// normalized, so a JSON number stays a `num`. + AckInferRef _jsonScalarType( + MethodInvocation base, + String path, + Element context, + ) => switch (_schemaTypes(base, path, context).boundary) { + AckScalarTypeRef(dartType: 'int' || 'double') => const AckScalarTypeRef( + 'num', + ), + final boundary => boundary, + }; + + Never _unsupportedJsonBoundary( + String path, + Element context, + String? nativeUnionPath, + ) => throw InvalidGenerationSource( + nativeUnionPath == null + ? '$path cannot prove a native JSON boundary. Use ' + 'Ack.preserveBoundary with statically resolvable JSON schemas; ' + 'opaque instance/codec input types and nullable union ' + 'alternatives are not inferred.' + : '$nativeUnionPath Ack.anyOf() branch $path is not native JSON: ' + 'it must be a statically resolvable, non-null JSON schema ' + 'without codecs or models. Wrap a codec-bearing object branch in ' + 'Ack.preserveBoundary(...), use Ack.discriminated(...) for a ' + 'model union, and mark the union itself .nullable().', + element: context, + ); Future _lazyType( MethodInvocation invocation, @@ -1391,6 +1493,14 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } + Never _rejectMapRoot(String path, Element element) => + throw InvalidGenerationSource( + '$path uses an Ack.map() root. Generated models need an object or ' + 'value root.', + element: element, + todo: 'Use Ack.map(...) as a field of an Ack.object(...) model.', + ); + Future _rejectObjectShapeChanges( Expression expression, { required String path, diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index 3f00fcb4..debb710b 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -210,7 +210,7 @@ abstract final class ${metadata.facadeName} { static ${_ack('AckSchema')}, $className> get schema => ${metadata.backingName}; - /// The structural JSON map schema behind [schema]. + /// Validates like [schema] and returns the JSON map instead of the model. static ${_ack('AckSchema')}, Map> get wireSchema => $wireName; diff --git a/packages/ack_generator/test/integration/ack_infer_graph_test.dart b/packages/ack_generator/test/integration/ack_infer_graph_test.dart index 8ccd1d37..ee9e4404 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -136,13 +136,19 @@ final policySchema = Ack.object({}, additionalProperties: true).copyWith( } test('preserved JSON boundaries honor an outer nullable clear', () async { - await _expectOutput(''' + await _expectOutput( + ''' $_head @Schemable() final boundaryFlagsSchema = Ack.object({ - 'value': Ack.preserveBoundary(Ack.string().nullable()).nullable(value: false), + 'value': Ack.preserveBoundary(Ack.map(Ack.string()).nullable()).nullable(value: false), }); -''', allOf([contains('final String value;'), contains('required this.value')])); +''', + allOf([ + contains('final Map value;'), + contains('required Map value'), + ]), + ); }); test('preserved JSON boundaries use effective nested flags', () async { @@ -156,7 +162,7 @@ final boundaryFlagsSchema = Ack.object({ 'copiedValues': Ack.preserveBoundary(Ack.map(Ack.string().copyWith(isNullable: true))), 'configuredValues': Ack.preserveBoundary(Ack.map(Ack.string().withRuntimeConfig(isNullable: true))), 'clearedValues': Ack.preserveBoundary(Ack.map(nullableAlias.nullable(value: false))), - 'items': Ack.preserveBoundary(Ack.list(nullableAlias.nullable(value: false))), + 'items': Ack.preserveBoundary(Ack.map(Ack.list(nullableAlias.nullable(value: false)))), 'outerMap': Ack.preserveBoundary(Ack.map(nullableValue).nullable()).nullable(value: false), }); ''', @@ -164,7 +170,7 @@ final boundaryFlagsSchema = Ack.object({ contains('final Map copiedValues;'), contains('final Map configuredValues;'), contains('final Map clearedValues;'), - contains('final List items;'), + contains('final Map> items;'), contains('final Map outerMap;'), ]), ); @@ -174,20 +180,22 @@ final boundaryFlagsSchema = Ack.object({ await _expectOutput( ''' $_head -final nullableText = Ack.preserveBoundary(Ack.string().nullable()); -final textAlias = nullableText; +final nullableProfile = Ack.preserveBoundary( + Ack.object({'name': Ack.string()}).nullable(), +); +final profileAlias = nullableProfile; final nullableMap = Ack.preserveBoundary(Ack.map(Ack.string().nullable()).nullable()); final mapAlias = nullableMap; @Schemable() final boundaryAliasesSchema = Ack.object({ - 'text': textAlias.nullable(value: false), + 'profile': profileAlias.nullable(value: false), 'values': mapAlias.nullable(value: false), 'groups': Ack.list(mapAlias.nullable(value: false)), 'byName': Ack.map(mapAlias.nullable(value: false)), }); ''', allOf([ - contains('final String text;'), + contains('final Map profile;'), contains('final Map values;'), contains('final List> groups;'), contains('final Map> byName;'), @@ -201,7 +209,7 @@ final boundaryAliasesSchema = Ack.object({ $_head @Schemable() final boundaryFlagsSchema = Ack.object({ - 'items': Ack.preserveBoundary(Ack.list(Ack.string().nullable())), + 'items': Ack.preserveBoundary(Ack.map(Ack.list(Ack.string().nullable()))), }); ''', ['boundaryFlagsSchema.items', 'cannot prove a native JSON boundary'], @@ -558,6 +566,138 @@ final payloadSchema = ${unsupported.key}; }); } + test('rejects a native union root reached through an alias', () async { + await _expectFailure( + ''' +$_head +final payloadUnion = Ack.anyOf([Ack.string(), Ack.integer()]); +@Schemable() +final payloadSchema = payloadUnion; +''', + ['payloadSchema', 'Ack.anyOf()'], + ); + }); + + for (final erased in { + 'Ack.anyOf([Ack.string(), Ack.integer()])': 'Ack.anyOf()', + 'Ack.any()': 'Ack.any()', + }.entries) { + test('rejects a ${erased.value} root behind a type-erased alias', () async { + await _expectFailure( + ''' +$_head +final AckSchema payloadValue = ${erased.key}; +@Schemable() +final payloadSchema = payloadValue; +''', + ['payloadSchema', erased.value], + ); + }); + } + + test('infers native JSON union fields as JSON values', () async { + await _expectOutput( + ''' +$_head +final nonEmpty = Ack.string().minLength(1); +final option = Ack.anyOf([ + nonEmpty, + Ack.object({'label': Ack.string(), 'value': nonEmpty}), +]); +final answer = Ack.anyOf([Ack.string(), Ack.number(), Ack.list(Ack.string())]); + +@Schemable() +final interactionSchema = Ack.object({ + 'value': Ack.anyOf([Ack.string(), Ack.integer(), Ack.boolean()]), + 'maybe': Ack.anyOf([Ack.literal('none'), Ack.any()]).nullable(), + 'options': Ack.list(option).minLength(1).optional(), + 'values': Ack.map(answer).optional(), + 'nested': Ack.anyOf([ + Ack.lazy('word', () => Ack.enumString(['a', 'b'])), + Ack.anyOf([Ack.double(), Ack.map(Ack.any().nullable())]), + ]), +}); +''', + allOf([ + contains('final Object value;'), + contains('final Object? maybe;'), + contains('final List? options;'), + contains('final Map? values;'), + contains('final Object nested;'), + ]), + ); + }); + + test('infers a union branch that is an explicit wire view', () async { + await _expectOutput(''' +$_head +final stamped = Ack.preserveBoundary(Ack.object({'at': Ack.datetime()})); + +@Schemable() +final eventSchema = Ack.object({ + 'detail': Ack.anyOf([Ack.string(), stamped]), +}); +''', contains('final Object detail;')); + }); + + test('infers a union branch built by a native schema extension', () async { + await _expectOutput(''' +$_head +extension SlugSchemas on StringSchema { + StringSchema slug() => this.matches(r'^[a-z-]+\$'); +} + +@Schemable() +final pageSchema = Ack.object({ + 'value': Ack.anyOf([Ack.integer(), Ack.string().slug()]), +}); +''', contains('final Object value;')); + }); + + for (final branch in [ + 'Ack.datetime()', + 'Ack.enumValues(Status.values)', + "Ack.string().codec(decode: int.parse, encode: (value) => '\$value')", + 'Ack.codec(input: Ack.string(), decode: (value) => value, encode: (value) => value)', + "Ack.object({'at': Ack.date()})", + 'tagSchema', + 'Ack.instance()', + 'Ack.string().nullable()', + 'Ack.string().asLink()', + "Ack.string().asLink().withDefault(Uri.parse('https://example.invalid'))", + 'Ack.string().asDeclaredLink()', + 'Ack.string().dates()', + ]) { + test('rejects a non-native union field branch $branch', () async { + await _expectFailure( + ''' +$_head +enum Status { active, archived } + +extension LinkSchemas on StringSchema { + CodecSchema asLink() => + this.codec(decode: Uri.parse, encode: (value) => value.toString()); + AckSchema asDeclaredLink() => asLink(); + ListSchema dates() => Ack.list(Ack.datetime()); +} + +@Schemable() +final tagSchema = Ack.object({'name': Ack.string()}); + +@Schemable() +final unionRecordSchema = Ack.object({ + 'value': Ack.list(Ack.anyOf([Ack.string(), $branch])), +}); +''', + [ + 'unionRecordSchema.value[] Ack.anyOf() branch ' + 'unionRecordSchema.value[].anyOf[1]', + 'is not native JSON', + ], + ); + }); + } + for (final change in [ "extend({'payload': Ack.instance>()})", "copyWith(properties: {'payload': Ack.instance>()})", @@ -596,7 +736,7 @@ final boundaryRecordSchema = Ack.object({ $_head @Schemable() final boundaryRecordSchema = Ack.object({ - 'value': Ack.preserveBoundary($boundary), + 'value': Ack.preserveBoundary(Ack.map($boundary)), }); ''', ['boundaryRecordSchema.value', 'cannot prove a native JSON boundary'], @@ -614,27 +754,33 @@ enum Status { active, archived } @Schemable() final nativeBoundarySchema = Ack.object({ - 'day': Ack.preserveBoundary(Ack.date()), - 'instant': Ack.preserveBoundary(Ack.datetime()), - 'url': Ack.preserveBoundary(Ack.uri()), - 'milliseconds': Ack.preserveBoundary(Ack.duration()), - 'status': Ack.preserveBoundary(Ack.enumValues(Status.values)), - 'boxed': Ack.preserveBoundary(Ack.codec( + 'day': Ack.preserveBoundary(Ack.map(Ack.date())), + 'instant': Ack.preserveBoundary(Ack.map(Ack.datetime())), + 'url': Ack.preserveBoundary(Ack.map(Ack.uri())), + 'milliseconds': Ack.preserveBoundary(Ack.map(Ack.duration())), + 'status': Ack.preserveBoundary(Ack.map(Ack.enumValues(Status.values))), + 'boxed': Ack.preserveBoundary(Ack.map(Ack.codec( input: Ack.string(), decode: DateTime.parse, encode: (DateTime value) => value.toIso8601String(), - )), - 'later': Ack.preserveBoundary(Ack.lazy('later', () => Ack.integer(), maxDepth: 4)), + ))), + 'later': Ack.preserveBoundary( + Ack.map(Ack.lazy('later', () => Ack.integer(), maxDepth: 4)), + ), + 'ratio': Ack.preserveBoundary(Ack.map(Ack.double())), + 'label': Ack.preserveBoundary(Ack.map(Ack.string())), }); ''', allOf([ - contains('final String day;'), - contains('final String instant;'), - contains('final String url;'), - contains('final int milliseconds;'), - contains('final String status;'), - contains('final String boxed;'), - contains('final int later;'), + contains('final Map day;'), + contains('final Map instant;'), + contains('final Map url;'), + contains('final Map milliseconds;'), + contains('final Map status;'), + contains('final Map boxed;'), + contains('final Map later;'), + contains('final Map ratio;'), + contains('final Map label;'), ]), ); }, @@ -832,18 +978,23 @@ final scoresSchema = $source; }); } - test('rejects an Ack.map() root reached through a variable', () async { - await _expectFailure( - ''' + for (final declaration in [ + 'final scoresMap = Ack.map(Ack.integer());', + 'final AckSchema scoresMap = Ack.map(Ack.integer());', + ]) { + test('rejects an Ack.map() root reached through $declaration', () async { + await _expectFailure( + ''' $_head -final scoresMap = Ack.map(Ack.integer()); +$declaration @Schemable() final scoresSchema = scoresMap; ''', - ['scoresSchema', 'Ack.map() root'], - ); - }); + ['scoresSchema', 'Ack.map() root'], + ); + }); + } test('rejects an unannotated named Ack.object field', () async { await _expectFailure( diff --git a/packages/ack_generator/test/integration/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index ab8d3071..1f27811f 100644 --- a/packages/ack_generator/test/integration/ack_infer_models_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_models_test.dart @@ -694,7 +694,7 @@ final valuesSchema = Ack.string().codec>( 'other.dart': ''' import 'package:ack/ack.dart'; -final payloadUnion = Ack.anyOf([Ack.string(), Ack.integer()]); +final payloadUnion = Ack.anyOf([Ack.datetime(), Ack.integer()]); ''', 'user.dart': ''' @@ -719,6 +719,37 @@ final userSchema = Ack.object({ }, ); + test( + 'follows a cross-library native union variable to an Object field', + () async { + await _build( + { + 'other.dart': ''' +import 'package:ack/ack.dart'; + +final payloadUnion = Ack.anyOf([Ack.string(), Ack.integer()]); +''', + 'user.dart': + ''' +$_imports +import 'other.dart'; +part 'user.g.dart'; + +@Schemable() +final userSchema = Ack.object({ + 'payload': payloadUnion, +}); +''', + }, + outputs: { + 'test_pkg|lib/user.g.dart': decodedMatches( + contains('final Object payload;'), + ), + }, + ); + }, + ); + test( 'follows a cross-library Ack.any() variable to an Object field', () async { diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index 6aa6dd34..18a9ed78 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -249,14 +249,16 @@ final keywordFieldsSchema = Ack.object({ 'yield': Ack.string(), }); +final nonEmptyText = Ack.string().minLength(1); final choiceOptionSchema = Ack.anyOf([ - Ack.string(), - Ack.object({'label': Ack.string(), 'value': Ack.string()}), + nonEmptyText, + Ack.object({'label': Ack.string(), 'value': nonEmptyText}), ]); -final choiceOptionsSchema = Ack.preserveBoundary(Ack.list(choiceOptionSchema)); @Schemable() -final choiceFieldSchema = Ack.object({'options': choiceOptionsSchema.optional()}); +final choiceFieldSchema = Ack.object({ + 'options': Ack.list(choiceOptionSchema).minLength(1).optional(), +}); final opaqueValueSchema = Ack.string().codec( decode: DateTime.parse, @@ -286,14 +288,15 @@ final opaqueCollectionsSchema = Ack.object({ final answerValueSchema = Ack.anyOf([ Ack.string(), Ack.number(), Ack.list(Ack.string()), ]); -final answerValuesSchema = Ack.preserveBoundary(Ack.map(answerValueSchema)); @Schemable() final answerRecordSchema = Ack.object({ - 'values': answerValuesSchema, - 'ratio': Ack.preserveBoundary(Ack.double()), + 'values': Ack.map(answerValueSchema).optional(), }); +@Schemable() +final answerSheetSchema = Ack.object({'values': Ack.map(answerValueSchema)}); + final nullableBoundaryValue = Ack.string().nullable(); final nullableBoundaryAlias = nullableBoundaryValue; @@ -302,19 +305,19 @@ final boundaryFlagsSchema = Ack.object({ 'copiedValues': Ack.preserveBoundary(Ack.map(Ack.string().copyWith(isNullable: true))), 'configuredValues': Ack.preserveBoundary(Ack.map(Ack.string().withRuntimeConfig(isNullable: true))), 'clearedValues': Ack.preserveBoundary(Ack.map(nullableBoundaryAlias.nullable(value: false))), - 'items': Ack.preserveBoundary(Ack.list(nullableBoundaryAlias.nullable(value: false))), - 'outer': Ack.preserveBoundary(nullableBoundaryValue).nullable(value: false), + 'items': Ack.preserveBoundary(Ack.map(Ack.list(nullableBoundaryAlias.nullable(value: false)))), + 'outer': Ack.preserveBoundary(Ack.object({'text': nullableBoundaryValue}).nullable()).nullable(value: false), 'outerMap': Ack.preserveBoundary(Ack.map(nullableBoundaryValue).nullable()).nullable(value: false), }); -final nullableBoundaryText = Ack.preserveBoundary(Ack.string().nullable()); -final boundaryTextAlias = nullableBoundaryText; +final nullableBoundaryProfile = Ack.preserveBoundary(Ack.object({'name': Ack.string()}).nullable()); +final boundaryProfileAlias = nullableBoundaryProfile; final nullableBoundaryMap = Ack.preserveBoundary(Ack.map(Ack.string().nullable()).nullable()); final boundaryMapAlias = nullableBoundaryMap; @Schemable() final boundaryAliasesSchema = Ack.object({ - 'text': boundaryTextAlias.nullable(value: false), + 'profile': boundaryProfileAlias.nullable(value: false), 'values': boundaryMapAlias.nullable(value: false), 'groups': Ack.list(boundaryMapAlias.nullable(value: false)), 'byName': Ack.map(boundaryMapAlias.nullable(value: false)), @@ -324,17 +327,18 @@ enum WireStatus { active, archived } @Schemable() final nativeBoundarySchema = Ack.object({ - 'day': Ack.preserveBoundary(Ack.date()), - 'instant': Ack.preserveBoundary(Ack.datetime()), - 'url': Ack.preserveBoundary(Ack.uri()), - 'milliseconds': Ack.preserveBoundary(Ack.duration()), - 'status': Ack.preserveBoundary(Ack.enumValues(WireStatus.values)), - 'boxed': Ack.preserveBoundary(Ack.codec( + 'day': Ack.preserveBoundary(Ack.map(Ack.date())), + 'instant': Ack.preserveBoundary(Ack.map(Ack.datetime())), + 'url': Ack.preserveBoundary(Ack.map(Ack.uri())), + 'milliseconds': Ack.preserveBoundary(Ack.map(Ack.duration())), + 'status': Ack.preserveBoundary(Ack.map(Ack.enumValues(WireStatus.values))), + 'boxed': Ack.preserveBoundary(Ack.map(Ack.codec( input: Ack.string(), decode: DateTime.parse, encode: (DateTime value) => value.toIso8601String(), - )), - 'later': Ack.preserveBoundary(Ack.lazy('later', () => Ack.integer(), maxDepth: 4)), + ))), + 'later': Ack.preserveBoundary(Ack.map(Ack.lazy('later', () => Ack.integer(), maxDepth: 4))), + 'ratio': Ack.preserveBoundary(Ack.map(Ack.double())), 'payload': Ack.preserveBoundary(Ack.object({ 'createdAt': Ack.datetime(), 'statuses': Ack.list(Ack.enumValues(WireStatus.values)), @@ -456,6 +460,8 @@ final personSchema = Ack.object({ File( p.join(temporary.path, 'test', 'runtime_test.dart'), ).writeAsStringSync(r''' +import 'dart:convert'; + import 'package:ack/ack.dart'; import 'package:ack_ack_infer_runtime/models.dart'; import 'package:ack_ack_infer_runtime/person.dart'; @@ -567,21 +573,69 @@ void main() { expect(parsed.toJson(), record.toJson()); }); - test('explicit JSON boundary unions encode normalized nested string lists', () { + test('native JSON union maps encode snapshotted nested string lists', () { final tags = ['a', 'b']; final values = {'tags': tags, 'title': 'Review', 'count': 2}; - final record = AnswerRecord(values: values, ratio: 0.5); + final record = AnswerRecord(values: values); tags.add('changed'); values.clear(); final expected = {'tags': ['a', 'b'], 'title': 'Review', 'count': 2}; expect(record.values, expected); - expect(record.toJson(), {'values': expected, 'ratio': 0.5}); - final double ratio = record.ratio; - expect(ratio, 0.5); - expect(AnswerRecord.parse({'values': expected, 'ratio': 0.5}).toJson(), - {'values': expected, 'ratio': 0.5}); - expect(() => (record.values['tags'] as List).clear(), throwsUnsupportedError); - expect(AnswerRecord(values: {'invalid': false}, ratio: 0.5).safeToJson().isFail, isTrue); + expect(record.toJson(), {'values': expected}); + expect(AnswerRecord.parse({'values': expected}).toJson(), {'values': expected}); + expect(() => (record.values!['tags'] as List).clear(), throwsUnsupportedError); + expect(AnswerRecord(values: {'invalid': false}).safeToJson().isFail, isTrue); + }); + + test('a required native JSON union map field round-trips JSON', () { + final json = jsonDecode( + '{"values": {"tags": ["a"], "count": 2, "title": "Review"}}', + ) as Map; + final sheet = AnswerSheet.parse(json); + final Map values = sheet.values; + expect(values, json['values']); + expect(sheet.toJson(), json); + expect(AnswerSheet.parse(sheet.toJson()), sheet); + expect(() => (values['tags'] as List).add('b'), throwsUnsupportedError); + expect(AnswerSheet.safeParse({}).isFail, isTrue); + expect(AnswerSheet.safeParse({'values': null}).isFail, isTrue); + expect(AnswerSheet.safeParse({'values': {'flag': true}}).isFail, isTrue); + }); + + test('native JSON union fields round-trip JSON like their former wrappers', () { + final optionsJson = jsonDecode( + '{"options": ["skip", {"label": "Keep", "value": "keep"}]}', + ) as Map; + final field = ChoiceField.parse(optionsJson); + final List? options = field.options; + expect(options, optionsJson['options']); + expect(field.toJson(), optionsJson); + expect(ChoiceField.parse(field.toJson()), field); + expect(() => (options!.last as Map)['value'] = 'changed', throwsUnsupportedError); + expect(ChoiceField.parse({}).options, isNull); + expect(ChoiceField.safeParse({'options': []}).isFail, isTrue); + expect(ChoiceField.safeParse({'options': ['']}).isFail, isTrue); + expect(ChoiceField.safeParse({ + 'options': [{'label': 'Keep', 'value': ''}], + }).isFail, isTrue); + + final answersJson = jsonDecode( + '{"values": {"tags": ["a"], "count": 2.5, "title": "Review"}}', + ) as Map; + final record = AnswerRecord.parse(answersJson); + final Map? values = record.values; + expect(values, answersJson['values']); + expect(record.toJson(), answersJson); + expect(() => (values!['tags'] as List).add('b'), throwsUnsupportedError); + expect(AnswerRecord.parse({}).values, isNull); + expect(AnswerRecord.safeParse({ + ...answersJson, + 'values': {'flag': true}, + }).isFail, isTrue); + expect( + (AnswerRecord.schema.toJsonSchema()['properties'] as Map)['values'], + Ack.preserveBoundary(Ack.map(answerValueSchema)).toJsonSchema(), + ); }); test('preserved boundary metadata agrees with generated runtime fields', () { @@ -589,22 +643,22 @@ void main() { 'copiedValues': {'a': null}, 'configuredValues': {'b': null}, 'clearedValues': {'c': 'present'}, - 'items': ['present'], - 'outer': 'present', + 'items': {'e': ['present']}, + 'outer': {'text': null}, 'outerMap': {'d': null}, }; final record = BoundaryFlags.parse(json); final Map copied = record.copiedValues; final Map configured = record.configuredValues; final Map cleared = record.clearedValues; - final List items = record.items; - final String outer = record.outer; + final Map> items = record.items; + final Map outer = record.outer; final Map outerMap = record.outerMap; expect(copied['a'], isNull); expect(configured['b'], isNull); expect(cleared['c'], 'present'); - expect(items, ['present']); - expect(outer, 'present'); + expect(items, {'e': ['present']}); + expect(outer, {'text': null}); expect(outerMap['d'], isNull); expect(record.toJson(), json); expect(BoundaryFlags.parse(record.toJson()), record); @@ -616,23 +670,23 @@ void main() { test('preserved boundary aliases clear only their outer nullable type', () { final json = { - 'text': 'present', + 'profile': {'name': 'present'}, 'values': {'a': null}, 'groups': [{'b': null}], 'byName': {'first': {'c': null}}, }; final record = BoundaryAliases.parse(json); - final String text = record.text; + final Map profile = record.profile; final Map values = record.values; final List> groups = record.groups; final Map> byName = record.byName; - expect(text, 'present'); + expect(profile, {'name': 'present'}); expect(values['a'], isNull); expect(groups.single['b'], isNull); expect(byName['first']!['c'], isNull); expect(record.toJson(), json); expect(BoundaryAliases.parse(record.toJson()), record); - expect(BoundaryAliases.safeParse({...json, 'text': null}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'profile': null}).isFail, isTrue); expect(BoundaryAliases.safeParse({...json, 'values': null}).isFail, isTrue); expect(BoundaryAliases.safeParse({...json, 'groups': [null]}).isFail, isTrue); expect(BoundaryAliases.safeParse({...json, 'byName': {'first': null}}).isFail, isTrue); @@ -640,36 +694,46 @@ void main() { test('preserved native codecs keep JSON boundary types and validation', () { final json = { - 'day': '2026-10-26', - 'instant': '2026-10-26T12:00:00Z', - 'url': 'https://example.invalid/review', - 'milliseconds': 1500, - 'status': 'active', - 'boxed': '2026-10-26T12:00:00Z', - 'later': 7, + 'day': {'a': '2026-10-26'}, + 'instant': {'a': '2026-10-26T12:00:00Z'}, + 'url': {'a': 'https://example.invalid/review'}, + 'milliseconds': {'a': 1500}, + 'status': {'a': 'active'}, + 'boxed': {'a': '2026-10-26T12:00:00Z'}, + 'later': {'a': 7}, + 'ratio': {'a': 0.5}, 'payload': { 'createdAt': '2026-10-26T12:00:00Z', 'statuses': ['active'], }, }; final record = NativeBoundary.parse(json); - final String instant = record.instant; - final String status = record.status; - final int milliseconds = record.milliseconds; - final int later = record.later; + final Map instant = record.instant; + final Map status = record.status; + final Map milliseconds = record.milliseconds; + final Map later = record.later; + final Map ratio = record.ratio; expect(instant, json['instant']); - expect(status, 'active'); - expect(milliseconds, 1500); - expect(later, 7); + expect(status, {'a': 'active'}); + expect(milliseconds, {'a': 1500}); + expect(later, {'a': 7}); + expect(ratio, {'a': 0.5}); expect(record.toJson(), json); - expect(record.copyWith(milliseconds: 2500).toJson(), { + expect(record.copyWith(milliseconds: {'a': 2500}).toJson(), { ...json, - 'milliseconds': 2500, + 'milliseconds': {'a': 2500}, }); - expect(NativeBoundary.safeParse({...json, 'instant': 'bad'}).isFail, isTrue); - expect(NativeBoundary.safeParse({...json, 'status': 'unknown'}).isFail, isTrue); - expect(NativeBoundary.safeParse({...json, 'milliseconds': 1.5}).isFail, isTrue); - expect(NativeBoundary.safeParse({...json, 'boxed': 'bad'}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'instant': {'a': 'bad'}}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'status': {'a': 'unknown'}}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'milliseconds': {'a': 1.5}}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'boxed': {'a': 'bad'}}).isFail, isTrue); + final otherNumbers = { + ...json, + 'milliseconds': {'a': 1500.0}, + 'later': {'a': 7.0}, + 'ratio': {'a': 1}, + }; + expect(NativeBoundary.parse(otherNumbers).toJson(), otherNumbers); }); test('contextual discriminator and capture members retain their wire shape', () {