diff --git a/example/lib/args_getter_example.g.dart b/example/lib/args_getter_example.g.dart index e08a05a6..4ed232b4 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( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return userConfigSchema.safeParseAs( data, - (validated) => UserConfigType(validated as Map), + (validated) => UserConfigType( + ackDeepFreezeObjectMap(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( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return apiRequestSchema.safeParseAs( data, - (validated) => ApiRequestType(validated as Map), + (validated) => ApiRequestType( + ackDeepFreezeObjectMap(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( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return featureFlagsSchema.safeParseAs( data, - (validated) => FeatureFlagsType(validated as Map), + (validated) => FeatureFlagsType( + ackDeepFreezeObjectMap(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( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return dynamicDataSchema.safeParseAs( data, - (validated) => DynamicDataType(validated as Map), + (validated) => DynamicDataType( + ackDeepFreezeObjectMap(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..4c3de8bd 100644 --- a/example/lib/schema_types_discriminated.g.dart +++ b/example/lib/schema_types_discriminated.g.dart @@ -16,7 +16,7 @@ extension type PetType(Map _data) static PetType parse(Object? data) { return petSchema.parseAs(data, (validated) { - final map = validated as Map; + final map = ackDeepFreezeObjectMap(validated as Map); return switch (map['kind']) { 'cat' => CatType(map), 'dog' => DogType(map), @@ -27,7 +27,7 @@ extension type PetType(Map _data) static SchemaResult safeParse(Object? data) { return petSchema.safeParseAs(data, (validated) { - final map = validated as Map; + final map = ackDeepFreezeObjectMap(validated as Map); return switch (map['kind']) { 'cat' => CatType(map), 'dog' => DogType(map), @@ -47,14 +47,16 @@ extension type CatType(Map _data) static CatType parse(Object? data) { return catSchema.parseAs( data, - (validated) => CatType(validated as Map), + (validated) => + CatType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return catSchema.safeParseAs( data, - (validated) => CatType(validated as Map), + (validated) => + CatType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -75,21 +77,25 @@ extension type DogType(Map _data) static DogType parse(Object? data) { return dogSchema.parseAs( data, - (validated) => DogType(validated as Map), + (validated) => + DogType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return dogSchema.safeParseAs( data, - (validated) => DogType(validated as Map), + (validated) => + DogType(ackDeepFreezeObjectMap(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..e66fbd07 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( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return productSchema.safeParseAs( data, - (validated) => ProductType(validated as Map), + (validated) => ProductType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -57,14 +61,16 @@ extension type GridType(Map _data) static GridType parse(Object? data) { return gridSchema.parseAs( data, - (validated) => GridType(validated as Map), + (validated) => + GridType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return gridSchema.safeParseAs( data, - (validated) => GridType(validated as Map), + (validated) => + GridType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -88,14 +94,18 @@ extension type AddressType(Map _data) static AddressType parse(Object? data) { return addressSchema.parseAs( data, - (validated) => AddressType(validated as Map), + (validated) => AddressType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return addressSchema.safeParseAs( data, - (validated) => AddressType(validated as Map), + (validated) => AddressType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -130,14 +140,16 @@ extension type PersonType(Map _data) static PersonType parse(Object? data) { return personSchema.parseAs( data, - (validated) => PersonType(validated as Map), + (validated) => + PersonType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return personSchema.safeParseAs( data, - (validated) => PersonType(validated as Map), + (validated) => + PersonType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -173,14 +185,18 @@ extension type EmployeeType(Map _data) static EmployeeType parse(Object? data) { return employeeSchema.parseAs( data, - (validated) => EmployeeType(validated as Map), + (validated) => EmployeeType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return employeeSchema.safeParseAs( data, - (validated) => EmployeeType(validated as Map), + (validated) => EmployeeType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -217,14 +233,18 @@ extension type ModifierType(Map _data) static ModifierType parse(Object? data) { return modifierSchema.parseAs( data, - (validated) => ModifierType(validated as Map), + (validated) => ModifierType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return modifierSchema.safeParseAs( data, - (validated) => ModifierType(validated as Map), + (validated) => ModifierType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -266,14 +286,18 @@ extension type TaggedItemType(Map _data) static TaggedItemType parse(Object? data) { return taggedItemSchema.parseAs( data, - (validated) => TaggedItemType(validated as Map), + (validated) => TaggedItemType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return taggedItemSchema.safeParseAs( data, - (validated) => TaggedItemType(validated as Map), + (validated) => TaggedItemType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -313,14 +337,18 @@ extension type ContactListType(Map _data) static ContactListType parse(Object? data) { return contactListSchema.parseAs( data, - (validated) => ContactListType(validated as Map), + (validated) => ContactListType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return contactListSchema.safeParseAs( data, - (validated) => ContactListType(validated as Map), + (validated) => ContactListType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -346,14 +374,16 @@ extension type EmptyType(Map _data) static EmptyType parse(Object? data) { return emptySchema.parseAs( data, - (validated) => EmptyType(validated as Map), + (validated) => + EmptyType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return emptySchema.safeParseAs( data, - (validated) => EmptyType(validated as Map), + (validated) => + EmptyType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -366,14 +396,18 @@ extension type MinimalType(Map _data) static MinimalType parse(Object? data) { return minimalSchema.parseAs( data, - (validated) => MinimalType(validated as Map), + (validated) => MinimalType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return minimalSchema.safeParseAs( data, - (validated) => MinimalType(validated as Map), + (validated) => MinimalType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -392,14 +426,18 @@ extension type NamedItemType(Map _data) static NamedItemType parse(Object? data) { return namedItemSchema.parseAs( data, - (validated) => NamedItemType(validated as Map), + (validated) => NamedItemType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return namedItemSchema.safeParseAs( data, - (validated) => NamedItemType(validated as Map), + (validated) => NamedItemType( + ackDeepFreezeObjectMap(validated as Map), + ), ); } @@ -418,14 +456,16 @@ extension type ItemType(Map _data) static ItemType parse(Object? data) { return item.parseAs( data, - (validated) => ItemType(validated as Map), + (validated) => + ItemType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return item.safeParseAs( data, - (validated) => ItemType(validated as Map), + (validated) => + ItemType(ackDeepFreezeObjectMap(validated as Map)), ); } @@ -444,14 +484,18 @@ extension type MyCustomSchema123Type(Map _data) static MyCustomSchema123Type parse(Object? data) { return myCustomSchema123.parseAs( data, - (validated) => MyCustomSchema123Type(validated as Map), + (validated) => MyCustomSchema123Type( + ackDeepFreezeObjectMap(validated as Map), + ), ); } static SchemaResult safeParse(Object? data) { return myCustomSchema123.safeParseAs( data, - (validated) => MyCustomSchema123Type(validated as Map), + (validated) => MyCustomSchema123Type( + ackDeepFreezeObjectMap(validated as Map), + ), ); } diff --git a/example/lib/schema_types_simple.g.dart b/example/lib/schema_types_simple.g.dart index 4cb8c117..ca8bae83 100644 --- a/example/lib/schema_types_simple.g.dart +++ b/example/lib/schema_types_simple.g.dart @@ -13,14 +13,16 @@ extension type UserType(Map _data) static UserType parse(Object? data) { return userSchema.parseAs( data, - (validated) => UserType(validated as Map), + (validated) => + UserType(ackDeepFreezeObjectMap(validated as Map)), ); } static SchemaResult safeParse(Object? data) { return userSchema.safeParseAs( data, - (validated) => UserType(validated as Map), + (validated) => + UserType(ackDeepFreezeObjectMap(validated as Map)), ); } 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 779d4881..7f7de4d9 100644 --- a/example/test/verify_implements_works.dart +++ b/example/test/verify_implements_works.dart @@ -1,4 +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() { @@ -52,6 +54,77 @@ 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('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('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/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..d94a4e46 100644 --- a/packages/ack/lib/src/utils/default_utils.dart +++ b/packages/ack/lib/src/utils/default_utils.dart @@ -5,10 +5,14 @@ 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. /// +/// 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'}}; @@ -41,6 +45,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/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/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 330b29c5..588c7a2a 100644 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ b/packages/ack_generator/lib/src/builders/type_builder.dart @@ -516,6 +516,11 @@ class TypeBuilder { List _buildStaticFactories(ModelInfo model, String schemaVarName) { final typeName = _getExtensionTypeName(model); final castType = model.representationType; + final representationValue = _castRepresentationValue( + sourceExpression: 'validated', + castType: castType, + deepFreezeObjectMap: true, + ); return [ // Static parse factory @@ -534,7 +539,7 @@ class TypeBuilder { ..body = Code(''' return $schemaVarName.parseAs( data, - (validated) => $typeName(validated as $castType), + (validated) => $typeName($representationValue), );'''), ), // Static safeParse method @@ -553,7 +558,7 @@ return $schemaVarName.parseAs( ..body = Code(''' return $schemaVarName.safeParseAs( data, - (validated) => $typeName(validated as $castType), + (validated) => $typeName($representationValue), );'''), ), ]; @@ -588,7 +593,7 @@ return $schemaVarName.safeParseAs( return $schemaVarName.parseAs( data, (validated) { - final map = validated as Map; + final map = ${_castRepresentationValue(sourceExpression: 'validated', castType: kMapType, deepFreezeObjectMap: true)}; return $switchExpression; }, );'''), @@ -624,13 +629,28 @@ return $schemaVarName.parseAs( return $schemaVarName.safeParseAs( data, (validated) { - final map = validated as Map; + final map = ${_castRepresentationValue(sourceExpression: 'validated', castType: kMapType, deepFreezeObjectMap: true)}; return $switchExpression; }, );'''), ); } + 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 castExpression; + } + return castExpression; + } + String _buildDiscriminatorSwitchExpression( String mapVarName, String discriminatorKey, @@ -716,16 +736,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 +784,10 @@ ${cases.join(',\n')}, // Maps if (field.isMap) { - return "_data['$key'] as Map"; + return _castRepresentationValue( + sourceExpression: "_data['$key']", + castType: kMapType, + ); } // Sets @@ -764,7 +798,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 +896,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 +1325,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/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_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..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 @@ -48,9 +48,7 @@ final deckToolArgsSchema = Ack.object({ 'extension type DeckToolArgsType(Map _data)', ), contains('SlideType get currentSlide'), - contains( - "SlideType(_data['currentSlide'] as Map)", - ), + contains("_data['currentSlide'] as Map"), contains('List get slides'), contains('SlideType(e as Map)'), ]), @@ -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_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart index 58c65644..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,6 +31,55 @@ 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_object_wrapper_immutability_test.dart b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart new file mode 100644 index 00000000..0c39b3b1 --- /dev/null +++ b/packages/ack_generator/test/integration/ack_type_object_wrapper_immutability_test.dart @@ -0,0 +1,68 @@ +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( + 'deep-freezes object wrappers 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('Map toJson() => _data;'), + contains('CatType(ackDeepFreezeObjectMap('), + contains('DogType(ackDeepFreezeObjectMap('), + contains( + 'ackDeepFreezeObjectMap(validated as Map)', + ), + contains('final map = ackDeepFreezeObjectMap('), + isNot(contains('Object? _\$ackDeepFreeze(')), + isNot( + contains('Map _\$ackDeepFreezeObjectMap('), + ), + ]), + ), + }, + ); + }, + ); + }); +} diff --git a/packages/ack_generator/test/integration/nested_model_test.dart b/packages/ack_generator/test/integration/nested_model_test.dart index 99d76f4b..bd831473 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,37 @@ 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('AddressType(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 +252,20 @@ 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('AddressType(e as Map)'), + contains('.toList()'), + ]), + ), + }, + ); + }, + ); }); } 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, });