From 02e4ba2049d4daee6dc846d89aeac6d02645c096 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 3 Mar 2026 13:50:19 -0500 Subject: [PATCH 1/5] Make @AckType object wrapper maps immutable --- example/lib/args_getter_example.g.dart | 54 ++++++-- example/lib/schema_types_discriminated.g.dart | 30 ++-- example/lib/schema_types_edge_cases.g.dart | 129 +++++++++++++----- example/lib/schema_types_simple.g.dart | 8 +- example/test/verify_implements_works.dart | 28 ++++ .../lib/src/builders/type_builder.dart | 77 +++++++++-- .../ack_type_cross_file_resolution_test.dart | 70 +++++----- .../integration/ack_type_getter_test.dart | 2 +- .../ack_type_nested_schema_test.dart | 2 +- ...type_object_wrapper_immutability_test.dart | 60 ++++++++ .../test/integration/nested_model_test.dart | 100 ++++++++------ 11 files changed, 405 insertions(+), 155 deletions(-) create mode 100644 packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart diff --git a/example/lib/args_getter_example.g.dart b/example/lib/args_getter_example.g.dart index e08a05a6..626fe65d 100644 --- a/example/lib/args_getter_example.g.dart +++ b/example/lib/args_getter_example.g.dart @@ -13,14 +13,18 @@ extension type UserConfigType(Map _data) static UserConfigType parse(Object? data) { return userConfigSchema.parseAs( data, - (validated) => UserConfigType(validated as Map), + (validated) => UserConfigType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return userConfigSchema.safeParseAs( data, - (validated) => UserConfigType(validated as Map), + (validated) => UserConfigType( + Map.unmodifiable(validated as Map), + ), ); } @@ -30,8 +34,10 @@ extension type UserConfigType(Map _data) String get email => _data['email'] as String; - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'username' && e.key != 'email'), + Map get args => Map.unmodifiable( + Map.fromEntries( + _data.entries.where((e) => e.key != 'username' && e.key != 'email'), + ), ); UserConfigType copyWith({String? username, String? email}) { @@ -48,14 +54,18 @@ extension type ApiRequestType(Map _data) static ApiRequestType parse(Object? data) { return apiRequestSchema.parseAs( data, - (validated) => ApiRequestType(validated as Map), + (validated) => ApiRequestType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return apiRequestSchema.safeParseAs( data, - (validated) => ApiRequestType(validated as Map), + (validated) => ApiRequestType( + Map.unmodifiable(validated as Map), + ), ); } @@ -65,8 +75,10 @@ extension type ApiRequestType(Map _data) String get url => _data['url'] as String; - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'method' && e.key != 'url'), + Map get args => Map.unmodifiable( + Map.fromEntries( + _data.entries.where((e) => e.key != 'method' && e.key != 'url'), + ), ); ApiRequestType copyWith({String? method, String? url}) { @@ -83,14 +95,18 @@ extension type FeatureFlagsType(Map _data) static FeatureFlagsType parse(Object? data) { return featureFlagsSchema.parseAs( data, - (validated) => FeatureFlagsType(validated as Map), + (validated) => FeatureFlagsType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return featureFlagsSchema.safeParseAs( data, - (validated) => FeatureFlagsType(validated as Map), + (validated) => FeatureFlagsType( + Map.unmodifiable(validated as Map), + ), ); } @@ -100,8 +116,12 @@ extension type FeatureFlagsType(Map _data) String get environment => _data['environment'] as String; - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'appVersion' && e.key != 'environment'), + Map get args => Map.unmodifiable( + Map.fromEntries( + _data.entries.where( + (e) => e.key != 'appVersion' && e.key != 'environment', + ), + ), ); FeatureFlagsType copyWith({String? appVersion, String? environment}) { @@ -118,18 +138,22 @@ extension type DynamicDataType(Map _data) static DynamicDataType parse(Object? data) { return dynamicDataSchema.parseAs( data, - (validated) => DynamicDataType(validated as Map), + (validated) => DynamicDataType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return dynamicDataSchema.safeParseAs( data, - (validated) => DynamicDataType(validated as Map), + (validated) => DynamicDataType( + Map.unmodifiable(validated as Map), + ), ); } Map toJson() => _data; - Map get args => _data; + Map get args => Map.unmodifiable(_data); } diff --git a/example/lib/schema_types_discriminated.g.dart b/example/lib/schema_types_discriminated.g.dart index 35d7c4ad..308287de 100644 --- a/example/lib/schema_types_discriminated.g.dart +++ b/example/lib/schema_types_discriminated.g.dart @@ -16,7 +16,9 @@ extension type PetType(Map _data) static PetType parse(Object? data) { return petSchema.parseAs(data, (validated) { - final map = validated as Map; + final map = Map.unmodifiable( + validated as Map, + ); return switch (map['kind']) { 'cat' => CatType(map), 'dog' => DogType(map), @@ -27,7 +29,9 @@ extension type PetType(Map _data) static SchemaResult safeParse(Object? data) { return petSchema.safeParseAs(data, (validated) { - final map = validated as Map; + final map = Map.unmodifiable( + validated as Map, + ); return switch (map['kind']) { 'cat' => CatType(map), 'dog' => DogType(map), @@ -47,14 +51,18 @@ extension type CatType(Map _data) static CatType parse(Object? data) { return catSchema.parseAs( data, - (validated) => CatType(validated as Map), + (validated) => CatType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return catSchema.safeParseAs( data, - (validated) => CatType(validated as Map), + (validated) => CatType( + Map.unmodifiable(validated as Map), + ), ); } @@ -75,21 +83,27 @@ extension type DogType(Map _data) static DogType parse(Object? data) { return dogSchema.parseAs( data, - (validated) => DogType(validated as Map), + (validated) => DogType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return dogSchema.safeParseAs( data, - (validated) => DogType(validated as Map), + (validated) => DogType( + Map.unmodifiable(validated as Map), + ), ); } bool get bark => _data['bark'] as bool; - Map get args => Map.fromEntries( - _data.entries.where((e) => e.key != 'kind' && e.key != 'bark'), + Map get args => Map.unmodifiable( + Map.fromEntries( + _data.entries.where((e) => e.key != 'kind' && e.key != 'bark'), + ), ); DogType copyWith({bool? bark}) { diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart index e6cbcdc9..86a693af 100644 --- a/example/lib/schema_types_edge_cases.g.dart +++ b/example/lib/schema_types_edge_cases.g.dart @@ -15,14 +15,18 @@ extension type ProductType(Map _data) static ProductType parse(Object? data) { return productSchema.parseAs( data, - (validated) => ProductType(validated as Map), + (validated) => ProductType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return productSchema.safeParseAs( data, - (validated) => ProductType(validated as Map), + (validated) => ProductType( + Map.unmodifiable(validated as Map), + ), ); } @@ -57,14 +61,18 @@ extension type GridType(Map _data) static GridType parse(Object? data) { return gridSchema.parseAs( data, - (validated) => GridType(validated as Map), + (validated) => GridType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return gridSchema.safeParseAs( data, - (validated) => GridType(validated as Map), + (validated) => GridType( + Map.unmodifiable(validated as Map), + ), ); } @@ -88,14 +96,18 @@ extension type AddressType(Map _data) static AddressType parse(Object? data) { return addressSchema.parseAs( data, - (validated) => AddressType(validated as Map), + (validated) => AddressType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return addressSchema.safeParseAs( data, - (validated) => AddressType(validated as Map), + (validated) => AddressType( + Map.unmodifiable(validated as Map), + ), ); } @@ -130,14 +142,18 @@ extension type PersonType(Map _data) static PersonType parse(Object? data) { return personSchema.parseAs( data, - (validated) => PersonType(validated as Map), + (validated) => PersonType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return personSchema.safeParseAs( data, - (validated) => PersonType(validated as Map), + (validated) => PersonType( + Map.unmodifiable(validated as Map), + ), ); } @@ -147,8 +163,9 @@ extension type PersonType(Map _data) String get email => _data['email'] as String; - AddressType get address => - AddressType(_data['address'] as Map); + AddressType get address => AddressType( + Map.unmodifiable(_data['address'] as Map), + ); int get age => _data['age'] as int; @@ -173,14 +190,18 @@ extension type EmployeeType(Map _data) static EmployeeType parse(Object? data) { return employeeSchema.parseAs( data, - (validated) => EmployeeType(validated as Map), + (validated) => EmployeeType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return employeeSchema.safeParseAs( data, - (validated) => EmployeeType(validated as Map), + (validated) => EmployeeType( + Map.unmodifiable(validated as Map), + ), ); } @@ -190,11 +211,17 @@ extension type EmployeeType(Map _data) String get employeeId => _data['employeeId'] as String; - AddressType get homeAddress => - AddressType(_data['homeAddress'] as Map); + AddressType get homeAddress => AddressType( + Map.unmodifiable( + _data['homeAddress'] as Map, + ), + ); - AddressType get workAddress => - AddressType(_data['workAddress'] as Map); + AddressType get workAddress => AddressType( + Map.unmodifiable( + _data['workAddress'] as Map, + ), + ); EmployeeType copyWith({ String? name, @@ -217,14 +244,18 @@ extension type ModifierType(Map _data) static ModifierType parse(Object? data) { return modifierSchema.parseAs( data, - (validated) => ModifierType(validated as Map), + (validated) => ModifierType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return modifierSchema.safeParseAs( data, - (validated) => ModifierType(validated as Map), + (validated) => ModifierType( + Map.unmodifiable(validated as Map), + ), ); } @@ -266,14 +297,18 @@ extension type TaggedItemType(Map _data) static TaggedItemType parse(Object? data) { return taggedItemSchema.parseAs( data, - (validated) => TaggedItemType(validated as Map), + (validated) => TaggedItemType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return taggedItemSchema.safeParseAs( data, - (validated) => TaggedItemType(validated as Map), + (validated) => TaggedItemType( + Map.unmodifiable(validated as Map), + ), ); } @@ -313,14 +348,18 @@ extension type ContactListType(Map _data) static ContactListType parse(Object? data) { return contactListSchema.parseAs( data, - (validated) => ContactListType(validated as Map), + (validated) => ContactListType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return contactListSchema.safeParseAs( data, - (validated) => ContactListType(validated as Map), + (validated) => ContactListType( + Map.unmodifiable(validated as Map), + ), ); } @@ -329,7 +368,11 @@ extension type ContactListType(Map _data) String get name => _data['name'] as String; List get addresses => (_data['addresses'] as List) - .map((e) => AddressType(e as Map)) + .map( + (e) => AddressType( + Map.unmodifiable(e as Map), + ), + ) .toList(); ContactListType copyWith({String? name, List? addresses}) { @@ -346,14 +389,18 @@ extension type EmptyType(Map _data) static EmptyType parse(Object? data) { return emptySchema.parseAs( data, - (validated) => EmptyType(validated as Map), + (validated) => EmptyType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return emptySchema.safeParseAs( data, - (validated) => EmptyType(validated as Map), + (validated) => EmptyType( + Map.unmodifiable(validated as Map), + ), ); } @@ -366,14 +413,18 @@ extension type MinimalType(Map _data) static MinimalType parse(Object? data) { return minimalSchema.parseAs( data, - (validated) => MinimalType(validated as Map), + (validated) => MinimalType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return minimalSchema.safeParseAs( data, - (validated) => MinimalType(validated as Map), + (validated) => MinimalType( + Map.unmodifiable(validated as Map), + ), ); } @@ -392,14 +443,18 @@ extension type NamedItemType(Map _data) static NamedItemType parse(Object? data) { return namedItemSchema.parseAs( data, - (validated) => NamedItemType(validated as Map), + (validated) => NamedItemType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return namedItemSchema.safeParseAs( data, - (validated) => NamedItemType(validated as Map), + (validated) => NamedItemType( + Map.unmodifiable(validated as Map), + ), ); } @@ -418,14 +473,18 @@ extension type ItemType(Map _data) static ItemType parse(Object? data) { return item.parseAs( data, - (validated) => ItemType(validated as Map), + (validated) => ItemType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return item.safeParseAs( data, - (validated) => ItemType(validated as Map), + (validated) => ItemType( + Map.unmodifiable(validated as Map), + ), ); } @@ -444,14 +503,18 @@ extension type MyCustomSchema123Type(Map _data) static MyCustomSchema123Type parse(Object? data) { return myCustomSchema123.parseAs( data, - (validated) => MyCustomSchema123Type(validated as Map), + (validated) => MyCustomSchema123Type( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return myCustomSchema123.safeParseAs( data, - (validated) => MyCustomSchema123Type(validated as Map), + (validated) => MyCustomSchema123Type( + Map.unmodifiable(validated as Map), + ), ); } diff --git a/example/lib/schema_types_simple.g.dart b/example/lib/schema_types_simple.g.dart index 4cb8c117..1c244a74 100644 --- a/example/lib/schema_types_simple.g.dart +++ b/example/lib/schema_types_simple.g.dart @@ -13,14 +13,18 @@ extension type UserType(Map _data) static UserType parse(Object? data) { return userSchema.parseAs( data, - (validated) => UserType(validated as Map), + (validated) => UserType( + Map.unmodifiable(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return userSchema.safeParseAs( data, - (validated) => UserType(validated as Map), + (validated) => UserType( + Map.unmodifiable(validated as Map), + ), ); } diff --git a/example/test/verify_implements_works.dart b/example/test/verify_implements_works.dart index 779d4881..3048d3d5 100644 --- a/example/test/verify_implements_works.dart +++ b/example/test/verify_implements_works.dart @@ -1,4 +1,5 @@ import 'package:ack_example/schema_types_simple.dart'; +import 'package:ack_example/schema_types_edge_cases.dart'; import 'package:test/test.dart'; void main() { @@ -52,6 +53,33 @@ void main() { processMap(user); }); + test('rejects map mutation at runtime', () { + final user = UserType.parse({'name': 'John', 'age': 30, 'active': true}); + + final Map map = user; + expect(() => map['name'] = 'Jane', throwsA(isA())); + }); + + test('rejects nested wrapper map mutation at runtime', () { + final person = PersonType.parse({ + 'name': 'John', + 'email': 'john@example.com', + 'address': { + 'street': 'Main St', + 'city': 'Quito', + 'zipCode': '17000', + 'country': 'Ecuador', + }, + 'age': 30, + }); + + final Map addressMap = person.address; + expect( + () => addressMap['city'] = 'Guayaquil', + throwsA(isA()), + ); + }); + test('safeParse returns SchemaResult', () { final result = UserType.safeParse({ 'name': 'John', diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart index 330b29c5..15d065eb 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -516,6 +516,10 @@ class TypeBuilder { List _buildStaticFactories(ModelInfo model, String schemaVarName) { final typeName = _getExtensionTypeName(model); final castType = model.representationType; + final representationValue = _wrapRepresentationValue( + castType: castType, + sourceExpression: 'validated', + ); return [ // Static parse factory @@ -534,7 +538,7 @@ class TypeBuilder { ..body = Code(''' return $schemaVarName.parseAs( data, - (validated) => $typeName(validated as $castType), + (validated) => $typeName($representationValue), );'''), ), // Static safeParse method @@ -553,7 +557,7 @@ return $schemaVarName.parseAs( ..body = Code(''' return $schemaVarName.safeParseAs( data, - (validated) => $typeName(validated as $castType), + (validated) => $typeName($representationValue), );'''), ), ]; @@ -588,7 +592,7 @@ return $schemaVarName.safeParseAs( return $schemaVarName.parseAs( data, (validated) { - final map = validated as Map; + final map = ${_validatedObjectMapExpression('validated')}; return $switchExpression; }, );'''), @@ -624,13 +628,40 @@ return $schemaVarName.parseAs( return $schemaVarName.safeParseAs( data, (validated) { - final map = validated as Map; + final map = ${_validatedObjectMapExpression('validated')}; return $switchExpression; }, );'''), ); } + String _wrapRepresentationValue({ + required String castType, + required String sourceExpression, + }) { + final castExpression = '$sourceExpression as $castType'; + if (castType == kMapType) { + return 'Map.unmodifiable($castExpression)'; + } + return castExpression; + } + + String _validatedObjectMapExpression(String sourceExpression) { + const mapType = 'Map'; + final castExpression = '$sourceExpression as $mapType'; + return '$mapType.unmodifiable($castExpression)'; + } + + String _castRepresentationValue({ + required String sourceExpression, + required String castType, + }) { + if (castType == kMapType) { + return 'Map.unmodifiable($sourceExpression as $castType)'; + } + return '$sourceExpression as $castType'; + } + String _buildDiscriminatorSwitchExpression( String mapVarName, String discriminatorKey, @@ -716,16 +747,27 @@ ${cases.join(',\n')}, if (field.nestedSchemaRef != null) { if (field.displayTypeOverride != null) { final castType = field.nestedSchemaCastTypeOverride ?? kMapType; - return "${field.displayTypeOverride!}(_data['$key'] as $castType)"; + final castExpression = _castRepresentationValue( + sourceExpression: "_data['$key']", + castType: castType, + ); + return '${field.displayTypeOverride!}($castExpression)'; } final referencedModel = _findSchemaModel(field.nestedSchemaRef!, lookups); if (referencedModel != null) { final typeName = '${referencedModel.className}Type'; final castType = referencedModel.representationType; - return "$typeName(_data['$key'] as $castType)"; + final castExpression = _castRepresentationValue( + sourceExpression: "_data['$key']", + castType: castType, + ); + return '$typeName($castExpression)'; } - return "_data['$key'] as Map"; + return _castRepresentationValue( + sourceExpression: "_data['$key']", + castType: kMapType, + ); } // Primitives @@ -753,7 +795,10 @@ ${cases.join(',\n')}, // Maps if (field.isMap) { - return "_data['$key'] as Map"; + return _castRepresentationValue( + sourceExpression: "_data['$key']", + castType: kMapType, + ); } // Sets @@ -764,7 +809,11 @@ ${cases.join(',\n')}, // Nested schema if (field.isNestedSchema && _hasAckType(field, lookups)) { final typeConstructor = _getTypeConstructor(field); - return "$typeConstructor(_data['$key'] as Map)"; + final castExpression = _castRepresentationValue( + sourceExpression: "_data['$key']", + castType: kMapType, + ); + return '$typeConstructor($castExpression)'; } // Generic or unknown - return as Object? @@ -858,7 +907,11 @@ ${cases.join(',\n')}, final constructorName = field.collectionElementIsCustomType ? _asExtensionTypeName(elementType) : '${elementType}Type'; - return "(_data['$key'] as List).map((e) => $constructorName(e as $castType))$listSuffix$suffix"; + final castExpression = _castRepresentationValue( + sourceExpression: 'e', + castType: castType, + ); + return "(_data['$key'] as List).map((e) => $constructorName($castExpression))$listSuffix$suffix"; } // Primitive lists/sets - direct cast @@ -1283,8 +1336,8 @@ ${assignments.join(',\n')}, // Generate filter condition inline for better performance final conditions = knownKeys.map((k) => "e.key != '$k'").toList(); final filterExpr = conditions.isEmpty - ? '_data' - : 'Map.fromEntries(_data.entries.where((e) => ${conditions.join(' && ')}))'; + ? 'Map.unmodifiable(_data)' + : 'Map.unmodifiable(Map.fromEntries(_data.entries.where((e) => ${conditions.join(' && ')})))'; return Method( (m) => m diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index 29044b3b..6afd194a 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -7,16 +7,14 @@ import '../test_utils/test_assets.dart'; void main() { group('@AckType cross-file schema references', () { - test( - 'resolves typed nested getters across files (direct import)', - () async { - final builder = ackGenerator(BuilderOptions.empty); + test('resolves typed nested getters across files (direct import)', () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/deck_schemas.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -26,7 +24,7 @@ final slideSchema = Ack.object({ 'title': Ack.string(), }); ''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' + 'test_pkg|lib/deck_tools_schemas.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; import 'deck_schemas.dart'; @@ -37,28 +35,28 @@ final deckToolArgsSchema = Ack.object({ 'slides': Ack.list(slideSchema), }); ''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains( - 'extension type DeckToolArgsType(Map _data)', - ), - contains('SlideType get currentSlide'), - contains( - "SlideType(_data['currentSlide'] as Map)", - ), - contains('List get slides'), - contains('SlideType(e as Map)'), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( + contains('extension type SlideType(Map _data)'), + ), + 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( + allOf([ + contains( + 'extension type DeckToolArgsType(Map _data)', + ), + contains('SlideType get currentSlide'), + contains('Map.unmodifiable('), + contains("_data['currentSlide'] as Map"), + contains('List get slides'), + contains( + 'Map.unmodifiable(e as Map)', + ), + ]), + ), + }, + ); + }); test('resolves typed nested getters for prefixed schema imports', () async { final builder = ackGenerator(BuilderOptions.empty); @@ -231,16 +229,12 @@ final deckToolArgsSchema = Ack.object({ contains( "SlideType? get currentSlide => _data['currentSlide'] != null", ), - contains( - "? SlideType(_data['currentSlide'] as Map)", - ), + contains("_data['currentSlide'] as Map"), contains('SlideType? get selectedSlide'), contains( "SlideType? get selectedSlide => _data['selectedSlide'] != null", ), - contains( - "? SlideType(_data['selectedSlide'] as Map)", - ), + contains("_data['selectedSlide'] as Map"), ]), ), }, diff --git a/packages/ack_generator/test/integration/ack_type_getter_test.dart b/packages/ack_generator/test/integration/ack_type_getter_test.dart index 62b72f9c..9ded9037 100644 --- a/packages/ack_generator/test/integration/ack_type_getter_test.dart +++ b/packages/ack_generator/test/integration/ack_type_getter_test.dart @@ -82,7 +82,7 @@ ObjectSchema get userSchema { contains('extension type UserType(Map _data)'), contains('CustomAddressType get address'), contains( - "CustomAddressType(_data['address'] as Map)", + "Map.unmodifiable(_data['address'] as Map)", ), ]), ), diff --git a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart b/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart index d8347a4d..d1e582b4 100644 --- a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart +++ b/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart @@ -50,7 +50,7 @@ final userSchema = Ack.object({ contains("StatusType(_data['status'] as String)"), contains('AddressType get address'), contains( - "AddressType(_data['address'] as Map)", + "Map.unmodifiable(_data['address'] as Map)", ), contains('List get aliases'), contains('StatusType(e as String)'), diff --git a/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart new file mode 100644 index 00000000..1849676a --- /dev/null +++ b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart @@ -0,0 +1,60 @@ +import 'package:ack_generator/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +import '../test_utils/test_assets.dart'; + +void main() { + group('@AckType object wrappers are immutable', () { + test('uses Map.unmodifiable while preserving Map compatibility', () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final catSchema = Ack.object({ + 'kind': Ack.literal('cat'), + 'lives': Ack.integer(), +}); + +@AckType() +final dogSchema = Ack.object({ + 'kind': Ack.literal('dog'), + 'bark': Ack.boolean(), +}); + +@AckType() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': catSchema, + 'dog': dogSchema, + }, +); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('extension type CatType(Map _data)'), + contains('implements PetType, Map'), + contains('(validated) => CatType('), + contains('(validated) => DogType('), + contains( + 'Map.unmodifiable(validated as Map)', + ), + contains('final map = Map.unmodifiable('), + ]), + ), + }, + ); + }); + }); +} diff --git a/packages/ack_generator/test/integration/nested_model_test.dart b/packages/ack_generator/test/integration/nested_model_test.dart index 99d76f4b..0107a5b5 100644 --- a/packages/ack_generator/test/integration/nested_model_test.dart +++ b/packages/ack_generator/test/integration/nested_model_test.dart @@ -181,14 +181,16 @@ class Company { ); }); - test('Issue #43: @AckType with Ack.list(schemaRef) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'Issue #43: @AckType with Ack.list(schemaRef) generates List getter', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/contact_list.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/contact_list.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -204,34 +206,39 @@ final contactListSchema = Ack.object({ 'addresses': Ack.list(addressSchema), }); ''', - }, - outputs: { - 'test_pkg|lib/contact_list.g.dart': decodedMatches( - allOf([ - // Extension type for Address - contains('extension type AddressType'), + }, + outputs: { + 'test_pkg|lib/contact_list.g.dart': decodedMatches( + allOf([ + // Extension type for Address + contains('extension type AddressType'), - // Extension type for ContactList - contains('extension type ContactListType'), + // Extension type for ContactList + contains('extension type ContactListType'), - // KEY: List getter with .map() and .toList() - contains('List get addresses'), - contains('.map((e) => AddressType(e as Map))'), - contains('.toList()'), - ]), - ), - }, - ); - }); + // KEY: List getter with .map() and .toList() + contains('List get addresses'), + contains( + 'Map.unmodifiable(e as Map)', + ), + contains('.toList()'), + ]), + ), + }, + ); + }, + ); - test('Ack.list(schemaRef.optional()) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'Ack.list(schemaRef.optional()) generates List getter', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/contact_list.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/contact_list.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -247,19 +254,22 @@ final contactListSchema = Ack.object({ 'addresses': Ack.list(addressSchema.optional()), }); ''', - }, - outputs: { - 'test_pkg|lib/contact_list.g.dart': decodedMatches( - allOf([ - contains('extension type AddressType'), - contains('extension type ContactListType'), - contains('List get addresses'), - contains('.map((e) => AddressType(e as Map))'), - contains('.toList()'), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/contact_list.g.dart': decodedMatches( + allOf([ + contains('extension type AddressType'), + contains('extension type ContactListType'), + contains('List get addresses'), + contains( + 'Map.unmodifiable(e as Map)', + ), + contains('.toList()'), + ]), + ), + }, + ); + }, + ); }); } From dc5966a90973969330d444cc8e9d7110e65820ae Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 3 Mar 2026 14:07:38 -0500 Subject: [PATCH 2/5] refactor: use shared ack deep-freeze helper in generated types --- example/lib/args_getter_example.g.dart | 16 ++-- example/lib/schema_types_discriminated.g.dart | 28 +++---- example/lib/schema_types_edge_cases.g.dart | 76 +++++++++---------- example/lib/schema_types_simple.g.dart | 10 +-- example/test/verify_implements_works.dart | 23 ++++++ packages/ack/lib/ack.dart | 1 + .../ack/lib/src/utils/deep_freeze_utils.dart | 18 +++++ packages/ack/lib/src/utils/default_utils.dart | 5 ++ packages/ack/test/default_utils_test.dart | 16 ++++ .../lib/src/builders/type_builder.dart | 33 +++----- ...type_object_wrapper_immutability_test.dart | 55 ++++++++------ 11 files changed, 161 insertions(+), 120 deletions(-) create mode 100644 packages/ack/lib/src/utils/deep_freeze_utils.dart diff --git a/example/lib/args_getter_example.g.dart b/example/lib/args_getter_example.g.dart index 626fe65d..4ed232b4 100644 --- a/example/lib/args_getter_example.g.dart +++ b/example/lib/args_getter_example.g.dart @@ -14,7 +14,7 @@ extension type UserConfigType(Map _data) return userConfigSchema.parseAs( data, (validated) => UserConfigType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -23,7 +23,7 @@ extension type UserConfigType(Map _data) return userConfigSchema.safeParseAs( data, (validated) => UserConfigType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -55,7 +55,7 @@ extension type ApiRequestType(Map _data) return apiRequestSchema.parseAs( data, (validated) => ApiRequestType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -64,7 +64,7 @@ extension type ApiRequestType(Map _data) return apiRequestSchema.safeParseAs( data, (validated) => ApiRequestType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -96,7 +96,7 @@ extension type FeatureFlagsType(Map _data) return featureFlagsSchema.parseAs( data, (validated) => FeatureFlagsType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -105,7 +105,7 @@ extension type FeatureFlagsType(Map _data) return featureFlagsSchema.safeParseAs( data, (validated) => FeatureFlagsType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -139,7 +139,7 @@ extension type DynamicDataType(Map _data) return dynamicDataSchema.parseAs( data, (validated) => DynamicDataType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -148,7 +148,7 @@ extension type DynamicDataType(Map _data) return dynamicDataSchema.safeParseAs( data, (validated) => DynamicDataType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } diff --git a/example/lib/schema_types_discriminated.g.dart b/example/lib/schema_types_discriminated.g.dart index 308287de..4c3de8bd 100644 --- a/example/lib/schema_types_discriminated.g.dart +++ b/example/lib/schema_types_discriminated.g.dart @@ -16,9 +16,7 @@ extension type PetType(Map _data) static PetType parse(Object? data) { return petSchema.parseAs(data, (validated) { - final map = Map.unmodifiable( - validated as Map, - ); + final map = ackDeepFreezeObjectMap(validated as Map); return switch (map['kind']) { 'cat' => CatType(map), 'dog' => DogType(map), @@ -29,9 +27,7 @@ extension type PetType(Map _data) static SchemaResult safeParse(Object? data) { return petSchema.safeParseAs(data, (validated) { - final map = Map.unmodifiable( - validated as Map, - ); + final map = ackDeepFreezeObjectMap(validated as Map); return switch (map['kind']) { 'cat' => CatType(map), 'dog' => DogType(map), @@ -51,18 +47,16 @@ extension type CatType(Map _data) static CatType parse(Object? data) { return catSchema.parseAs( data, - (validated) => CatType( - Map.unmodifiable(validated as Map), - ), + (validated) => + CatType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return catSchema.safeParseAs( data, - (validated) => CatType( - Map.unmodifiable(validated as Map), - ), + (validated) => + CatType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -83,18 +77,16 @@ extension type DogType(Map _data) static DogType parse(Object? data) { return dogSchema.parseAs( data, - (validated) => DogType( - Map.unmodifiable(validated as Map), - ), + (validated) => + DogType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return dogSchema.safeParseAs( data, - (validated) => DogType( - Map.unmodifiable(validated as Map), - ), + (validated) => + DogType(ackDeepFreezeObjectMap(validated as Map)), ); } diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart index 86a693af..42237695 100644 --- a/example/lib/schema_types_edge_cases.g.dart +++ b/example/lib/schema_types_edge_cases.g.dart @@ -16,7 +16,7 @@ extension type ProductType(Map _data) return productSchema.parseAs( data, (validated) => ProductType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -25,7 +25,7 @@ extension type ProductType(Map _data) return productSchema.safeParseAs( data, (validated) => ProductType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -61,18 +61,16 @@ extension type GridType(Map _data) static GridType parse(Object? data) { return gridSchema.parseAs( data, - (validated) => GridType( - Map.unmodifiable(validated as Map), - ), + (validated) => + GridType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return gridSchema.safeParseAs( data, - (validated) => GridType( - Map.unmodifiable(validated as Map), - ), + (validated) => + GridType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -97,7 +95,7 @@ extension type AddressType(Map _data) return addressSchema.parseAs( data, (validated) => AddressType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -106,7 +104,7 @@ extension type AddressType(Map _data) return addressSchema.safeParseAs( data, (validated) => AddressType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -142,18 +140,16 @@ extension type PersonType(Map _data) static PersonType parse(Object? data) { return personSchema.parseAs( data, - (validated) => PersonType( - Map.unmodifiable(validated as Map), - ), + (validated) => + PersonType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return personSchema.safeParseAs( data, - (validated) => PersonType( - Map.unmodifiable(validated as Map), - ), + (validated) => + PersonType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -191,7 +187,7 @@ extension type EmployeeType(Map _data) return employeeSchema.parseAs( data, (validated) => EmployeeType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -200,7 +196,7 @@ extension type EmployeeType(Map _data) return employeeSchema.safeParseAs( data, (validated) => EmployeeType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -245,7 +241,7 @@ extension type ModifierType(Map _data) return modifierSchema.parseAs( data, (validated) => ModifierType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -254,7 +250,7 @@ extension type ModifierType(Map _data) return modifierSchema.safeParseAs( data, (validated) => ModifierType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -298,7 +294,7 @@ extension type TaggedItemType(Map _data) return taggedItemSchema.parseAs( data, (validated) => TaggedItemType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -307,7 +303,7 @@ extension type TaggedItemType(Map _data) return taggedItemSchema.safeParseAs( data, (validated) => TaggedItemType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -349,7 +345,7 @@ extension type ContactListType(Map _data) return contactListSchema.parseAs( data, (validated) => ContactListType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -358,7 +354,7 @@ extension type ContactListType(Map _data) return contactListSchema.safeParseAs( data, (validated) => ContactListType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -389,18 +385,16 @@ extension type EmptyType(Map _data) static EmptyType parse(Object? data) { return emptySchema.parseAs( data, - (validated) => EmptyType( - Map.unmodifiable(validated as Map), - ), + (validated) => + EmptyType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return emptySchema.safeParseAs( data, - (validated) => EmptyType( - Map.unmodifiable(validated as Map), - ), + (validated) => + EmptyType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -414,7 +408,7 @@ extension type MinimalType(Map _data) return minimalSchema.parseAs( data, (validated) => MinimalType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -423,7 +417,7 @@ extension type MinimalType(Map _data) return minimalSchema.safeParseAs( data, (validated) => MinimalType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -444,7 +438,7 @@ extension type NamedItemType(Map _data) return namedItemSchema.parseAs( data, (validated) => NamedItemType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -453,7 +447,7 @@ extension type NamedItemType(Map _data) return namedItemSchema.safeParseAs( data, (validated) => NamedItemType( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -473,18 +467,16 @@ extension type ItemType(Map _data) static ItemType parse(Object? data) { return item.parseAs( data, - (validated) => ItemType( - Map.unmodifiable(validated as Map), - ), + (validated) => + ItemType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return item.safeParseAs( data, - (validated) => ItemType( - Map.unmodifiable(validated as Map), - ), + (validated) => + ItemType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -504,7 +496,7 @@ extension type MyCustomSchema123Type(Map _data) return myCustomSchema123.parseAs( data, (validated) => MyCustomSchema123Type( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } @@ -513,7 +505,7 @@ extension type MyCustomSchema123Type(Map _data) return myCustomSchema123.safeParseAs( data, (validated) => MyCustomSchema123Type( - Map.unmodifiable(validated as Map), + ackDeepFreezeObjectMap(validated as Map), ), ); } diff --git a/example/lib/schema_types_simple.g.dart b/example/lib/schema_types_simple.g.dart index 1c244a74..ca8bae83 100644 --- a/example/lib/schema_types_simple.g.dart +++ b/example/lib/schema_types_simple.g.dart @@ -13,18 +13,16 @@ extension type UserType(Map _data) static UserType parse(Object? data) { return userSchema.parseAs( data, - (validated) => UserType( - Map.unmodifiable(validated as Map), - ), + (validated) => + UserType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return userSchema.safeParseAs( data, - (validated) => UserType( - Map.unmodifiable(validated as Map), - ), + (validated) => + UserType(ackDeepFreezeObjectMap(validated as Map)), ); } diff --git a/example/test/verify_implements_works.dart b/example/test/verify_implements_works.dart index 3048d3d5..c1a5a20b 100644 --- a/example/test/verify_implements_works.dart +++ b/example/test/verify_implements_works.dart @@ -80,6 +80,29 @@ void main() { ); }); + test('rejects nested map mutation via raw Map access at runtime', () { + final person = PersonType.parse({ + 'name': 'John', + 'email': 'john@example.com', + 'address': { + 'street': 'Main St', + 'city': 'Quito', + 'zipCode': '17000', + 'country': 'Ecuador', + }, + 'age': 30, + }); + + final Map map = person; + final Map addressMap = + map['address'] as Map; + + expect( + () => addressMap['city'] = 'Guayaquil', + throwsA(isA()), + ); + }); + test('safeParse returns SchemaResult', () { final result = UserType.safeParse({ 'name': 'John', diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 2f2418c3..11501c6e 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -22,6 +22,7 @@ export 'src/schemas/extensions/string_schema_extensions.dart'; export 'src/json_schema.dart'; // Core schemas export 'src/schemas/schema.dart'; +export 'src/utils/deep_freeze_utils.dart'; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; // Validation results diff --git a/packages/ack/lib/src/utils/deep_freeze_utils.dart b/packages/ack/lib/src/utils/deep_freeze_utils.dart new file mode 100644 index 00000000..439eadc6 --- /dev/null +++ b/packages/ack/lib/src/utils/deep_freeze_utils.dart @@ -0,0 +1,18 @@ +/// Public deep-freeze helpers used by generated Ack types. +library; + +import 'default_utils.dart'; + +/// Recursively freezes supported collection values. +/// +/// Maps, lists, and sets are converted to unmodifiable deep copies. +Object? ackDeepFreeze(Object? value) => cloneDefault(value); + +/// Deep-freezes a JSON object map while preserving its key type. +Map ackDeepFreezeObjectMap(Map value) { + final frozen = {}; + value.forEach((key, entryValue) { + frozen[key] = ackDeepFreeze(entryValue); + }); + return Map.unmodifiable(frozen); +} diff --git a/packages/ack/lib/src/utils/default_utils.dart b/packages/ack/lib/src/utils/default_utils.dart index b2f054cf..a5ccd554 100644 --- a/packages/ack/lib/src/utils/default_utils.dart +++ b/packages/ack/lib/src/utils/default_utils.dart @@ -5,6 +5,7 @@ library; /// /// - Maps: Creates unmodifiable copy with recursively cloned values /// - Lists: Creates unmodifiable copy with recursively cloned items +/// - Sets: Creates unmodifiable copy with recursively cloned items /// - Primitives: Returns as-is (immutable by nature) /// /// Ensures default values are safely reused without shared-state bugs. @@ -41,6 +42,10 @@ Object? cloneDefault(Object? value) { return List.unmodifiable(value.map(cloneDefault)); } + if (value is Set) { + return Set.unmodifiable(value.map(cloneDefault)); + } + // Primitives / value types are immutable enough for defaults (String, num, bool, etc.). return value; } diff --git a/packages/ack/test/default_utils_test.dart b/packages/ack/test/default_utils_test.dart index 816c83cd..e2beffb2 100644 --- a/packages/ack/test/default_utils_test.dart +++ b/packages/ack/test/default_utils_test.dart @@ -65,5 +65,21 @@ void main() { const primitive = 'hello'; expect(identical(cloneDefault(primitive), primitive), isTrue); }); + + test('deep clones sets and nested set contents', () { + final original = { + 'items': { + 'a', + {'value': 1}, + }, + }; + + final cloned = cloneDefault(original) as Map; + final clonedSet = cloned['items'] as Set; + final clonedNestedMap = clonedSet.firstWhere((e) => e is Map) as Map; + + expect(() => clonedSet.add('b'), throwsUnsupportedError); + expect(() => clonedNestedMap['value'] = 2, throwsUnsupportedError); + }); }); } diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart index 15d065eb..3d2189e6 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -516,9 +516,10 @@ class TypeBuilder { List _buildStaticFactories(ModelInfo model, String schemaVarName) { final typeName = _getExtensionTypeName(model); final castType = model.representationType; - final representationValue = _wrapRepresentationValue( - castType: castType, + final representationValue = _castRepresentationValue( sourceExpression: 'validated', + castType: castType, + deepFreezeObjectMap: true, ); return [ @@ -592,7 +593,7 @@ return $schemaVarName.safeParseAs( return $schemaVarName.parseAs( data, (validated) { - final map = ${_validatedObjectMapExpression('validated')}; + final map = ${_castRepresentationValue(sourceExpression: 'validated', castType: kMapType, deepFreezeObjectMap: true)}; return $switchExpression; }, );'''), @@ -628,40 +629,28 @@ return $schemaVarName.parseAs( return $schemaVarName.safeParseAs( data, (validated) { - final map = ${_validatedObjectMapExpression('validated')}; + final map = ${_castRepresentationValue(sourceExpression: 'validated', castType: kMapType, deepFreezeObjectMap: true)}; return $switchExpression; }, );'''), ); } - String _wrapRepresentationValue({ - required String castType, + String _castRepresentationValue({ required String sourceExpression, + required String castType, + bool deepFreezeObjectMap = false, }) { final castExpression = '$sourceExpression as $castType'; if (castType == kMapType) { + if (deepFreezeObjectMap) { + return '${_qualifyAckSymbol('ackDeepFreezeObjectMap')}($castExpression)'; + } return 'Map.unmodifiable($castExpression)'; } return castExpression; } - String _validatedObjectMapExpression(String sourceExpression) { - const mapType = 'Map'; - final castExpression = '$sourceExpression as $mapType'; - return '$mapType.unmodifiable($castExpression)'; - } - - String _castRepresentationValue({ - required String sourceExpression, - required String castType, - }) { - if (castType == kMapType) { - return 'Map.unmodifiable($sourceExpression as $castType)'; - } - return '$sourceExpression as $castType'; - } - String _buildDiscriminatorSwitchExpression( String mapVarName, String discriminatorKey, diff --git a/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart index 1849676a..a711a9be 100644 --- a/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart +++ b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart @@ -7,14 +7,16 @@ import '../test_utils/test_assets.dart'; void main() { group('@AckType object wrappers are immutable', () { - test('uses Map.unmodifiable while preserving Map compatibility', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'deep-freezes object wrappers while preserving Map compatibility', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -39,22 +41,27 @@ final petSchema = Ack.discriminated( }, ); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type CatType(Map _data)'), - contains('implements PetType, Map'), - contains('(validated) => CatType('), - contains('(validated) => DogType('), - contains( - 'Map.unmodifiable(validated as Map)', - ), - contains('final map = Map.unmodifiable('), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('extension type CatType(Map _data)'), + contains('implements PetType, Map'), + contains('CatType(ackDeepFreezeObjectMap('), + contains('DogType(ackDeepFreezeObjectMap('), + contains( + 'ackDeepFreezeObjectMap(validated as Map)', + ), + contains('final map = ackDeepFreezeObjectMap('), + isNot(contains('Object? _\$ackDeepFreeze(')), + isNot( + contains('Map _\$ackDeepFreezeObjectMap('), + ), + ]), + ), + }, + ); + }, + ); }); } From d64ac3b8f233a1a6d65108e9054232466d64e3d7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 3 Mar 2026 14:13:32 -0500 Subject: [PATCH 3/5] chore: apply dart format across packages --- .../list_schema_extensions_test.dart | 12 +- .../ack_firebase_ai/example/basic_usage.dart | 22 +- .../ack_firebase_ai/lib/ack_firebase_ai.dart | 6 +- .../test/to_firebase_ai_schema_test.dart | 228 ++++++++---------- .../lib/src/validation/model_validator.dart | 4 +- .../test/additional_properties_args_test.dart | 166 +++++++------ .../test/description_generation_test.dart | 4 +- packages/ack_generator/test/enum_test.dart | 43 ++-- .../lib/ack_json_schema_builder.dart | 17 +- .../test/to_json_schema_builder_test.dart | 124 +++++++--- 10 files changed, 341 insertions(+), 285 deletions(-) diff --git a/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart b/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart index 3590ccb1..250f5838 100644 --- a/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart +++ b/packages/ack/test/schemas/extensions/list_schema_extensions_test.dart @@ -179,7 +179,11 @@ void main() { // 1 (int) and 1.0 (double) are different types, so both should be allowed final result = schema.safeParse([1, 1.0]); - expect(result.isOk, isTrue, reason: 'int and double are different types'); + expect( + result.isOk, + isTrue, + reason: 'int and double are different types', + ); }); test('should detect duplicate integers', () { @@ -191,7 +195,11 @@ void main() { test('should detect duplicate doubles', () { final schema = Ack.list(Ack.any()).unique(); final result = schema.safeParse([1.0, 2.0, 1.0]); - expect(result.isOk, isFalse, reason: 'same double values are duplicates'); + expect( + result.isOk, + isFalse, + reason: 'same double values are duplicates', + ); }); }); }); diff --git a/packages/ack_firebase_ai/example/basic_usage.dart b/packages/ack_firebase_ai/example/basic_usage.dart index 8d854999..800a0bbe 100644 --- a/packages/ack_firebase_ai/example/basic_usage.dart +++ b/packages/ack_firebase_ai/example/basic_usage.dart @@ -25,10 +25,7 @@ void main() { final blogSchema = Ack.object({ 'title': Ack.string().minLength(5).maxLength(100), 'content': Ack.string().minLength(10), - 'author': Ack.object({ - 'name': Ack.string(), - 'email': Ack.string().email(), - }), + 'author': Ack.object({'name': Ack.string(), 'email': Ack.string().email()}), 'tags': Ack.list(Ack.string()).minLength(1).maxLength(5), 'published': Ack.boolean(), }); @@ -51,7 +48,9 @@ void main() { }); final geminiProductSchema = productSchema.toFirebaseAiSchema(); - print('Product schema required fields: ${geminiProductSchema.toJson()['required']}'); + print( + 'Product schema required fields: ${geminiProductSchema.toJson()['required']}', + ); print(''); // Example 4: Validating AI Response @@ -64,10 +63,7 @@ void main() { final geminiSimpleSchema = simpleSchema.toFirebaseAiSchema(); // Simulate AI response - final aiResponse = { - 'message': 'Hello, World!', - 'count': 42, - }; + final aiResponse = {'message': 'Hello, World!', 'count': 42}; // Validate with ACK final result = simpleSchema.safeParse(aiResponse); @@ -93,8 +89,12 @@ void main() { final constrainedJson = geminiConstrainedSchema.toJson(); // Firebase AI Schema doesn't expose string length constraints in JSON. // This means Gemini won't enforce minLength/maxLength - you must validate afterward. - print('Gemini schema exposes minLength key: ${constrainedJson.containsKey("minLength")}'); - print('Gemini schema exposes maxLength key: ${constrainedJson.containsKey("maxLength")}'); + print( + 'Gemini schema exposes minLength key: ${constrainedJson.containsKey("minLength")}', + ); + print( + 'Gemini schema exposes maxLength key: ${constrainedJson.containsKey("maxLength")}', + ); // Test invalid data final tooShort = 'hi'; diff --git a/packages/ack_firebase_ai/lib/ack_firebase_ai.dart b/packages/ack_firebase_ai/lib/ack_firebase_ai.dart index 470e0058..4d4cc150 100644 --- a/packages/ack_firebase_ai/lib/ack_firebase_ai.dart +++ b/packages/ack_firebase_ai/lib/ack_firebase_ai.dart @@ -118,13 +118,15 @@ firebase_ai.Schema _array(JsonSchema schema) { firebase_ai.Schema _object(JsonSchema schema) { final properties = {}; - for (final entry in schema.properties?.entries ?? const >[]) { + for (final entry + in schema.properties?.entries ?? const >[]) { properties[entry.key] = _convert(entry.value); } final optional = []; final required = schema.required ?? const []; - for (final entry in schema.properties?.entries ?? const >[]) { + for (final entry + in schema.properties?.entries ?? const >[]) { if (!required.contains(entry.key)) { optional.add(entry.key); } diff --git a/packages/ack_firebase_ai/test/to_firebase_ai_schema_test.dart b/packages/ack_firebase_ai/test/to_firebase_ai_schema_test.dart index face9c18..e8a3be8b 100644 --- a/packages/ack_firebase_ai/test/to_firebase_ai_schema_test.dart +++ b/packages/ack_firebase_ai/test/to_firebase_ai_schema_test.dart @@ -5,6 +5,7 @@ import 'package:test/test.dart'; // Test enum types for Dart enum conversion enum Color { red, green, blue, yellow } + enum Status { pending, active, completed } /// Tests for the toFirebaseAiSchema() extension method. @@ -47,8 +48,11 @@ void main() { final result = schema.toFirebaseAiSchema(); expect(result.type, firebase_ai.SchemaType.string); - expect(result.toJson().containsKey('minLength'), isFalse, - reason: 'firebase_ai Schema currently omits minLength metadata'); + expect( + result.toJson().containsKey('minLength'), + isFalse, + reason: 'firebase_ai Schema currently omits minLength metadata', + ); }); test('converts string with maxLength (not currently surfaced)', () { @@ -56,8 +60,11 @@ void main() { final result = schema.toFirebaseAiSchema(); expect(result.type, firebase_ai.SchemaType.string); - expect(result.toJson().containsKey('maxLength'), isFalse, - reason: 'firebase_ai Schema currently omits maxLength metadata'); + expect( + result.toJson().containsKey('maxLength'), + isFalse, + reason: 'firebase_ai Schema currently omits maxLength metadata', + ); }); test('converts string with email format', () { @@ -117,10 +124,7 @@ void main() { group('Objects', () { test('converts basic object schema', () { - final schema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), - }); + final schema = Ack.object({'name': Ack.string(), 'age': Ack.integer()}); final result = schema.toFirebaseAiSchema(); expect(result.type, firebase_ai.SchemaType.object); @@ -221,10 +225,7 @@ void main() { test('converts array of objects', () { final schema = Ack.list( - Ack.object({ - 'id': Ack.integer(), - 'name': Ack.string(), - }), + Ack.object({'id': Ack.integer(), 'name': Ack.string()}), ); final result = schema.toFirebaseAiSchema(); @@ -236,20 +237,14 @@ void main() { test('array schema matches expected Firebase Schema snapshot', () { final schema = Ack.list( - Ack.object({ - 'id': Ack.string(), - 'score': Ack.double().min(0).max(1), - }), + Ack.object({'id': Ack.string(), 'score': Ack.double().min(0).max(1)}), ).minLength(1).maxLength(10); final expected = firebase_ai.Schema.array( items: firebase_ai.Schema.object( properties: { 'id': firebase_ai.Schema.string(), - 'score': firebase_ai.Schema.number( - minimum: 0, - maximum: 1, - ), + 'score': firebase_ai.Schema.number(minimum: 0, maximum: 1), }, propertyOrdering: ['id', 'score'], ), @@ -309,7 +304,10 @@ void main() { expect(company.type, firebase_ai.SchemaType.object); final address = company.properties!['address']!; expect(address.type, firebase_ai.SchemaType.object); - expect(address.properties!.keys, containsAll(['street', 'city', 'country'])); + expect( + address.properties!.keys, + containsAll(['street', 'city', 'country']), + ); }); }); @@ -323,10 +321,7 @@ void main() { }); test('handles anyOf by converting to Schema.anyOf', () { - final schema = Ack.anyOf([ - Ack.string(), - Ack.integer(), - ]); + final schema = Ack.anyOf([Ack.string(), Ack.integer()]); final result = schema.toFirebaseAiSchema(); expect(result.type, firebase_ai.SchemaType.anyOf); @@ -345,20 +340,23 @@ void main() { expect(result.nullable, isNull); }); - test('throws UnsupportedError for unsupported schema types with helpful message', () { - const schema = TestUnsupportedAckSchema(); + test( + 'throws UnsupportedError for unsupported schema types with helpful message', + () { + const schema = TestUnsupportedAckSchema(); - expect( - () => schema.toFirebaseAiSchema(), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('TestUnsupportedAckSchema'), + expect( + () => schema.toFirebaseAiSchema(), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('TestUnsupportedAckSchema'), + ), ), - ), - ); - }); + ); + }, + ); test('handles empty anyOf gracefully', () { // AnyOf with empty schemas list @@ -371,9 +369,7 @@ void main() { }); test('handles empty object in anyOf', () { - final schema = Ack.anyOf([ - Ack.object({}), - ]); + final schema = Ack.anyOf([Ack.object({})]); final result = schema.toFirebaseAiSchema(); @@ -400,10 +396,7 @@ void main() { }); // Should not throw - expect( - () => schema.toFirebaseAiSchema(), - returnsNormally, - ); + expect(() => schema.toFirebaseAiSchema(), returnsNormally); final result = schema.toFirebaseAiSchema(); expect(result.type, firebase_ai.SchemaType.object); @@ -451,7 +444,9 @@ void main() { test('TransformedSchema preserves underlying schema format', () { // Datetime has format 'date-time', add description via copyWith - final datetimeSchema = Ack.datetime().copyWith(description: 'Event timestamp'); + final datetimeSchema = Ack.datetime().copyWith( + description: 'Event timestamp', + ); final result = datetimeSchema.toFirebaseAiSchema(); @@ -460,34 +455,45 @@ void main() { expect(result.description, 'Event timestamp'); }); - test('description override on TransformedSchema wins over base schema', () { - // Test that TransformedSchema's description takes precedence - final withDescription = Ack.date().copyWith(description: 'Overridden description'); + test( + 'description override on TransformedSchema wins over base schema', + () { + // Test that TransformedSchema's description takes precedence + final withDescription = Ack.date().copyWith( + description: 'Overridden description', + ); - final result = withDescription.toFirebaseAiSchema(); + final result = withDescription.toFirebaseAiSchema(); - expect(result.description, 'Overridden description'); - }); + expect(result.description, 'Overridden description'); + }, + ); }); group('TransformedSchema support', () { - test('converts date schema by unwrapping to underlying string schema', () { - final schema = Ack.date(); + test( + 'converts date schema by unwrapping to underlying string schema', + () { + final schema = Ack.date(); - final result = schema.toFirebaseAiSchema(); + final result = schema.toFirebaseAiSchema(); - expect(result.type, firebase_ai.SchemaType.string); - expect(result.format, 'date'); - }); + expect(result.type, firebase_ai.SchemaType.string); + expect(result.format, 'date'); + }, + ); - test('converts datetime schema by unwrapping to underlying string schema', () { - final schema = Ack.datetime(); + test( + 'converts datetime schema by unwrapping to underlying string schema', + () { + final schema = Ack.datetime(); - final result = schema.toFirebaseAiSchema(); + final result = schema.toFirebaseAiSchema(); - expect(result.type, firebase_ai.SchemaType.string); - expect(result.format, 'date-time'); - }); + expect(result.type, firebase_ai.SchemaType.string); + expect(result.format, 'date-time'); + }, + ); test('converts transformed schema in arrays', () { final schema = Ack.list(Ack.date()); @@ -517,9 +523,7 @@ void main() { }); test('converts top-level TransformedSchema properties correctly', () { - final schema = Ack.object({ - 'timestamp': Ack.datetime(), - }); + final schema = Ack.object({'timestamp': Ack.datetime()}); final result = schema.toFirebaseAiSchema(); @@ -532,9 +536,7 @@ void main() { test('converts deeply nested TransformedSchema properties correctly', () { final schema = Ack.object({ 'data': Ack.object({ - 'metadata': Ack.object({ - 'createdAt': Ack.date(), - }), + 'metadata': Ack.object({'createdAt': Ack.date()}), }), }); @@ -676,10 +678,7 @@ void main() { final result = schema.toFirebaseAiSchema(); // When all fields are required, optionalProperties should be null or empty - expect( - result.optionalProperties, - anyOf(isNull, isEmpty), - ); + expect(result.optionalProperties, anyOf(isNull, isEmpty)); }); }); @@ -715,10 +714,7 @@ void main() { }), ); - expect( - () => schema.toFirebaseAiSchema(), - returnsNormally, - ); + expect(() => schema.toFirebaseAiSchema(), returnsNormally); }); test('handles anyOf with objects', () { @@ -779,9 +775,7 @@ void main() { final schema = Ack.discriminated( discriminatorKey: 'type', schemas: { - 'circle': Ack.object({ - 'radius': Ack.double(), - }), + 'circle': Ack.object({'radius': Ack.double()}), 'rectangle': Ack.object({ 'width': Ack.double(), 'height': Ack.double(), @@ -808,10 +802,7 @@ void main() { }); test('handles empty discriminated schema', () { - final schema = Ack.discriminated( - discriminatorKey: 'type', - schemas: {}, - ); + final schema = Ack.discriminated(discriminatorKey: 'type', schemas: {}); final result = schema.toFirebaseAiSchema(); @@ -824,13 +815,8 @@ void main() { final schema = Ack.discriminated( discriminatorKey: 'type', schemas: { - 'circle': Ack.object({ - 'radius': Ack.double(), - }), - 'point': Ack.object({ - 'x': Ack.double(), - 'y': Ack.double(), - }), + 'circle': Ack.object({'radius': Ack.double()}), + 'point': Ack.object({'x': Ack.double(), 'y': Ack.double()}), }, ); @@ -871,10 +857,7 @@ void main() { ), }); - expect( - () => schema.toFirebaseAiSchema(), - returnsNormally, - ); + expect(() => schema.toFirebaseAiSchema(), returnsNormally); final result = schema.toFirebaseAiSchema(); final shapeProp = result.properties!['shape']!; @@ -958,9 +941,7 @@ void main() { group('Error wrapping', () { test('includes property path when child conversion fails', () { - final schema = Ack.object({ - 'bad': const TestUnsupportedAckSchema(), - }); + final schema = Ack.object({'bad': const TestUnsupportedAckSchema()}); expect( () => schema.toFirebaseAiSchema(), @@ -1045,8 +1026,12 @@ void main() { expect(schema.safeParse('hi').isFail, isTrue); expect(geminiSchema.type, firebase_ai.SchemaType.string); - expect(geminiSchema.toJson().containsKey('minLength'), isFalse, - reason: 'firebase_ai Schema omits minLength metadata; track externally'); + expect( + geminiSchema.toJson().containsKey('minLength'), + isFalse, + reason: + 'firebase_ai Schema omits minLength metadata; track externally', + ); }); test('maxLength constraint preserves validation behavior', () { @@ -1057,8 +1042,12 @@ void main() { expect(schema.safeParse('this is way too long').isFail, isTrue); expect(geminiSchema.type, firebase_ai.SchemaType.string); - expect(geminiSchema.toJson().containsKey('maxLength'), isFalse, - reason: 'firebase_ai Schema omits maxLength metadata; track externally'); + expect( + geminiSchema.toJson().containsKey('maxLength'), + isFalse, + reason: + 'firebase_ai Schema omits maxLength metadata; track externally', + ); }); test('email format constraint preserves validation behavior', () { @@ -1132,10 +1121,7 @@ void main() { group('Semantic validation - Object Structure', () { test('required fields validation behavior preserved', () { - final schema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), - }); + final schema = Ack.object({'name': Ack.string(), 'age': Ack.integer()}); final geminiSchema = schema.toFirebaseAiSchema(); expect(schema.safeParse({'name': 'John', 'age': 30}).isOk, isTrue); @@ -1156,7 +1142,11 @@ void main() { expect(schema.safeParse({'name': 'John'}).isOk, isTrue); expect( - schema.safeParse({'name': 'John', 'age': 30, 'email': 'john@example.com'}).isOk, + schema.safeParse({ + 'name': 'John', + 'age': 30, + 'email': 'john@example.com', + }).isOk, isTrue, ); expect(schema.safeParse({'age': 30}).isFail, isTrue); @@ -1234,10 +1224,7 @@ void main() { test('array of objects validation behavior preserved', () { final schema = Ack.list( - Ack.object({ - 'id': Ack.integer(), - 'name': Ack.string().minLength(1), - }), + Ack.object({'id': Ack.integer(), 'name': Ack.string().minLength(1)}), ); final geminiSchema = schema.toFirebaseAiSchema(); @@ -1281,9 +1268,7 @@ void main() { }); test('nullable object accepts null', () { - final schema = Ack.object({ - 'name': Ack.string(), - }).nullable(); + final schema = Ack.object({'name': Ack.string()}).nullable(); final geminiSchema = schema.toFirebaseAiSchema(); expect(schema.safeParse(null).isOk, isTrue); @@ -1346,7 +1331,10 @@ void main() { ); final requiredFromJson = geminiSchema.toJson()['required'] as List; - expect(requiredFromJson, unorderedEquals(['username', 'email', 'password'])); + expect( + requiredFromJson, + unorderedEquals(['username', 'email', 'password']), + ); }); test('blog post schema validates correctly', () { @@ -1366,10 +1354,7 @@ void main() { schema.safeParse({ 'title': 'My First Blog Post', 'content': 'This is the content of my blog post.', - 'author': { - 'name': 'John Doe', - 'email': 'john@example.com', - }, + 'author': {'name': 'John Doe', 'email': 'john@example.com'}, 'tags': ['tech', 'tutorial'], 'published': true, }).isOk, @@ -1410,10 +1395,7 @@ void main() { final schema = Ack.string(); // This should work (static method) - expect( - () => schema.toFirebaseAiSchema(), - returnsNormally, - ); + expect(() => schema.toFirebaseAiSchema(), returnsNormally); // Note: We cannot directly test private constructor in Dart, // but attempting to instantiate would be a compile-time error: diff --git a/packages/ack_generator/lib/src/validation/model_validator.dart b/packages/ack_generator/lib/src/validation/model_validator.dart index 20797801..bb062ee5 100644 --- a/packages/ack_generator/lib/src/validation/model_validator.dart +++ b/packages/ack_generator/lib/src/validation/model_validator.dart @@ -41,7 +41,9 @@ class ModelValidator { for (final field in modelInfo.fields) { if (field.isNestedSchema) { // Use withNullability: false to get type name without '?' suffix - final fieldTypeName = field.type.getDisplayString(withNullability: false); + final fieldTypeName = field.type.getDisplayString( + withNullability: false, + ); if (fieldTypeName == modelInfo.className) { // Direct self-reference is okay if it's nullable if (!field.isNullable) { diff --git a/packages/ack_generator/test/additional_properties_args_test.dart b/packages/ack_generator/test/additional_properties_args_test.dart index 01cdcf48..fc31462b 100644 --- a/packages/ack_generator/test/additional_properties_args_test.dart +++ b/packages/ack_generator/test/additional_properties_args_test.dart @@ -7,16 +7,16 @@ import 'test_utils/test_assets.dart'; void main() { group('Additional Properties Args Getter', () { - - test('generates args getter for schema variable with .passthrough()', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + test( + 'generates args getter for schema variable with .passthrough()', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -26,28 +26,29 @@ final userSchema = Ack.object({ 'age': Ack.integer(), }).passthrough(); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name' && e.key != 'age'"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('Map get args =>'), + contains("e.key != 'name' && e.key != 'age'"), + ]), + ), + }, + ); + }, + ); test( - 'generates args getter for schema variable with explicit additionalProperties: true', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + 'generates args getter for schema variable with explicit additionalProperties: true', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -57,27 +58,29 @@ final userSchema = Ack.object({ 'age': Ack.integer(), }, additionalProperties: true); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name' && e.key != 'age'"), - ]), - ), - }, - ); - }); - - test('does not generate args getter for schema variable without additionalProperties', - () async { - final builder = ackGenerator(BuilderOptions.empty); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('Map get args =>'), + contains("e.key != 'name' && e.key != 'age'"), + ]), + ), + }, + ); + }, + ); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + test( + 'does not generate args getter for schema variable without additionalProperties', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -87,43 +90,46 @@ final userSchema = Ack.object({ 'age': Ack.integer(), }); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - isNot(contains('Map get args')), - ), - }, - ); - }); - - test('generates args getter with no conditions when there are no fields', - () async { - final builder = ackGenerator(BuilderOptions.empty); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + isNot(contains('Map get args')), + ), + }, + ); + }, + ); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/empty.dart': ''' + test( + 'generates args getter with no conditions when there are no fields', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/empty.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @AckType() final emptySchema = Ack.object({}, additionalProperties: true); ''', - }, - outputs: { - 'test_pkg|lib/empty.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains('_data'), - // Should not have filter conditions when no fields exist - isNot(contains('where')), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/empty.g.dart': decodedMatches( + allOf([ + contains('Map get args =>'), + contains('_data'), + // Should not have filter conditions when no fields exist + isNot(contains('where')), + ]), + ), + }, + ); + }, + ); test('generates correct filter for single field', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/description_generation_test.dart b/packages/ack_generator/test/description_generation_test.dart index d5128b35..ed45baf5 100644 --- a/packages/ack_generator/test/description_generation_test.dart +++ b/packages/ack_generator/test/description_generation_test.dart @@ -302,7 +302,9 @@ class User { contains('final userSchema = Ack.object({'), // Verify annotation description is used, not doc comment contains('Public user ID'), - isNot(contains('Internal identifier used for database operations')), + isNot( + contains('Internal identifier used for database operations'), + ), ]), ), }, diff --git a/packages/ack_generator/test/enum_test.dart b/packages/ack_generator/test/enum_test.dart index c93ce5a2..c339811d 100644 --- a/packages/ack_generator/test/enum_test.dart +++ b/packages/ack_generator/test/enum_test.dart @@ -7,14 +7,16 @@ import 'test_utils/test_assets.dart'; void main() { group('Enum Support Tests', () { - test('should generate schema for simple enum field using Ack.enumValues', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'should generate schema for simple enum field using Ack.enumValues', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/model.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/model.dart': ''' import 'package:ack_annotations/ack_annotations.dart'; enum Status { active, inactive, pending } @@ -27,19 +29,20 @@ class User { User({required this.name, required this.status}); } ''', - }, - outputs: { - 'test_pkg|lib/model.g.dart': decodedMatches( - allOf([ - contains('final userSchema = Ack.object('), - contains("'name': Ack.string()"), - // Now uses Ack.enumValues(T.values) instead of enumString - contains("'status': Ack.enumValues(Status.values)"), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches( + allOf([ + contains('final userSchema = Ack.object('), + contains("'name': Ack.string()"), + // Now uses Ack.enumValues(T.values) instead of enumString + contains("'status': Ack.enumValues(Status.values)"), + ]), + ), + }, + ); + }, + ); test('should handle nullable enum fields', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart index 5b484092..a6052a51 100644 --- a/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart +++ b/packages/ack_json_schema_builder/lib/ack_json_schema_builder.dart @@ -14,7 +14,7 @@ /// 'age': Ack.integer().min(0).optional(), /// }); /// -/// // Convert to json_schema_builder +/// // Convert to json_schema_builder /// final jsbSchema = schema.toJsonSchemaBuilder(); /// ``` library; @@ -137,7 +137,9 @@ jsb.Schema _convertBoolean(JsonSchema schema, bool? nullableFlag) { } jsb.Schema _convertArray(JsonSchema schema, bool? nullableFlag) { - final items = schema.items != null ? _convert(schema.items!) : jsb.Schema.any(); + final items = schema.items != null + ? _convert(schema.items!) + : jsb.Schema.any(); final base = jsb.Schema.list( items: items, description: schema.description, @@ -151,13 +153,18 @@ jsb.Schema _convertArray(JsonSchema schema, bool? nullableFlag) { jsb.Schema _convertObject(JsonSchema schema, bool? nullableFlag) { final props = {}; - for (final entry in schema.properties?.entries ?? const >[]) { - props[entry.key] = wrapPropertyConversion(entry.key, () => _convert(entry.value)); + for (final entry + in schema.properties?.entries ?? const >[]) { + props[entry.key] = wrapPropertyConversion( + entry.key, + () => _convert(entry.value), + ); } final required = schema.required ?? const []; - final additional = schema.additionalPropertiesAllowed ?? + final additional = + schema.additionalPropertiesAllowed ?? (schema.additionalPropertiesSchema != null ? _convert(schema.additionalPropertiesSchema!) : true); diff --git a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart index e84c72ef..94933a1a 100644 --- a/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/to_json_schema_builder_test.dart @@ -216,7 +216,9 @@ void main() { test('object respects additionalProperties flag', () { final strict = Ack.object({'name': Ack.string()}); - final passthrough = Ack.object({'name': Ack.string()}, additionalProperties: true); + final passthrough = Ack.object({ + 'name': Ack.string(), + }, additionalProperties: true); final strictResult = strict.toJsonSchemaBuilder(); expect(strictResult.value['additionalProperties'], false); @@ -258,26 +260,29 @@ void main() { expect(outerAnyOf.last.value['type'], 'null'); }); - test('TransformedSchema overrides are applied (description + nullable)', () { - final schema = Ack.date().copyWith( - description: 'Birth date', - isNullable: true, - ); - - final result = schema.toJsonSchemaBuilder(); - final anyOf = (result.value['anyOf'] as List) - .map(_schemaFrom) - .toList(growable: false); - - // First branch should carry description override and date format - final dateBranch = anyOf.first; - expect(dateBranch.value['description'], 'Birth date'); - expect(dateBranch.value['format'], 'date'); - - // Second branch represents nullability - final nullBranch = anyOf.last; - expect(nullBranch.value['type'], 'null'); - }); + test( + 'TransformedSchema overrides are applied (description + nullable)', + () { + final schema = Ack.date().copyWith( + description: 'Birth date', + isNullable: true, + ); + + final result = schema.toJsonSchemaBuilder(); + final anyOf = (result.value['anyOf'] as List) + .map(_schemaFrom) + .toList(growable: false); + + // First branch should carry description override and date format + final dateBranch = anyOf.first; + expect(dateBranch.value['description'], 'Birth date'); + expect(dateBranch.value['format'], 'date'); + + // Second branch represents nullability + final nullBranch = anyOf.last; + expect(nullBranch.value['type'], 'null'); + }, + ); }); group('oneOf composition', () { @@ -292,10 +297,17 @@ void main() { final result = schema.toJsonSchemaBuilder(); // MUST have oneOf, NOT anyOf - discriminated unions require exactly-one semantics - expect(result.value.containsKey('oneOf'), isTrue, - reason: 'Discriminated schema should use oneOf for exactly-one semantics'); - expect(result.value.containsKey('anyOf'), isFalse, - reason: 'oneOf should not be converted to anyOf'); + expect( + result.value.containsKey('oneOf'), + isTrue, + reason: + 'Discriminated schema should use oneOf for exactly-one semantics', + ); + expect( + result.value.containsKey('anyOf'), + isFalse, + reason: 'oneOf should not be converted to anyOf', + ); }); test('oneOf preserves branch schemas', () { @@ -337,7 +349,12 @@ void main() { }); test('integer preserves all numeric constraints together', () { - final schema = Ack.integer().min(0).max(100).greaterThan(-1).lessThan(101).multipleOf(5); + final schema = Ack.integer() + .min(0) + .max(100) + .greaterThan(-1) + .lessThan(101) + .multipleOf(5); final result = schema.toJsonSchemaBuilder(); expect(result.value['minimum'], 0); @@ -369,7 +386,12 @@ void main() { }); test('double preserves all numeric constraints together', () { - final schema = Ack.double().min(0).max(100).greaterThan(-0.5).lessThan(100.5).multipleOf(0.1); + final schema = Ack.double() + .min(0) + .max(100) + .greaterThan(-0.5) + .lessThan(100.5) + .multipleOf(0.1); final result = schema.toJsonSchemaBuilder(); expect(result.value['minimum'], closeTo(0, 1e-9)); @@ -405,9 +427,7 @@ void main() { }); test('wraps property conversion errors with path', () { - final schema = Ack.object({ - 'bad': const TestUnsupportedAckSchema(), - }); + final schema = Ack.object({'bad': const TestUnsupportedAckSchema()}); expect( () => schema.toJsonSchemaBuilder(), @@ -431,7 +451,9 @@ void main() { }); test('additionalProperties: true converts to boolean', () { - final schema = Ack.object({'name': Ack.string()}, additionalProperties: true); + final schema = Ack.object({ + 'name': Ack.string(), + }, additionalProperties: true); final result = schema.toJsonSchemaBuilder(); // Should be true (boolean), not {} (schema) @@ -447,8 +469,18 @@ void main() { // Create a JsonSchema with allOf directly (ACK doesn't have Ack.allOf() yet) final jsonSchema = JsonSchema.fromJson({ 'allOf': [ - {'type': 'object', 'properties': {'name': {'type': 'string'}}}, - {'type': 'object', 'properties': {'age': {'type': 'integer'}}}, + { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + }, + { + 'type': 'object', + 'properties': { + 'age': {'type': 'integer'}, + }, + }, ], }); @@ -456,10 +488,16 @@ void main() { final result = convertJsonSchemaToBuilder(jsonSchema); // Verify allOf is in the output - expect(result.value.containsKey('allOf'), isTrue, - reason: 'allOf should be converted to allOf'); - expect(result.value.containsKey('anyOf'), isFalse, - reason: 'allOf should not become anyOf'); + expect( + result.value.containsKey('allOf'), + isTrue, + reason: 'allOf should be converted to allOf', + ); + expect( + result.value.containsKey('anyOf'), + isFalse, + reason: 'allOf should not become anyOf', + ); final allOf = result.value['allOf'] as List; expect(allOf, hasLength(2)); @@ -487,7 +525,9 @@ void main() { test('minProperties is preserved in conversion', () { final jsonSchema = JsonSchema.fromJson({ 'type': 'object', - 'properties': {'name': {'type': 'string'}}, + 'properties': { + 'name': {'type': 'string'}, + }, 'minProperties': 2, }); @@ -499,7 +539,9 @@ void main() { test('maxProperties is preserved in conversion', () { final jsonSchema = JsonSchema.fromJson({ 'type': 'object', - 'properties': {'name': {'type': 'string'}}, + 'properties': { + 'name': {'type': 'string'}, + }, 'maxProperties': 5, }); @@ -511,7 +553,9 @@ void main() { test('both minProperties and maxProperties together', () { final jsonSchema = JsonSchema.fromJson({ 'type': 'object', - 'properties': {'name': {'type': 'string'}}, + 'properties': { + 'name': {'type': 'string'}, + }, 'minProperties': 1, 'maxProperties': 10, }); From fbf40b3f0bcb6047dac15f2b6747d5b0159c0694 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 3 Mar 2026 16:36:27 -0500 Subject: [PATCH 4/5] fix: remove redundant unmodifiable wraps and add runtime immutability tests - Remove getter-level Map.unmodifiable() on nested objects since data is already deep-frozen at parse time - Add acyclic-input assumption doc comment to cloneDefault - Add runtime tests verifying toJson(), args, and nested map mutation throws - Add aliased Ack import test for deep-freeze helper prefix resolution - Document toJson() frozen-map behavioral change in CHANGELOG --- example/lib/schema_types_edge_cases.g.dart | 25 +++----- example/test/extension_type_to_json_test.dart | 7 +++ example/test/verify_implements_works.dart | 22 +++++++ packages/ack/lib/src/utils/default_utils.dart | 3 + packages/ack_generator/CHANGELOG.md | 7 ++- .../lib/src/builders/type_builder.dart | 2 +- .../ack_type_cross_file_resolution_test.dart | 60 +++++++++---------- .../ack_type_discriminated_test.dart | 52 ++++++++++++++++ .../integration/ack_type_getter_test.dart | 2 +- .../ack_type_nested_schema_test.dart | 2 +- ...type_object_wrapper_immutability_test.dart | 1 + .../test/integration/nested_model_test.dart | 8 +-- 12 files changed, 133 insertions(+), 58 deletions(-) diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart index 42237695..e66fbd07 100644 --- a/example/lib/schema_types_edge_cases.g.dart +++ b/example/lib/schema_types_edge_cases.g.dart @@ -159,9 +159,8 @@ extension type PersonType(Map _data) String get email => _data['email'] as String; - AddressType get address => AddressType( - Map.unmodifiable(_data['address'] as Map), - ); + AddressType get address => + AddressType(_data['address'] as Map); int get age => _data['age'] as int; @@ -207,17 +206,11 @@ extension type EmployeeType(Map _data) String get employeeId => _data['employeeId'] as String; - AddressType get homeAddress => AddressType( - Map.unmodifiable( - _data['homeAddress'] as Map, - ), - ); + AddressType get homeAddress => + AddressType(_data['homeAddress'] as Map); - AddressType get workAddress => AddressType( - Map.unmodifiable( - _data['workAddress'] as Map, - ), - ); + AddressType get workAddress => + AddressType(_data['workAddress'] as Map); EmployeeType copyWith({ String? name, @@ -364,11 +357,7 @@ extension type ContactListType(Map _data) String get name => _data['name'] as String; List get addresses => (_data['addresses'] as List) - .map( - (e) => AddressType( - Map.unmodifiable(e as Map), - ), - ) + .map((e) => AddressType(e as Map)) .toList(); ContactListType copyWith({String? name, List? addresses}) { diff --git a/example/test/extension_type_to_json_test.dart b/example/test/extension_type_to_json_test.dart index c831da7e..e6b61882 100644 --- a/example/test/extension_type_to_json_test.dart +++ b/example/test/extension_type_to_json_test.dart @@ -11,6 +11,13 @@ void main() { expect(user.toJson(), isA>()); }); + test('object extension type toJson map is immutable', () { + final user = UserType.parse({'name': 'Alice', 'age': 30, 'active': true}); + final json = user.toJson(); + + expect(() => json['name'] = 'Bob', throwsA(isA())); + }); + test('primitive extension type returns wrapped value', () { final password = PasswordType.parse('mySecurePassword123'); diff --git a/example/test/verify_implements_works.dart b/example/test/verify_implements_works.dart index c1a5a20b..7f7de4d9 100644 --- a/example/test/verify_implements_works.dart +++ b/example/test/verify_implements_works.dart @@ -1,5 +1,6 @@ import 'package:ack_example/schema_types_simple.dart'; import 'package:ack_example/schema_types_edge_cases.dart'; +import 'package:ack_example/args_getter_example.dart'; import 'package:test/test.dart'; void main() { @@ -103,6 +104,27 @@ void main() { ); }); + test('rejects args map mutation at runtime', () { + final config = UserConfigType.parse({ + 'username': 'john', + 'email': 'john@example.com', + 'theme': 'dark', + 'metadata': {'region': 'us'}, + }); + + expect(config.args, { + 'theme': 'dark', + 'metadata': {'region': 'us'}, + }); + expect( + () => config.args['theme'] = 'light', + throwsA(isA()), + ); + + final metadata = config.args['metadata'] as Map; + expect(() => metadata['region'] = 'eu', throwsA(isA())); + }); + test('safeParse returns SchemaResult', () { final result = UserType.safeParse({ 'name': 'John', diff --git a/packages/ack/lib/src/utils/default_utils.dart b/packages/ack/lib/src/utils/default_utils.dart index a5ccd554..d94a4e46 100644 --- a/packages/ack/lib/src/utils/default_utils.dart +++ b/packages/ack/lib/src/utils/default_utils.dart @@ -10,6 +10,9 @@ library; /// /// Ensures default values are safely reused without shared-state bugs. /// +/// Assumes acyclic input (for example JSON-compatible values). Cyclic +/// structures are not supported and may recurse until stack overflow. +/// /// Example: /// ```dart /// final defaultValue = {'user': {'name': 'Guest'}}; diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 799a2ed4..5d79477b 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -11,6 +11,11 @@ ### Bug Fixes * **Discriminated validation**: `@AckType` now fails generation for invalid discriminated schemas (for example empty `schemas`, inline branches, nullable bases, or invalid branch references). +* **`@AckType` object wrappers**: `toJson()` for object-shaped extension types now returns the deep-frozen backing map. Callers should treat the result as read-only. + +### Improvements + +* **`@AckType` nested map getters**: Stop wrapping already frozen nested maps with extra `Map.unmodifiable(...)` allocations. ## 1.0.0-beta.7 @@ -70,4 +75,4 @@ ## 1.0.0-beta.1 (2025-10-06) -* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0-beta.1) for details. \ No newline at end of file +* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0-beta.1) for details. diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart index 3d2189e6..588c7a2a 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -646,7 +646,7 @@ return $schemaVarName.safeParseAs( if (deepFreezeObjectMap) { return '${_qualifyAckSymbol('ackDeepFreezeObjectMap')}($castExpression)'; } - return 'Map.unmodifiable($castExpression)'; + return castExpression; } return castExpression; } diff --git a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart index 6afd194a..70529662 100644 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ b/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart @@ -7,14 +7,16 @@ import '../test_utils/test_assets.dart'; void main() { group('@AckType cross-file schema references', () { - test('resolves typed nested getters across files (direct import)', () async { - final builder = ackGenerator(BuilderOptions.empty); + test( + 'resolves typed nested getters across files (direct import)', + () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/deck_schemas.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; @@ -24,7 +26,7 @@ final slideSchema = Ack.object({ 'title': Ack.string(), }); ''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' + 'test_pkg|lib/deck_tools_schemas.dart': ''' import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; import 'deck_schemas.dart'; @@ -35,28 +37,26 @@ final deckToolArgsSchema = Ack.object({ 'slides': Ack.list(slideSchema), }); ''', - }, - outputs: { - 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( - contains('extension type SlideType(Map _data)'), - ), - 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( - allOf([ - contains( - 'extension type DeckToolArgsType(Map _data)', - ), - contains('SlideType get currentSlide'), - contains('Map.unmodifiable('), - contains("_data['currentSlide'] as Map"), - contains('List get slides'), - contains( - 'Map.unmodifiable(e as Map)', - ), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/deck_schemas.g.dart': decodedMatches( + contains('extension type SlideType(Map _data)'), + ), + 'test_pkg|lib/deck_tools_schemas.g.dart': decodedMatches( + allOf([ + contains( + 'extension type DeckToolArgsType(Map _data)', + ), + contains('SlideType get currentSlide'), + contains("_data['currentSlide'] as Map"), + contains('List get slides'), + contains('SlideType(e as Map)'), + ]), + ), + }, + ); + }, + ); test('resolves typed nested getters for prefixed schema imports', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart index 58c65644..894660d1 100644 --- a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart +++ b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart @@ -31,6 +31,58 @@ Future _expectGenerationFailure({ void main() { group('@AckType discriminated schemas', () { + test( + 'uses prefixed deep-freeze helper when Ack import is aliased', + () async { + final builder = ackGenerator(BuilderOptions.empty); + + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' +import 'package:ack/ack.dart' as ack; +import 'package:ack_annotations/ack_annotations.dart'; + +@AckType() +final catSchema = ack.Ack.object({ + 'kind': ack.Ack.literal('cat'), + 'lives': ack.Ack.integer(), +}); + +@AckType() +final dogSchema = ack.Ack.object({ + 'kind': ack.Ack.literal('dog'), + 'bark': ack.Ack.boolean(), +}); + +@AckType() +final petSchema = ack.Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': catSchema, + 'dog': dogSchema, + }, +); +''', + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('ack.SchemaResult safeParse'), + contains('ack.SchemaResult safeParse'), + contains('ack.SchemaResult safeParse'), + contains( + 'ack.ackDeepFreezeObjectMap(validated as Map)', + ), + contains('final map = ack.ackDeepFreezeObjectMap('), + ]), + ), + }, + ); + }, + ); + test('generates discriminated base and subtype extension types', () async { final builder = ackGenerator(BuilderOptions.empty); diff --git a/packages/ack_generator/test/integration/ack_type_getter_test.dart b/packages/ack_generator/test/integration/ack_type_getter_test.dart index 9ded9037..62b72f9c 100644 --- a/packages/ack_generator/test/integration/ack_type_getter_test.dart +++ b/packages/ack_generator/test/integration/ack_type_getter_test.dart @@ -82,7 +82,7 @@ ObjectSchema get userSchema { contains('extension type UserType(Map _data)'), contains('CustomAddressType get address'), contains( - "Map.unmodifiable(_data['address'] as Map)", + "CustomAddressType(_data['address'] as Map)", ), ]), ), diff --git a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart b/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart index d1e582b4..d8347a4d 100644 --- a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart +++ b/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart @@ -50,7 +50,7 @@ final userSchema = Ack.object({ contains("StatusType(_data['status'] as String)"), contains('AddressType get address'), contains( - "Map.unmodifiable(_data['address'] as Map)", + "AddressType(_data['address'] as Map)", ), contains('List get aliases'), contains('StatusType(e as String)'), diff --git a/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart index a711a9be..0c39b3b1 100644 --- a/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart +++ b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart @@ -47,6 +47,7 @@ final petSchema = Ack.discriminated( allOf([ contains('extension type CatType(Map _data)'), contains('implements PetType, Map'), + contains('Map toJson() => _data;'), contains('CatType(ackDeepFreezeObjectMap('), contains('DogType(ackDeepFreezeObjectMap('), contains( diff --git a/packages/ack_generator/test/integration/nested_model_test.dart b/packages/ack_generator/test/integration/nested_model_test.dart index 0107a5b5..bd831473 100644 --- a/packages/ack_generator/test/integration/nested_model_test.dart +++ b/packages/ack_generator/test/integration/nested_model_test.dart @@ -218,9 +218,7 @@ final contactListSchema = Ack.object({ // KEY: List getter with .map() and .toList() contains('List get addresses'), - contains( - 'Map.unmodifiable(e as Map)', - ), + contains('AddressType(e as Map)'), contains('.toList()'), ]), ), @@ -261,9 +259,7 @@ final contactListSchema = Ack.object({ contains('extension type AddressType'), contains('extension type ContactListType'), contains('List get addresses'), - contains( - 'Map.unmodifiable(e as Map)', - ), + contains('AddressType(e as Map)'), contains('.toList()'), ]), ), From 7273aeadab51692da1d57baf7b4bb99a5957dbc8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 3 Mar 2026 17:15:22 -0500 Subject: [PATCH 5/5] chore: apply dart format --- .../ack_type_discriminated_test.dart | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart index 894660d1..0bfc78b6 100644 --- a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart +++ b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart @@ -31,16 +31,14 @@ Future _expectGenerationFailure({ void main() { group('@AckType discriminated schemas', () { - test( - 'uses prefixed deep-freeze helper when Ack import is aliased', - () async { - final builder = ackGenerator(BuilderOptions.empty); + test('uses prefixed deep-freeze helper when Ack import is aliased', () async { + final builder = ackGenerator(BuilderOptions.empty); - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' + await testBuilder( + builder, + { + ...allAssets, + 'test_pkg|lib/schema.dart': ''' import 'package:ack/ack.dart' as ack; import 'package:ack_annotations/ack_annotations.dart'; @@ -65,23 +63,22 @@ final petSchema = ack.Ack.discriminated( }, ); ''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('ack.SchemaResult safeParse'), - contains('ack.SchemaResult safeParse'), - contains('ack.SchemaResult safeParse'), - contains( - 'ack.ackDeepFreezeObjectMap(validated as Map)', - ), - contains('final map = ack.ackDeepFreezeObjectMap('), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/schema.g.dart': decodedMatches( + allOf([ + contains('ack.SchemaResult safeParse'), + contains('ack.SchemaResult safeParse'), + contains('ack.SchemaResult safeParse'), + contains( + 'ack.ackDeepFreezeObjectMap(validated as Map)', + ), + contains('final map = ack.ackDeepFreezeObjectMap('), + ]), + ), + }, + ); + }); test('generates discriminated base and subtype extension types', () async { final builder = ackGenerator(BuilderOptions.empty);