From b3caec7c01994ee09bef5c6b257288e64171e791 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 00:32:06 -0400 Subject: [PATCH 01/29] feat!: consolidate Ack 2.0 single-part codegen and Draft 2020-12 runtime --- docs/core-concepts/typesafe-schemas.mdx | 10 +- .../additional_properties_example.ack.g.dart | 84 - .../lib/additional_properties_example.dart | 3 +- ...t => additional_properties_example.g.dart} | 119 +- example/lib/class_first_models.ack.g.dart | 43 - example/lib/class_first_models.dart | 3 +- ...els.ack.dart => class_first_models.g.dart} | 57 +- example/lib/pet.ack.g.dart | 25 - example/lib/pet.dart | 3 +- example/lib/{pet.ack.dart => pet.g.dart} | 25 +- .../lib/schema_types_discriminated.ack.g.dart | 32 - example/lib/schema_types_discriminated.dart | 3 +- ...dart => schema_types_discriminated.g.dart} | 41 +- .../lib/schema_types_edge_cases.ack.g.dart | 163 - example/lib/schema_types_edge_cases.dart | 3 +- ...ck.dart => schema_types_edge_cases.g.dart} | 365 +- .../lib/schema_types_primitives.ack.g.dart | 112 - example/lib/schema_types_primitives.dart | 3 +- ...ck.dart => schema_types_primitives.g.dart} | 148 +- example/lib/schema_types_simple.ack.g.dart | 23 - example/lib/schema_types_simple.dart | 3 +- ...le.ack.dart => schema_types_simple.g.dart} | 31 +- .../lib/schema_types_transforms.ack.g.dart | 45 - example/lib/schema_types_transforms.dart | 3 +- ...ck.dart => schema_types_transforms.g.dart} | 106 +- example/lib/user_with_color.ack.g.dart | 55 - example/lib/user_with_color.dart | 3 +- ..._color.ack.dart => user_with_color.g.dart} | 112 +- packages/ack/benchmark/harness.dart | 57 + .../ack/benchmark/large_schema_benchmark.dart | 137 + packages/ack/benchmark/parse_benchmark.dart | 58 +- packages/ack/lib/ack.dart | 2 - packages/ack/lib/ack_generator_support.dart | 4 - packages/ack/lib/annotations.dart | 3 - packages/ack/lib/src/ack.dart | 8 +- .../ack/lib/src/annotations/ack_field.dart | 39 +- .../src/annotations/ack_generated_json.dart | 10 - .../ack/lib/src/annotations/ack_infer.dart | 59 - .../ack/lib/src/annotations/ack_model.dart | 71 - .../ack/lib/src/annotations/ack_type.dart | 53 - .../ack/lib/src/annotations/constraints.dart | 7 - .../ack/lib/src/annotations/schemable.dart | 26 +- .../src/json_schema/json_schema_compiler.dart | 496 +- .../src/json_schema/json_schema_formats.dart | 498 + .../json_schema/standard_meta_schemas.dart | 358 + .../src/schema_model/ack_schema_model.dart | 131 + .../ack_schema_model_builder.dart | 39 +- .../lib/src/schemas/imported_json_schema.dart | 637 +- packages/ack/lib/src/schemas/list_schema.dart | 7 +- packages/ack/lib/src/schemas/map_schema.dart | 7 +- .../ack/lib/src/schemas/object_schema.dart | 4 +- packages/ack/lib/src/schemas/schema.dart | 5 + packages/ack/pubspec.yaml | 2 + .../ack/test/annotations/ack_model_test.dart | 30 +- .../ack/test/annotations/ack_type_test.dart | 25 - .../ack/test/annotations/exports_test.dart | 5 +- .../test/class_first_prerequisites_test.dart | 5 +- .../fixtures/json_schema_import/README.md | 17 +- .../json_schema_import/draft2020-12.json | 11965 ++++++++++++---- ..._schema_2020_12_full_conformance_test.dart | 59 + ...n_schema_dynamic_ref_conformance_test.dart | 64 + .../json_schema_format_conformance_test.dart | 100 + .../test/json_schema_format_import_test.dart | 162 - .../test/json_schema_import_a2ui_test.dart | 62 +- .../ack/test/json_schema_import_api_test.dart | 39 +- .../json_schema_import_conformance_test.dart | 32 +- .../ack/test/json_schema_import_test.dart | 842 +- .../ack/test/reference_semantics_test.dart | 1 - .../ack/test/runtime_dependencies_test.dart | 7 +- .../ack_schema_model_builder_test.dart | 19 +- .../schemas/datetime_validation_test.dart | 6 +- .../discriminated_object_schema_test.dart | 2 +- .../schemas/duration_validation_test.dart | 7 +- .../schemas/effective_nullability_test.dart | 25 + .../ack/test/schemas/uri_validation_test.dart | 4 +- .../manifest.json | 8 +- ...ack_schema_date_transform_constraints.json | 3 +- .../ack_schema_datetime_transform.json | 3 +- ...schema_duration_transform_constraints.json | 3 +- .../ack_schema_generic_transform.json | 3 +- .../ack_schema_uri_transform.json | 3 +- .../firebase_ai_native_schema_cases.dart | 8 +- packages/ack_generator/build.yaml | 37 +- packages/ack_generator/lib/ack_generator.dart | 3 +- .../analyzer/class_model_graph_builder.dart | 327 +- .../lib/src/analyzer/schema_ast_analyzer.dart | 3784 ----- .../lib/src/analyzer/schema_first_names.dart | 111 + .../analyzer/schema_model_graph_builder.dart | 189 +- packages/ack_generator/lib/src/builder.dart | 45 +- .../lib/src/builders/class_model_emitter.dart | 194 +- .../lib/src/builders/model_emitter.dart | 342 +- .../lib/src/builders/type_builder.dart | 1082 -- packages/ack_generator/lib/src/generator.dart | 450 - .../lib/src/json/ack_json_generator.dart | 124 - .../lib/src/json/ack_runtime_type_helper.dart | 59 - .../lib/src/json/helper_names.dart | 55 +- .../lib/src/json/json_configs.dart | 34 - .../src/json_schema/json_schema_builder.dart | 72 + .../json_schema_generation_exception.dart | 33 + .../src/json_schema/json_schema_library.dart | 1265 ++ .../lib/src/model_generator.dart | 99 +- .../lib/src/models/constraint_info.dart | 7 - .../lib/src/models/field_info.dart | 126 - .../lib/src/models/model_info.dart | 68 - .../lib/src/validation/code_validator.dart | 60 - packages/ack_generator/pubspec.yaml | 6 +- .../test/additional_properties_args_test.dart | 197 - .../test/bugs/schema_variable_bugs_test.dart | 1347 -- .../test/code_validation_test.dart | 32 - .../integration/ack_infer_contract_test.dart | 48 +- .../ack_infer_data_class_test.dart | 22 +- .../integration/ack_infer_graph_test.dart | 8 +- .../integration/ack_infer_models_test.dart | 348 +- .../ack_infer_runtime_build_test.dart | 35 +- .../ack_type_cross_file_resolution_test.dart | 930 -- .../ack_type_custom_name_test.dart | 317 - .../ack_type_discriminated_test.dart | 678 - .../ack_type_enum_literal_fields_test.dart | 438 - .../integration/ack_type_getter_test.dart | 203 - .../integration/ack_type_golden_test.dart | 70 - .../integration/ack_type_map_schema_test.dart | 27 - .../ack_type_nested_schema_test.dart | 150 - .../integration/ack_type_transform_test.dart | 326 - .../class_first_cycle_build_test.dart | 10 +- .../integration/class_first_json_test.dart | 96 +- ...class_first_nested_runtime_build_test.dart | 57 +- .../class_first_prerequisites_test.dart | 67 - .../class_first_runtime_build_test.dart | 62 +- .../integration/class_model_emitter_test.dart | 140 +- .../integration/class_model_graph_test.dart | 168 +- .../deprecated_annotations_test.dart | 158 - .../example_folder_build_test.dart | 63 +- .../integration/inference_literal_test.dart | 22 - .../json_schema_runtime_build_test.dart | 234 + .../json_serializable_build_test.dart | 72 +- .../legacy_runtime_build_test.dart | 120 - .../test/integration/mixed_graph_test.dart | 704 - .../type_owned_schema_build_test.dart | 5 +- .../integration/type_owned_schema_test.dart | 8 +- .../test/json_schema/fixture_library.dart | 28 + .../json_schema/fixtures/mapping.schema.dart | 51 + .../json_schema/fixtures/mapping.schema.json | 77 + .../fixtures/mapping_preserve.schema.dart | 56 + .../test/json_schema/instance_corpus.dart | 220 + .../json_schema_equivalence_test.dart | 72 + .../json_schema/json_schema_golden_test.dart | 36 + .../json_schema/json_schema_library_test.dart | 543 + .../json_schema_required_corpus_test.dart | 46 + .../test/src/ack_exports_test.dart | 2 +- .../test/src/generator_test.dart | 72 +- .../test/src/json_builder_test.dart | 56 +- .../test/test_utils/test_assets.dart | 354 - .../lib/ack_json_schema_builder.dart | 19 +- .../test/from_json_schema_builder_test.dart | 37 +- packages/ack_mcp_dart/README.md | 2 +- 155 files changed, 15902 insertions(+), 19353 deletions(-) delete mode 100644 example/lib/additional_properties_example.ack.g.dart rename example/lib/{additional_properties_example.ack.dart => additional_properties_example.g.dart} (84%) delete mode 100644 example/lib/class_first_models.ack.g.dart rename example/lib/{class_first_models.ack.dart => class_first_models.g.dart} (91%) delete mode 100644 example/lib/pet.ack.g.dart rename example/lib/{pet.ack.dart => pet.g.dart} (93%) delete mode 100644 example/lib/schema_types_discriminated.ack.g.dart rename example/lib/{schema_types_discriminated.ack.dart => schema_types_discriminated.g.dart} (89%) delete mode 100644 example/lib/schema_types_edge_cases.ack.g.dart rename example/lib/{schema_types_edge_cases.ack.dart => schema_types_edge_cases.g.dart} (83%) delete mode 100644 example/lib/schema_types_primitives.ack.g.dart rename example/lib/{schema_types_primitives.ack.dart => schema_types_primitives.g.dart} (88%) delete mode 100644 example/lib/schema_types_simple.ack.g.dart rename example/lib/{schema_types_simple.ack.dart => schema_types_simple.g.dart} (85%) delete mode 100644 example/lib/schema_types_transforms.ack.g.dart rename example/lib/{schema_types_transforms.ack.dart => schema_types_transforms.g.dart} (76%) delete mode 100644 example/lib/user_with_color.ack.g.dart rename example/lib/{user_with_color.ack.dart => user_with_color.g.dart} (81%) create mode 100644 packages/ack/benchmark/harness.dart create mode 100644 packages/ack/benchmark/large_schema_benchmark.dart delete mode 100644 packages/ack/lib/ack_generator_support.dart delete mode 100644 packages/ack/lib/src/annotations/ack_generated_json.dart delete mode 100644 packages/ack/lib/src/annotations/ack_infer.dart delete mode 100644 packages/ack/lib/src/annotations/ack_model.dart delete mode 100644 packages/ack/lib/src/annotations/ack_type.dart create mode 100644 packages/ack/lib/src/json_schema/json_schema_formats.dart create mode 100644 packages/ack/lib/src/json_schema/standard_meta_schemas.dart delete mode 100644 packages/ack/test/annotations/ack_type_test.dart create mode 100644 packages/ack/test/json_schema_2020_12_full_conformance_test.dart create mode 100644 packages/ack/test/json_schema_dynamic_ref_conformance_test.dart create mode 100644 packages/ack/test/json_schema_format_conformance_test.dart delete mode 100644 packages/ack/test/json_schema_format_import_test.dart delete mode 100644 packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart create mode 100644 packages/ack_generator/lib/src/analyzer/schema_first_names.dart delete mode 100644 packages/ack_generator/lib/src/builders/type_builder.dart delete mode 100644 packages/ack_generator/lib/src/generator.dart delete mode 100644 packages/ack_generator/lib/src/json/ack_json_generator.dart delete mode 100644 packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart delete mode 100644 packages/ack_generator/lib/src/json/json_configs.dart create mode 100644 packages/ack_generator/lib/src/json_schema/json_schema_builder.dart create mode 100644 packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart create mode 100644 packages/ack_generator/lib/src/json_schema/json_schema_library.dart delete mode 100644 packages/ack_generator/lib/src/models/constraint_info.dart delete mode 100644 packages/ack_generator/lib/src/models/field_info.dart delete mode 100644 packages/ack_generator/lib/src/models/model_info.dart delete mode 100644 packages/ack_generator/lib/src/validation/code_validator.dart delete mode 100644 packages/ack_generator/test/additional_properties_args_test.dart delete mode 100644 packages/ack_generator/test/bugs/schema_variable_bugs_test.dart delete mode 100644 packages/ack_generator/test/code_validation_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_custom_name_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_discriminated_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_getter_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_golden_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_map_schema_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_nested_schema_test.dart delete mode 100644 packages/ack_generator/test/integration/ack_type_transform_test.dart delete mode 100644 packages/ack_generator/test/integration/class_first_prerequisites_test.dart delete mode 100644 packages/ack_generator/test/integration/deprecated_annotations_test.dart create mode 100644 packages/ack_generator/test/integration/json_schema_runtime_build_test.dart delete mode 100644 packages/ack_generator/test/integration/legacy_runtime_build_test.dart delete mode 100644 packages/ack_generator/test/integration/mixed_graph_test.dart create mode 100644 packages/ack_generator/test/json_schema/fixture_library.dart create mode 100644 packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart create mode 100644 packages/ack_generator/test/json_schema/fixtures/mapping.schema.json create mode 100644 packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart create mode 100644 packages/ack_generator/test/json_schema/instance_corpus.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_golden_test.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_library_test.dart create mode 100644 packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart delete mode 100644 packages/ack_generator/test/test_utils/test_assets.dart diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 46c86663..2407e82b 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -36,11 +36,10 @@ dart pub add ack dart pub add --dev ack_generator build_runner ``` -Every annotated library needs both generated parts: +Every annotated library needs the generated part: ```dart -part 'models.ack.dart'; -part 'models.ack.g.dart'; +part 'models.g.dart'; ``` Run the generator after adding or changing a model: @@ -52,14 +51,13 @@ dart run build_runner build ## A working example of both directions This file contains one schema-first model and one class-first model. Both use -the same builders and generated parts. +the same builder and generated part. ```dart title="lib/models.dart" import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'models.ack.dart'; -part 'models.ack.g.dart'; +part 'models.g.dart'; // Schema-first: write the schema; Ack generates Order. @Schemable() diff --git a/example/lib/additional_properties_example.ack.g.dart b/example/lib/additional_properties_example.ack.g.dart deleted file mode 100644 index cc2c9bd7..00000000 --- a/example/lib/additional_properties_example.ack.g.dart +++ /dev/null @@ -1,84 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'additional_properties_example.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -UserConfig _$UserConfigFromJson(Map json) => UserConfig( - username: UserConfig._ackFromRuntimeUsername(json['username']), - email: UserConfig._ackFromRuntimeEmail(json['email']), - additionalProperties: - UserConfig._ackFromRuntimeAdditionalProperties( - json['additionalProperties'], - ) ?? - const {}, -); - -Map _$UserConfigToJson(UserConfig instance) => - { - 'username': UserConfig._ackToRuntimeUsername(instance.username), - 'email': UserConfig._ackToRuntimeEmail(instance.email), - 'additionalProperties': UserConfig._ackToRuntimeAdditionalProperties( - instance.additionalProperties, - ), - }; - -ApiRequest _$ApiRequestFromJson(Map json) => ApiRequest( - method: ApiRequest._ackFromRuntimeMethod(json['method']), - url: ApiRequest._ackFromRuntimeUrl(json['url']), - additionalProperties: - ApiRequest._ackFromRuntimeAdditionalProperties( - json['additionalProperties'], - ) ?? - const {}, -); - -Map _$ApiRequestToJson(ApiRequest instance) => - { - 'method': ApiRequest._ackToRuntimeMethod(instance.method), - 'url': ApiRequest._ackToRuntimeUrl(instance.url), - 'additionalProperties': ApiRequest._ackToRuntimeAdditionalProperties( - instance.additionalProperties, - ), - }; - -FeatureFlags _$FeatureFlagsFromJson(Map json) => FeatureFlags( - appVersion: FeatureFlags._ackFromRuntimeAppVersion(json['appVersion']), - environment: FeatureFlags._ackFromRuntimeEnvironment(json['environment']), - additionalProperties: - FeatureFlags._ackFromRuntimeAdditionalProperties( - json['additionalProperties'], - ) ?? - const {}, -); - -Map _$FeatureFlagsToJson( - FeatureFlags instance, -) => { - 'appVersion': FeatureFlags._ackToRuntimeAppVersion(instance.appVersion), - 'environment': FeatureFlags._ackToRuntimeEnvironment(instance.environment), - 'additionalProperties': FeatureFlags._ackToRuntimeAdditionalProperties( - instance.additionalProperties, - ), -}; - -DynamicData _$DynamicDataFromJson(Map json) => DynamicData( - additionalProperties: - DynamicData._ackFromRuntimeAdditionalProperties( - json['additionalProperties'], - ) ?? - const {}, -); - -Map _$DynamicDataToJson(DynamicData instance) => - { - 'additionalProperties': DynamicData._ackToRuntimeAdditionalProperties( - instance.additionalProperties, - ), - }; diff --git a/example/lib/additional_properties_example.dart b/example/lib/additional_properties_example.dart index 4c50d0a8..9bcb42fe 100644 --- a/example/lib/additional_properties_example.dart +++ b/example/lib/additional_properties_example.dart @@ -4,8 +4,7 @@ library; import 'package:ack/ack.dart'; -part 'additional_properties_example.ack.dart'; -part 'additional_properties_example.ack.g.dart'; +part 'additional_properties_example.g.dart'; /// Example 1: User configuration with additional metadata /// The generated model has `additionalProperties`, which contains diff --git a/example/lib/additional_properties_example.ack.dart b/example/lib/additional_properties_example.g.dart similarity index 84% rename from example/lib/additional_properties_example.ack.dart rename to example/lib/additional_properties_example.g.dart index ce11396a..b099903a 100644 --- a/example/lib/additional_properties_example.ack.dart +++ b/example/lib/additional_properties_example.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'additional_properties_example.dart'; @@ -10,8 +6,10 @@ part of 'additional_properties_example.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Immutable model generated from `userConfigSchema`. -@Schemable.generatedJson final class UserConfig { /// Creates a model without validating it. /// @@ -95,40 +93,24 @@ final class UserConfig { static UserConfig _fromAckRuntime(Map value) { const declared = {'username', 'email'}; - return _$UserConfigFromJson({ - ...value, - 'additionalProperties': Map.fromEntries( + return UserConfig( + username: value['username'] as String, + email: value['email'] as String, + additionalProperties: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), - }); + ); } Map _toAckRuntime() { const declared = {'username', 'email'}; - final result = {..._$UserConfigToJson(this)}; - result.remove('additionalProperties'); return { for (final entry in additionalProperties.entries) if (!declared.contains(entry.key)) entry.key: entry.value, - ...result, + 'username': username, + 'email': email, }; } - - static String _ackFromRuntimeUsername(Object? value) => value as String; - - static Object? _ackToRuntimeUsername(String value) => value; - - static String _ackFromRuntimeEmail(Object? value) => value as String; - - static Object? _ackToRuntimeEmail(String value) => value; - - static Map? _ackFromRuntimeAdditionalProperties( - Object? value, - ) => value as Map?; - - static Object? _ackToRuntimeAdditionalProperties( - Map value, - ) => value; } /// Creates copies of [UserConfig] with selected fields replaced. @@ -162,7 +144,6 @@ final class _$UserConfigCopyWith implements $UserConfigCopyWith { } /// Immutable model generated from `apiRequestSchema`. -@Schemable.generatedJson final class ApiRequest { /// Creates a model without validating it. /// @@ -246,40 +227,24 @@ final class ApiRequest { static ApiRequest _fromAckRuntime(Map value) { const declared = {'method', 'url'}; - return _$ApiRequestFromJson({ - ...value, - 'additionalProperties': Map.fromEntries( + return ApiRequest( + method: value['method'] as String, + url: value['url'] as String, + additionalProperties: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), - }); + ); } Map _toAckRuntime() { const declared = {'method', 'url'}; - final result = {..._$ApiRequestToJson(this)}; - result.remove('additionalProperties'); return { for (final entry in additionalProperties.entries) if (!declared.contains(entry.key)) entry.key: entry.value, - ...result, + 'method': method, + 'url': url, }; } - - static String _ackFromRuntimeMethod(Object? value) => value as String; - - static Object? _ackToRuntimeMethod(String value) => value; - - static String _ackFromRuntimeUrl(Object? value) => value as String; - - static Object? _ackToRuntimeUrl(String value) => value; - - static Map? _ackFromRuntimeAdditionalProperties( - Object? value, - ) => value as Map?; - - static Object? _ackToRuntimeAdditionalProperties( - Map value, - ) => value; } /// Creates copies of [ApiRequest] with selected fields replaced. @@ -313,7 +278,6 @@ final class _$ApiRequestCopyWith implements $ApiRequestCopyWith { } /// Immutable model generated from `featureFlagsSchema`. -@Schemable.generatedJson final class FeatureFlags { /// Creates a model without validating it. /// @@ -398,40 +362,24 @@ final class FeatureFlags { static FeatureFlags _fromAckRuntime(Map value) { const declared = {'appVersion', 'environment'}; - return _$FeatureFlagsFromJson({ - ...value, - 'additionalProperties': Map.fromEntries( + return FeatureFlags( + appVersion: value['appVersion'] as String, + environment: value['environment'] as String, + additionalProperties: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), - }); + ); } Map _toAckRuntime() { const declared = {'appVersion', 'environment'}; - final result = {..._$FeatureFlagsToJson(this)}; - result.remove('additionalProperties'); return { for (final entry in additionalProperties.entries) if (!declared.contains(entry.key)) entry.key: entry.value, - ...result, + 'appVersion': appVersion, + 'environment': environment, }; } - - static String _ackFromRuntimeAppVersion(Object? value) => value as String; - - static Object? _ackToRuntimeAppVersion(String value) => value; - - static String _ackFromRuntimeEnvironment(Object? value) => value as String; - - static Object? _ackToRuntimeEnvironment(String value) => value; - - static Map? _ackFromRuntimeAdditionalProperties( - Object? value, - ) => value as Map?; - - static Object? _ackToRuntimeAdditionalProperties( - Map value, - ) => value; } /// Creates copies of [FeatureFlags] with selected fields replaced. @@ -466,7 +414,6 @@ final class _$FeatureFlagsCopyWith } /// Immutable model generated from `dynamicDataSchema`. -@Schemable.generatedJson final class DynamicData { /// Creates a model without validating it. /// @@ -537,32 +484,20 @@ final class DynamicData { static DynamicData _fromAckRuntime(Map value) { const declared = {}; - return _$DynamicDataFromJson({ - ...value, - 'additionalProperties': Map.fromEntries( + return DynamicData( + additionalProperties: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), - }); + ); } Map _toAckRuntime() { const declared = {}; - final result = {..._$DynamicDataToJson(this)}; - result.remove('additionalProperties'); return { for (final entry in additionalProperties.entries) if (!declared.contains(entry.key)) entry.key: entry.value, - ...result, }; } - - static Map? _ackFromRuntimeAdditionalProperties( - Object? value, - ) => value as Map?; - - static Object? _ackToRuntimeAdditionalProperties( - Map value, - ) => value; } /// Creates copies of [DynamicData] with selected fields replaced. diff --git a/example/lib/class_first_models.ack.g.dart b/example/lib/class_first_models.ack.g.dart deleted file mode 100644 index 776a3a51..00000000 --- a/example/lib/class_first_models.ack.g.dart +++ /dev/null @@ -1,43 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'class_first_models.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -Account _$AccountFromJson(Map json) => Account( - displayName: _ackAccountFromRuntimeDisplayName(json['display_name']), - website: _ackAccountFromRuntimeWebsite(json['website']), - role: _ackAccountFromRuntimeRole(json['role']) ?? 'member', -); - -Map _$AccountToJson(Account instance) => { - 'display_name': _ackAccountToRuntimeDisplayName(instance.displayName), - 'website': ?_ackAccountToRuntimeWebsite(instance.website), - 'role': _ackAccountToRuntimeRole(instance.role), -}; - -Cat _$CatFromJson(Map json) => Cat( - id: _ackCatFromRuntimeId(json['id']), - lives: _ackCatFromRuntimeLives(json['lives']), -); - -Map _$CatToJson(Cat instance) => { - 'id': _ackCatToRuntimeId(instance.id), - 'lives': _ackCatToRuntimeLives(instance.lives), -}; - -Dog _$DogFromJson(Map json) => Dog( - id: _ackDogFromRuntimeId(json['id']), - breed: _ackDogFromRuntimeBreed(json['breed']), -); - -Map _$DogToJson(Dog instance) => { - 'id': _ackDogToRuntimeId(instance.id), - 'breed': _ackDogToRuntimeBreed(instance.breed), -}; diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index 35e2fe32..856252a1 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -1,8 +1,7 @@ import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'class_first_models.ack.dart'; -part 'class_first_models.ack.g.dart'; +part 'class_first_models.g.dart'; /// A signed-in user's account. @Schemable( diff --git a/example/lib/class_first_models.ack.dart b/example/lib/class_first_models.g.dart similarity index 91% rename from example/lib/class_first_models.ack.dart rename to example/lib/class_first_models.g.dart index 0a04aea6..a9bbdbda 100644 --- a/example/lib/class_first_models.ack.dart +++ b/example/lib/class_first_models.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'class_first_models.dart'; @@ -10,6 +6,9 @@ part of 'class_first_models.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + final _catObject = Ack.object({ 'type': Ack.literal('cat').optional(), 'id': Ack.string(), @@ -68,12 +67,13 @@ abstract final class CatSchema { } Cat _$CatFromRuntime(Map value) => - _$CatFromJson(Map.from(value)); + Cat(id: value['id'] as String, lives: value['lives'] as int); -Map _$CatToRuntime(Cat model) { - final result = {..._$CatToJson(model)}; - return {...result, 'type': 'cat'}; -} +Map _$CatToRuntime(Cat model) => { + 'id': model.id, + 'lives': model.lives, + 'type': 'cat', +}; /// Creates copies of [Cat] with selected fields replaced. abstract interface class $CatCopyWith<$Result> { @@ -136,11 +136,6 @@ mixin _$CatAck { CatSchema.safeEncode(this as Cat); } -String _ackCatFromRuntimeId(Object? value) => value as String; -Object? _ackCatToRuntimeId(String value) => value; -int _ackCatFromRuntimeLives(Object? value) => value as int; -Object? _ackCatToRuntimeLives(int value) => value; - final _dogObject = Ack.object({ 'type': Ack.literal('Dog').optional(), 'id': Ack.string(), @@ -200,12 +195,13 @@ abstract final class DogSchema { } Dog _$DogFromRuntime(Map value) => - _$DogFromJson(Map.from(value)); + Dog(id: value['id'] as String, breed: value['breed'] as String); -Map _$DogToRuntime(Dog model) { - final result = {..._$DogToJson(model)}; - return {...result, 'type': 'Dog'}; -} +Map _$DogToRuntime(Dog model) => { + 'id': model.id, + 'breed': model.breed, + 'type': 'Dog', +}; /// Creates copies of [Dog] with selected fields replaced. abstract interface class $DogCopyWith<$Result> { @@ -268,11 +264,6 @@ mixin _$DogAck { DogSchema.safeEncode(this as Dog); } -String _ackDogFromRuntimeId(Object? value) => value as String; -Object? _ackDogToRuntimeId(String value) => value; -String _ackDogFromRuntimeBreed(Object? value) => value as String; -Object? _ackDogToRuntimeBreed(String value) => value; - final _petObject = Ack.discriminated( discriminatorKey: 'type', schemas: {'cat': _catObject, 'Dog': _dogObject}, @@ -411,11 +402,16 @@ abstract final class AccountSchema { AckSchemaModelExtension(_accountSchema).toSchemaModel(); } -Account _$AccountFromRuntime(Map value) => - _$AccountFromJson(Map.from(value)); +Account _$AccountFromRuntime(Map value) => Account( + displayName: value['display_name'] as String, + website: value['website'] as Uri?, + role: value['role'] as String? ?? 'member', +); Map _$AccountToRuntime(Account model) => { - ..._$AccountToJson(model), + 'display_name': model.displayName, + 'website': ?model.website, + 'role': model.role, }; final class _AccountCopyWithUnset { @@ -497,10 +493,3 @@ mixin _$AccountAck { SchemaResult> safeToJson() => AccountSchema.safeEncode(this as Account); } - -String _ackAccountFromRuntimeDisplayName(Object? value) => value as String; -Object? _ackAccountToRuntimeDisplayName(String value) => value; -Uri? _ackAccountFromRuntimeWebsite(Object? value) => value as Uri?; -Object? _ackAccountToRuntimeWebsite(Uri? value) => value; -String? _ackAccountFromRuntimeRole(Object? value) => value as String?; -Object? _ackAccountToRuntimeRole(String value) => value; diff --git a/example/lib/pet.ack.g.dart b/example/lib/pet.ack.g.dart deleted file mode 100644 index 00ab7b36..00000000 --- a/example/lib/pet.ack.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'pet.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -Cat _$CatFromJson(Map json) => - Cat(lives: Cat._ackFromRuntimeLives(json['lives'])); - -Map _$CatToJson(Cat instance) => { - 'lives': Cat._ackToRuntimeLives(instance.lives), -}; - -Dog _$DogFromJson(Map json) => - Dog(breed: Dog._ackFromRuntimeBreed(json['breed'])); - -Map _$DogToJson(Dog instance) => { - 'breed': Dog._ackToRuntimeBreed(instance.breed), -}; diff --git a/example/lib/pet.dart b/example/lib/pet.dart index 0293df70..c781d445 100644 --- a/example/lib/pet.dart +++ b/example/lib/pet.dart @@ -1,7 +1,6 @@ import 'package:ack/ack.dart'; -part 'pet.ack.dart'; -part 'pet.ack.g.dart'; +part 'pet.g.dart'; /// Pet schemas: discriminated by 'type' @Schemable() diff --git a/example/lib/pet.ack.dart b/example/lib/pet.g.dart similarity index 93% rename from example/lib/pet.ack.dart rename to example/lib/pet.g.dart index cfd86e86..36ea4f93 100644 --- a/example/lib/pet.ack.dart +++ b/example/lib/pet.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'pet.dart'; @@ -10,6 +6,9 @@ part of 'pet.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Discriminated model base generated from `petSchema`. sealed class Pet { /// Creates a branch of this union. @@ -68,7 +67,6 @@ sealed class Pet { } /// Discriminated model branch generated from `catSchema`. -@Schemable.generatedJson final class Cat extends Pet { /// Creates a model without validating it. /// @@ -127,17 +125,13 @@ final class Cat extends Pet { String toString() => 'Cat(lives: $lives)'; static Cat _fromAckRuntime(Map value) => - _$CatFromJson(Map.from(value)); + Cat(lives: value['lives'] as int); @override Map _toAckRuntime() => { 'type': 'cat', - ..._$CatToJson(this), + 'lives': lives, }; - - static int _ackFromRuntimeLives(Object? value) => value as int; - - static Object? _ackToRuntimeLives(int value) => value; } /// Creates copies of [Cat] with selected fields replaced. @@ -159,7 +153,6 @@ final class _$CatCopyWith implements $CatCopyWith { } /// Discriminated model branch generated from `dogSchema`. -@Schemable.generatedJson final class Dog extends Pet { /// Creates a model without validating it. /// @@ -218,17 +211,13 @@ final class Dog extends Pet { String toString() => 'Dog(breed: $breed)'; static Dog _fromAckRuntime(Map value) => - _$DogFromJson(Map.from(value)); + Dog(breed: value['breed'] as String); @override Map _toAckRuntime() => { 'type': 'dog', - ..._$DogToJson(this), + 'breed': breed, }; - - static String _ackFromRuntimeBreed(Object? value) => value as String; - - static Object? _ackToRuntimeBreed(String value) => value; } /// Creates copies of [Dog] with selected fields replaced. diff --git a/example/lib/schema_types_discriminated.ack.g.dart b/example/lib/schema_types_discriminated.ack.g.dart deleted file mode 100644 index aee001e7..00000000 --- a/example/lib/schema_types_discriminated.ack.g.dart +++ /dev/null @@ -1,32 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'schema_types_discriminated.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -Cat _$CatFromJson(Map json) => - Cat(lives: Cat._ackFromRuntimeLives(json['lives'])); - -Map _$CatToJson(Cat instance) => { - 'lives': Cat._ackToRuntimeLives(instance.lives), -}; - -Dog _$DogFromJson(Map json) => Dog( - bark: Dog._ackFromRuntimeBark(json['bark']), - additionalProperties: - Dog._ackFromRuntimeAdditionalProperties(json['additionalProperties']) ?? - const {}, -); - -Map _$DogToJson(Dog instance) => { - 'bark': Dog._ackToRuntimeBark(instance.bark), - 'additionalProperties': Dog._ackToRuntimeAdditionalProperties( - instance.additionalProperties, - ), -}; diff --git a/example/lib/schema_types_discriminated.dart b/example/lib/schema_types_discriminated.dart index 83dfa06a..896f18fc 100644 --- a/example/lib/schema_types_discriminated.dart +++ b/example/lib/schema_types_discriminated.dart @@ -1,7 +1,6 @@ import 'package:ack/ack.dart'; -part 'schema_types_discriminated.ack.dart'; -part 'schema_types_discriminated.ack.g.dart'; +part 'schema_types_discriminated.g.dart'; /// Discriminated schema example for immutable model generation with @Schemable. @Schemable() diff --git a/example/lib/schema_types_discriminated.ack.dart b/example/lib/schema_types_discriminated.g.dart similarity index 89% rename from example/lib/schema_types_discriminated.ack.dart rename to example/lib/schema_types_discriminated.g.dart index fde07106..416653d1 100644 --- a/example/lib/schema_types_discriminated.ack.dart +++ b/example/lib/schema_types_discriminated.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'schema_types_discriminated.dart'; @@ -10,6 +6,9 @@ part of 'schema_types_discriminated.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Discriminated model base generated from `petSchema`. sealed class Pet { /// Creates a branch of this union. @@ -68,7 +67,6 @@ sealed class Pet { } /// Discriminated model branch generated from `catSchema`. -@Schemable.generatedJson final class Cat extends Pet { /// Creates a model without validating it. /// @@ -127,17 +125,13 @@ final class Cat extends Pet { String toString() => 'Cat(lives: $lives)'; static Cat _fromAckRuntime(Map value) => - _$CatFromJson(Map.from(value)); + Cat(lives: value['lives'] as int); @override Map _toAckRuntime() => { 'kind': 'cat', - ..._$CatToJson(this), + 'lives': lives, }; - - static int _ackFromRuntimeLives(Object? value) => value as int; - - static Object? _ackToRuntimeLives(int value) => value; } /// Creates copies of [Cat] with selected fields replaced. @@ -159,7 +153,6 @@ final class _$CatCopyWith implements $CatCopyWith { } /// Discriminated model branch generated from `dogSchema`. -@Schemable.generatedJson final class Dog extends Pet { /// Creates a model without validating it. /// @@ -231,38 +224,24 @@ final class Dog extends Pet { static Dog _fromAckRuntime(Map value) { const declared = {'kind', 'bark'}; - return _$DogFromJson({ - ...value, - 'additionalProperties': Map.fromEntries( + return Dog( + bark: value['bark'] as bool, + additionalProperties: Map.fromEntries( value.entries.where((entry) => !declared.contains(entry.key)), ), - }); + ); } @override Map _toAckRuntime() { const declared = {'kind', 'bark'}; - final result = {..._$DogToJson(this)}; - result.remove('additionalProperties'); return { for (final entry in additionalProperties.entries) if (!declared.contains(entry.key)) entry.key: entry.value, 'kind': 'dog', - ...result, + 'bark': bark, }; } - - static bool _ackFromRuntimeBark(Object? value) => value as bool; - - static Object? _ackToRuntimeBark(bool value) => value; - - static Map? _ackFromRuntimeAdditionalProperties( - Object? value, - ) => value as Map?; - - static Object? _ackToRuntimeAdditionalProperties( - Map value, - ) => value; } /// Creates copies of [Dog] with selected fields replaced. diff --git a/example/lib/schema_types_edge_cases.ack.g.dart b/example/lib/schema_types_edge_cases.ack.g.dart deleted file mode 100644 index d2164912..00000000 --- a/example/lib/schema_types_edge_cases.ack.g.dart +++ /dev/null @@ -1,163 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'schema_types_edge_cases.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -Product _$ProductFromJson(Map json) => Product( - name: Product._ackFromRuntimeName(json['name']), - tags: Product._ackFromRuntimeTags(json['tags']), - scores: Product._ackFromRuntimeScores(json['scores']), - flags: Product._ackFromRuntimeFlags(json['flags']), -); - -Map _$ProductToJson(Product instance) => { - 'name': Product._ackToRuntimeName(instance.name), - 'tags': Product._ackToRuntimeTags(instance.tags), - 'scores': Product._ackToRuntimeScores(instance.scores), - 'flags': Product._ackToRuntimeFlags(instance.flags), -}; - -Grid _$GridFromJson(Map json) => Grid( - name: Grid._ackFromRuntimeName(json['name']), - matrix: Grid._ackFromRuntimeMatrix(json['matrix']), -); - -Map _$GridToJson(Grid instance) => { - 'name': Grid._ackToRuntimeName(instance.name), - 'matrix': Grid._ackToRuntimeMatrix(instance.matrix), -}; - -Address _$AddressFromJson(Map json) => Address( - street: Address._ackFromRuntimeStreet(json['street']), - city: Address._ackFromRuntimeCity(json['city']), - zipCode: Address._ackFromRuntimeZipCode(json['zipCode']), - country: Address._ackFromRuntimeCountry(json['country']), -); - -Map _$AddressToJson(Address instance) => { - 'street': Address._ackToRuntimeStreet(instance.street), - 'city': Address._ackToRuntimeCity(instance.city), - 'zipCode': Address._ackToRuntimeZipCode(instance.zipCode), - 'country': Address._ackToRuntimeCountry(instance.country), -}; - -Person _$PersonFromJson(Map json) => Person( - name: Person._ackFromRuntimeName(json['name']), - email: Person._ackFromRuntimeEmail(json['email']), - address: Person._ackFromRuntimeAddress(json['address']), - age: Person._ackFromRuntimeAge(json['age']), -); - -Map _$PersonToJson(Person instance) => { - 'name': Person._ackToRuntimeName(instance.name), - 'email': Person._ackToRuntimeEmail(instance.email), - 'address': Person._ackToRuntimeAddress(instance.address), - 'age': Person._ackToRuntimeAge(instance.age), -}; - -Employee _$EmployeeFromJson(Map json) => Employee( - name: Employee._ackFromRuntimeName(json['name']), - employeeId: Employee._ackFromRuntimeEmployeeId(json['employeeId']), - homeAddress: Employee._ackFromRuntimeHomeAddress(json['homeAddress']), - workAddress: Employee._ackFromRuntimeWorkAddress(json['workAddress']), -); - -Map _$EmployeeToJson(Employee instance) => { - 'name': Employee._ackToRuntimeName(instance.name), - 'employeeId': Employee._ackToRuntimeEmployeeId(instance.employeeId), - 'homeAddress': Employee._ackToRuntimeHomeAddress(instance.homeAddress), - 'workAddress': Employee._ackToRuntimeWorkAddress(instance.workAddress), -}; - -Modifier _$ModifierFromJson(Map json) => Modifier( - requiredField: Modifier._ackFromRuntimeRequiredField(json['requiredField']), - optionalField: Modifier._ackFromRuntimeOptionalField(json['optionalField']), - nullableField: Modifier._ackFromRuntimeNullableField(json['nullableField']), - optionalNullable: Modifier._ackFromRuntimeOptionalNullable( - json['optionalNullable'], - ), - nullableOptional: Modifier._ackFromRuntimeNullableOptional( - json['nullableOptional'], - ), -); - -Map _$ModifierToJson(Modifier instance) => { - 'requiredField': Modifier._ackToRuntimeRequiredField(instance.requiredField), - 'optionalField': ?Modifier._ackToRuntimeOptionalField(instance.optionalField), - 'nullableField': ?Modifier._ackToRuntimeNullableField(instance.nullableField), - 'optionalNullable': ?Modifier._ackToRuntimeOptionalNullable( - instance.optionalNullable, - ), - 'nullableOptional': ?Modifier._ackToRuntimeNullableOptional( - instance.nullableOptional, - ), -}; - -TaggedItem _$TaggedItemFromJson(Map json) => TaggedItem( - name: TaggedItem._ackFromRuntimeName(json['name']), - requiredTags: TaggedItem._ackFromRuntimeRequiredTags(json['requiredTags']), - optionalTags: TaggedItem._ackFromRuntimeOptionalTags(json['optionalTags']), - nullableTags: TaggedItem._ackFromRuntimeNullableTags(json['nullableTags']), -); - -Map _$TaggedItemToJson( - TaggedItem instance, -) => { - 'name': TaggedItem._ackToRuntimeName(instance.name), - 'requiredTags': TaggedItem._ackToRuntimeRequiredTags(instance.requiredTags), - 'optionalTags': ?TaggedItem._ackToRuntimeOptionalTags(instance.optionalTags), - 'nullableTags': ?TaggedItem._ackToRuntimeNullableTags(instance.nullableTags), -}; - -ContactList _$ContactListFromJson(Map json) => ContactList( - name: ContactList._ackFromRuntimeName(json['name']), - addresses: ContactList._ackFromRuntimeAddresses(json['addresses']), -); - -Map _$ContactListToJson(ContactList instance) => - { - 'name': ContactList._ackToRuntimeName(instance.name), - 'addresses': ContactList._ackToRuntimeAddresses(instance.addresses), - }; - -Empty _$EmptyFromJson(Map json) => Empty(); - -Map _$EmptyToJson(Empty instance) => {}; - -Minimal _$MinimalFromJson(Map json) => - Minimal(id: Minimal._ackFromRuntimeId(json['id'])); - -Map _$MinimalToJson(Minimal instance) => { - 'id': Minimal._ackToRuntimeId(instance.id), -}; - -NamedItem _$NamedItemFromJson(Map json) => - NamedItem(name: NamedItem._ackFromRuntimeName(json['name'])); - -Map _$NamedItemToJson(NamedItem instance) => { - 'name': NamedItem._ackToRuntimeName(instance.name), -}; - -Item _$ItemFromJson(Map json) => - Item(id: Item._ackFromRuntimeId(json['id'])); - -Map _$ItemToJson(Item instance) => { - 'id': Item._ackToRuntimeId(instance.id), -}; - -MyCustomSchema123 _$MyCustomSchema123FromJson(Map json) => - MyCustomSchema123( - value: MyCustomSchema123._ackFromRuntimeValue(json['value']), - ); - -Map _$MyCustomSchema123ToJson(MyCustomSchema123 instance) => - { - 'value': MyCustomSchema123._ackToRuntimeValue(instance.value), - }; diff --git a/example/lib/schema_types_edge_cases.dart b/example/lib/schema_types_edge_cases.dart index f501b1fb..b04fb5ce 100644 --- a/example/lib/schema_types_edge_cases.dart +++ b/example/lib/schema_types_edge_cases.dart @@ -11,8 +11,7 @@ library; import 'package:ack/ack.dart'; -part 'schema_types_edge_cases.ack.dart'; -part 'schema_types_edge_cases.ack.g.dart'; +part 'schema_types_edge_cases.g.dart'; // ============================================================================ // EDGE CASE 1: List Type Extraction diff --git a/example/lib/schema_types_edge_cases.ack.dart b/example/lib/schema_types_edge_cases.g.dart similarity index 83% rename from example/lib/schema_types_edge_cases.ack.dart rename to example/lib/schema_types_edge_cases.g.dart index 6e987250..b6b8078c 100644 --- a/example/lib/schema_types_edge_cases.ack.dart +++ b/example/lib/schema_types_edge_cases.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'schema_types_edge_cases.dart'; @@ -10,8 +6,10 @@ part of 'schema_types_edge_cases.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Immutable model generated from `productSchema`. -@Schemable.generatedJson final class Product { /// Creates a model without validating it. /// @@ -99,34 +97,19 @@ final class Product { String toString() => 'Product(name: $name, tags: $tags, scores: $scores, flags: $flags)'; - static Product _fromAckRuntime(Map value) => - _$ProductFromJson(Map.from(value)); + static Product _fromAckRuntime(Map value) => Product( + name: value['name'] as String, + tags: (value['tags'] as List).map((item) => item as String).toList(), + scores: (value['scores'] as List).map((item) => item as int).toList(), + flags: (value['flags'] as List).map((item) => item as bool).toList(), + ); Map _toAckRuntime() => { - ..._$ProductToJson(this), + 'name': name, + 'tags': tags.map((item) => item).toList(growable: false), + 'scores': scores.map((item) => item).toList(growable: false), + 'flags': flags.map((item) => item).toList(growable: false), }; - - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static List _ackFromRuntimeTags(Object? value) => - (value as List).map((item) => item as String).toList(); - - static Object? _ackToRuntimeTags(List value) => - value.map((item) => item).toList(growable: false); - - static List _ackFromRuntimeScores(Object? value) => - (value as List).map((item) => item as int).toList(); - - static Object? _ackToRuntimeScores(List value) => - value.map((item) => item).toList(growable: false); - - static List _ackFromRuntimeFlags(Object? value) => - (value as List).map((item) => item as bool).toList(); - - static Object? _ackToRuntimeFlags(List value) => - value.map((item) => item).toList(growable: false); } /// Creates copies of [Product] with selected fields replaced. @@ -163,7 +146,6 @@ final class _$ProductCopyWith implements $ProductCopyWith { } /// Immutable model generated from `gridSchema`. -@Schemable.generatedJson final class Grid { /// Creates a model without validating it. /// @@ -234,24 +216,19 @@ final class Grid { @override String toString() => 'Grid(name: $name, matrix: $matrix)'; - static Grid _fromAckRuntime(Map value) => - _$GridFromJson(Map.from(value)); + static Grid _fromAckRuntime(Map value) => Grid( + name: value['name'] as String, + matrix: (value['matrix'] as List) + .map((item) => (item as List).map((item) => item as int).toList()) + .toList(), + ); Map _toAckRuntime() => { - ..._$GridToJson(this), + 'name': name, + 'matrix': matrix + .map((item) => item.map((item) => item).toList(growable: false)) + .toList(growable: false), }; - - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static List> _ackFromRuntimeMatrix(Object? value) => (value as List) - .map((item) => (item as List).map((item) => item as int).toList()) - .toList(); - - static Object? _ackToRuntimeMatrix(List> value) => value - .map((item) => item.map((item) => item).toList(growable: false)) - .toList(growable: false); } /// Creates copies of [Grid] with selected fields replaced. @@ -274,7 +251,6 @@ final class _$GridCopyWith implements $GridCopyWith { } /// Immutable model generated from `addressSchema`. -@Schemable.generatedJson final class Address { /// Creates a model without validating it. /// @@ -360,28 +336,19 @@ final class Address { String toString() => 'Address(street: $street, city: $city, zipCode: $zipCode, country: $country)'; - static Address _fromAckRuntime(Map value) => - _$AddressFromJson(Map.from(value)); + static Address _fromAckRuntime(Map value) => Address( + street: value['street'] as String, + city: value['city'] as String, + zipCode: value['zipCode'] as String, + country: value['country'] as String, + ); Map _toAckRuntime() => { - ..._$AddressToJson(this), + 'street': street, + 'city': city, + 'zipCode': zipCode, + 'country': country, }; - - static String _ackFromRuntimeStreet(Object? value) => value as String; - - static Object? _ackToRuntimeStreet(String value) => value; - - static String _ackFromRuntimeCity(Object? value) => value as String; - - static Object? _ackToRuntimeCity(String value) => value; - - static String _ackFromRuntimeZipCode(Object? value) => value as String; - - static Object? _ackToRuntimeZipCode(String value) => value; - - static String _ackFromRuntimeCountry(Object? value) => value as String; - - static Object? _ackToRuntimeCountry(String value) => value; } /// Creates copies of [Address] with selected fields replaced. @@ -418,7 +385,6 @@ final class _$AddressCopyWith implements $AddressCopyWith
{ } /// Immutable model generated from `personSchema`. -@Schemable.generatedJson final class Person { /// Creates a model without validating it. /// @@ -503,30 +469,19 @@ final class Person { String toString() => 'Person(name: $name, email: $email, address: $address, age: $age)'; - static Person _fromAckRuntime(Map value) => - _$PersonFromJson(Map.from(value)); + static Person _fromAckRuntime(Map value) => Person( + name: value['name'] as String, + email: value['email'] as String, + address: Address.$ack.fromRuntime(value['address'] as Map), + age: value['age'] as int, + ); Map _toAckRuntime() => { - ..._$PersonToJson(this), + 'name': name, + 'email': email, + 'address': Address.$ack.toRuntime(address), + 'age': age, }; - - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static String _ackFromRuntimeEmail(Object? value) => value as String; - - static Object? _ackToRuntimeEmail(String value) => value; - - static Address _ackFromRuntimeAddress(Object? value) => - Address.$ack.fromRuntime(value as Map); - - static Object? _ackToRuntimeAddress(Address value) => - Address.$ack.toRuntime(value); - - static int _ackFromRuntimeAge(Object? value) => value as int; - - static Object? _ackToRuntimeAge(int value) => value; } /// Creates copies of [Person] with selected fields replaced. @@ -554,7 +509,6 @@ final class _$PersonCopyWith implements $PersonCopyWith { } /// Immutable model generated from `employeeSchema`. -@Schemable.generatedJson final class Employee { /// Creates a model without validating it. /// @@ -640,32 +594,23 @@ final class Employee { String toString() => 'Employee(name: $name, employeeId: $employeeId, homeAddress: $homeAddress, workAddress: $workAddress)'; - static Employee _fromAckRuntime(Map value) => - _$EmployeeFromJson(Map.from(value)); + static Employee _fromAckRuntime(Map value) => Employee( + name: value['name'] as String, + employeeId: value['employeeId'] as String, + homeAddress: Address.$ack.fromRuntime( + value['homeAddress'] as Map, + ), + workAddress: Address.$ack.fromRuntime( + value['workAddress'] as Map, + ), + ); Map _toAckRuntime() => { - ..._$EmployeeToJson(this), + 'name': name, + 'employeeId': employeeId, + 'homeAddress': Address.$ack.toRuntime(homeAddress), + 'workAddress': Address.$ack.toRuntime(workAddress), }; - - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static String _ackFromRuntimeEmployeeId(Object? value) => value as String; - - static Object? _ackToRuntimeEmployeeId(String value) => value; - - static Address _ackFromRuntimeHomeAddress(Object? value) => - Address.$ack.fromRuntime(value as Map); - - static Object? _ackToRuntimeHomeAddress(Address value) => - Address.$ack.toRuntime(value); - - static Address _ackFromRuntimeWorkAddress(Object? value) => - Address.$ack.fromRuntime(value as Map); - - static Object? _ackToRuntimeWorkAddress(Address value) => - Address.$ack.toRuntime(value); } /// Creates copies of [Employee] with selected fields replaced. @@ -702,7 +647,6 @@ final class _$EmployeeCopyWith implements $EmployeeCopyWith { } /// Immutable model generated from `modifierSchema`. -@Schemable.generatedJson final class Modifier { /// Creates a model without validating it. /// @@ -793,40 +737,21 @@ final class Modifier { String toString() => 'Modifier(requiredField: $requiredField, optionalField: $optionalField, nullableField: $nullableField, optionalNullable: $optionalNullable, nullableOptional: $nullableOptional)'; - static Modifier _fromAckRuntime(Map value) => - _$ModifierFromJson(Map.from(value)); - - Map _toAckRuntime() { - final result = {..._$ModifierToJson(this)}; - if (nullableField == null) { - result['nullableField'] = null; - } - return {...result}; - } - - static String _ackFromRuntimeRequiredField(Object? value) => value as String; - - static Object? _ackToRuntimeRequiredField(String value) => value; - - static String? _ackFromRuntimeOptionalField(Object? value) => - value as String?; - - static Object? _ackToRuntimeOptionalField(String? value) => value; - - static String? _ackFromRuntimeNullableField(Object? value) => - value as String?; - - static Object? _ackToRuntimeNullableField(String? value) => value; - - static String? _ackFromRuntimeOptionalNullable(Object? value) => - value as String?; - - static Object? _ackToRuntimeOptionalNullable(String? value) => value; - - static String? _ackFromRuntimeNullableOptional(Object? value) => - value as String?; + static Modifier _fromAckRuntime(Map value) => Modifier( + requiredField: value['requiredField'] as String, + optionalField: value['optionalField'] as String?, + nullableField: value['nullableField'] as String?, + optionalNullable: value['optionalNullable'] as String?, + nullableOptional: value['nullableOptional'] as String?, + ); - static Object? _ackToRuntimeNullableOptional(String? value) => value; + Map _toAckRuntime() => { + 'requiredField': requiredField, + 'optionalField': ?optionalField, + 'nullableField': nullableField, + 'optionalNullable': ?optionalNullable, + 'nullableOptional': ?nullableOptional, + }; } final class _ModifierCopyWithUnset { @@ -881,7 +806,6 @@ final class _$ModifierCopyWith implements $ModifierCopyWith { } /// Immutable model generated from `taggedItemSchema`. -@Schemable.generatedJson final class TaggedItem { /// Creates a model without validating it. /// @@ -981,54 +905,29 @@ final class TaggedItem { String toString() => 'TaggedItem(name: $name, requiredTags: $requiredTags, optionalTags: $optionalTags, nullableTags: $nullableTags)'; - static TaggedItem _fromAckRuntime(Map value) => - _$TaggedItemFromJson(Map.from(value)); - - Map _toAckRuntime() { - final result = {..._$TaggedItemToJson(this)}; - if (nullableTags == null) { - result['nullableTags'] = null; - } - return {...result}; - } + static TaggedItem _fromAckRuntime(Map value) => TaggedItem( + name: value['name'] as String, + requiredTags: (value['requiredTags'] as List) + .map((item) => item as String) + .toList(), + optionalTags: switch (value['optionalTags']) { + null => null, + final fieldValue => + (fieldValue as List).map((item) => item as String).toList(), + }, + nullableTags: switch (value['nullableTags']) { + null => null, + final fieldValue => + (fieldValue as List).map((item) => item as String).toList(), + }, + ); - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static List _ackFromRuntimeRequiredTags(Object? value) => - (value as List).map((item) => item as String).toList(); - - static Object? _ackToRuntimeRequiredTags(List value) => - value.map((item) => item).toList(growable: false); - - static List? _ackFromRuntimeOptionalTags(Object? value) => - switch (value) { - null => null, - final fieldValue => - (fieldValue as List).map((item) => item as String).toList(), - }; - - static Object? _ackToRuntimeOptionalTags(List? value) => - switch (value) { - null => null, - final fieldValue => - fieldValue.map((item) => item).toList(growable: false), - }; - - static List? _ackFromRuntimeNullableTags(Object? value) => - switch (value) { - null => null, - final fieldValue => - (fieldValue as List).map((item) => item as String).toList(), - }; - - static Object? _ackToRuntimeNullableTags(List? value) => - switch (value) { - null => null, - final fieldValue => - fieldValue.map((item) => item).toList(growable: false), - }; + Map _toAckRuntime() => { + 'name': name, + 'requiredTags': requiredTags.map((item) => item).toList(growable: false), + 'optionalTags': ?optionalTags?.map((item) => item).toList(growable: false), + 'nullableTags': nullableTags?.map((item) => item).toList(growable: false), + }; } final class _TaggedItemCopyWithUnset { @@ -1076,7 +975,6 @@ final class _$TaggedItemCopyWith implements $TaggedItemCopyWith { } /// Immutable model generated from `contactListSchema`. -@Schemable.generatedJson final class ContactList { /// Creates a model without validating it. /// @@ -1149,24 +1047,19 @@ final class ContactList { @override String toString() => 'ContactList(name: $name, addresses: $addresses)'; - static ContactList _fromAckRuntime(Map value) => - _$ContactListFromJson(Map.from(value)); + static ContactList _fromAckRuntime(Map value) => ContactList( + name: value['name'] as String, + addresses: (value['addresses'] as List) + .map((item) => Address.$ack.fromRuntime(item as Map)) + .toList(), + ); Map _toAckRuntime() => { - ..._$ContactListToJson(this), + 'name': name, + 'addresses': addresses + .map((item) => Address.$ack.toRuntime(item)) + .toList(growable: false), }; - - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static List
_ackFromRuntimeAddresses(Object? value) => - (value as List) - .map((item) => Address.$ack.fromRuntime(item as Map)) - .toList(); - - static Object? _ackToRuntimeAddresses(List
value) => - value.map((item) => Address.$ack.toRuntime(item)).toList(growable: false); } /// Creates copies of [ContactList] with selected fields replaced. @@ -1191,7 +1084,6 @@ final class _$ContactListCopyWith implements $ContactListCopyWith { } /// Immutable model generated from `emptySchema`. -@Schemable.generatedJson final class Empty { /// Creates a model without validating it. /// @@ -1251,12 +1143,9 @@ final class Empty { @override String toString() => 'Empty()'; - static Empty _fromAckRuntime(Map value) => - _$EmptyFromJson(Map.from(value)); + static Empty _fromAckRuntime(Map value) => Empty(); - Map _toAckRuntime() => { - ..._$EmptyToJson(this), - }; + Map _toAckRuntime() => {}; } /// Creates copies of [Empty] with selected fields replaced. @@ -1276,7 +1165,6 @@ final class _$EmptyCopyWith implements $EmptyCopyWith { } /// Immutable model generated from `minimalSchema`. -@Schemable.generatedJson final class Minimal { /// Creates a model without validating it. /// @@ -1342,15 +1230,9 @@ final class Minimal { String toString() => 'Minimal(id: $id)'; static Minimal _fromAckRuntime(Map value) => - _$MinimalFromJson(Map.from(value)); + Minimal(id: value['id'] as String); - Map _toAckRuntime() => { - ..._$MinimalToJson(this), - }; - - static String _ackFromRuntimeId(Object? value) => value as String; - - static Object? _ackToRuntimeId(String value) => value; + Map _toAckRuntime() => {'id': id}; } /// Creates copies of [Minimal] with selected fields replaced. @@ -1372,7 +1254,6 @@ final class _$MinimalCopyWith implements $MinimalCopyWith { } /// Immutable model generated from `namedItemSchema`. -@Schemable.generatedJson final class NamedItem { /// Creates a model without validating it. /// @@ -1438,15 +1319,9 @@ final class NamedItem { String toString() => 'NamedItem(name: $name)'; static NamedItem _fromAckRuntime(Map value) => - _$NamedItemFromJson(Map.from(value)); - - Map _toAckRuntime() => { - ..._$NamedItemToJson(this), - }; - - static String _ackFromRuntimeName(Object? value) => value as String; + NamedItem(name: value['name'] as String); - static Object? _ackToRuntimeName(String value) => value; + Map _toAckRuntime() => {'name': name}; } /// Creates copies of [NamedItem] with selected fields replaced. @@ -1468,7 +1343,6 @@ final class _$NamedItemCopyWith implements $NamedItemCopyWith { } /// Immutable model generated from `item`. -@Schemable.generatedJson final class Item { /// Creates a model without validating it. /// @@ -1533,15 +1407,9 @@ final class Item { String toString() => 'Item(id: $id)'; static Item _fromAckRuntime(Map value) => - _$ItemFromJson(Map.from(value)); - - Map _toAckRuntime() => { - ..._$ItemToJson(this), - }; + Item(id: value['id'] as String); - static String _ackFromRuntimeId(Object? value) => value as String; - - static Object? _ackToRuntimeId(String value) => value; + Map _toAckRuntime() => {'id': id}; } /// Creates copies of [Item] with selected fields replaced. @@ -1563,7 +1431,6 @@ final class _$ItemCopyWith implements $ItemCopyWith { } /// Immutable model generated from `myCustomSchema123`. -@Schemable.generatedJson final class MyCustomSchema123 { /// Creates a model without validating it. /// @@ -1630,15 +1497,9 @@ final class MyCustomSchema123 { String toString() => 'MyCustomSchema123(value: $value)'; static MyCustomSchema123 _fromAckRuntime(Map value) => - _$MyCustomSchema123FromJson(Map.from(value)); - - Map _toAckRuntime() => { - ..._$MyCustomSchema123ToJson(this), - }; - - static String _ackFromRuntimeValue(Object? value) => value as String; + MyCustomSchema123(value: value['value'] as String); - static Object? _ackToRuntimeValue(String value) => value; + Map _toAckRuntime() => {'value': value}; } /// Creates copies of [MyCustomSchema123] with selected fields replaced. diff --git a/example/lib/schema_types_primitives.ack.g.dart b/example/lib/schema_types_primitives.ack.g.dart deleted file mode 100644 index 8206ae62..00000000 --- a/example/lib/schema_types_primitives.ack.g.dart +++ /dev/null @@ -1,112 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'schema_types_primitives.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -Password _$PasswordFromJson(Map json) => - Password(Password._ackFromRuntimeValue(json['value'])); - -Map _$PasswordToJson(Password instance) => { - 'value': Password._ackToRuntimeValue(instance.value), -}; - -Age _$AgeFromJson(Map json) => - Age(Age._ackFromRuntimeValue(json['value'])); - -Map _$AgeToJson(Age instance) => { - 'value': Age._ackToRuntimeValue(instance.value), -}; - -Price _$PriceFromJson(Map json) => - Price(Price._ackFromRuntimeValue(json['value'])); - -Map _$PriceToJson(Price instance) => { - 'value': Price._ackToRuntimeValue(instance.value), -}; - -Active _$ActiveFromJson(Map json) => - Active(Active._ackFromRuntimeValue(json['value'])); - -Map _$ActiveToJson(Active instance) => { - 'value': Active._ackToRuntimeValue(instance.value), -}; - -Tags _$TagsFromJson(Map json) => - Tags(Tags._ackFromRuntimeValue(json['value'])); - -Map _$TagsToJson(Tags instance) => { - 'value': Tags._ackToRuntimeValue(instance.value), -}; - -Scores _$ScoresFromJson(Map json) => - Scores(Scores._ackFromRuntimeValue(json['value'])); - -Map _$ScoresToJson(Scores instance) => { - 'value': Scores._ackToRuntimeValue(instance.value), -}; - -StatusLiteral _$StatusLiteralFromJson(Map json) => - StatusLiteral(StatusLiteral._ackFromRuntimeValue(json['value'])); - -Map _$StatusLiteralToJson(StatusLiteral instance) => - { - 'value': StatusLiteral._ackToRuntimeValue(instance.value), - }; - -Role _$RoleFromJson(Map json) => - Role(Role._ackFromRuntimeValue(json['value'])); - -Map _$RoleToJson(Role instance) => { - 'value': Role._ackToRuntimeValue(instance.value), -}; - -UserRoleModel _$UserRoleModelFromJson(Map json) => - UserRoleModel(UserRoleModel._ackFromRuntimeValue(json['value'])); - -Map _$UserRoleModelToJson(UserRoleModel instance) => - { - 'value': UserRoleModel._ackToRuntimeValue(instance.value), - }; - -StatusEnum _$StatusEnumFromJson(Map json) => - StatusEnum(StatusEnum._ackFromRuntimeValue(json['value'])); - -Map _$StatusEnumToJson(StatusEnum instance) => - {'value': StatusEnum._ackToRuntimeValue(instance.value)}; - -OptionalStatus _$OptionalStatusFromJson(Map json) => - OptionalStatus(OptionalStatus._ackFromRuntimeValue(json['value'])); - -Map _$OptionalStatusToJson(OptionalStatus instance) => - { - 'value': OptionalStatus._ackToRuntimeValue(instance.value), - }; - -DefaultedEnum _$DefaultedEnumFromJson(Map json) => - DefaultedEnum(DefaultedEnum._ackFromRuntimeValue(json['value'])); - -Map _$DefaultedEnumToJson(DefaultedEnum instance) => - { - 'value': DefaultedEnum._ackToRuntimeValue(instance.value), - }; - -ChainedEnumString _$ChainedEnumStringFromJson(Map json) => - ChainedEnumString(ChainedEnumString._ackFromRuntimeValue(json['value'])); - -Map _$ChainedEnumStringToJson(ChainedEnumString instance) => - { - 'value': ChainedEnumString._ackToRuntimeValue(instance.value), - }; - -RefinedAge _$RefinedAgeFromJson(Map json) => - RefinedAge(RefinedAge._ackFromRuntimeValue(json['value'])); - -Map _$RefinedAgeToJson(RefinedAge instance) => - {'value': RefinedAge._ackToRuntimeValue(instance.value)}; diff --git a/example/lib/schema_types_primitives.dart b/example/lib/schema_types_primitives.dart index a8727bae..cb061eb0 100644 --- a/example/lib/schema_types_primitives.dart +++ b/example/lib/schema_types_primitives.dart @@ -1,7 +1,6 @@ import 'package:ack/ack.dart'; -part 'schema_types_primitives.ack.dart'; -part 'schema_types_primitives.ack.g.dart'; +part 'schema_types_primitives.g.dart'; // Primitive schemas generate immutable value models while the schema remains // available directly for parse() and safeParse(). diff --git a/example/lib/schema_types_primitives.ack.dart b/example/lib/schema_types_primitives.g.dart similarity index 88% rename from example/lib/schema_types_primitives.ack.dart rename to example/lib/schema_types_primitives.g.dart index 452bcf2d..86b6f22f 100644 --- a/example/lib/schema_types_primitives.ack.dart +++ b/example/lib/schema_types_primitives.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'schema_types_primitives.dart'; @@ -10,8 +6,10 @@ part of 'schema_types_primitives.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Immutable value model generated from `passwordSchema`. -@Schemable.generatedJson final class Password { /// Creates a model without validating it. /// @@ -77,14 +75,9 @@ final class Password { @override String toString() => 'Password(value: $value)'; - static Password _fromAckRuntime(String value) => - _$PasswordFromJson({'value': value}); - - String _toAckRuntime() => _$PasswordToJson(this)['value'] as String; + static Password _fromAckRuntime(String value) => Password(value); - static String _ackFromRuntimeValue(Object? value) => value as String; - - static Object? _ackToRuntimeValue(String value) => value; + String _toAckRuntime() => value; } /// Creates copies of [Password] with selected fields replaced. @@ -106,7 +99,6 @@ final class _$PasswordCopyWith implements $PasswordCopyWith { } /// Immutable value model generated from `ageSchema`. -@Schemable.generatedJson final class Age { /// Creates a model without validating it. /// @@ -171,14 +163,9 @@ final class Age { @override String toString() => 'Age(value: $value)'; - static Age _fromAckRuntime(int value) => - _$AgeFromJson({'value': value}); - - int _toAckRuntime() => _$AgeToJson(this)['value'] as int; + static Age _fromAckRuntime(int value) => Age(value); - static int _ackFromRuntimeValue(Object? value) => value as int; - - static Object? _ackToRuntimeValue(int value) => value; + int _toAckRuntime() => value; } /// Creates copies of [Age] with selected fields replaced. @@ -200,7 +187,6 @@ final class _$AgeCopyWith implements $AgeCopyWith { } /// Immutable value model generated from `priceSchema`. -@Schemable.generatedJson final class Price { /// Creates a model without validating it. /// @@ -265,14 +251,9 @@ final class Price { @override String toString() => 'Price(value: $value)'; - static Price _fromAckRuntime(double value) => - _$PriceFromJson({'value': value}); - - double _toAckRuntime() => _$PriceToJson(this)['value'] as double; + static Price _fromAckRuntime(double value) => Price(value); - static double _ackFromRuntimeValue(Object? value) => value as double; - - static Object? _ackToRuntimeValue(double value) => value; + double _toAckRuntime() => value; } /// Creates copies of [Price] with selected fields replaced. @@ -294,7 +275,6 @@ final class _$PriceCopyWith implements $PriceCopyWith { } /// Immutable value model generated from `activeSchema`. -@Schemable.generatedJson final class Active { /// Creates a model without validating it. /// @@ -359,14 +339,9 @@ final class Active { @override String toString() => 'Active(value: $value)'; - static Active _fromAckRuntime(bool value) => - _$ActiveFromJson({'value': value}); - - bool _toAckRuntime() => _$ActiveToJson(this)['value'] as bool; + static Active _fromAckRuntime(bool value) => Active(value); - static bool _ackFromRuntimeValue(Object? value) => value as bool; - - static Object? _ackToRuntimeValue(bool value) => value; + bool _toAckRuntime() => value; } /// Creates copies of [Active] with selected fields replaced. @@ -388,7 +363,6 @@ final class _$ActiveCopyWith implements $ActiveCopyWith { } /// Immutable value model generated from `tagsSchema`. -@Schemable.generatedJson final class Tags { /// Creates a model without validating it. /// @@ -454,15 +428,9 @@ final class Tags { @override String toString() => 'Tags(value: $value)'; - static Tags _fromAckRuntime(List value) => - _$TagsFromJson({'value': value}); - - List _toAckRuntime() => _$TagsToJson(this)['value'] as List; + static Tags _fromAckRuntime(List value) => Tags(value); - static List _ackFromRuntimeValue(Object? value) => - (value as List).map((item) => item as String).toList(); - - static Object? _ackToRuntimeValue(List value) => + List _toAckRuntime() => value.map((item) => item).toList(growable: false); } @@ -485,7 +453,6 @@ final class _$TagsCopyWith implements $TagsCopyWith { } /// Immutable value model generated from `scoresSchema`. -@Schemable.generatedJson final class Scores { /// Creates a model without validating it. /// @@ -551,15 +518,9 @@ final class Scores { @override String toString() => 'Scores(value: $value)'; - static Scores _fromAckRuntime(List value) => - _$ScoresFromJson({'value': value}); - - List _toAckRuntime() => _$ScoresToJson(this)['value'] as List; + static Scores _fromAckRuntime(List value) => Scores(value); - static List _ackFromRuntimeValue(Object? value) => - (value as List).map((item) => item as int).toList(); - - static Object? _ackToRuntimeValue(List value) => + List _toAckRuntime() => value.map((item) => item).toList(growable: false); } @@ -582,7 +543,6 @@ final class _$ScoresCopyWith implements $ScoresCopyWith { } /// Immutable value model generated from `statusSchema`. -@Schemable.generatedJson final class StatusLiteral { /// Creates a model without validating it. /// @@ -649,14 +609,9 @@ final class StatusLiteral { @override String toString() => 'StatusLiteral(value: $value)'; - static StatusLiteral _fromAckRuntime(String value) => - _$StatusLiteralFromJson({'value': value}); - - String _toAckRuntime() => _$StatusLiteralToJson(this)['value'] as String; - - static String _ackFromRuntimeValue(Object? value) => value as String; + static StatusLiteral _fromAckRuntime(String value) => StatusLiteral(value); - static Object? _ackToRuntimeValue(String value) => value; + String _toAckRuntime() => value; } /// Creates copies of [StatusLiteral] with selected fields replaced. @@ -679,7 +634,6 @@ final class _$StatusLiteralCopyWith } /// Immutable value model generated from `roleSchema`. -@Schemable.generatedJson final class Role { /// Creates a model without validating it. /// @@ -744,14 +698,9 @@ final class Role { @override String toString() => 'Role(value: $value)'; - static Role _fromAckRuntime(String value) => - _$RoleFromJson({'value': value}); - - String _toAckRuntime() => _$RoleToJson(this)['value'] as String; - - static String _ackFromRuntimeValue(Object? value) => value as String; + static Role _fromAckRuntime(String value) => Role(value); - static Object? _ackToRuntimeValue(String value) => value; + String _toAckRuntime() => value; } /// Creates copies of [Role] with selected fields replaced. @@ -773,7 +722,6 @@ final class _$RoleCopyWith implements $RoleCopyWith { } /// Immutable value model generated from `userRoleSchema`. -@Schemable.generatedJson final class UserRoleModel { /// Creates a model without validating it. /// @@ -840,14 +788,9 @@ final class UserRoleModel { @override String toString() => 'UserRoleModel(value: $value)'; - static UserRoleModel _fromAckRuntime(UserRole value) => - _$UserRoleModelFromJson({'value': value}); + static UserRoleModel _fromAckRuntime(UserRole value) => UserRoleModel(value); - UserRole _toAckRuntime() => _$UserRoleModelToJson(this)['value'] as UserRole; - - static UserRole _ackFromRuntimeValue(Object? value) => value as UserRole; - - static Object? _ackToRuntimeValue(UserRole value) => value; + UserRole _toAckRuntime() => value; } /// Creates copies of [UserRoleModel] with selected fields replaced. @@ -871,7 +814,6 @@ final class _$UserRoleModelCopyWith } /// Immutable value model generated from `statusEnumSchema`. -@Schemable.generatedJson final class StatusEnum { /// Creates a model without validating it. /// @@ -937,14 +879,9 @@ final class StatusEnum { @override String toString() => 'StatusEnum(value: $value)'; - static StatusEnum _fromAckRuntime(Status value) => - _$StatusEnumFromJson({'value': value}); - - Status _toAckRuntime() => _$StatusEnumToJson(this)['value'] as Status; - - static Status _ackFromRuntimeValue(Object? value) => value as Status; + static StatusEnum _fromAckRuntime(Status value) => StatusEnum(value); - static Object? _ackToRuntimeValue(Status value) => value; + Status _toAckRuntime() => value; } /// Creates copies of [StatusEnum] with selected fields replaced. @@ -966,7 +903,6 @@ final class _$StatusEnumCopyWith implements $StatusEnumCopyWith { } /// Immutable value model generated from `optionalStatusSchema`. -@Schemable.generatedJson final class OptionalStatus { /// Creates a model without validating it. /// @@ -1033,14 +969,9 @@ final class OptionalStatus { @override String toString() => 'OptionalStatus(value: $value)'; - static OptionalStatus _fromAckRuntime(String value) => - _$OptionalStatusFromJson({'value': value}); + static OptionalStatus _fromAckRuntime(String value) => OptionalStatus(value); - String _toAckRuntime() => _$OptionalStatusToJson(this)['value'] as String; - - static String _ackFromRuntimeValue(Object? value) => value as String; - - static Object? _ackToRuntimeValue(String value) => value; + String _toAckRuntime() => value; } /// Creates copies of [OptionalStatus] with selected fields replaced. @@ -1064,7 +995,6 @@ final class _$OptionalStatusCopyWith } /// Immutable value model generated from `defaultedEnumSchema`. -@Schemable.generatedJson final class DefaultedEnum { /// Creates a model without validating it. /// @@ -1131,14 +1061,9 @@ final class DefaultedEnum { @override String toString() => 'DefaultedEnum(value: $value)'; - static DefaultedEnum _fromAckRuntime(UserRole value) => - _$DefaultedEnumFromJson({'value': value}); - - UserRole _toAckRuntime() => _$DefaultedEnumToJson(this)['value'] as UserRole; - - static UserRole _ackFromRuntimeValue(Object? value) => value as UserRole; + static DefaultedEnum _fromAckRuntime(UserRole value) => DefaultedEnum(value); - static Object? _ackToRuntimeValue(UserRole value) => value; + UserRole _toAckRuntime() => value; } /// Creates copies of [DefaultedEnum] with selected fields replaced. @@ -1162,7 +1087,6 @@ final class _$DefaultedEnumCopyWith } /// Immutable value model generated from `chainedEnumStringSchema`. -@Schemable.generatedJson final class ChainedEnumString { /// Creates a model without validating it. /// @@ -1230,13 +1154,9 @@ final class ChainedEnumString { String toString() => 'ChainedEnumString(value: $value)'; static ChainedEnumString _fromAckRuntime(String value) => - _$ChainedEnumStringFromJson({'value': value}); + ChainedEnumString(value); - String _toAckRuntime() => _$ChainedEnumStringToJson(this)['value'] as String; - - static String _ackFromRuntimeValue(Object? value) => value as String; - - static Object? _ackToRuntimeValue(String value) => value; + String _toAckRuntime() => value; } /// Creates copies of [ChainedEnumString] with selected fields replaced. @@ -1260,7 +1180,6 @@ final class _$ChainedEnumStringCopyWith } /// Immutable value model generated from `refinedAgeSchema`. -@Schemable.generatedJson final class RefinedAge { /// Creates a model without validating it. /// @@ -1326,14 +1245,9 @@ final class RefinedAge { @override String toString() => 'RefinedAge(value: $value)'; - static RefinedAge _fromAckRuntime(int value) => - _$RefinedAgeFromJson({'value': value}); - - int _toAckRuntime() => _$RefinedAgeToJson(this)['value'] as int; - - static int _ackFromRuntimeValue(Object? value) => value as int; + static RefinedAge _fromAckRuntime(int value) => RefinedAge(value); - static Object? _ackToRuntimeValue(int value) => value; + int _toAckRuntime() => value; } /// Creates copies of [RefinedAge] with selected fields replaced. diff --git a/example/lib/schema_types_simple.ack.g.dart b/example/lib/schema_types_simple.ack.g.dart deleted file mode 100644 index bc97d21f..00000000 --- a/example/lib/schema_types_simple.ack.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'schema_types_simple.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -User _$UserFromJson(Map json) => User( - name: User._ackFromRuntimeName(json['name']), - age: User._ackFromRuntimeAge(json['age']), - active: User._ackFromRuntimeActive(json['active']), -); - -Map _$UserToJson(User instance) => { - 'name': User._ackToRuntimeName(instance.name), - 'age': User._ackToRuntimeAge(instance.age), - 'active': User._ackToRuntimeActive(instance.active), -}; diff --git a/example/lib/schema_types_simple.dart b/example/lib/schema_types_simple.dart index 4d154a62..7132d1dc 100644 --- a/example/lib/schema_types_simple.dart +++ b/example/lib/schema_types_simple.dart @@ -1,7 +1,6 @@ import 'package:ack/ack.dart'; -part 'schema_types_simple.ack.dart'; -part 'schema_types_simple.ack.g.dart'; +part 'schema_types_simple.g.dart'; /// Simple example: Basic primitives @Schemable() diff --git a/example/lib/schema_types_simple.ack.dart b/example/lib/schema_types_simple.g.dart similarity index 85% rename from example/lib/schema_types_simple.ack.dart rename to example/lib/schema_types_simple.g.dart index 08b48c00..17265be9 100644 --- a/example/lib/schema_types_simple.ack.dart +++ b/example/lib/schema_types_simple.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'schema_types_simple.dart'; @@ -10,8 +6,10 @@ part of 'schema_types_simple.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Immutable model generated from `userSchema`. -@Schemable.generatedJson final class User { /// Creates a model without validating it. /// @@ -86,24 +84,17 @@ final class User { @override String toString() => 'User(name: $name, age: $age, active: $active)'; - static User _fromAckRuntime(Map value) => - _$UserFromJson(Map.from(value)); + static User _fromAckRuntime(Map value) => User( + name: value['name'] as String, + age: value['age'] as int, + active: value['active'] as bool, + ); Map _toAckRuntime() => { - ..._$UserToJson(this), + 'name': name, + 'age': age, + 'active': active, }; - - static String _ackFromRuntimeName(Object? value) => value as String; - - static Object? _ackToRuntimeName(String value) => value; - - static int _ackFromRuntimeAge(Object? value) => value as int; - - static Object? _ackToRuntimeAge(int value) => value; - - static bool _ackFromRuntimeActive(Object? value) => value as bool; - - static Object? _ackToRuntimeActive(bool value) => value; } /// Creates copies of [User] with selected fields replaced. diff --git a/example/lib/schema_types_transforms.ack.g.dart b/example/lib/schema_types_transforms.ack.g.dart deleted file mode 100644 index 71d82350..00000000 --- a/example/lib/schema_types_transforms.ack.g.dart +++ /dev/null @@ -1,45 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'schema_types_transforms.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -ColorModel _$ColorModelFromJson(Map json) => - ColorModel(ColorModel._ackFromRuntimeValue(json['value'])); - -Map _$ColorModelToJson(ColorModel instance) => - {'value': ColorModel._ackToRuntimeValue(instance.value)}; - -Profile _$ProfileFromJson(Map json) => Profile( - homepage: Profile._ackFromRuntimeHomepage(json['homepage']), - birthday: Profile._ackFromRuntimeBirthday(json['birthday']), - lastLogin: Profile._ackFromRuntimeLastLogin(json['lastLogin']), - timeout: Profile._ackFromRuntimeTimeout(json['timeout']), - links: Profile._ackFromRuntimeLinks(json['links']), - favoriteColor: Profile._ackFromRuntimeFavoriteColor(json['favoriteColor']), - slug: Profile._ackFromRuntimeSlug(json['slug']), - accent: Profile._ackFromRuntimeAccent(json['accent']), - colors: Profile._ackFromRuntimeColors(json['colors']), - customColors: Profile._ackFromRuntimeCustomColors(json['customColors']), - tagList: Profile._ackFromRuntimeTagList(json['tagList']), -); - -Map _$ProfileToJson(Profile instance) => { - 'homepage': Profile._ackToRuntimeHomepage(instance.homepage), - 'birthday': Profile._ackToRuntimeBirthday(instance.birthday), - 'lastLogin': Profile._ackToRuntimeLastLogin(instance.lastLogin), - 'timeout': Profile._ackToRuntimeTimeout(instance.timeout), - 'links': Profile._ackToRuntimeLinks(instance.links), - 'favoriteColor': Profile._ackToRuntimeFavoriteColor(instance.favoriteColor), - 'slug': Profile._ackToRuntimeSlug(instance.slug), - 'accent': Profile._ackToRuntimeAccent(instance.accent), - 'colors': Profile._ackToRuntimeColors(instance.colors), - 'customColors': Profile._ackToRuntimeCustomColors(instance.customColors), - 'tagList': Profile._ackToRuntimeTagList(instance.tagList), -}; diff --git a/example/lib/schema_types_transforms.dart b/example/lib/schema_types_transforms.dart index f306cb50..66abedcf 100644 --- a/example/lib/schema_types_transforms.dart +++ b/example/lib/schema_types_transforms.dart @@ -1,7 +1,6 @@ import 'package:ack/ack.dart'; -part 'schema_types_transforms.ack.dart'; -part 'schema_types_transforms.ack.g.dart'; +part 'schema_types_transforms.g.dart'; class Color { final String value; diff --git a/example/lib/schema_types_transforms.ack.dart b/example/lib/schema_types_transforms.g.dart similarity index 76% rename from example/lib/schema_types_transforms.ack.dart rename to example/lib/schema_types_transforms.g.dart index 612c1623..86006278 100644 --- a/example/lib/schema_types_transforms.ack.dart +++ b/example/lib/schema_types_transforms.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'schema_types_transforms.dart'; @@ -10,8 +6,10 @@ part of 'schema_types_transforms.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Immutable value model generated from `colorSchema`. -@Schemable.generatedJson final class ColorModel { /// Creates a model without validating it. /// @@ -77,14 +75,9 @@ final class ColorModel { @override String toString() => 'ColorModel(value: $value)'; - static ColorModel _fromAckRuntime(Color value) => - _$ColorModelFromJson({'value': value}); - - Color _toAckRuntime() => _$ColorModelToJson(this)['value'] as Color; - - static Color _ackFromRuntimeValue(Object? value) => value as Color; + static ColorModel _fromAckRuntime(Color value) => ColorModel(value); - static Object? _ackToRuntimeValue(Color value) => value; + Color _toAckRuntime() => value; } /// Creates copies of [ColorModel] with selected fields replaced. @@ -106,7 +99,6 @@ final class _$ColorModelCopyWith implements $ColorModelCopyWith { } /// Immutable model generated from `profileSchema`. -@Schemable.generatedJson final class Profile { /// Creates a model without validating it. /// @@ -231,67 +223,39 @@ final class Profile { String toString() => 'Profile(homepage: $homepage, birthday: $birthday, lastLogin: $lastLogin, timeout: $timeout, links: $links, favoriteColor: $favoriteColor, slug: $slug, accent: $accent, colors: $colors, customColors: $customColors, tagList: $tagList)'; - static Profile _fromAckRuntime(Map value) => - _$ProfileFromJson(Map.from(value)); + static Profile _fromAckRuntime(Map value) => Profile( + homepage: value['homepage'] as Uri, + birthday: value['birthday'] as DateTime, + lastLogin: value['lastLogin'] as DateTime, + timeout: value['timeout'] as Duration, + links: (value['links'] as List).map((item) => item as Uri).toList(), + favoriteColor: value['favoriteColor'] as Color, + slug: value['slug'] as String, + accent: ColorModel.$ack.fromRuntime(value['accent'] as Color), + colors: (value['colors'] as List) + .map((item) => ColorModel.$ack.fromRuntime(item as Color)) + .toList(), + customColors: (value['customColors'] as List) + .map((item) => item as Color) + .toList(), + tagList: value['tagList'] as TagList, + ); Map _toAckRuntime() => { - ..._$ProfileToJson(this), + 'homepage': homepage, + 'birthday': birthday, + 'lastLogin': lastLogin, + 'timeout': timeout, + 'links': links.map((item) => item).toList(growable: false), + 'favoriteColor': favoriteColor, + 'slug': slug, + 'accent': ColorModel.$ack.toRuntime(accent), + 'colors': colors + .map((item) => ColorModel.$ack.toRuntime(item)) + .toList(growable: false), + 'customColors': customColors.map((item) => item).toList(growable: false), + 'tagList': tagList, }; - - static Uri _ackFromRuntimeHomepage(Object? value) => value as Uri; - - static Object? _ackToRuntimeHomepage(Uri value) => value; - - static DateTime _ackFromRuntimeBirthday(Object? value) => value as DateTime; - - static Object? _ackToRuntimeBirthday(DateTime value) => value; - - static DateTime _ackFromRuntimeLastLogin(Object? value) => value as DateTime; - - static Object? _ackToRuntimeLastLogin(DateTime value) => value; - - static Duration _ackFromRuntimeTimeout(Object? value) => value as Duration; - - static Object? _ackToRuntimeTimeout(Duration value) => value; - - static List _ackFromRuntimeLinks(Object? value) => - (value as List).map((item) => item as Uri).toList(); - - static Object? _ackToRuntimeLinks(List value) => - value.map((item) => item).toList(growable: false); - - static Color _ackFromRuntimeFavoriteColor(Object? value) => value as Color; - - static Object? _ackToRuntimeFavoriteColor(Color value) => value; - - static String _ackFromRuntimeSlug(Object? value) => value as String; - - static Object? _ackToRuntimeSlug(String value) => value; - - static ColorModel _ackFromRuntimeAccent(Object? value) => - ColorModel.$ack.fromRuntime(value as Color); - - static Object? _ackToRuntimeAccent(ColorModel value) => - ColorModel.$ack.toRuntime(value); - - static List _ackFromRuntimeColors(Object? value) => - (value as List) - .map((item) => ColorModel.$ack.fromRuntime(item as Color)) - .toList(); - - static Object? _ackToRuntimeColors(List value) => value - .map((item) => ColorModel.$ack.toRuntime(item)) - .toList(growable: false); - - static List _ackFromRuntimeCustomColors(Object? value) => - (value as List).map((item) => item as Color).toList(); - - static Object? _ackToRuntimeCustomColors(List value) => - value.map((item) => item).toList(growable: false); - - static TagList _ackFromRuntimeTagList(Object? value) => value as TagList; - - static Object? _ackToRuntimeTagList(TagList value) => value; } /// Creates copies of [Profile] with selected fields replaced. diff --git a/example/lib/user_with_color.ack.g.dart b/example/lib/user_with_color.ack.g.dart deleted file mode 100644 index bc895860..00000000 --- a/example/lib/user_with_color.ack.g.dart +++ /dev/null @@ -1,55 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file - -part of 'user_with_color.dart'; - -// ************************************************************************** -// AckJsonSerializableGenerator -// ************************************************************************** - -ColorModel _$ColorModelFromJson(Map json) => - ColorModel(ColorModel._ackFromRuntimeValue(json['value'])); - -Map _$ColorModelToJson(ColorModel instance) => - {'value': ColorModel._ackToRuntimeValue(instance.value)}; - -Profile _$ProfileFromJson(Map json) => Profile( - bio: Profile._ackFromRuntimeBio(json['bio']), - website: Profile._ackFromRuntimeWebsite(json['website']), -); - -Map _$ProfileToJson(Profile instance) => { - 'bio': Profile._ackToRuntimeBio(instance.bio), - 'website': ?Profile._ackToRuntimeWebsite(instance.website), -}; - -UserWithColor _$UserWithColorFromJson(Map json) => - UserWithColor( - firstName: UserWithColor._ackFromRuntimeFirstName(json['firstName']), - lastName: UserWithColor._ackFromRuntimeLastName(json['lastName']), - age: UserWithColor._ackFromRuntimeAge(json['age']), - profile: UserWithColor._ackFromRuntimeProfile(json['profile']), - color: UserWithColor._ackFromRuntimeColor(json['color']), - favoriteColor: UserWithColor._ackFromRuntimeFavoriteColor( - json['favoriteColor'], - ), - pet: UserWithColor._ackFromRuntimePet(json['pet']), - pets: UserWithColor._ackFromRuntimePets(json['pets']), - ); - -Map _$UserWithColorToJson(UserWithColor instance) => - { - 'firstName': UserWithColor._ackToRuntimeFirstName(instance.firstName), - 'lastName': UserWithColor._ackToRuntimeLastName(instance.lastName), - 'age': UserWithColor._ackToRuntimeAge(instance.age), - 'profile': UserWithColor._ackToRuntimeProfile(instance.profile), - 'color': UserWithColor._ackToRuntimeColor(instance.color), - 'favoriteColor': ?UserWithColor._ackToRuntimeFavoriteColor( - instance.favoriteColor, - ), - 'pet': UserWithColor._ackToRuntimePet(instance.pet), - 'pets': UserWithColor._ackToRuntimePets(instance.pets), - }; diff --git a/example/lib/user_with_color.dart b/example/lib/user_with_color.dart index 34cbed47..9f5671eb 100644 --- a/example/lib/user_with_color.dart +++ b/example/lib/user_with_color.dart @@ -2,8 +2,7 @@ import 'package:ack/ack.dart'; import 'pet.dart'; -part 'user_with_color.ack.dart'; -part 'user_with_color.ack.g.dart'; +part 'user_with_color.g.dart'; class Color { final int value; diff --git a/example/lib/user_with_color.ack.dart b/example/lib/user_with_color.g.dart similarity index 81% rename from example/lib/user_with_color.ack.dart rename to example/lib/user_with_color.g.dart index 79e3e561..bb79fb27 100644 --- a/example/lib/user_with_color.ack.dart +++ b/example/lib/user_with_color.g.dart @@ -1,8 +1,4 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ignore_for_file: type=lint -// coverage:ignore-file part of 'user_with_color.dart'; @@ -10,8 +6,10 @@ part of 'user_with_color.dart'; // AckModelGenerator // ************************************************************************** +// ignore_for_file: type=lint +// coverage:ignore-file + /// Immutable value model generated from `colorSchema`. -@Schemable.generatedJson final class ColorModel { /// Creates a model without validating it. /// @@ -77,14 +75,9 @@ final class ColorModel { @override String toString() => 'ColorModel(value: $value)'; - static ColorModel _fromAckRuntime(Color value) => - _$ColorModelFromJson({'value': value}); - - Color _toAckRuntime() => _$ColorModelToJson(this)['value'] as Color; + static ColorModel _fromAckRuntime(Color value) => ColorModel(value); - static Color _ackFromRuntimeValue(Object? value) => value as Color; - - static Object? _ackToRuntimeValue(Color value) => value; + Color _toAckRuntime() => value; } /// Creates copies of [ColorModel] with selected fields replaced. @@ -106,7 +99,6 @@ final class _$ColorModelCopyWith implements $ColorModelCopyWith { } /// Immutable model generated from `profileSchema`. -@Schemable.generatedJson final class Profile { /// Creates a model without validating it. /// @@ -176,19 +168,12 @@ final class Profile { String toString() => 'Profile(bio: $bio, website: $website)'; static Profile _fromAckRuntime(Map value) => - _$ProfileFromJson(Map.from(value)); + Profile(bio: value['bio'] as String, website: value['website'] as Uri?); Map _toAckRuntime() => { - ..._$ProfileToJson(this), + 'bio': bio, + 'website': ?website, }; - - static String _ackFromRuntimeBio(Object? value) => value as String; - - static Object? _ackToRuntimeBio(String value) => value; - - static Uri? _ackFromRuntimeWebsite(Object? value) => value as Uri?; - - static Object? _ackToRuntimeWebsite(Uri? value) => value; } final class _ProfileCopyWithUnset { @@ -222,7 +207,6 @@ final class _$ProfileCopyWith implements $ProfileCopyWith { } /// Immutable model generated from `userWithColorSchema`. -@Schemable.generatedJson final class UserWithColor { /// Creates a model without validating it. /// @@ -330,59 +314,39 @@ final class UserWithColor { 'UserWithColor(firstName: $firstName, lastName: $lastName, age: $age, profile: $profile, color: $color, favoriteColor: $favoriteColor, pet: $pet, pets: $pets)'; static UserWithColor _fromAckRuntime(Map value) => - _$UserWithColorFromJson(Map.from(value)); + UserWithColor( + firstName: value['firstName'] as String, + lastName: value['lastName'] as String, + age: value['age'] as int, + profile: Profile.$ack.fromRuntime( + value['profile'] as Map, + ), + color: ColorModel.$ack.fromRuntime(value['color'] as Color), + favoriteColor: switch (value['favoriteColor']) { + null => null, + final fieldValue => ColorModel.$ack.fromRuntime(fieldValue as Color), + }, + pet: Pet.$ack.fromRuntime(value['pet'] as Map), + pets: (value['pets'] as List) + .map((item) => Pet.$ack.fromRuntime(item as Map)) + .toList(), + ); Map _toAckRuntime() => { - ..._$UserWithColorToJson(this), + 'firstName': firstName, + 'lastName': lastName, + 'age': age, + 'profile': Profile.$ack.toRuntime(profile), + 'color': ColorModel.$ack.toRuntime(color), + 'favoriteColor': ?switch (favoriteColor) { + null => null, + final fieldValue => ColorModel.$ack.toRuntime(fieldValue), + }, + 'pet': Pet.$ack.toRuntime(pet), + 'pets': pets + .map((item) => Pet.$ack.toRuntime(item)) + .toList(growable: false), }; - - static String _ackFromRuntimeFirstName(Object? value) => value as String; - - static Object? _ackToRuntimeFirstName(String value) => value; - - static String _ackFromRuntimeLastName(Object? value) => value as String; - - static Object? _ackToRuntimeLastName(String value) => value; - - static int _ackFromRuntimeAge(Object? value) => value as int; - - static Object? _ackToRuntimeAge(int value) => value; - - static Profile _ackFromRuntimeProfile(Object? value) => - Profile.$ack.fromRuntime(value as Map); - - static Object? _ackToRuntimeProfile(Profile value) => - Profile.$ack.toRuntime(value); - - static ColorModel _ackFromRuntimeColor(Object? value) => - ColorModel.$ack.fromRuntime(value as Color); - - static Object? _ackToRuntimeColor(ColorModel value) => - ColorModel.$ack.toRuntime(value); - - static ColorModel? _ackFromRuntimeFavoriteColor(Object? value) => - switch (value) { - null => null, - final fieldValue => ColorModel.$ack.fromRuntime(fieldValue as Color), - }; - - static Object? _ackToRuntimeFavoriteColor(ColorModel? value) => - switch (value) { - null => null, - final fieldValue => ColorModel.$ack.toRuntime(fieldValue), - }; - - static Pet _ackFromRuntimePet(Object? value) => - Pet.$ack.fromRuntime(value as Map); - - static Object? _ackToRuntimePet(Pet value) => Pet.$ack.toRuntime(value); - - static List _ackFromRuntimePets(Object? value) => (value as List) - .map((item) => Pet.$ack.fromRuntime(item as Map)) - .toList(); - - static Object? _ackToRuntimePets(List value) => - value.map((item) => Pet.$ack.toRuntime(item)).toList(growable: false); } final class _UserWithColorCopyWithUnset { diff --git a/packages/ack/benchmark/harness.dart b/packages/ack/benchmark/harness.dart new file mode 100644 index 00000000..02fadff6 --- /dev/null +++ b/packages/ack/benchmark/harness.dart @@ -0,0 +1,57 @@ +/// Shared timing for the files in this directory. +/// +/// JIT numbers drift with warmup, so measure an AOT build: +/// +/// ```sh +/// dart compile exe benchmark/.dart -o build/ +/// build/ +/// ``` +library; + +const draft = 'https://json-schema.org/draft/2020-12/schema'; + +/// Throws when a benchmark case does not succeed, so a failing case cannot be +/// timed as if it were the success path. +void check(String name, bool ok) { + if (!ok) throw StateError('$name did not validate'); +} + +/// Warms up for 300 ms, then reports the best and median of 15 samples of +/// at least 50 ms each. +void report(String name, Object? Function() body) { + Object? sink; + final warmup = Stopwatch()..start(); + var batch = 1; + while (warmup.elapsedMilliseconds < 300) { + for (var i = 0; i < batch; i++) { + sink = body(); + } + batch *= 2; + } + final calibrate = Stopwatch()..start(); + var perSample = 0; + while (calibrate.elapsedMilliseconds < 50) { + sink = body(); + perSample++; + } + final samples = []; + for (var s = 0; s < 15; s++) { + final watch = Stopwatch()..start(); + for (var i = 0; i < perSample; i++) { + sink = body(); + } + samples.add(watch.elapsedMicroseconds / perSample); + } + samples.sort(); + if (identical(sink, _never)) print(''); + print( + ' ${name.padRight(36)} best ${_format(samples.first).padLeft(10)}' + ' median ${_format(samples[samples.length ~/ 2]).padLeft(10)}', + ); +} + +final _never = Object(); + +String _format(double us) => us >= 1000 + ? '${(us / 1000).toStringAsFixed(2)} ms' + : '${us.toStringAsFixed(2)} µs'; diff --git a/packages/ack/benchmark/large_schema_benchmark.dart b/packages/ack/benchmark/large_schema_benchmark.dart new file mode 100644 index 00000000..8a523df5 --- /dev/null +++ b/packages/ack/benchmark/large_schema_benchmark.dart @@ -0,0 +1,137 @@ +/// Large schemas and values: `Ack.fromJsonSchema` compilation, `toJsonSchema` +/// export, and parsing with native and imported schemas. See `harness.dart` +/// for how to run. +library; + +import 'dart:convert'; + +import 'package:ack/ack.dart'; + +import 'harness.dart'; + +const _fields = 20; + +void main() { + for (final types in [10, 100, 1000]) { + final document = _document(types); + final native = _native(types); + final imported = Ack.fromJsonSchema(document); + final value = _rootValue(types); + check('imported root', imported.safeParse(value).isOk); + check('native root', native.safeParse(value).isOk); + final docKb = jsonEncode(document).length / 1024; + final valueKb = jsonEncode(value).length / 1024; + print( + '\n$types types x $_fields fields ' + '(schema ${docKb.toStringAsFixed(0)} KB, ' + 'value ${valueKb.toStringAsFixed(0)} KB)', + ); + report('fromJsonSchema compile', () => Ack.fromJsonSchema(document)); + report('imported toJsonSchema', imported.toJsonSchema); + report('native toJsonSchema', native.toJsonSchema); + report('imported safeParse root', () => imported.safeParse(value)); + report('native safeParse root', () => native.safeParse(value)); + } + + for (final count in [1000, 10000]) { + final document = { + r'$schema': draft, + 'type': 'array', + 'items': _recordDocument(), + }; + final imported = Ack.fromJsonSchema(document); + final native = Ack.list(_nativeRecord()); + final value = [for (var i = 0; i < count; i++) _recordValue(i)]; + check('imported list', imported.safeParse(value).isOk); + check('native list', native.safeParse(value).isOk); + check('imported list encode', imported.safeEncode(value).isOk); + final text = jsonEncode(value); + print('\n$count records (${(text.length / 1024).toStringAsFixed(0)} KB)'); + report('imported safeParse', () => imported.safeParse(value)); + report('imported safeEncode', () => imported.safeEncode(value)); + report('native safeParse', () => native.safeParse(value)); + report('jsonDecode (reference)', () => jsonDecode(text)); + } +} + +/// A root object with one property per type. Type `i` has [_fields] fields +/// and, for `i > 0`, an optional `parent` that refers to type `i ~/ 2`. +Map _document(int types) => { + r'$schema': draft, + 'type': 'object', + 'additionalProperties': false, + 'required': [for (var i = 0; i < types; i++) 't$i'], + 'properties': { + for (var i = 0; i < types; i++) 't$i': {r'$ref': '#/\$defs/T$i'}, + }, + r'$defs': { + for (var i = 0; i < types; i++) + 'T$i': _recordDocument(parent: i == 0 ? null : '#/\$defs/T${i ~/ 2}'), + }, +}; + +Map _recordDocument({String? parent}) => { + 'type': 'object', + 'additionalProperties': false, + 'required': [for (var f = 0; f < _fields; f++) 'f$f'], + 'properties': { + for (var f = 0; f < _fields; f++) 'f$f': _fieldDocument(f), + if (parent != null) 'parent': {r'$ref': parent}, + }, +}; + +Map _fieldDocument(int f) => switch (f % 5) { + 0 => {'type': 'string', 'minLength': 1, 'pattern': r'^[a-z0-9]+$'}, + 1 => {'type': 'integer', 'minimum': 0, 'maximum': 1000000}, + 2 => { + 'type': 'string', + 'enum': ['a', 'b', 'c'], + }, + 3 => {'type': 'boolean'}, + _ => { + 'type': 'array', + 'maxItems': 10, + 'items': {'type': 'string'}, + }, +}; + +AckSchema _native(int types) { + final records = []; + for (var i = 0; i < types; i++) { + records.add(_nativeRecord(parent: i == 0 ? null : records[i ~/ 2])); + } + return Ack.object({for (var i = 0; i < types; i++) 't$i': records[i]}); +} + +ObjectSchema _nativeRecord({ObjectSchema? parent}) => Ack.object({ + for (var f = 0; f < _fields; f++) 'f$f': _nativeField(f), + 'parent': ?parent?.optional(), +}); + +AckSchema _nativeField(int f) => switch (f % 5) { + 0 => Ack.string().minLength(1).matches(r'^[a-z0-9]+$'), + 1 => Ack.integer().min(0).max(1000000), + 2 => Ack.enumString(['a', 'b', 'c']), + 3 => Ack.boolean(), + _ => Ack.list(Ack.string()).maxItems(10), +}; + +/// Fills every type, including its full `parent` chain. +Map _rootValue(int types) { + Map record(int i) => { + ..._recordValue(i), + if (i > 0) 'parent': record(i ~/ 2), + }; + return {for (var i = 0; i < types; i++) 't$i': record(i)}; +} + +Map _recordValue(int i) => { + for (var f = 0; f < _fields; f++) + 'f$f': switch (f % 5) { + 0 => 'v$i', + 1 => i, + 2 => 'b', + 3 => i.isEven, + _ => ['x', 'y', 'z'], + }, +}; diff --git a/packages/ack/benchmark/parse_benchmark.dart b/packages/ack/benchmark/parse_benchmark.dart index 01641abf..4891f782 100644 --- a/packages/ack/benchmark/parse_benchmark.dart +++ b/packages/ack/benchmark/parse_benchmark.dart @@ -1,14 +1,9 @@ -/// JIT numbers drift with warmup, so measure an AOT build: -/// -/// ```sh -/// dart compile exe benchmark/parse_benchmark.dart -o build/parse_benchmark -/// build/parse_benchmark -/// ``` +/// Small schemas and values. See `harness.dart` for how to run. library; import 'package:ack/ack.dart'; -const _draft = 'https://json-schema.org/draft/2020-12/schema'; +import 'harness.dart'; void main() { final string = Ack.string(); @@ -19,7 +14,7 @@ void main() { }); final anything = Ack.fromJsonSchema(true); final flat = Ack.fromJsonSchema({ - r'$schema': _draft, + r'$schema': draft, 'type': 'object', 'properties': { for (var i = 0; i < 8; i++) @@ -28,7 +23,7 @@ void main() { }); final flatValue = {for (var i = 0; i < 8; i++) 'p$i': 'abc'}; final nested = Ack.fromJsonSchema({ - r'$schema': _draft, + r'$schema': draft, 'type': 'object', 'properties': { 'items': { @@ -56,40 +51,21 @@ void main() { }, ], }; + const objectValue = {'a': 'x', 'b': 'y', 'c': 'z'}; + check('string', string.safeParse('x').isOk); + check('object', object.safeParse(objectValue).isOk); + check('anything', anything.safeParse('x').isOk); + check('flat', flat.safeParse(flatValue).isOk); + check('nested', nested.safeParse(nestedValue).isOk); - _report('Ack.string()', 200000, () => string.safeParse('x')); - _report( - 'Ack.object, 3 string fields', - 200000, - () => object.safeParse(const {'a': 'x', 'b': 'y', 'c': 'z'}), - ); - _report('fromJsonSchema(true)', 200000, () => anything.safeParse('x')); - _report('imported, 8 properties', 100000, () => flat.safeParse(flatValue)); - _report( - 'imported, 8 properties (encode)', - 100000, - () => flat.safeEncode(flatValue), - ); - _report( - 'imported, 50 nested items', - 10000, - () => nested.safeParse(nestedValue), - ); - _report( + report('Ack.string()', () => string.safeParse('x')); + report('Ack.object, 3 string fields', () => object.safeParse(objectValue)); + report('fromJsonSchema(true)', () => anything.safeParse('x')); + report('imported, 8 properties', () => flat.safeParse(flatValue)); + report('imported, 8 properties (encode)', () => flat.safeEncode(flatValue)); + report('imported, 50 nested items', () => nested.safeParse(nestedValue)); + report( 'imported, 50 nested items (encode)', - 10000, () => nested.safeEncode(nestedValue), ); } - -void _report(String name, int iterations, void Function() body) { - for (var i = 0; i < iterations ~/ 10; i++) { - body(); - } - final stopwatch = Stopwatch()..start(); - for (var i = 0; i < iterations; i++) { - body(); - } - final ns = stopwatch.elapsedMicroseconds * 1000 / iterations; - print('${name.padRight(36)} ${ns.toStringAsFixed(0).padLeft(7)} ns'); -} diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 090a4e86..ae9ab384 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -47,6 +47,4 @@ export 'src/validation/schema_error.dart'; export 'src/validation/schema_result.dart'; // Model annotations. Constraint annotations live in annotations.dart. export 'src/annotations/ack_field.dart'; -export 'src/annotations/ack_model.dart' - show AckCaseStyle, AckUnknownPropertyPolicy; export 'src/annotations/schemable.dart'; diff --git a/packages/ack/lib/ack_generator_support.dart b/packages/ack/lib/ack_generator_support.dart deleted file mode 100644 index 05a0f368..00000000 --- a/packages/ack/lib/ack_generator_support.dart +++ /dev/null @@ -1,4 +0,0 @@ -/// Internal support types used by `ack_generator`. -library; - -export 'src/annotations/ack_generated_json.dart'; diff --git a/packages/ack/lib/annotations.dart b/packages/ack/lib/annotations.dart index 7877e7a5..d313de0b 100644 --- a/packages/ack/lib/annotations.dart +++ b/packages/ack/lib/annotations.dart @@ -2,8 +2,5 @@ library; export 'src/annotations/ack_field.dart'; -export 'src/annotations/ack_infer.dart'; -export 'src/annotations/ack_model.dart'; -export 'src/annotations/ack_type.dart'; export 'src/annotations/constraints.dart'; export 'src/annotations/schemable.dart'; diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index af4f7892..0b6aa7b4 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -11,16 +11,20 @@ final class Ack { /// /// [document] must be a map or boolean, not a JSON string. Referenced /// [documents] are supplied by retrieval URI; no files or URLs are fetched. - /// The supported `date-time` format is asserted. Other formats remain - /// unsupported. Unsupported semantics throw [JsonSchemaImportException]. + /// [assertFormats] opts into format assertions under the standard dialect; + /// a supplied dialect requiring the format-assertion vocabulary also enables + /// them. Unknown asserted formats are rejected when compiling. + /// Unsupported semantics throw [JsonSchemaImportException]. static AckSchema fromJsonSchema( Object document, { Uri? baseUri, Map documents = const {}, + bool assertFormats = false, }) => ImportedJsonSchema.fromDocument( document, baseUri: baseUri, documents: documents, + assertFormats: assertFormats, ); /// Creates a string schema. Boundary and runtime are both `String`. diff --git a/packages/ack/lib/src/annotations/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 54d62feb..3f4d1d8e 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -1,18 +1,5 @@ import 'package:meta/meta_meta.dart'; -// The deprecated presence API is defined in this file. -// ignore_for_file: deprecated_member_use_from_same_package - -/// Overrides inferred input-presence for a class-first field. -/// -/// [inferred] keeps constructor-based presence. [required] always requires the -/// JSON key. [optional] is valid only when the constructor can accept a missing -/// value, with a discriminator-specific exception for union branches. -@Deprecated( - 'Use @Optional() or @Required(). AckFieldPresence will be removed in 2.0.0.', -) -enum AckFieldPresence { inferred, required, optional } - /// Marks a class-first field as optional on the wire. /// /// The JSON key may be omitted. This does not change whether a present JSON @@ -47,38 +34,26 @@ final class NotNull { const NotNull(); } -/// Overrides the inferred schema and/or presence for a class-first field. +/// Overrides the JSON key, inferred schema, and/or description for a +/// class-first field. /// +/// [name] overrides the wire JSON key for this field. /// [schema] must be a const tear-off of a top-level function returning an Ack /// schema. The generator validates the declaration and follows its expression. /// [description] describes this property, not a nested model's schema. /// It takes precedence over an `@description` doc tag. A no-op `@AckField()` /// is rejected. -/// -/// Prefer `@Optional()` and `@Required()` for key presence. [presence] is -/// deprecated and will be removed in 2.0.0. @Target({TargetKind.field, TargetKind.parameter}) final class AckField { /// Creates a field annotation. - const AckField({ - this.schema, - this.description, - @Deprecated( - 'Use @Optional() or @Required(). AckField.presence will be removed in ' - '2.0.0.', - ) - this.presence = AckFieldPresence.inferred, - }); + const AckField({this.name, this.schema, this.description}); + + /// Explicit JSON key for this property. + final String? name; /// Top-level schema-function tear-off followed by `ack_generator`. final Object Function()? schema; /// Schema description for this property. final String? description; - - /// Presence override applied after constructor inference. - @Deprecated( - 'Use @Optional() or @Required(). AckField.presence will be removed in 2.0.0.', - ) - final AckFieldPresence presence; } diff --git a/packages/ack/lib/src/annotations/ack_generated_json.dart b/packages/ack/lib/src/annotations/ack_generated_json.dart deleted file mode 100644 index c439e8ce..00000000 --- a/packages/ack/lib/src/annotations/ack_generated_json.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// Generator-support marker applied to Ack-generated model classes. -/// -/// This is not part of the user-facing annotation API. `ack_generator` emits -/// `Schemable.generatedJson` on generated schema-first models so its JSON -/// builder can find them. The marker carries no configuration: the generator -/// owns the fixed JSON mapping rules. -final class AckGeneratedJson { - /// Creates the internal JSON-mapping marker. - const AckGeneratedJson(); -} diff --git a/packages/ack/lib/src/annotations/ack_infer.dart b/packages/ack/lib/src/annotations/ack_infer.dart deleted file mode 100644 index eda60b2f..00000000 --- a/packages/ack/lib/src/annotations/ack_infer.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:meta/meta_meta.dart'; - -import 'ack_generated_json.dart'; - -/// Marks a top-level Ack schema for immutable model-class generation. -/// -/// Apply `@AckInfer()` to a top-level schema variable or getter: -/// -/// ```dart -/// @AckInfer() -/// final userSchema = Ack.object({ -/// 'name': Ack.string(), -/// 'age': Ack.integer(), -/// }); -/// ``` -/// -/// The declaring library must include both generated parts, for example -/// `part 'user.ack.dart';` and `part 'user.ack.g.dart';`. `ack_generator` emits -/// the model class, public parse/JSON API, and runtime bridges in the Ack -/// part. Structural field mapping is generated into the Ack JSON part. -/// Ack remains responsible for validation and codec-aware serialization. -/// -/// Supported targets: -/// - Top-level variables -/// - Top-level getters -/// -/// `meta` cannot express "top-level getter only", so the annotation allows all -/// getters and `ack_generator` enforces the top-level restriction. -/// -/// Unsupported targets: -/// - Classes -/// - Instance members -/// - Local variables -@Deprecated('Use @Schemable(). AckInfer will be removed in 2.0.0.') -@Target({TargetKind.topLevelVariable, TargetKind.getter}) -final class AckInfer { - /// Marker on model classes generated with `@AckInfer`; new output uses - /// `Schemable.generatedJson`. - /// - /// Typed as [Object] so generated code only needs a constant annotation - /// expression. The generator inspects the actual [AckGeneratedJson] type. - static const Object jsonSerializable = AckGeneratedJson(); - - /// Optional exact name for the generated model class. - /// - /// If omitted, the class name is derived from the schema declaration: - /// - `userSchema` -> `User` - /// - `passwordSchema` -> `Password` - /// - /// If provided, the value is used directly: - /// - `@AckInfer(name: 'AppUser')` -> `AppUser` - final String? name; - - /// Creates an annotation for immutable Ack model generation. - /// - /// [name] must be an unchanged UpperCamelCase Dart identifier. It is used - /// exactly; a `Type` suffix is retained when intentionally supplied. - const AckInfer({this.name}); -} diff --git a/packages/ack/lib/src/annotations/ack_model.dart b/packages/ack/lib/src/annotations/ack_model.dart deleted file mode 100644 index 0394a4c5..00000000 --- a/packages/ack/lib/src/annotations/ack_model.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:meta/meta_meta.dart'; - -/// JSON field-name styles supported by class-first Ack generation. -/// -/// This enum is deliberately closed: `ack_generator` maps each style onto a -/// fixed field-rename rule when it generates the JSON part. -enum AckCaseStyle { none, snake, kebab, pascal, screamingSnake } - -/// How a class-first object treats properties that are not declared fields. -/// -/// [reject] is the default: unknown properties fail validation. -/// [discard] accepts unknown properties during validation but does not store -/// them on the model. It is intended for tolerant, read-only consumers. -/// [capture] stores unknown properties in the configured `captureField` -/// and flattens them back onto the wire during encoding. Declared fields and -/// union discriminators always win over captured extras. Models that must -/// round-trip unknown properties use [capture]. -enum AckUnknownPropertyPolicy { reject, discard, capture } - -/// Marks a hand-written class for Ack schema generation. -/// -/// The declaring library must include both generated parts, for example -/// `part 'user.ack.dart';` and `part 'user.ack.g.dart';`. Instantiable models and -/// implicit sealed-union branches must be `final class` declarations, use only -/// final stored fields, and apply the generated `_$ClassAck` mixin. Annotated -/// sealed union bases remain supported. -@Deprecated('Use @Schemable(). AckModel will be removed in 2.0.0.') -@Target({TargetKind.classType}) -final class AckModel { - /// Creates a class-first Ack model annotation. - const AckModel({ - this.schemaName, - this.description, - this.caseStyle = AckCaseStyle.none, - this.discriminatorKey, - this.discriminatorValue, - this.unknownProperties = AckUnknownPropertyPolicy.reject, - this.captureField = 'additionalProperties', - }); - - /// Exact public schema facade class name. - /// - /// The name must be a public UpperCamel identifier. When omitted, Ack derives - /// `Schema`, preserving the model class's written capitalization. - final String? schemaName; - - /// Schema description. This takes precedence over an `@description` doc tag. - /// Untagged documentation does not become a schema description. - final String? description; - - /// Naming convention applied to JSON field keys. - final AckCaseStyle caseStyle; - - /// Required discriminator key for an annotated sealed union base. - final String? discriminatorKey; - - /// Wire discriminator for a concrete union branch. - /// - /// When omitted, the verbatim class name is used. - final String? discriminatorValue; - - /// Policy for undeclared object properties. - final AckUnknownPropertyPolicy unknownProperties; - - /// Dart field that stores captured unknown properties. - /// - /// Used only when [unknownProperties] is - /// [AckUnknownPropertyPolicy.capture]. Defaults to - /// `additionalProperties` and may be `args`. - final String captureField; -} diff --git a/packages/ack/lib/src/annotations/ack_type.dart b/packages/ack/lib/src/annotations/ack_type.dart deleted file mode 100644 index d1f4098c..00000000 --- a/packages/ack/lib/src/annotations/ack_type.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:meta/meta_meta.dart'; - -/// Marks a top-level Ack schema for frozen legacy extension-type generation. -/// -/// Legacy `AckType` behavior is frozen until removal in Ack 2. -/// -/// Apply `@AckType()` to a top-level schema variable or getter: -/// -/// ```dart -/// @AckType() -/// final userSchema = Ack.object({ -/// 'name': Ack.string(), -/// 'age': Ack.integer(), -/// }); -/// ``` -/// -/// `ack_generator` emits a typed wrapper around the schema's validated -/// representation plus `parse()` and `safeParse()` helpers. -/// -/// Supported targets: -/// - Top-level variables -/// - Top-level getters -/// -/// `meta` cannot express "top-level getter only", so the annotation allows all -/// getters and `ack_generator` enforces the top-level restriction. -/// -/// Unsupported targets: -/// - Classes -/// - Instance members -/// - Local variables -@Deprecated( - 'Use @Schemable() for schema-first or class-first models. AckType will ' - 'be removed in 2.0.0.', -) -@Target({TargetKind.topLevelVariable, TargetKind.getter}) -class AckType { - /// Optional custom name for the generated extension type. - /// - /// If not provided, the type name is derived from the schema variable name: - /// - `userSchema` -> `UserType` - /// - `passwordSchema` -> `PasswordType` - /// - /// If provided, the custom name is used with the `Type` suffix: - /// - `@AckType(name: 'CustomUser')` -> `CustomUserType` - /// - `@AckType(name: 'MyPassword')` -> `MyPasswordType` - final String? name; - - /// Creates an annotation to generate extension types for validated data. - /// - /// The [name] value must be a valid Dart identifier and should omit the - /// trailing `Type` suffix. - const AckType({this.name}); -} diff --git a/packages/ack/lib/src/annotations/constraints.dart b/packages/ack/lib/src/annotations/constraints.dart index eb9b39d0..72a32129 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -60,13 +60,6 @@ final class Matches { final String pattern; } -/// Former name of [Matches]. -/// -/// Not exported from `package:ack/ack.dart`, where it would hide -/// `dart:core`'s `Pattern`. -@Deprecated('Use @Matches(). Pattern will be removed in 2.0.0.') -typedef Pattern = Matches; - /// Adds `.email()` to an inferred string schema. @Target({TargetKind.field, TargetKind.parameter}) final class Email { diff --git a/packages/ack/lib/src/annotations/schemable.dart b/packages/ack/lib/src/annotations/schemable.dart index 55dd1d97..0d73dfb2 100644 --- a/packages/ack/lib/src/annotations/schemable.dart +++ b/packages/ack/lib/src/annotations/schemable.dart @@ -1,7 +1,21 @@ import 'package:meta/meta_meta.dart'; -import 'ack_generated_json.dart'; -import 'ack_model.dart'; +/// JSON field-name styles supported by class-first Ack generation. +/// +/// This enum is deliberately closed: `ack_generator` maps each style onto a +/// fixed field-rename rule when it generates the model schema. +enum AckCaseStyle { none, snake, kebab, pascal, screamingSnake } + +/// How a class-first object treats properties that are not declared fields. +/// +/// [reject] is the default: unknown properties fail validation. +/// [discard] accepts unknown properties during validation but does not store +/// them on the model. It is intended for tolerant, read-only consumers. +/// [capture] stores unknown properties in the configured `captureField` +/// and flattens them back onto the wire during encoding. Declared fields and +/// union discriminators always win over captured extras. Models that must +/// round-trip unknown properties use [capture]. +enum AckUnknownPropertyPolicy { reject, discard, capture } /// Marks an Ack schema declaration. /// @@ -9,16 +23,8 @@ import 'ack_model.dart'; /// schema-first model. Use it on a class to generate a class-first schema. /// [name] applies only to a top-level schema; every other option applies only /// to a class. `ack_generator` rejects an option set for the other target. -/// The deprecated `@AckInfer` and `@AckModel` spellings remain supported until -/// 2.0.0. @Target({TargetKind.classType, TargetKind.topLevelVariable, TargetKind.getter}) final class Schemable { - /// Internal marker used on generated schema-first model classes. - /// - /// Typed as [Object] so generated code only needs a constant annotation - /// expression. The generator inspects the actual [AckGeneratedJson] type. - static const Object generatedJson = AckGeneratedJson(); - /// Creates a unified schema declaration annotation. const Schemable({ this.name, diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index de427128..337d2fae 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -13,46 +13,112 @@ String _keywordPointer(_ImportedNode node, String keyword) { } final class _ImportedNode { - _ImportedNode(this.source, this.documentUri, this.pointer, this.baseUri); + _ImportedNode( + this.source, + this.documentUri, + this.pointer, + this.baseUri, + this.dialectUri, + ); final Object source; final Uri documentUri; final String pointer; final Uri baseUri; + final Uri dialectUri; + bool validationVocabulary = true; + bool formatAssertion = false; final keywords = {}; final children = {}; final maps = >{}; final lists = >{}; _ImportedNode? reference; + _ImportedNode? dynamicReference; + String? dynamicReferenceName; + String? dynamicAnchorName; + late _ImportedNode resourceRoot; + final dynamicAnchors = {}; + + // Typed fields for validation, avoiding per-value map lookups. + bool get isFalse => identical(source, false); + List? types; + bool hasConst = false; + Object? constValue; + List? enumValues; + num? minimum; + num? maximum; + num? exclusiveMinimum; + num? exclusiveMaximum; + num? multipleOf; + num? minLength; + num? maxLength; + num? minItems; + num? maxItems; + num? minProperties; + num? maxProperties; + num? minContains; + num? maxContains; + bool uniqueItems = false; RegExp? pattern; - Iterable<_ImportedNode> get dependencies sync* { - if (reference case final target?) yield target; - yield* children.values; - for (final map in maps.values) { - yield* map.values; - } - for (final list in lists.values) { - yield* list; - } - } + String? format; + List? required; + Map>? dependentRequired; + Map? properties; + List<(String, RegExp, _ImportedNode)>? patternProperties; + Map? dependentSchemas; + _ImportedNode? items; + _ImportedNode? additionalProperties; + _ImportedNode? not; + _ImportedNode? contains; + _ImportedNode? propertyNames; + _ImportedNode? unevaluatedProperties; + _ImportedNode? unevaluatedItems; + _ImportedNode? ifNode; + _ImportedNode? thenNode; + _ImportedNode? elseNode; + List<_ImportedNode>? anyOf; + List<_ImportedNode>? allOf; + List<_ImportedNode>? oneOf; + List<_ImportedNode>? prefixItems; - Iterable<_ImportedNode> get inPlaceDependencies sync* { - if (reference case final target?) yield target; - for (final key in ['not', 'if', 'then', 'else']) { - if (children[key] case final target?) yield target; - } - for (final key in ['anyOf', 'allOf', 'oneOf']) { - yield* lists[key] ?? const <_ImportedNode>[]; - } - } + List<_ImportedNode> get dependencies => [ + ?reference, + ?dynamicReference, + ...children.values, + for (final map in maps.values) ...map.values, + for (final list in lists.values) ...list, + ]; + + /// Sub-schemas applied to the same instance, for the cycle check. + List<_ImportedNode> get inPlaceDependencies => [ + ?reference, + ?dynamicReference, + ?not, + ?ifNode, + ?thenNode, + ?elseNode, + ...?anyOf, + ...?allOf, + ...?oneOf, + ...?dependentSchemas?.values, + ]; - Map render(String Function(_ImportedNode) name) { - if (source == false) return {'not': {}}; + /// The 1.x Draft-7 representation of an imported node. Newer 2020-12 + /// assertions that cannot be lowered must use the preserving export. + Map renderLegacy(String Function(_ImportedNode) refOf) { + if (dynamicReference != null || + dynamicAnchorName != null || + (source is Map && + (source as Map).containsKey('format'))) { + throw UnsupportedError( + 'This imported schema needs 2020-12 semantics. Use ' + 'toJsonSchemaPreservingImportedDialect().', + ); + } + if (isFalse) return {'not': {}}; // Empty enums are valid in 2020-12 but not Draft-7's meta-schema. if (keywords['enum'] case []) return {'not': {}}; - Map ref(_ImportedNode node) => { - r'$ref': '#/definitions/${_importPointerToken(name(node))}', - }; + Map ref(_ImportedNode node) => {r'$ref': refOf(node)}; return { ...keywords, // Draft-7 ignores $ref siblings; an allOf envelope preserves them. @@ -69,10 +135,21 @@ final class _ImportedNode { } final class _JsonSchemaCompiler { + _JsonSchemaCompiler({this.assertFormats = false}); + + final bool assertFormats; + static final standardDialect = Uri.parse( + 'https://json-schema.org/draft/2020-12/schema', + ); + static const validationVocabularyUri = + 'https://json-schema.org/draft/2020-12/vocab/validation'; + static const formatAssertionVocabularyUri = + 'https://json-schema.org/draft/2020-12/vocab/format-assertion'; final diagnostics = []; final locations = <(Uri, String), _ImportedNode>{}; final resources = {}; final anchors = {}; + final dynamicAnchors = {}; static const mapKeywords = { r'$defs', @@ -87,11 +164,11 @@ final class _JsonSchemaCompiler { 'not', 'contains', 'propertyNames', - 'unevaluatedProperties', - 'unevaluatedItems', 'if', 'then', 'else', + 'unevaluatedProperties', + 'unevaluatedItems', }; static const listKeywords = {'anyOf', 'allOf', 'oneOf', 'prefixItems'}; static const annotations = { @@ -103,6 +180,20 @@ final class _JsonSchemaCompiler { 'readOnly', 'writeOnly', 'deprecated', + 'format', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + }; + static const knownVocabularies = { + 'https://json-schema.org/draft/2020-12/vocab/core', + 'https://json-schema.org/draft/2020-12/vocab/applicator', + 'https://json-schema.org/draft/2020-12/vocab/unevaluated', + 'https://json-schema.org/draft/2020-12/vocab/validation', + 'https://json-schema.org/draft/2020-12/vocab/meta-data', + 'https://json-schema.org/draft/2020-12/vocab/format-annotation', + formatAssertionVocabularyUri, + 'https://json-schema.org/draft/2020-12/vocab/content', }; static const counts = { 'minLength', @@ -123,7 +214,8 @@ final class _JsonSchemaCompiler { if (uri.hasFragment && uri.fragment.isNotEmpty) { throw ArgumentError.value(uri, 'baseUri', 'Must not contain a fragment.'); } - if (!_isImportJson(document, HashSet.identity())) { + final copy = _copyImportJson(document, HashSet.identity()); + if (identical(copy, _notImportJson)) { throw JsonSchemaImportException._([ JsonSchemaImportDiagnostic._( code: 'invalid_schema', @@ -144,10 +236,11 @@ final class _JsonSchemaCompiler { ); } return _index( - cloneDefault(document)!, + copy!, uri.removeFragment(), '#', uri.removeFragment(), + dialect: standardDialect, isRoot: true, ); } @@ -157,10 +250,12 @@ final class _JsonSchemaCompiler { Uri document, String pointer, Uri base, { + Uri? dialect, bool isRoot = false, }) { if (locations[(document, pointer)] case final existing?) return existing; - final provisional = _ImportedNode(source, document, pointer, base); + dialect ??= standardDialect; + final provisional = _ImportedNode(source, document, pointer, base, dialect); if (source is! bool && source is! Map) { _fail(provisional, '', 'Expected a schema object or boolean.'); } @@ -173,26 +268,24 @@ final class _JsonSchemaCompiler { } base = base.removeFragment(); } - final node = _ImportedNode(source, document, pointer, base); + if (source is Map && source.containsKey(r'$schema')) { + final declared = source[r'$schema']; + if (declared is! String) { + _fail(provisional, r'$schema', 'Expected a URI string.'); + } + final resolved = _resolve(provisional, r'$schema', base, declared); + if (resolved.fragment.isNotEmpty) { + _fail(provisional, r'$schema', 'Dialect URIs must not have fragments.'); + } + dialect = resolved.removeFragment(); + } + final node = _ImportedNode(source, document, pointer, base, dialect); locations[(document, pointer)] = node; if (isRoot) _register(resources, document, node, r'$id'); if (isRoot || (source is Map && source.containsKey(r'$id'))) { _register(resources, base, node, r'$id'); } if (source is! Map) return node; - if (source.containsKey(r'$schema')) { - final dialect = source[r'$schema']; - // An empty fragment identifies the same meta-schema resource. - if (dialect != 'https://json-schema.org/draft/2020-12/schema' && - dialect != 'https://json-schema.org/draft/2020-12/schema#') { - _fail( - node, - r'$schema', - 'Only draft 2020-12 input is supported.', - code: 'unsupported_dialect', - ); - } - } if (source.containsKey(r'$anchor')) { final anchor = source[r'$anchor']; if (anchor is! String || @@ -201,6 +294,17 @@ final class _JsonSchemaCompiler { } _register(anchors, base.replace(fragment: anchor), node, r'$anchor'); } + if (source.containsKey(r'$dynamicAnchor')) { + final anchor = source[r'$dynamicAnchor']; + if (anchor is! String || + !RegExp(r'^[A-Za-z_][-A-Za-z0-9._]*$').hasMatch(anchor)) { + _fail(node, r'$dynamicAnchor', 'Invalid dynamic anchor name.'); + } + node.dynamicAnchorName = anchor; + final uri = base.replace(fragment: anchor); + _register(dynamicAnchors, uri, node, r'$dynamicAnchor'); + _register(anchors, uri, node, r'$dynamicAnchor'); + } for (final entry in source.entries) { final key = entry.key; final value = entry.value; @@ -215,10 +319,17 @@ final class _JsonSchemaCompiler { document, '$path/${_importPointerToken(child.key)}', base, + dialect: dialect, ); } } else if (childKeywords.contains(key)) { - _index(value ?? _invalidSchema(node, key), document, path, base); + _index( + value ?? _invalidSchema(node, key), + document, + path, + base, + dialect: dialect, + ); } else if (listKeywords.contains(key)) { if (value is! List || value.isEmpty) { _fail(node, key, 'Expected a non-empty list of schemas.'); @@ -229,6 +340,7 @@ final class _JsonSchemaCompiler { document, '$path/$i', base, + dialect: dialect, ); } } @@ -262,20 +374,81 @@ final class _JsonSchemaCompiler { } void compile(_ImportedNode root) { + for (final node in locations.values) { + _configureDialect(node); + } final nodes = _compileReachable(root); + for (final node in locations.values) { + node.resourceRoot = resources[node.baseUri]!; + } + for (final entry in dynamicAnchors.entries) { + resources[entry.key.removeFragment()]!.dynamicAnchors[Uri.decodeComponent( + entry.key.fragment, + )] = + entry.value; + } _checkProductiveCycles(nodes); } + void _configureDialect(_ImportedNode node) { + final dialect = resources[node.dialectUri]; + if (dialect == null) { + _fail( + node, + r'$schema', + 'Unsupported or unsupplied dialect: ${node.dialectUri}.', + code: 'unsupported_dialect', + ); + } + final vocabulary = switch (dialect.source) { + final Map source => source[r'$vocabulary'], + _ => null, + }; + if (vocabulary is! Map) { + _fail( + node, + r'$schema', + 'Dialect has no declared vocabularies: ${node.dialectUri}.', + code: 'unsupported_dialect', + ); + } + for (final entry in vocabulary.entries) { + if (entry.value == true && !knownVocabularies.contains(entry.key)) { + _fail( + node, + r'$schema', + 'Unknown required vocabulary: ${entry.key}.', + code: 'unsupported_vocabulary', + ); + } + } + node.validationVocabulary = vocabulary.containsKey(validationVocabularyUri); + node.formatAssertion = + assertFormats || vocabulary.containsKey(formatAssertionVocabularyUri); + } + List<_ImportedNode> _compileReachable(_ImportedNode root) { final nodes = <_ImportedNode>[]; final pending = Queue<_ImportedNode>()..add(root); final compiled = <_ImportedNode>{}; - while (pending.isNotEmpty) { - final node = pending.removeFirst(); - if (!compiled.add(node)) continue; - _compileNode(node); - nodes.add(node); - pending.addAll(node.dependencies); + while (true) { + while (pending.isNotEmpty) { + final node = pending.removeFirst(); + if (!compiled.add(node)) continue; + _compileNode(node); + nodes.add(node); + pending.addAll(node.dependencies); + } + if (!nodes.any((node) => node.dynamicReference != null)) break; + final activeResources = nodes.map((node) => node.baseUri).toSet(); + final anchorsToCompile = dynamicAnchors.entries + .where( + (entry) => activeResources.contains(entry.key.removeFragment()), + ) + .map((entry) => entry.value) + .where((node) => !compiled.contains(node)); + if (anchorsToCompile.isEmpty) break; + pending.addAll(anchorsToCompile); } return nodes; } @@ -334,7 +507,15 @@ final class _JsonSchemaCompiler { // A reference can designate a schema inside an extension container, such // as A2UI's /components/Text. Only explicitly targeted locations become // schemas; objects in defaults and enum values are never scanned as schemas. - return _index(value!, resource.documentUri, pointer, base); + final indexed = _index( + value!, + resource.documentUri, + pointer, + base, + dialect: resource.dialectUri, + ); + _configureDialect(indexed); + return indexed; } void _compileNode(_ImportedNode node) { @@ -343,10 +524,11 @@ final class _JsonSchemaCompiler { for (final entry in source.entries) { final key = entry.key; final value = entry.value; - if ({ + if (const { r'$id', r'$schema', r'$anchor', + r'$dynamicAnchor', r'$defs', 'definitions', }.contains(key)) { @@ -354,14 +536,48 @@ final class _JsonSchemaCompiler { } if (annotations.contains(key)) { final valid = switch (key) { - 'title' || 'description' || r'$comment' => value is String, + 'title' || + 'description' || + r'$comment' || + 'format' || + 'contentEncoding' || + 'contentMediaType' => value is String, 'readOnly' || 'writeOnly' || 'deprecated' => value is bool, 'examples' => value is List, + 'contentSchema' => value is bool || value is Map, _ => true, }; if (!valid) _fail(node, key, 'Invalid annotation value for "$key".'); + if (key == 'format') { + if (node.formatAssertion && + !_isSupportedJsonSchemaFormat(value as String)) { + _fail( + node, + key, + 'Unknown asserted format: $value.', + code: 'unsupported_format', + ); + } + node.format = value as String; + } node.keywords[key] = value; - } else if (key == r'$ref') { + } else if (key == r'$vocabulary') { + if (value is! Map || + value.values.any((required) => required is! bool)) { + _fail(node, key, 'Expected a map of vocabulary URIs to booleans.'); + } + for (final entry in value.entries) { + if (entry.value == true && !knownVocabularies.contains(entry.key)) { + _fail( + node, + key, + 'Unknown required vocabulary: ${entry.key}.', + code: 'unsupported_vocabulary', + ); + } + } + node.keywords[key] = value; + } else if (key == r'$ref' || key == r'$dynamicRef') { if (value is! String) _fail(node, key, 'Expected a URI string.'); final uri = _resolve(node, key, node.baseUri, value); final resource = resources[uri.removeFragment()]; @@ -394,38 +610,82 @@ final class _JsonSchemaCompiler { code: 'unresolved_reference', ); } - node.reference = target; - } else if (key == 'properties') { - node.maps[key] = { + if (key == r'$ref') { + node.reference = target; + } else { + node.dynamicReference = target; + if (!fragment.startsWith('/') && + target.dynamicAnchorName == fragment) { + node.dynamicReferenceName = fragment; + } + } + } else if (key == 'properties' || + key == 'patternProperties' || + key == 'dependentSchemas') { + final compiledMap = { for (final name in (value as Map).keys) name: _child(node, '$key/${_importPointerToken(name)}'), }; - } else if ({ - 'items', - 'additionalProperties', - 'not', - 'contains', - 'propertyNames', - 'if', - 'then', - 'else', - }.contains(key)) { - if ((key == 'items' && source.containsKey('prefixItems')) || - (key == 'additionalProperties' && - source.containsKey('patternProperties'))) { - _unsupported( - node, - key, - 'Cannot retain $key without its unsupported sibling.', - ); - } else { - node.children[key] = _child(node, key); + node.maps[key] = compiledMap; + switch (key) { + case 'properties': + node.properties = compiledMap; + case 'dependentSchemas': + node.dependentSchemas = compiledMap; + case 'patternProperties': + final compiledPatterns = <(String, RegExp, _ImportedNode)>[]; + for (final entry in compiledMap.entries) { + try { + compiledPatterns.add(( + entry.key, + RegExp(entry.key, unicode: true), + entry.value, + )); + } on FormatException catch (e) { + _fail(node, key, 'Invalid pattern property: ${e.message}'); + } + } + node.patternProperties = compiledPatterns; + } + } else if (childKeywords.contains(key)) { + final child = node.children[key] = _child(node, key); + switch (key) { + case 'items': + node.items = child; + case 'additionalProperties': + node.additionalProperties = child; + case 'not': + node.not = child; + case 'contains': + node.contains = child; + case 'propertyNames': + node.propertyNames = child; + case 'unevaluatedProperties': + node.unevaluatedProperties = child; + case 'unevaluatedItems': + node.unevaluatedItems = child; + case 'if': + node.ifNode = child; + case 'then': + node.thenNode = child; + case 'else': + node.elseNode = child; } - } else if ({'anyOf', 'allOf', 'oneOf'}.contains(key)) { - node.lists[key] = [ + } else if (listKeywords.contains(key)) { + final targets = node.lists[key] = [ for (var i = 0; i < (value as List).length; i++) _child(node, '$key/$i'), ]; + switch (key) { + case 'anyOf': + node.anyOf = targets; + case 'allOf': + node.allOf = targets; + case 'oneOf': + node.oneOf = targets; + case 'prefixItems': + node.prefixItems = targets; + } } else if (key == 'type') { const types = { 'null', @@ -447,6 +707,7 @@ final class _JsonSchemaCompiler { ); } node.keywords[key] = value; + node.types = values; } else if (key == 'required') { if (value is! List || value.any((v) => v is! String) || @@ -454,6 +715,22 @@ final class _JsonSchemaCompiler { _fail(node, key, 'Expected a list of unique property names.'); } node.keywords[key] = value; + node.required = List.unmodifiable(value); + } else if (key == 'dependentRequired') { + if (value is! Map || + value.values.any( + (names) => + names is! List || + names.any((name) => name is! String) || + names.toSet().length != names.length, + )) { + _fail(node, key, 'Expected a map of unique property-name lists.'); + } + node.keywords[key] = value; + node.dependentRequired = { + for (final entry in value.entries) + entry.key: List.unmodifiable(entry.value! as List), + }; } else if (key == 'enum') { if (value is! List) { _fail(node, key, 'Expected an enum array.'); @@ -465,20 +742,58 @@ final class _JsonSchemaCompiler { unique.add(candidate); } } - node.keywords[key] = List.unmodifiable(unique); + node.keywords[key] = node.enumValues = List.unmodifiable( + unique, + ); } else if (key == 'const') { - node.keywords[key] = value; - } else if (counts.contains(key)) { + node.keywords[key] = node.constValue = value; + node.hasConst = true; + } else if (counts.contains(key) || + key == 'minContains' || + key == 'maxContains') { if (value is! num || value < 0 || value % 1 != 0) { _fail(node, key, 'Expected a non-negative integer.'); } node.keywords[key] = value; + switch (key) { + case 'minLength': + node.minLength = value; + case 'maxLength': + node.maxLength = value; + case 'minItems': + node.minItems = value; + case 'maxItems': + node.maxItems = value; + case 'minProperties': + node.minProperties = value; + case 'maxProperties': + node.maxProperties = value; + case 'minContains': + node.minContains = value; + case 'maxContains': + node.maxContains = value; + } } else if (bounds.contains(key)) { if (value is! num) _fail(node, key, 'Expected a number.'); node.keywords[key] = value; + switch (key) { + case 'minimum': + node.minimum = value; + case 'maximum': + node.maximum = value; + case 'exclusiveMinimum': + node.exclusiveMinimum = value; + case 'exclusiveMaximum': + node.exclusiveMaximum = value; + } + } else if (key == 'multipleOf') { + if (value is! num || !value.isFinite || value <= 0) { + _fail(node, key, 'Expected a positive finite number.'); + } + node.keywords[key] = node.multipleOf = value; } else if (key == 'uniqueItems') { if (value is! bool) _fail(node, key, 'Expected a boolean.'); - node.keywords[key] = value; + node.keywords[key] = node.uniqueItems = value; } else if (key == 'pattern') { if (value is! String) { _fail(node, key, 'Expected a regular expression string.'); @@ -489,17 +804,10 @@ final class _JsonSchemaCompiler { _fail(node, key, 'Invalid regular expression: ${e.message}'); } node.keywords[key] = value; - } else if (key == 'format') { - if (value is! String) { - _fail(node, key, 'Expected a format string.'); - } - if (value != 'date-time') { - _unsupported(node, key, 'Format "$value" is not supported.'); - continue; - } - node.keywords[key] = value; } else { - _unsupported(node, key, 'Keyword "$key" is not supported.'); + // Unknown keywords are annotations in the standard 2020-12 dialect. + // Their JSON values survive export, but never assert against instances. + node.keywords[key] = value; } } } diff --git a/packages/ack/lib/src/json_schema/json_schema_formats.dart b/packages/ack/lib/src/json_schema/json_schema_formats.dart new file mode 100644 index 00000000..531abf19 --- /dev/null +++ b/packages/ack/lib/src/json_schema/json_schema_formats.dart @@ -0,0 +1,498 @@ +part of '../schemas/schema.dart'; + +const _jsonSchemaFormats = { + 'date', + 'time', + 'date-time', + 'duration', + 'email', + 'idn-email', + 'hostname', + 'idn-hostname', + 'ipv4', + 'ipv6', + 'uri', + 'uri-reference', + 'iri', + 'iri-reference', + 'uri-template', + 'json-pointer', + 'relative-json-pointer', + 'regex', + 'uuid', +}; + +bool _isSupportedJsonSchemaFormat(String format) => + _jsonSchemaFormats.contains(format); + +bool _isValidJsonSchemaFormat(String format, String value) => switch (format) { + 'date' => _isFullDate(value), + 'time' => _isFullTime(value), + 'date-time' => _isDateTime(value), + 'duration' => _isDuration(value), + 'email' => _isEmail(value, international: false), + 'idn-email' => _isEmail(value, international: true), + 'hostname' => _isHostname(value, international: false), + 'idn-hostname' => _isHostname(value, international: true), + 'ipv4' => _isIpv4(value), + 'ipv6' => _isIpv6(value), + 'uri' => _isUri(value, absolute: true, international: false), + 'uri-reference' => _isUri(value, absolute: false, international: false), + 'iri' => _isUri(value, absolute: true, international: true), + 'iri-reference' => _isUri(value, absolute: false, international: true), + 'uri-template' => _isUriTemplate(value), + 'json-pointer' => _isJsonPointer(value), + 'relative-json-pointer' => _isRelativeJsonPointer(value), + 'regex' => _isRegex(value), + 'uuid' => RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ).hasMatch(value), + _ => false, +}; + +bool _isFullDate(String value) { + final match = RegExp(r'^([0-9]{4})-([0-9]{2})-([0-9]{2})$').firstMatch(value); + if (match == null) return false; + final year = int.parse(match[1]!); + final month = int.parse(match[2]!); + final day = int.parse(match[3]!); + if (year == 0 || month < 1 || month > 12 || day < 1) return false; + final leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + final days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= days[month - 1]; +} + +final _fullTimePattern = RegExp( + r'^([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?([Zz]|([+-])([0-9]{2}):([0-9]{2}))$', +); + +bool _isFullTime(String value) { + final match = _fullTimePattern.firstMatch(value); + if (match == null) return false; + final hour = int.parse(match[1]!); + final minute = int.parse(match[2]!); + final second = int.parse(match[3]!); + final offsetHour = match[7] == null ? 0 : int.parse(match[7]!); + final offsetMinute = match[8] == null ? 0 : int.parse(match[8]!); + if (hour > 23 || + minute > 59 || + second > 60 || + offsetHour > 23 || + offsetMinute > 59) { + return false; + } + if (second == 60) { + final signedOffset = + (offsetHour * 60 + offsetMinute) * (match[6] == '-' ? -1 : 1); + final utcMinute = (hour * 60 + minute - signedOffset) % (24 * 60); + if (utcMinute != 23 * 60 + 59) return false; + } + return true; +} + +bool _isDateTime(String value) { + if (value.length < 12 || (value[10] != 'T' && value[10] != 't')) { + return false; + } + return _isFullDate(value.substring(0, 10)) && + _isFullTime(value.substring(11)); +} + +bool _isDuration(String value) { + if (RegExp(r'^P[0-9]+W$').hasMatch(value)) return true; + final match = RegExp( + r'^P(?:([0-9]+)Y)?(?:([0-9]+)M)?(?:([0-9]+)D)?(?:T(?:([0-9]+)H)?(?:([0-9]+)M)?(?:([0-9]+)S)?)?$', + ).firstMatch(value); + if (match == null) return false; + final present = [for (var i = 1; i <= 6; i++) match[i] != null]; + if (!present.contains(true)) return false; + if (value.contains('T') && !present.sublist(3).contains(true)) return false; + if (present[0] && present[2] && !present[1]) return false; + if (present[3] && present[5] && !present[4]) return false; + return true; +} + +bool _isIpv4(String value) { + final parts = value.split('.'); + if (parts.length != 4) return false; + for (final part in parts) { + if (!RegExp(r'^(0|[1-9][0-9]{0,2})$').hasMatch(part)) return false; + if (int.parse(part) > 255) return false; + } + return true; +} + +bool _isIpv6(String value) { + // Uri.parseIPv6Address differs across supported Dart SDKs (notably for + // whitespace and IPv4 tails), so validate the RFC 3986 grammar ourselves. + if (value.isEmpty || RegExp(r'[^0-9A-Fa-f:.]').hasMatch(value)) { + return false; + } + var address = value; + var embeddedGroups = 0; + if (value.contains('.')) { + final separator = value.lastIndexOf(':'); + if (separator < 0) return false; + final tail = value.substring(separator + 1); + if (!_isIpv4(tail)) return false; + address = value.substring(0, separator); + if (address.endsWith(':')) address += ':'; + if (address.contains('.')) return false; + embeddedGroups = 2; + } + final compressed = address.contains('::'); + if (compressed) { + if (address.contains(':::') || + address.indexOf('::', address.indexOf('::') + 2) >= 0 || + (address.startsWith(':') && !address.startsWith('::')) || + (address.endsWith(':') && !address.endsWith('::'))) { + return false; + } + } else if (address.startsWith(':') || address.endsWith(':')) { + return false; + } + final groups = address.split(':').where((group) => group.isNotEmpty).toList(); + if (groups.any((group) => group.length > 4)) return false; + final expectedGroups = 8 - embeddedGroups; + return compressed + ? groups.length < expectedGroups + : groups.length == expectedGroups; +} + +bool _isJsonPointer(String value) { + if (value.isEmpty) return true; + if (!value.startsWith('/')) return false; + return !RegExp(r'~(?![01])').hasMatch(value); +} + +bool _isRelativeJsonPointer(String value) { + final match = RegExp(r'^(0|[1-9][0-9]*)(#|(?:/.*)?)$').firstMatch(value); + if (match == null) return false; + final suffix = match[2]!; + return suffix == '#' || _isJsonPointer(suffix); +} + +bool _isRegex(String value) { + try { + RegExp(value, unicode: true); + return true; + } on FormatException { + return false; + } +} + +bool _isHostname(String value, {required bool international}) { + if (!international && value.runes.any((rune) => rune > 127)) return false; + final mapped = international + ? unorm + .nfkc( + value.replaceAll(RegExp('[。.。]'), '.').replaceAll('\u200b', ''), + ) + .toLowerCase() + : value.toLowerCase(); + if (mapped.isEmpty) return false; + final asciiLabels = []; + final unicodeLabels = []; + for (final rawLabel in mapped.split('.')) { + if (rawLabel.isEmpty || + rawLabel.startsWith('-') || + rawLabel.endsWith('-')) { + return false; + } + if (!international && rawLabel.runes.any((rune) => rune > 127)) { + return false; + } + var label = rawLabel; + if (rawLabel.startsWith('xn--')) { + try { + label = punycoder.punycode.decode(rawLabel.substring(4)); + } on FormatException { + return false; + } + if (label.isEmpty || label.runes.every((rune) => rune < 128)) { + return false; + } + label = unorm.nfkc(label).toLowerCase(); + if (punycoder.punycode.encode(label).toLowerCase() != + rawLabel.substring(4)) { + return false; + } + } else if (rawLabel.length >= 4 && + rawLabel[2] == '-' && + rawLabel[3] == '-') { + return false; + } + if (!_isIdnaLabel(label)) return false; + final ascii = label.runes.every((rune) => rune < 128) + ? label + : 'xn--${punycoder.punycode.encode(label).toLowerCase()}'; + if (ascii.length > 63) return false; + asciiLabels.add(ascii); + unicodeLabels.add(label); + } + if (asciiLabels.join('.').length > 253) return false; + if (unicodeLabels.any((label) => label.runes.any(_isRtlRune))) { + for (final label in unicodeLabels) { + if (label.runes.any(_isRtlRune)) continue; + if (!RegExp(r'^[a-z]').hasMatch(label)) return false; + } + } + return true; +} + +bool _isIdnaLabel(String label) { + final runes = label.runes.toList(); + if (runes.isEmpty || + RegExp(r'^\p{M}', unicode: true).hasMatch(label) || + (label.length >= 4 && label[2] == '-' && label[3] == '-') || + label.startsWith('-') || + label.endsWith('-')) { + return false; + } + var hasArabicIndic = false; + var hasExtendedArabicIndic = false; + var hasJapanese = false; + var hasRtl = false; + var hasLatin = false; + var hasEuropeanDigit = false; + for (var i = 0; i < runes.length; i++) { + final rune = runes[i]; + if (rune < 128 && + !((rune >= 0x61 && rune <= 0x7a) || + (rune >= 0x30 && rune <= 0x39) || + rune == 0x2d)) { + return false; + } + if (rune >= 128 && + !RegExp( + r'^[\p{L}\p{M}\p{N}]$', + unicode: true, + ).hasMatch(String.fromCharCode(rune)) && + !const { + 0x00b7, + 0x0375, + 0x05f3, + 0x05f4, + 0x06fd, + 0x06fe, + 0x0f0b, + 0x200c, + 0x200d, + 0x30fb, + }.contains(rune)) { + return false; + } + if (rune <= 0x20 || + (rune >= 0x7f && rune <= 0x9f) || + rune == 0x302e || + rune == 0x302f || + (rune >= 0x3031 && rune <= 0x3035) || + rune == 0x303b || + rune == 0x0640 || + rune == 0x07fa) { + return false; + } + if (rune >= 0x61 && rune <= 0x7a) hasLatin = true; + if (rune >= 0x30 && rune <= 0x39) hasEuropeanDigit = true; + if (rune >= 0x0660 && rune <= 0x0669) hasArabicIndic = true; + if (rune >= 0x06f0 && rune <= 0x06f9) hasExtendedArabicIndic = true; + if (_isRtlRune(rune)) hasRtl = true; + if (rune != 0x30fb && _isJapaneseRune(rune)) hasJapanese = true; + if (rune == 0x00b7 && + (i == 0 || + i == runes.length - 1 || + runes[i - 1] != 0x6c || + runes[i + 1] != 0x6c)) { + return false; + } + if (rune == 0x0375 && + (i == runes.length - 1 || !_isGreekRune(runes[i + 1]))) { + return false; + } + if ((rune == 0x05f3 || rune == 0x05f4) && + (i == 0 || !_isHebrewRune(runes[i - 1]))) { + return false; + } + if ((rune == 0x200c || rune == 0x200d) && + (i == 0 || !_isVirama(runes[i - 1]))) { + if (rune == 0x200d || + i == 0 || + i == runes.length - 1 || + !_isArabicRune(runes[i - 1]) || + !_isArabicRune(runes[i + 1])) { + return false; + } + } + } + if (hasArabicIndic && hasExtendedArabicIndic) return false; + if (hasArabicIndic && !hasRtl && !hasLatin) return false; + if (runes.contains(0x30fb) && !hasJapanese) return false; + if (hasRtl) { + if (!_isRtlRune(runes.first) || + hasLatin || + (hasEuropeanDigit && (hasArabicIndic || hasExtendedArabicIndic))) { + return false; + } + } + return true; +} + +bool _isHebrewRune(int rune) => rune >= 0x0590 && rune <= 0x05ff; +bool _isArabicRune(int rune) => + rune >= 0x0600 && + rune <= 0x08ff && + !(rune >= 0x0660 && rune <= 0x0669) && + !(rune >= 0x06f0 && rune <= 0x06f9); +bool _isRtlRune(int rune) => _isHebrewRune(rune) || _isArabicRune(rune); +bool _isGreekRune(int rune) => + (rune >= 0x0370 && rune <= 0x03ff) || (rune >= 0x1f00 && rune <= 0x1fff); +bool _isJapaneseRune(int rune) => + (rune >= 0x3040 && rune <= 0x30ff) || (rune >= 0x3400 && rune <= 0x9fff); +bool _isVirama(int rune) => const { + 0x094d, + 0x09cd, + 0x0a4d, + 0x0acd, + 0x0b4d, + 0x0bcd, + 0x0c4d, + 0x0ccd, + 0x0d4d, + 0x0dca, + 0x0e3a, + 0x0f84, + 0x1039, + 0x1714, +}.contains(rune); + +bool _isEmail(String value, {required bool international}) { + final match = RegExp( + r'^("(?:[^"\\]|\\[\s\S])*"|[^@]+)@(.+)$', + ).firstMatch(value); + if (match == null) return false; + final local = match[1]!; + final domain = match[2]!; + if (!international && !local.runes.every((rune) => rune < 128)) { + return false; + } + if (local.startsWith('"')) { + if (!local.endsWith('"')) return false; + if (RegExp(r'[\u0000-\u001f\u007f]').hasMatch(local)) return false; + } else { + final atom = international + ? RegExp(r'^[^\s@\x00-\x20\x7f(),:;<>\[\]"\\]+$') + : RegExp(r"^[A-Za-z0-9!#\$%&'*+/=?^_`{|}~.-]+$"); + if (!atom.hasMatch(local) || + local.startsWith('.') || + local.endsWith('.') || + local.contains('..')) { + return false; + } + } + if (domain.startsWith('[') && domain.endsWith(']')) { + final literal = domain.substring(1, domain.length - 1); + if (literal.toLowerCase().startsWith('ipv6:')) { + return _isIpv6(literal.substring(5)); + } + return _isIpv4(literal); + } + return _isHostname(domain, international: international); +} + +bool _hasValidPercents(String value) { + for (var i = 0; i < value.length; i++) { + if (value[i] != '%') continue; + if (i + 2 >= value.length || + !RegExp(r'^[0-9a-fA-F]{2}$').hasMatch(value.substring(i + 1, i + 3))) { + return false; + } + i += 2; + } + return true; +} + +bool _isUri( + String value, { + required bool absolute, + required bool international, +}) { + if (!_hasValidPercents(value)) return false; + if (RegExp(r'[\u0000-\u0020\u007f<>"{}|\\^`]').hasMatch(value)) { + return false; + } + if (!international && value.runes.any((rune) => rune > 127)) return false; + if (absolute && !RegExp(r'^[A-Za-z][A-Za-z0-9+.-]*:').hasMatch(value)) { + return false; + } + if (!absolute && + RegExp(r'^[^/?.#][^/]*:').hasMatch(value) && + !RegExp(r'^[A-Za-z][A-Za-z0-9+.-]*:').hasMatch(value)) { + return false; + } + try { + final firstBracket = value.indexOf('['); + final lastBracket = value.lastIndexOf(']'); + if (firstBracket >= 0 || lastBracket >= 0) { + final authorityMarker = value.indexOf('//'); + if (authorityMarker < 0) return false; + final authorityStart = authorityMarker + 2; + var authorityEnd = value.length; + for (final delimiter in ['/', '?', '#']) { + final next = value.indexOf(delimiter, authorityStart); + if (next >= 0 && next < authorityEnd) authorityEnd = next; + } + if (firstBracket < authorityStart || + firstBracket >= authorityEnd || + lastBracket < firstBracket || + lastBracket >= authorityEnd) { + return false; + } + final literal = value.substring(firstBracket + 1, lastBracket); + if (!_isIpv6(literal) && + !RegExp( + r"^[vV][0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+$", + ).hasMatch(literal)) { + return false; + } + } + final uri = Uri.parse(value); + if (absolute && !uri.hasScheme) return false; + if (uri.path.contains('[') || uri.path.contains(']')) return false; + if (uri.hasAuthority && + uri.host.contains(':') && + !value.contains('[${uri.host}]')) { + return false; + } + return true; + } on FormatException { + return false; + } +} + +bool _isUriTemplate(String value) { + var index = 0; + while (index < value.length) { + final char = value[index]; + if (char == '}') return false; + if (char != '{') { + index++; + continue; + } + final end = value.indexOf('}', index + 1); + if (end < 0) return false; + var expression = value.substring(index + 1, end); + if (expression.isEmpty || expression.contains('{')) return false; + if ('+#./;?&'.contains(expression[0])) expression = expression.substring(1); + if (expression.isEmpty) return false; + for (final variable in expression.split(',')) { + if (variable.isEmpty) return false; + final match = RegExp( + r'^([A-Za-z0-9_]|%[0-9A-Fa-f]{2})+(?:\.(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})+)*(?:\*|:[1-9][0-9]{0,3})?$', + ).firstMatch(variable); + if (match == null) return false; + } + index = end + 1; + } + return _hasValidPercents(value.replaceAll(RegExp(r'\{[^{}]*\}'), '')) && + !RegExp(r'[\u0000-\u0020\u007f]').hasMatch(value); +} diff --git a/packages/ack/lib/src/json_schema/standard_meta_schemas.dart b/packages/ack/lib/src/json_schema/standard_meta_schemas.dart new file mode 100644 index 00000000..54e1dd51 --- /dev/null +++ b/packages/ack/lib/src/json_schema/standard_meta_schemas.dart @@ -0,0 +1,358 @@ +// Standard draft 2020-12 meta-schemas bundled for offline VM/web use. +// Source: https://json-schema.org/draft/2020-12/schema and its meta/* refs. +// Do not edit these upstream JSON documents by hand. +part of '../schemas/schema.dart'; + +const _standardMetaSchemaJson = { + 'https://json-schema.org/draft/2020-12/schema': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/unevaluated"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format-annotation"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", + "properties": { + "definitions": { + "$comment": "\"definitions\" has been replaced by \"$defs\".", + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "deprecated": true, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$dynamicRef": "#meta" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + }, + "deprecated": true, + "default": {} + }, + "$recursiveAnchor": { + "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", + "$ref": "meta/core#/$defs/anchorString", + "deprecated": true + }, + "$recursiveRef": { + "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", + "$ref": "meta/core#/$defs/uriReferenceString", + "deprecated": true + } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/core': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/core", + "$dynamicAnchor": "meta", + + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "$ref": "#/$defs/uriReferenceString", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$ref": { "$ref": "#/$defs/uriReferenceString" }, + "$anchor": { "$ref": "#/$defs/anchorString" }, + "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, + "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, + "$vocabulary": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/uriString" }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" } + } + }, + "$defs": { + "anchorString": { + "type": "string", + "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" + }, + "uriString": { + "type": "string", + "format": "uri" + }, + "uriReferenceString": { + "type": "string", + "format": "uri-reference" + } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/applicator': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/applicator", + "$dynamicAnchor": "meta", + + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "prefixItems": { "$ref": "#/$defs/schemaArray" }, + "items": { "$dynamicRef": "#meta" }, + "contains": { "$dynamicRef": "#meta" }, + "additionalProperties": { "$dynamicRef": "#meta" }, + "properties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "propertyNames": { "$dynamicRef": "#meta" }, + "if": { "$dynamicRef": "#meta" }, + "then": { "$dynamicRef": "#meta" }, + "else": { "$dynamicRef": "#meta" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$dynamicRef": "#meta" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$dynamicRef": "#meta" } + } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/unevaluated': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", + "$dynamicAnchor": "meta", + + "title": "Unevaluated applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "unevaluatedItems": { "$dynamicRef": "#meta" }, + "unevaluatedProperties": { "$dynamicRef": "#meta" } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/validation': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/validation", + "$dynamicAnchor": "meta", + + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/meta-data': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", + "$dynamicAnchor": "meta", + + "title": "Meta-data vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/format-annotation': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", + "$dynamicAnchor": "meta", + + "title": "Format vocabulary meta-schema for annotation results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/format-assertion': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-assertion", + "$dynamicAnchor": "meta", + + "title": "Format vocabulary meta-schema for assertion results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} +''', + 'https://json-schema.org/draft/2020-12/meta/content': r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/content", + "$dynamicAnchor": "meta", + + "title": "Content vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$dynamicRef": "#meta" } + } +} +''', +}; + +final _standardMetaSchemaDocuments = { + for (final entry in _standardMetaSchemaJson.entries) + Uri.parse(entry.key): jsonDecode(entry.value)!, +}; diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 25893fee..8a54c88f 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -241,6 +241,10 @@ sealed class AckSchemaModel { keywords, commonHandled, ), + AckRawSchemaModel schema => schema._withJsonSchemaKeywords( + keywords, + commonHandled, + ), AckNullSchemaModel schema => schema._withJsonSchemaKeywords( keywords, commonHandled, @@ -328,6 +332,133 @@ sealed class AckSchemaModel { } } +/// A precompiled JSON Schema resource whose 2020-12 keywords must remain +/// attached to their original resource instead of being lowered to Draft 7. +final class AckRawSchemaModel extends AckSchemaModel { + const AckRawSchemaModel({ + required this.schema, + super.title, + super.description, + super.nullable, + super.defaultValue, + super.warnings, + super.extensions, + }); + + AckRawSchemaModel._(_AckSchemaModelCommon common, {required this.schema}) + : super._(common); + + final Map schema; + + @override + Map toJsonSchema() { + final body = {...schema, ..._common.toJson()}; + if (!nullable) return body; + const resourceKeywords = {r'$schema', r'$id', r'$defs', 'definitions'}; + final relocated = _relocateRootPointers( + body, + Uri.parse(body[r'$id'] as String), + resourceKeywords, + ); + final resource = { + for (final entry in relocated.entries) + if (resourceKeywords.contains(entry.key)) entry.key: entry.value, + }; + final assertion = { + for (final entry in relocated.entries) + if (!resourceKeywords.contains(entry.key)) entry.key: entry.value, + }; + return { + ...resource, + 'anyOf': [assertion, _nullSchemaJson], + }; + } + + @override + AckRawSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + AckRawSchemaModel._(common, schema: schema); + + AckSchemaModel _withJsonSchemaKeywords( + Map keywords, + Set commonHandled, + ) => _withUnhandledKeywords(keywords, commonHandled); +} + +Map _relocateRootPointers( + Map source, + Uri rootUri, + Set retainedKeywords, +) { + const mapKeywords = { + r'$defs', + 'definitions', + 'properties', + 'patternProperties', + 'dependentSchemas', + }; + const childKeywords = { + 'items', + 'additionalProperties', + 'not', + 'contains', + 'propertyNames', + 'if', + 'then', + 'else', + 'unevaluatedProperties', + 'unevaluatedItems', + }; + const listKeywords = {'anyOf', 'allOf', 'oneOf', 'prefixItems'}; + + Object? visit(Object? value, Uri base) { + if (value is! Map) return value; + final declaredId = value[r'$id']; + final currentBase = declaredId is String + ? base.resolve(declaredId).removeFragment() + : base; + final rewritten = Map.from(value); + for (final key in [r'$ref', r'$dynamicRef']) { + final reference = rewritten[key]; + if (reference is! String) continue; + final resolved = currentBase.resolve(reference); + final fragment = resolved.fragment; + if (resolved.removeFragment() != rootUri) continue; + if (fragment.isEmpty) { + rewritten[key] = rootUri.replace(fragment: '/anyOf/0').toString(); + continue; + } + if (!fragment.startsWith('/')) continue; + final first = fragment.substring(1).split('/').first; + if (retainedKeywords.contains(first)) continue; + rewritten[key] = rootUri + .replace(fragment: '/anyOf/0$fragment') + .toString(); + } + for (final key in mapKeywords) { + if (rewritten[key] case final Map children) { + rewritten[key] = children.map( + (name, child) => MapEntry(name, visit(child, currentBase)), + ); + } + } + for (final key in childKeywords) { + if (rewritten.containsKey(key)) { + rewritten[key] = visit(rewritten[key], currentBase); + } + } + for (final key in listKeywords) { + if (rewritten[key] case final List children) { + rewritten[key] = [ + for (final child in children) visit(child, currentBase), + ]; + } + } + return rewritten; + } + + return visit(source, rootUri)! as Map; +} + final class AckRefSchemaModel extends AckSchemaModel { const AckRefSchemaModel({ required this.refName, diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 0c1a1fcf..d8e8ac07 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -18,15 +18,26 @@ extension AckSchemaModelExtension< > on AckSchema { AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); + + /// Preserves imported Draft 2020-12 resources, including dynamic references. + /// Native Ack schemas continue to use their Draft-7 representation. + AckSchemaModel toSchemaModelPreservingImportedDialect() => + _SchemaModelBuilder(preserveImportedDialect: true).build(this); + + /// Exports imported schemas with their Draft 2020-12 dialect and resources. + Map toJsonSchemaPreservingImportedDialect() => + toSchemaModelPreservingImportedDialect().toJsonSchema(); } final class _SchemaModelBuilder { + _SchemaModelBuilder({this.preserveImportedDialect = false}); + + final bool preserveImportedDialect; // Every emitted definition name is reserved here. A null value marks a lazy // target that is currently being built. final _definitions = {}; - // Lazy-target identity is tracked separately because imported definitions - // are complete schema bodies, not recursive lazy targets. + // Lazy-target identity is tracked separately from emitted definitions. final _lazyTargets = {}; var _importCount = 0; @@ -84,15 +95,9 @@ final class _SchemaModelBuilder { AckSchemaModel _build(AckSchema schema) { if (schema is WrapperSchema) { final base = _build(schema.inner); - // Defaults and boundary-preserving wrappers do not transform the wire - // value, so they should not advertise themselves as transformed. - final extensions = schema is DefaultSchema || schema is BoundarySchema - ? base.extensions - : {...base.extensions, 'x-transformed': true}; final layered = base .withDescription(schema.description ?? base.description) - .withNullable(schema.isNullable || base.nullable) - .withExtensions(extensions); + .withNullable(schema.isNullable || base.nullable); // `DefaultSchema.constraints` is a passthrough to `inner.constraints`, // which `_build(schema.inner)` already applied. Re-running them here // would emit duplicate warnings (e.g. datetime range under a default). @@ -161,7 +166,17 @@ final class _SchemaModelBuilder { } AckSchemaModel _imported(ImportedJsonSchema schema) { - final prefix = '_ack_import_${_importCount++}_'; + final importIndex = _importCount++; + if (preserveImportedDialect) { + return AckRawSchemaModel( + schema: schema.export2020Document(importIndex: importIndex), + description: schema.description, + nullable: + schema.isNullable && + (!schema.sourceAllowsNull || schema.constraints.isNotEmpty), + ); + } + final prefix = '_ack_import_${importIndex}_'; for (final entry in schema.exportDefinitions(prefix).entries) { if (_definitions.containsKey(entry.key)) { throw ArgumentError( @@ -423,7 +438,9 @@ AckSchemaModel _applyConstraints( bool projectKeywords = true, }) { var next = model; - final appliedKeywordValues = { + // Rendering the model walks its whole subtree, so only do it once a + // constraint actually projects keywords. + late final appliedKeywordValues = { for (final entry in _renderedKeywords(model).entries) entry.key: [entry.value], }; diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index b354580c..3a858508 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -14,33 +14,167 @@ final class ImportedJsonSchema extends AckSchema Object document, { Uri? baseUri, Map documents = const {}, + bool assertFormats = false, }) { - final compiler = _JsonSchemaCompiler(); + final compiler = _JsonSchemaCompiler(assertFormats: assertFormats); final base = baseUri ?? Uri.parse('ack-import:///root.json'); final root = compiler.addDocument(document, base); + final externalRoots = <_ImportedNode>[]; for (final entry in documents.entries) { - compiler.addDocument(entry.value, base.resolveUri(entry.key)); + externalRoots.add( + compiler.addDocument(entry.value, base.resolveUri(entry.key)), + ); + } + for (final entry in _standardMetaSchemaDocuments.entries) { + if (!compiler.resources.containsKey(entry.key)) { + compiler.addDocument(entry.value, entry.key); + } } compiler.compile(root); if (compiler.diagnostics.isNotEmpty) { throw JsonSchemaImportException._(compiler.diagnostics); } - return ImportedJsonSchema._(root); + return ImportedJsonSchema._( + root, + externalRoots: externalRoots, + assertFormats: assertFormats, + ); } ImportedJsonSchema._( this._root, { + required List<_ImportedNode> externalRoots, + required bool assertFormats, bool? isNullable, super.isOptional, String? description, super.constraints, super.refinements, - }) : super( + }) : _externalRoots = externalRoots, + _assertFormats = assertFormats, + super( isNullable: isNullable ?? _checkImportedNode(_root, null) == null, description: description ?? _root.keywords['description'] as String?, ); final _ImportedNode _root; + final List<_ImportedNode> _externalRoots; + final bool _assertFormats; + + static final _assertionDialect = Uri.parse( + 'urn:ack:json-schema:2020-12:format-assertion', + ); + + /// Keeps the documented 1.x Draft-7 definitions/ref export for imports. + @internal + Map> exportDefinitions(String prefix) { + final names = <_ImportedNode, String>{}; + final refs = <_ImportedNode, String>{}; + void visit(_ImportedNode node) { + if (names.containsKey(node)) return; + final name = '$prefix${names.length}'; + names[node] = name; + refs[node] = '#/definitions/${_importPointerToken(name)}'; + for (final child in node.dependencies) { + visit(child); + } + } + + visit(_root); + return { + for (final entry in names.entries) + entry.value: entry.key.renderLegacy((node) => refs[node]!), + }; + } + + /// Exports the original resources with 2020-12 identity and reference + /// semantics intact. Supplied documents are embedded as distinct resources. + @internal + Map export2020Document({required int importIndex}) { + Map resource(_ImportedNode node, {Uri? syntheticId}) { + final source = cloneDefault(node.source); + return { + if (source is Map) ...source, + r'$id': (syntheticId ?? node.baseUri).toString(), + if (_assertFormats && + node.dialectUri == _JsonSchemaCompiler.standardDialect) + r'$schema': _assertionDialect.toString(), + if (source is bool) 'allOf': [source], + }; + } + + final syntheticId = + _root.documentUri.toString() == 'ack-import:///root.json' && + !(_root.source is Map && + (_root.source as Map).containsKey(r'$id')) + ? Uri.parse('ack-import:///root-$importIndex.json') + : null; + final root = resource(_root, syntheticId: syntheticId); + root.putIfAbsent( + r'$schema', + () => _JsonSchemaCompiler.standardDialect.toString(), + ); + if (_externalRoots.isNotEmpty || _assertFormats) { + final definitions = { + ...?root[r'$defs'] as Map?, + }; + var index = 0; + var aliasIndex = 0; + for (final external in _externalRoots) { + while (definitions.containsKey('_ack_document_$index')) { + index++; + } + final name = '_ack_document_${index++}'; + definitions[name] = resource(external); + if (external.documentUri != external.baseUri) { + while (definitions.containsKey('_ack_retrieval_$aliasIndex')) { + aliasIndex++; + } + definitions['_ack_retrieval_${aliasIndex++}'] = { + r'$id': external.documentUri.toString(), + r'$ref': external.baseUri.toString(), + }; + } + } + if (_assertFormats) { + var name = '_ack_format_assertion_dialect'; + while (definitions.containsKey(name)) { + name = '${name}_'; + } + definitions[name] = { + r'$id': _assertionDialect.toString(), + r'$schema': _JsonSchemaCompiler.standardDialect.toString(), + r'$vocabulary': { + for (final uri in _JsonSchemaCompiler.knownVocabularies) uri: true, + }, + r'$dynamicAnchor': 'meta', + 'allOf': [ + for (final name in [ + 'core', + 'applicator', + 'unevaluated', + 'validation', + 'meta-data', + 'format-annotation', + 'format-assertion', + 'content', + ]) + {r'$ref': 'https://json-schema.org/draft/2020-12/meta/$name'}, + ], + }; + } + root[r'$defs'] = definitions; + } + if (!isNullable && sourceAllowsNull) { + root['allOf'] = [ + ...?root['allOf'] as List?, + const { + 'not': {'type': 'null'}, + }, + ]; + } + return root; + } /// Whether the source document itself accepts `null`, before fluent /// nullability overrides are applied. @@ -118,25 +252,6 @@ final class ImportedJsonSchema extends AckSchema return applyConstraintsAndRefinements(value, context); } - /// Builds root-scoped definitions for the shared schema-model renderer. - @internal - Map> exportDefinitions(String prefix) { - final names = <_ImportedNode, String>{}; - void visit(_ImportedNode node) { - if (names.containsKey(node)) return; - names[node] = '$prefix${names.length}'; - for (final child in node.dependencies) { - visit(child); - } - } - - visit(_root); - return { - for (final entry in names.entries) - entry.value: entry.key.render((node) => names[node]!), - }; - } - @override ImportedJsonSchema copyWith({ bool? isNullable, @@ -146,6 +261,8 @@ final class ImportedJsonSchema extends AckSchema List>? refinements, }) => ImportedJsonSchema._( _root, + externalRoots: _externalRoots, + assertFormats: _assertFormats, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -204,137 +321,370 @@ final class _ImportViolation { ); } -_ImportViolation? _checkImportedNode(_ImportedNode node, Object? value) { - _ImportViolation fail(String keyword) => _ImportViolation(node, keyword); - if (node.source == false) return fail(''); - final keywords = node.keywords; - if (keywords['type'] case final type?) { - final matches = type is List - ? type.any((t) => _isImportType(t, value)) - : _isImportType(type, value); - if (!matches) return fail('type'); - } - if (keywords.containsKey('const') && !deepEquals(value, keywords['const'])) { - return fail('const'); +final class _ImportEvaluation { + _ImportEvaluation([this.error]); + + final _ImportViolation? error; + final properties = {}; + final items = {}; + + void include(_ImportEvaluation other) { + properties.addAll(other.properties); + items.addAll(other.items); } - if (keywords['enum'] case final List values) { - if (!values.any((v) => deepEquals(v, value))) return fail('enum'); +} + +_ImportViolation? _checkImportedNode(_ImportedNode node, Object? value) => + _evaluateImportedNode(node, value).error; + +_ImportEvaluation _evaluateImportedNode( + _ImportedNode node, + Object? value, [ + List<_ImportedNode> scope = const [], +]) { + _ImportEvaluation fail(String keyword) => + _ImportEvaluation(_ImportViolation(node, keyword)); + _ImportEvaluation failAt(String keyword, String segment) => + _ImportEvaluation(_ImportViolation(node, keyword).at(segment)); + final evaluated = _ImportEvaluation(); + final resource = node.resourceRoot; + final currentScope = scope.isNotEmpty && identical(scope.last, resource) + ? scope + : [...scope, resource]; + if (node.isFalse) return fail(''); + if (node.validationVocabulary) { + if (node.types case final types?) { + if (!types.any((t) => _isImportType(t, value))) return fail('type'); + } + if (node.hasConst && !deepEquals(value, node.constValue)) { + return fail('const'); + } + if (node.enumValues case final values?) { + if (!values.any((v) => deepEquals(v, value))) return fail('enum'); + } } if (node.reference case final target?) { - final error = _checkImportedNode(target, value); - if (error != null) return error.via(const [r'$ref']); - } - for (final (i, target) in (node.lists['allOf'] ?? const []).indexed) { - final error = _checkImportedNode(target, value); - if (error != null) return error.via(['allOf', '$i']); - } - if (node.lists['anyOf'] case final branches?) { - if (!branches.any((n) => _checkImportedNode(n, value) == null)) { - return fail('anyOf'); + final result = _evaluateImportedNode(target, value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via(const [r'$ref'])); } + evaluated.include(result); } - if (node.lists['oneOf'] case final branches?) { - if (branches.where((n) => _checkImportedNode(n, value) == null).length != - 1) { - return fail('oneOf'); + if (node.dynamicReference case final staticTarget?) { + var target = staticTarget; + if (node.dynamicReferenceName case final name?) { + for (final resource in currentScope) { + if (resource.dynamicAnchors[name] case final override?) { + target = override; + break; + } + } } - } - if (node.children['not'] case final target?) { - if (_checkImportedNode(target, value) == null) return fail('not'); - } - if (node.children['if'] case final condition?) { - final key = _checkImportedNode(condition, value) == null ? 'then' : 'else'; - if (node.children[key] case final branch?) { - final error = _checkImportedNode(branch, value); - if (error != null) return error.via([key]); + final result = _evaluateImportedNode(target, value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via(const [r'$dynamicRef'])); } + evaluated.include(result); } - if (value is num) { - for (final key in _JsonSchemaCompiler.bounds) { - if (keywords[key] case final num limit) { - final valid = switch (key) { - 'minimum' => value >= limit, - 'maximum' => value <= limit, - 'exclusiveMinimum' => value > limit, - _ => value < limit, - }; - if (!valid) return fail(key); + if (node.allOf case final branches?) { + for (var i = 0; i < branches.length; i++) { + final result = _evaluateImportedNode(branches[i], value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via(['allOf', '$i'])); } + evaluated.include(result); } } - final (length, minKey, maxKey) = switch (value) { - String() => (value.runes.length, 'minLength', 'maxLength'), - List() => (value.length, 'minItems', 'maxItems'), - Map() => (value.length, 'minProperties', 'maxProperties'), - _ => (null, '', ''), - }; - if (length != null) { - if (keywords[minKey] case final num minimum) { - if (length < minimum) return fail(minKey); - } - if (keywords[maxKey] case final num maximum) { - if (length > maximum) return fail(maxKey); + if (node.anyOf case final branches?) { + var success = false; + for (final branch in branches) { + final result = _evaluateImportedNode(branch, value, currentScope); + if (result.error == null) { + success = true; + evaluated.include(result); + } } + if (!success) return fail('anyOf'); } - if (value is String) { - if (keywords['format'] == 'date-time' && !isValidRfc3339DateTime(value)) { - return fail('format'); + if (node.oneOf case final branches?) { + var successes = 0; + _ImportEvaluation? matched; + for (final branch in branches) { + final result = _evaluateImportedNode(branch, value, currentScope); + if (result.error == null) { + successes++; + matched = result; + } } - if (node.pattern case final pattern?) { - if (!pattern.hasMatch(value)) return fail('pattern'); + if (successes != 1) return fail('oneOf'); + evaluated.include(matched!); + } + if (node.not case final target?) { + if (_evaluateImportedNode(target, value, currentScope).error == null) { + return fail('not'); } } - if (value is Map) { - for (final key in keywords['required'] as List? ?? const []) { - if (!value.containsKey(key)) return fail('required').at(key as String); + if (node.ifNode case final condition?) { + final conditionResult = _evaluateImportedNode( + condition, + value, + currentScope, + ); + final passed = conditionResult.error == null; + if (passed) evaluated.include(conditionResult); + final branch = passed ? node.thenNode : node.elseNode; + if (branch != null) { + final result = _evaluateImportedNode(branch, value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via([passed ? 'then' : 'else'])); + } + evaluated.include(result); } - final properties = node.maps['properties'] ?? const {}; - for (final entry in value.entries) { - final key = entry.key as String; - final declared = properties[key]; - final target = declared ?? node.children['additionalProperties']; - if (target == null) continue; - final error = _checkImportedNode(target, entry.value); - if (error != null) { - return error.at( - key, - declared == null - ? const ['additionalProperties'] - : ['properties', key], + } + switch (value) { + case num(): + if (node.validationVocabulary) { + if (node.multipleOf case final divisor? + when !_isJsonMultipleOf(value, divisor)) { + return fail('multipleOf'); + } + if (node.minimum case final limit? when value < limit) { + return fail('minimum'); + } + if (node.maximum case final limit? when value > limit) { + return fail('maximum'); + } + if (node.exclusiveMinimum case final limit? when value <= limit) { + return fail('exclusiveMinimum'); + } + if (node.exclusiveMaximum case final limit? when value >= limit) { + return fail('exclusiveMaximum'); + } + } + case String(): + if (node.validationVocabulary) { + // Counting code points allocates, so only do it when a bound exists. + if (node.minLength != null || node.maxLength != null) { + final keyword = _sizeViolation( + value.runes.length, + node.minLength, + node.maxLength, + 'minLength', + 'maxLength', + ); + if (keyword != null) return fail(keyword); + } + if (node.pattern case final pattern? when !pattern.hasMatch(value)) { + return fail('pattern'); + } + } + if (node.formatAssertion) { + if (node.format case final format? + when !_isValidJsonSchemaFormat(format, value)) { + return fail('format'); + } + } + case Map(): + if (node.validationVocabulary) { + final keyword = _sizeViolation( + value.length, + node.minProperties, + node.maxProperties, + 'minProperties', + 'maxProperties', ); + if (keyword != null) return fail(keyword); + if (node.required case final required?) { + for (final key in required) { + if (!value.containsKey(key)) return failAt('required', key); + } + } + if (node.dependentRequired case final requirements?) { + for (final entry in requirements.entries) { + if (!value.containsKey(entry.key)) continue; + for (final dependent in entry.value) { + if (!value.containsKey(dependent)) { + return failAt('dependentRequired', dependent); + } + } + } + } } - } - if (node.children['propertyNames'] case final target?) { - for (final key in value.keys) { - final error = _checkImportedNode(target, key); - if (error != null) { - return error.atPropertyName(key as String, const ['propertyNames']); + final properties = node.properties; + final patterns = node.patternProperties; + final additional = node.additionalProperties; + if (properties != null || patterns != null || additional != null) { + for (final MapEntry(:key, value: item) in value.entries) { + final propKey = key as String; + var matched = false; + if (properties?[propKey] case final target?) { + matched = true; + final result = _evaluateImportedNode(target, item, currentScope); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(propKey, ['properties', propKey]), + ); + } + evaluated.properties.add(propKey); + } + if (patterns != null) { + for (final (rawPattern, regex, target) in patterns) { + if (!regex.hasMatch(propKey)) continue; + matched = true; + final result = _evaluateImportedNode(target, item, currentScope); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(propKey, ['patternProperties', rawPattern]), + ); + } + evaluated.properties.add(propKey); + } + } + if (!matched && additional != null) { + final result = _evaluateImportedNode( + additional, + item, + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(propKey, const ['additionalProperties']), + ); + } + evaluated.properties.add(propKey); + } } } - } - } - if (value is List) { - if (keywords['uniqueItems'] == true) { - for (var i = 0; i < value.length; i++) { - for (var j = 0; j < i; j++) { - if (deepEquals(value[i], value[j])) { - return fail('uniqueItems'); + if (node.dependentSchemas case final dependencies?) { + for (final dependency in dependencies.entries) { + if (!value.containsKey(dependency.key)) continue; + final result = _evaluateImportedNode( + dependency.value, + value, + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation( + error.via(['dependentSchemas', dependency.key]), + ); } + evaluated.include(result); } } - } - if (node.children['contains'] case final target?) { - if (!value.any((item) => _checkImportedNode(target, item) == null)) { - return fail('contains'); + if (node.propertyNames case final target?) { + for (final key in value.keys) { + final result = _evaluateImportedNode(target, key, currentScope); + if (result.error case final error?) { + return _ImportEvaluation( + error.atPropertyName(key as String, const ['propertyNames']), + ); + } + } } - } - if (node.children['items'] case final target?) { - for (var i = 0; i < value.length; i++) { - final error = _checkImportedNode(target, value[i]); - if (error != null) return error.at('$i', const ['items']); + if (node.unevaluatedProperties case final target?) { + for (final entry in value.entries) { + final key = entry.key as String; + if (evaluated.properties.contains(key)) continue; + final result = _evaluateImportedNode( + target, + entry.value, + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(key, const ['unevaluatedProperties']), + ); + } + evaluated.properties.add(key); + } + } + case List(): + if (node.validationVocabulary) { + final keyword = _sizeViolation( + value.length, + node.minItems, + node.maxItems, + 'minItems', + 'maxItems', + ); + if (keyword != null) return fail(keyword); + if (node.uniqueItems) { + for (var i = 0; i < value.length; i++) { + for (var j = 0; j < i; j++) { + if (deepEquals(value[i], value[j])) { + return fail('uniqueItems'); + } + } + } + } + } + final prefixItems = node.prefixItems ?? const <_ImportedNode>[]; + for (var i = 0; i < prefixItems.length && i < value.length; i++) { + final result = _evaluateImportedNode( + prefixItems[i], + value[i], + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation(error.at('$i', ['prefixItems', '$i'])); + } + evaluated.items.add(i); + } + if (node.items case final target?) { + for (var i = prefixItems.length; i < value.length; i++) { + final result = _evaluateImportedNode(target, value[i], currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.at('$i', const ['items'])); + } + evaluated.items.add(i); + } + } + if (node.contains case final target?) { + var matches = 0; + for (var i = 0; i < value.length; i++) { + if (_evaluateImportedNode(target, value[i], currentScope).error == + null) { + matches++; + evaluated.items.add(i); + } + } + final minimum = node.validationVocabulary ? (node.minContains ?? 1) : 1; + final maximum = node.validationVocabulary ? node.maxContains : null; + if (matches < minimum) { + return fail( + node.validationVocabulary && node.minContains != null + ? 'minContains' + : 'contains', + ); + } + if (maximum != null && matches > maximum) return fail('maxContains'); + } + if (node.unevaluatedItems case final target?) { + for (var i = 0; i < value.length; i++) { + if (evaluated.items.contains(i)) continue; + final result = _evaluateImportedNode(target, value[i], currentScope); + if (result.error case final error?) { + return _ImportEvaluation( + error.at('$i', const ['unevaluatedItems']), + ); + } + evaluated.items.add(i); + } } - } } + return evaluated; +} + +/// The failing size keyword, if any. +String? _sizeViolation( + int size, + num? min, + num? max, + String minKey, + String maxKey, +) { + if (min != null && size < min) return minKey; + if (max != null && size > max) return maxKey; return null; } @@ -348,3 +698,36 @@ bool _isImportType(Object? type, Object? value) => switch (type) { 'object' => value is Map, _ => false, }; + +/// Compares the decimal values represented by finite Dart numbers. Dividing +/// binary floating-point values directly would reject JSON decimals such as +/// `0.3` with `multipleOf: 0.1`. +bool _isJsonMultipleOf(num value, num divisor) { + final (valueCoefficient, valueScale) = _decimalParts(value); + final (divisorCoefficient, divisorScale) = _decimalParts(divisor); + final scale = valueScale > divisorScale ? valueScale : divisorScale; + final ten = BigInt.from(10); + final numerator = valueCoefficient * ten.pow(scale - valueScale); + final denominator = divisorCoefficient * ten.pow(scale - divisorScale); + return numerator % denominator == BigInt.zero; +} + +(BigInt, int) _decimalParts(num value) { + final text = value.toString().toLowerCase(); + final exponentIndex = text.indexOf('e'); + final mantissa = exponentIndex < 0 ? text : text.substring(0, exponentIndex); + final exponent = exponentIndex < 0 + ? 0 + : int.parse(text.substring(exponentIndex + 1)); + final pointIndex = mantissa.indexOf('.'); + final fractionalDigits = pointIndex < 0 + ? 0 + : mantissa.length - pointIndex - 1; + var coefficient = BigInt.parse(mantissa.replaceAll('.', '')); + final scale = fractionalDigits - exponent; + if (scale < 0) { + coefficient *= BigInt.from(10).pow(-scale); + return (coefficient, 0); + } + return (coefficient, scale); +} diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index db049490..71734ccf 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -85,10 +85,7 @@ final class ListSchema ); } - return applyConstraintsAndRefinements( - List.unmodifiable(typed), - context, - ); + return applyConstraintsAndRefinements(UnmodifiableListView(typed), context); } @override @@ -160,7 +157,7 @@ final class ListSchema ); } - return SchemaResult.ok(List.unmodifiable(encoded)); + return SchemaResult.ok(UnmodifiableListView(encoded)); } @override diff --git a/packages/ack/lib/src/schemas/map_schema.dart b/packages/ack/lib/src/schemas/map_schema.dart index 68025147..29a8d9ac 100644 --- a/packages/ack/lib/src/schemas/map_schema.dart +++ b/packages/ack/lib/src/schemas/map_schema.dart @@ -89,10 +89,7 @@ final class MapSchema ); } - return applyConstraintsAndRefinements( - Map.unmodifiable(typed), - context, - ); + return applyConstraintsAndRefinements(UnmodifiableMapView(typed), context); } @override @@ -157,7 +154,7 @@ final class MapSchema ); } - return SchemaResult.ok(Map.unmodifiable(encoded)); + return SchemaResult.ok(UnmodifiableMapView(encoded)); } @override diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index 7c22a7d5..6060806f 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -147,7 +147,7 @@ final class ObjectSchema extends AckSchema } return applyConstraintsAndRefinements( - Map.unmodifiable(validatedMap), + UnmodifiableMapView(validatedMap), context, ); } @@ -353,7 +353,7 @@ final class ObjectSchema extends AckSchema ); } - return SchemaResult.ok(Map.unmodifiable(encoded)); + return SchemaResult.ok(UnmodifiableMapView(encoded)); } @override diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 535753b5..f9b6891e 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -1,7 +1,10 @@ import 'dart:collection'; +import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import 'package:punycoder/punycoder.dart' as punycoder; +import 'package:unorm_dart/unorm_dart.dart' as unorm; import '../common_types.dart'; import '../constraints/constraint.dart'; @@ -15,7 +18,9 @@ import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; part '../json_schema/json_schema_compiler.dart'; +part '../json_schema/json_schema_formats.dart'; part '../json_schema/json_schema_import_error.dart'; +part '../json_schema/standard_meta_schemas.dart'; part 'any_of_schema.dart'; part 'any_schema.dart'; part 'boundary_schema.dart'; diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index b1203409..ce648593 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -18,6 +18,8 @@ environment: dependencies: meta: ^1.15.0 collection: ^1.18.0 + punycoder: ^0.3.0 + unorm_dart: ^0.3.2 dev_dependencies: test: ^1.25.15 diff --git a/packages/ack/test/annotations/ack_model_test.dart b/packages/ack/test/annotations/ack_model_test.dart index 34cd9bb1..584317fe 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -1,6 +1,3 @@ -// These tests also cover the deprecated AckField.presence API. -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:ack/annotations.dart'; import 'package:test/test.dart'; @@ -48,19 +45,20 @@ void main() { }, ); - test('AckField accepts a schema tear-off and a presence override', () { - const inferred = AckField(schema: _customSchema); + test('AckField accepts name, schema tear-off, and description', () { + const inferred = AckField( + name: 'wire_key', + schema: _customSchema, + description: 'Field description', + ); + expect(inferred.name, 'wire_key'); expect(inferred.schema, same(_customSchema)); - expect(inferred.presence, AckFieldPresence.inferred); + expect(inferred.description, 'Field description'); - const optional = AckField(presence: AckFieldPresence.optional); - expect(optional.schema, isNull); - expect(optional.presence, AckFieldPresence.optional); - expect(AckFieldPresence.values, const [ - AckFieldPresence.inferred, - AckFieldPresence.required, - AckFieldPresence.optional, - ]); + const defaults = AckField(); + expect(defaults.name, isNull); + expect(defaults.schema, isNull); + expect(defaults.description, isNull); }); test('presence and null annotations are const', () { @@ -81,7 +79,7 @@ void main() { Negative(), MinLength(1), MaxLength(100), - Pattern(r'^[a-z]+$'), + Matches(r'^[a-z]+$'), Email(), NotEmpty(), MinItems(1), @@ -94,7 +92,7 @@ void main() { expect((annotations[2] as MultipleOf).value, 2); expect((annotations[5] as MinLength).length, 1); expect((annotations[6] as MaxLength).length, 100); - expect((annotations[7] as Pattern).pattern, r'^[a-z]+$'); + expect((annotations[7] as Matches).pattern, r'^[a-z]+$'); expect((annotations[10] as MinItems).count, 1); expect((annotations[11] as MaxItems).count, 10); }); diff --git a/packages/ack/test/annotations/ack_type_test.dart b/packages/ack/test/annotations/ack_type_test.dart deleted file mode 100644 index 5b56c91c..00000000 --- a/packages/ack/test/annotations/ack_type_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -// These tests intentionally cover the deprecated annotations. -// ignore_for_file: deprecated_member_use_from_same_package - -import 'package:ack/annotations.dart'; -import 'package:ack/ack_generator_support.dart'; -import 'package:test/test.dart'; - -void main() { - test('public barrel exposes legacy AckType and modern AckInfer', () { - const legacy = AckType(name: 'User'); - const modern = AckInfer(name: 'User'); - expect(legacy.name, 'User'); - expect(modern.name, 'User'); - expect(AckInfer.jsonSerializable, isA()); - }); - - test('support barrel exposes the configuration-free marker', () { - const marker = AckGeneratedJson(); - expect(identical(Schemable.generatedJson, marker), isTrue); - expect( - identical(AckInfer.jsonSerializable, const AckGeneratedJson()), - isTrue, - ); - }); -} diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index 7d4cee43..4ddf4b4d 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -63,9 +63,8 @@ void main() { expect(const ack.Email(), isA()); }); - test('the deprecated Pattern spelling creates a Matches annotation', () { - // ignore: deprecated_member_use_from_same_package - const annotation = ack.Pattern('^a'); + test('Matches annotation exposes pattern', () { + const annotation = ack.Matches('^a'); expect(annotation, isA()); expect(annotation.pattern, '^a'); diff --git a/packages/ack/test/class_first_prerequisites_test.dart b/packages/ack/test/class_first_prerequisites_test.dart index 63b9cbe0..e49c620f 100644 --- a/packages/ack/test/class_first_prerequisites_test.dart +++ b/packages/ack/test/class_first_prerequisites_test.dart @@ -65,9 +65,6 @@ void main() { encode: (_) => {'name': 'cat', 'createdAt': DateTime.utc(2026)}, ); - expect(codec.toJsonSchema(), { - ...input.toJsonSchema(), - 'x-transformed': true, - }); + expect(codec.toJsonSchema(), input.toJsonSchema()); }); } diff --git a/packages/ack/test/fixtures/json_schema_import/README.md b/packages/ack/test/fixtures/json_schema_import/README.md index 5803ef50..d2f293f8 100644 --- a/packages/ack/test/fixtures/json_schema_import/README.md +++ b/packages/ack/test/fixtures/json_schema_import/README.md @@ -3,7 +3,7 @@ Source: [JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) at commit `80c87e8fca8b207a7a7ae944b875f0fcf889f46a` (MIT; see LICENSE). -`draft2020-12.json` contains the 205 test groups (678 instance cases) that use +`draft2020-12.json` contains the 341 test groups (1,178 instance cases) that use the importer's supported subset, selected from `tests/draft2020-12/*.json`. Descriptions are prefixed with the original filename. Schema and instance values are unchanged. Referenced fixtures from `remotes/` are included in each @@ -13,5 +13,20 @@ Selection happened when importing the fixtures, not at test runtime. Every listed group must continue importing exactly; unsupported features cannot silently turn these regression tests into skips. +The `multipleOf`, `contains`, `minContains`, `maxContains`, +`dependentRequired`, `dependentSchemas`, `patternProperties`, `prefixItems`, +`if-then-else`, `unevaluatedProperties`, `unevaluatedItems`, `format` annotation, +`content` annotation, and remaining +`items` groups, plus the +Unicode-property-escape group in `pattern.json`, were added as those semantics +were implemented. Pattern compilation uses Unicode mode, as required by the +draft 2020-12 dialect. + +Two official `unevaluated*` groups use `$dynamicRef` and remain outside this +selected fixture. They, along with all 21 `dynamicRef.json` groups, run in the +complete conformance suite and the dedicated dynamic-reference test in both +source-validation and export/re-import modes. This selected fixture is not the +complete conformance suite. + The complete suite also contains unsupported keywords; this fixture selection is **not** a claim of complete draft 2020-12 conformance. diff --git a/packages/ack/test/fixtures/json_schema_import/draft2020-12.json b/packages/ack/test/fixtures/json_schema_import/draft2020-12.json index 354e7fe0..29cfe011 100644 --- a/packages/ack/test/fixtures/json_schema_import/draft2020-12.json +++ b/packages/ack/test/fixtures/json_schema_import/draft2020-12.json @@ -1544,6 +1544,54 @@ } ] }, + { + "description": "contains.json: items + contains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "multipleOf": 2 + }, + "contains": { + "multipleOf": 3 + } + }, + "tests": [ + { + "description": "matches items, does not match contains", + "data": [ + 2, + 4, + 8 + ], + "valid": false + }, + { + "description": "does not match items, matches contains", + "data": [ + 3, + 6, + 9 + ], + "valid": false + }, + { + "description": "matches both items and contains", + "data": [ + 6, + 12 + ], + "valid": true + }, + { + "description": "matches neither items nor contains", + "data": [ + 1, + 5 + ], + "valid": false + } + ] + }, { "description": "contains.json: contains with false if subschema", "schema": { @@ -1586,6 +1634,145 @@ } ] }, + { + "description": "content.json: validation of string-encoded content based on media type", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentMediaType": "application/json" + }, + "tests": [ + { + "description": "a valid JSON document", + "data": "{\"foo\": \"bar\"}", + "valid": true + }, + { + "description": "an invalid JSON document; validates true", + "data": "{:}", + "valid": true + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + }, + { + "description": "content.json: validation of binary string-encoding", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentEncoding": "base64" + }, + "tests": [ + { + "description": "a valid base64 string", + "data": "eyJmb28iOiAiYmFyIn0K", + "valid": true + }, + { + "description": "an invalid base64 string (% is not a valid character); validates true", + "data": "eyJmb28iOi%iYmFyIn0K", + "valid": true + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + }, + { + "description": "content.json: validation of binary-encoded media type documents", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentMediaType": "application/json", + "contentEncoding": "base64" + }, + "tests": [ + { + "description": "a valid base64-encoded JSON document", + "data": "eyJmb28iOiAiYmFyIn0K", + "valid": true + }, + { + "description": "a validly-encoded invalid JSON document; validates true", + "data": "ezp9Cg==", + "valid": true + }, + { + "description": "an invalid base64 string that is valid JSON; validates true", + "data": "{}", + "valid": true + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + }, + { + "description": "content.json: validation of binary-encoded media type documents with schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contentMediaType": "application/json", + "contentEncoding": "base64", + "contentSchema": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "string" + } + } + } + }, + "tests": [ + { + "description": "a valid base64-encoded JSON document", + "data": "eyJmb28iOiAiYmFyIn0K", + "valid": true + }, + { + "description": "another valid base64-encoded JSON document", + "data": "eyJib28iOiAyMCwgImZvbyI6ICJiYXoifQ==", + "valid": true + }, + { + "description": "an invalid base64-encoded JSON document; validates true", + "data": "eyJib28iOiAyMH0=", + "valid": true + }, + { + "description": "an empty object as a base64-encoded JSON document; validates true", + "data": "e30=", + "valid": true + }, + { + "description": "an empty array as a base64-encoded JSON document", + "data": "W10=", + "valid": true + }, + { + "description": "a validly-encoded invalid JSON document; validates true", + "data": "ezp9Cg==", + "valid": true + }, + { + "description": "an invalid base64 string that is valid JSON; validates true", + "data": "{}", + "valid": true + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + } + ] + }, { "description": "default.json: invalid type for default", "schema": { @@ -1675,5259 +1862,10877 @@ ] }, { - "description": "enum.json: simple enum validation", + "description": "dependentRequired.json: single dependency", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - 1, - 2, - 3 - ] - }, - "tests": [ - { - "description": "one of the enum is valid", - "data": 1, - "valid": true - }, - { - "description": "something else is invalid", - "data": 4, - "valid": false + "dependentRequired": { + "bar": [ + "foo" + ] } - ] - }, - { - "description": "enum.json: heterogeneous enum validation", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - 6, - "foo", - [], - true, - { - "foo": 12 - } - ] }, "tests": [ { - "description": "one of the enum is valid", - "data": [], + "description": "neither", + "data": {}, "valid": true }, { - "description": "something else is invalid", - "data": null, - "valid": false - }, - { - "description": "objects are deep compared", + "description": "nondependant", "data": { - "foo": false + "foo": 1 }, - "valid": false + "valid": true }, { - "description": "valid object matches", + "description": "with dependency", "data": { - "foo": 12 + "foo": 1, + "bar": 2 }, "valid": true }, { - "description": "extra properties in object is invalid", + "description": "missing dependency", "data": { - "foo": 12, - "boo": 42 + "bar": 2 }, "valid": false + }, + { + "description": "ignores arrays", + "data": [ + "bar" + ], + "valid": true + }, + { + "description": "ignores strings", + "data": "foobar", + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true } ] }, { - "description": "enum.json: heterogeneous enum-with-null validation", + "description": "dependentRequired.json: empty dependents", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - 6, - null - ] + "dependentRequired": { + "bar": [] + } }, "tests": [ { - "description": "null is valid", - "data": null, + "description": "empty object", + "data": {}, "valid": true }, { - "description": "number is valid", - "data": 6, + "description": "object with one property", + "data": { + "bar": 2 + }, "valid": true }, { - "description": "something else is invalid", - "data": "test", - "valid": false + "description": "non-object is valid", + "data": 1, + "valid": true } ] }, { - "description": "enum.json: enums in properties", + "description": "dependentRequired.json: multiple dependents required", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "foo": { - "enum": [ - "foo" - ] - }, - "bar": { - "enum": [ - "bar" - ] - } - }, - "required": [ - "bar" - ] + "dependentRequired": { + "quux": [ + "foo", + "bar" + ] + } }, "tests": [ { - "description": "both properties are valid", + "description": "neither", + "data": {}, + "valid": true + }, + { + "description": "nondependants", "data": { - "foo": "foo", - "bar": "bar" + "foo": 1, + "bar": 2 }, "valid": true }, { - "description": "wrong foo value", + "description": "with dependencies", "data": { - "foo": "foot", - "bar": "bar" + "foo": 1, + "bar": 2, + "quux": 3 }, - "valid": false + "valid": true }, { - "description": "wrong bar value", + "description": "missing dependency", "data": { - "foo": "foo", - "bar": "bart" + "foo": 1, + "quux": 2 }, "valid": false }, { - "description": "missing optional property is valid", + "description": "missing other dependency", "data": { - "bar": "bar" + "bar": 1, + "quux": 2 }, - "valid": true + "valid": false }, { - "description": "missing required property is invalid", + "description": "missing both dependencies", "data": { - "foo": "foo" + "quux": 1 }, "valid": false - }, - { - "description": "missing all properties is invalid", - "data": {}, - "valid": false } ] }, { - "description": "enum.json: enum with escaped characters", + "description": "dependentRequired.json: dependencies with escaped characters", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - "foo\nbar", - "foo\rbar" - ] + "dependentRequired": { + "foo\nbar": [ + "foo\rbar" + ], + "foo\"bar": [ + "foo'bar" + ] + } }, "tests": [ { - "description": "member 1 is valid", - "data": "foo\nbar", + "description": "CRLF", + "data": { + "foo\nbar": 1, + "foo\rbar": 2 + }, "valid": true }, { - "description": "member 2 is valid", - "data": "foo\rbar", + "description": "quoted quotes", + "data": { + "foo'bar": 1, + "foo\"bar": 2 + }, "valid": true }, { - "description": "another string is invalid", - "data": "abc", + "description": "CRLF missing dependent", + "data": { + "foo\nbar": 1, + "foo": 2 + }, + "valid": false + }, + { + "description": "quoted quotes missing dependent", + "data": { + "foo\"bar": 2 + }, "valid": false } ] }, { - "description": "enum.json: enum with false does not match 0", + "description": "dependentSchemas.json: single dependency", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - false - ] + "dependentSchemas": { + "bar": { + "properties": { + "foo": { + "type": "integer" + }, + "bar": { + "type": "integer" + } + } + } + } }, "tests": [ { - "description": "false is valid", - "data": false, + "description": "valid", + "data": { + "foo": 1, + "bar": 2 + }, "valid": true }, { - "description": "integer zero is invalid", - "data": 0, + "description": "no dependency", + "data": { + "foo": "quux" + }, + "valid": true + }, + { + "description": "wrong type", + "data": { + "foo": "quux", + "bar": 2 + }, "valid": false }, { - "description": "float zero is invalid", - "data": 0.0, + "description": "wrong type other", + "data": { + "foo": 2, + "bar": "quux" + }, "valid": false - } - ] - }, - { - "description": "enum.json: enum with [false] does not match [0]", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - [ - false - ] - ] - }, - "tests": [ + }, { - "description": "[false] is valid", + "description": "wrong type both", + "data": { + "foo": "quux", + "bar": "quux" + }, + "valid": false + }, + { + "description": "ignores arrays", "data": [ - false + "bar" ], "valid": true }, { - "description": "[0] is invalid", - "data": [ - 0 - ], - "valid": false + "description": "ignores strings", + "data": "foobar", + "valid": true }, { - "description": "[0.0] is invalid", - "data": [ - 0.0 - ], - "valid": false + "description": "ignores other non-objects", + "data": 12, + "valid": true } ] }, { - "description": "enum.json: enum with true does not match 1", + "description": "dependentSchemas.json: boolean subschemas", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - true - ] + "dependentSchemas": { + "foo": true, + "bar": false + } }, "tests": [ { - "description": "true is valid", - "data": true, + "description": "object with property having schema true is valid", + "data": { + "foo": 1 + }, "valid": true }, { - "description": "integer one is invalid", - "data": 1, + "description": "object with property having schema false is invalid", + "data": { + "bar": 2 + }, "valid": false }, { - "description": "float one is invalid", - "data": 1.0, + "description": "object with both properties is invalid", + "data": { + "foo": 1, + "bar": 2 + }, "valid": false + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true } ] }, { - "description": "enum.json: enum with [true] does not match [1]", + "description": "dependentSchemas.json: dependencies with escaped characters", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - [ - true - ] - ] + "dependentSchemas": { + "foo\tbar": { + "minProperties": 4 + }, + "foo'bar": { + "required": [ + "foo\"bar" + ] + } + } }, "tests": [ { - "description": "[true] is valid", - "data": [ - true - ], + "description": "quoted tab", + "data": { + "foo\tbar": 1, + "a": 2, + "b": 3, + "c": 4 + }, "valid": true }, { - "description": "[1] is invalid", - "data": [ - 1 - ], + "description": "quoted quote", + "data": { + "foo'bar": { + "foo\"bar": 1 + } + }, "valid": false }, { - "description": "[1.0] is invalid", - "data": [ - 1.0 - ], + "description": "quoted tab invalid under dependent schema", + "data": { + "foo\tbar": 1, + "a": 2 + }, + "valid": false + }, + { + "description": "quoted quote invalid under dependent schema", + "data": { + "foo'bar": 1 + }, "valid": false } ] }, { - "description": "enum.json: enum with 0 does not match false", + "description": "dependentSchemas.json: dependent subschema incompatible with root", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - 0 - ] + "properties": { + "foo": {} + }, + "dependentSchemas": { + "foo": { + "properties": { + "bar": {} + }, + "additionalProperties": false + } + } }, "tests": [ { - "description": "false is invalid", - "data": false, + "description": "matches root", + "data": { + "foo": 1 + }, "valid": false }, { - "description": "integer zero is valid", - "data": 0, + "description": "matches dependency", + "data": { + "bar": 1 + }, "valid": true }, { - "description": "float zero is valid", - "data": 0.0, + "description": "matches both", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": false + }, + { + "description": "no dependency", + "data": { + "baz": 1 + }, "valid": true } ] }, { - "description": "enum.json: enum with [0] does not match [false]", + "description": "enum.json: simple enum validation", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ - [ - 0 - ] + 1, + 2, + 3 ] }, "tests": [ { - "description": "[false] is invalid", - "data": [ - false - ], - "valid": false - }, - { - "description": "[0] is valid", - "data": [ - 0 - ], + "description": "one of the enum is valid", + "data": 1, "valid": true }, { - "description": "[0.0] is valid", - "data": [ - 0.0 - ], - "valid": true + "description": "something else is invalid", + "data": 4, + "valid": false } ] }, { - "description": "enum.json: enum with 1 does not match true", + "description": "enum.json: heterogeneous enum validation", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ - 1 + 6, + "foo", + [], + true, + { + "foo": 12 + } ] }, "tests": [ { - "description": "true is invalid", - "data": true, - "valid": false - }, - { - "description": "integer one is valid", - "data": 1, + "description": "one of the enum is valid", + "data": [], "valid": true }, { - "description": "float one is valid", - "data": 1.0, - "valid": true - } - ] - }, - { - "description": "enum.json: enum with [1] does not match [true]", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [ - [ - 1 - ] - ] - }, - "tests": [ + "description": "something else is invalid", + "data": null, + "valid": false + }, { - "description": "[true] is invalid", - "data": [ - true - ], + "description": "objects are deep compared", + "data": { + "foo": false + }, "valid": false }, { - "description": "[1] is valid", - "data": [ - 1 - ], + "description": "valid object matches", + "data": { + "foo": 12 + }, "valid": true }, { - "description": "[1.0] is valid", - "data": [ - 1.0 - ], - "valid": true + "description": "extra properties in object is invalid", + "data": { + "foo": 12, + "boo": 42 + }, + "valid": false } ] }, { - "description": "enum.json: nul characters in strings", + "description": "enum.json: heterogeneous enum-with-null validation", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ - "hello\u0000there" + 6, + null ] }, "tests": [ { - "description": "match string with nul", - "data": "hello\u0000there", + "description": "null is valid", + "data": null, "valid": true }, { - "description": "do not match string lacking nul", - "data": "hellothere", + "description": "number is valid", + "data": 6, + "valid": true + }, + { + "description": "something else is invalid", + "data": "test", "valid": false } ] }, { - "description": "enum.json: empty enum", + "description": "enum.json: enums in properties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "enum": [] + "type": "object", + "properties": { + "foo": { + "enum": [ + "foo" + ] + }, + "bar": { + "enum": [ + "bar" + ] + } + }, + "required": [ + "bar" + ] }, "tests": [ { - "description": "string is invalid", - "data": "foo", - "valid": false + "description": "both properties are valid", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true }, { - "description": "number is invalid", - "data": 42, + "description": "wrong foo value", + "data": { + "foo": "foot", + "bar": "bar" + }, "valid": false }, { - "description": "null is invalid", - "data": null, + "description": "wrong bar value", + "data": { + "foo": "foo", + "bar": "bart" + }, "valid": false }, { - "description": "object is invalid", - "data": {}, - "valid": false + "description": "missing optional property is valid", + "data": { + "bar": "bar" + }, + "valid": true }, { - "description": "array is invalid", - "data": [], + "description": "missing required property is invalid", + "data": { + "foo": "foo" + }, "valid": false }, { - "description": "boolean is invalid", - "data": false, + "description": "missing all properties is invalid", + "data": {}, "valid": false } ] }, { - "description": "enum.json: characters with the same visual representation, but different number of codepoints", + "description": "enum.json: enum with escaped characters", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ - "ä" - ], - "$comment": "U+00E4" + "foo\nbar", + "foo\rbar" + ] }, "tests": [ { - "description": "character uses the same codepoint", - "data": "ä", - "comment": "U+00E4", + "description": "member 1 is valid", + "data": "foo\nbar", "valid": true }, { - "description": "character looks the same but uses combining marks", - "data": "ä", - "comment": "a, U+0308", + "description": "member 2 is valid", + "data": "foo\rbar", + "valid": true + }, + { + "description": "another string is invalid", + "data": "abc", "valid": false } ] }, { - "description": "exclusiveMaximum.json: exclusiveMaximum validation", + "description": "enum.json: enum with false does not match 0", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "exclusiveMaximum": 3.0 + "enum": [ + false + ] }, "tests": [ { - "description": "below the exclusiveMaximum is valid", - "data": 2.2, + "description": "false is valid", + "data": false, "valid": true }, { - "description": "boundary point is invalid", - "data": 3.0, + "description": "integer zero is invalid", + "data": 0, "valid": false }, { - "description": "above the exclusiveMaximum is invalid", - "data": 3.5, + "description": "float zero is invalid", + "data": 0.0, "valid": false - }, - { - "description": "ignores non-numbers", - "data": "x", - "valid": true } ] }, { - "description": "exclusiveMinimum.json: exclusiveMinimum validation", + "description": "enum.json: enum with [false] does not match [0]", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "exclusiveMinimum": 1.1 + "enum": [ + [ + false + ] + ] }, "tests": [ { - "description": "above the exclusiveMinimum is valid", - "data": 1.2, + "description": "[false] is valid", + "data": [ + false + ], "valid": true }, { - "description": "boundary point is invalid", - "data": 1.1, + "description": "[0] is invalid", + "data": [ + 0 + ], "valid": false }, { - "description": "below the exclusiveMinimum is invalid", - "data": 0.6, + "description": "[0.0] is invalid", + "data": [ + 0.0 + ], "valid": false - }, - { - "description": "ignores non-numbers", - "data": "x", - "valid": true } ] }, { - "description": "if-then-else.json: ignore if without then or else", + "description": "enum.json: enum with true does not match 1", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "if": { - "const": 0 - } + "enum": [ + true + ] }, "tests": [ { - "description": "valid when valid against lone if", - "data": 0, + "description": "true is valid", + "data": true, "valid": true }, { - "description": "valid when invalid against lone if", - "data": "hello", - "valid": true + "description": "integer one is invalid", + "data": 1, + "valid": false + }, + { + "description": "float one is invalid", + "data": 1.0, + "valid": false } ] }, { - "description": "if-then-else.json: ignore then without if", + "description": "enum.json: enum with [true] does not match [1]", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "then": { - "const": 0 - } - }, + "enum": [ + [ + true + ] + ] + }, "tests": [ { - "description": "valid when valid against lone then", - "data": 0, + "description": "[true] is valid", + "data": [ + true + ], "valid": true }, { - "description": "valid when invalid against lone then", - "data": "hello", - "valid": true + "description": "[1] is invalid", + "data": [ + 1 + ], + "valid": false + }, + { + "description": "[1.0] is invalid", + "data": [ + 1.0 + ], + "valid": false } ] }, { - "description": "if-then-else.json: ignore else without if", + "description": "enum.json: enum with 0 does not match false", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "else": { - "const": 0 - } + "enum": [ + 0 + ] }, "tests": [ { - "description": "valid when valid against lone else", + "description": "false is invalid", + "data": false, + "valid": false + }, + { + "description": "integer zero is valid", "data": 0, "valid": true }, { - "description": "valid when invalid against lone else", - "data": "hello", + "description": "float zero is valid", + "data": 0.0, "valid": true } ] }, { - "description": "if-then-else.json: if and then without else", + "description": "enum.json: enum with [0] does not match [false]", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "if": { - "exclusiveMaximum": 0 - }, - "then": { - "minimum": -10 - } + "enum": [ + [ + 0 + ] + ] }, "tests": [ { - "description": "valid through then", - "data": -1, - "valid": true + "description": "[false] is invalid", + "data": [ + false + ], + "valid": false }, { - "description": "invalid through then", - "data": -100, - "valid": false + "description": "[0] is valid", + "data": [ + 0 + ], + "valid": true }, { - "description": "valid when if test fails", - "data": 3, + "description": "[0.0] is valid", + "data": [ + 0.0 + ], "valid": true } ] }, { - "description": "if-then-else.json: if with boolean schema true", + "description": "enum.json: enum with 1 does not match true", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "if": true, - "then": { - "const": "then" - }, - "else": { - "const": "else" - } + "enum": [ + 1 + ] }, "tests": [ { - "description": "boolean schema true in if always chooses the then path (valid)", - "data": "then", + "description": "true is invalid", + "data": true, + "valid": false + }, + { + "description": "integer one is valid", + "data": 1, "valid": true }, { - "description": "boolean schema true in if always chooses the then path (invalid)", - "data": "else", - "valid": false + "description": "float one is valid", + "data": 1.0, + "valid": true } ] }, { - "description": "if-then-else.json: if with boolean schema false", + "description": "enum.json: enum with [1] does not match [true]", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "if": false, - "then": { - "const": "then" - }, - "else": { - "const": "else" - } + "enum": [ + [ + 1 + ] + ] }, "tests": [ { - "description": "boolean schema false in if always chooses the else path (invalid)", - "data": "then", + "description": "[true] is invalid", + "data": [ + true + ], "valid": false }, { - "description": "boolean schema false in if always chooses the else path (valid)", - "data": "else", + "description": "[1] is valid", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "[1.0] is valid", + "data": [ + 1.0 + ], "valid": true } ] }, { - "description": "if-then-else.json: if appears at the end when serialized (keyword processing sequence)", + "description": "enum.json: nul characters in strings", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "then": { - "const": "yes" - }, - "else": { - "const": "other" + "enum": [ + "hello\u0000there" + ] + }, + "tests": [ + { + "description": "match string with nul", + "data": "hello\u0000there", + "valid": true }, - "if": { - "maxLength": 4 + { + "description": "do not match string lacking nul", + "data": "hellothere", + "valid": false } + ] + }, + { + "description": "enum.json: empty enum", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [] }, "tests": [ { - "description": "yes redirects to then and passes", - "data": "yes", - "valid": true + "description": "string is invalid", + "data": "foo", + "valid": false }, { - "description": "other redirects to else and passes", - "data": "other", - "valid": true + "description": "number is invalid", + "data": 42, + "valid": false }, { - "description": "no redirects to then and fails", - "data": "no", + "description": "null is invalid", + "data": null, "valid": false }, { - "description": "invalid redirects to else and fails", - "data": "invalid", + "description": "object is invalid", + "data": {}, + "valid": false + }, + { + "description": "array is invalid", + "data": [], + "valid": false + }, + { + "description": "boolean is invalid", + "data": false, "valid": false } ] }, { - "description": "if-then-else.json: then: false fails when condition matches", + "description": "enum.json: characters with the same visual representation, but different number of codepoints", "schema": { - "if": { - "const": 1 - }, - "then": false + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": [ + "ä" + ], + "$comment": "U+00E4" }, "tests": [ { - "description": "matches if → then=false → invalid", - "data": 1, - "valid": false + "description": "character uses the same codepoint", + "data": "ä", + "comment": "U+00E4", + "valid": true }, { - "description": "does not match if → then ignored → valid", - "data": 2, - "valid": true + "description": "character looks the same but uses combining marks", + "data": "ä", + "comment": "a, U+0308", + "valid": false } ] }, { - "description": "if-then-else.json: else: false fails when condition does not match", + "description": "exclusiveMaximum.json: exclusiveMaximum validation", "schema": { - "if": { - "const": 1 - }, - "else": false + "$schema": "https://json-schema.org/draft/2020-12/schema", + "exclusiveMaximum": 3.0 }, "tests": [ { - "description": "matches if → else ignored → valid", - "data": 1, + "description": "below the exclusiveMaximum is valid", + "data": 2.2, "valid": true }, { - "description": "does not match if → else executes → invalid", - "data": 2, + "description": "boundary point is invalid", + "data": 3.0, + "valid": false + }, + { + "description": "above the exclusiveMaximum is invalid", + "data": 3.5, "valid": false + }, + { + "description": "ignores non-numbers", + "data": "x", + "valid": true } ] }, { - "description": "infinite-loop-detection.json: evaluating the same schema location against the same data location twice is not a sign of an infinite loop", + "description": "exclusiveMinimum.json: exclusiveMinimum validation", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "int": { - "type": "integer" - } - }, - "allOf": [ - { - "properties": { - "foo": { - "$ref": "#/$defs/int" - } - } - }, - { - "additionalProperties": { - "$ref": "#/$defs/int" - } - } - ] + "exclusiveMinimum": 1.1 }, "tests": [ { - "description": "passing case", - "data": { - "foo": 1 - }, + "description": "above the exclusiveMinimum is valid", + "data": 1.2, "valid": true }, { - "description": "failing case", - "data": { - "foo": "a string" - }, + "description": "boundary point is invalid", + "data": 1.1, + "valid": false + }, + { + "description": "below the exclusiveMinimum is invalid", + "data": 0.6, "valid": false + }, + { + "description": "ignores non-numbers", + "data": "x", + "valid": true } ] }, { - "description": "items.json: a schema given for items", + "description": "format.json: email format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "items": { - "type": "integer" - } + "format": "email" }, "tests": [ { - "description": "valid items", - "data": [ - 1, - 2, - 3 - ], + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "wrong type of items", - "data": [ - 1, - "x" - ], - "valid": false + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true }, { - "description": "ignores non-arrays", - "data": { - "foo": "bar" - }, + "description": "all string formats ignore objects", + "data": {}, "valid": true }, { - "description": "JavaScript pseudo-array is valid", - "data": { - "0": "invalid", - "length": 1 - }, + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid email string is only an annotation by default", + "data": "2962", "valid": true } ] }, { - "description": "items.json: items with boolean schema (true)", + "description": "format.json: idn-email format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "items": true + "format": "idn-email" }, "tests": [ { - "description": "any array is valid", - "data": [ - 1, - "foo", - true - ], + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "empty array is valid", + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", "data": [], "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid idn-email string is only an annotation by default", + "data": "2962", + "valid": true } ] }, { - "description": "items.json: items with boolean schema (false)", + "description": "format.json: regex format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "items": false + "format": "regex" }, "tests": [ { - "description": "any non-empty array is invalid", - "data": [ - 1, - "foo", - true - ], - "valid": false + "description": "all string formats ignore integers", + "data": 12, + "valid": true }, { - "description": "empty array is valid", + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", "data": [], "valid": true - } - ] - }, - { - "description": "items.json: nested items", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - } - } - }, - "tests": [ + }, { - "description": "valid nested array", - "data": [ - [ - [ - [ - 1 - ] - ], - [ - [ - 2 - ], - [ - 3 - ] - ] - ], - [ - [ - [ - 4 - ], - [ - 5 - ], - [ - 6 - ] - ] - ] - ], + "description": "all string formats ignore booleans", + "data": false, "valid": true }, { - "description": "nested array with invalid type", - "data": [ - [ - [ - [ - "1" - ] - ], - [ - [ - 2 - ], - [ - 3 - ] - ] - ], - [ - [ - [ - 4 - ], - [ - 5 - ], - [ - 6 - ] - ] - ] - ], - "valid": false + "description": "all string formats ignore nulls", + "data": null, + "valid": true }, { - "description": "not deep enough", - "data": [ - [ - [ - 1 - ], - [ - 2 - ], - [ - 3 - ] - ], - [ - [ - 4 - ], - [ - 5 - ], - [ - 6 - ] - ] - ], - "valid": false + "description": "invalid regex string is only an annotation by default", + "data": "^(abc]", + "valid": true } ] }, { - "description": "items.json: items with null instance elements", + "description": "format.json: ipv4 format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "items": { - "type": "null" - } + "format": "ipv4" }, "tests": [ { - "description": "allows null elements", - "data": [ - null - ], + "description": "all string formats ignore integers", + "data": 12, "valid": true - } - ] - }, - { - "description": "maxItems.json: maxItems validation", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxItems": 2 - }, - "tests": [ + }, { - "description": "shorter is valid", - "data": [ - 1 - ], + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "exact length is valid", - "data": [ - 1, - 2 - ], + "description": "all string formats ignore objects", + "data": {}, "valid": true }, { - "description": "too long is invalid", - "data": [ - 1, - 2, - 3 - ], - "valid": false + "description": "all string formats ignore arrays", + "data": [], + "valid": true }, { - "description": "ignores non-arrays", - "data": "foobar", + "description": "all string formats ignore booleans", + "data": false, "valid": true - } - ] - }, - { - "description": "maxItems.json: maxItems validation with a decimal", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxItems": 2.0 - }, - "tests": [ + }, { - "description": "shorter is valid", - "data": [ - 1 - ], + "description": "all string formats ignore nulls", + "data": null, "valid": true }, { - "description": "too long is invalid", - "data": [ - 1, - 2, - 3 - ], - "valid": false + "description": "invalid ipv4 string is only an annotation by default", + "data": "127.0.0.0.1", + "valid": true } ] }, { - "description": "maxLength.json: maxLength validation", + "description": "format.json: ipv6 format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxLength": 2 + "format": "ipv6" }, "tests": [ { - "description": "shorter is valid", - "data": "f", + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "exact length is valid", - "data": "fo", + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "too long is invalid", - "data": "foo", - "valid": false + "description": "all string formats ignore objects", + "data": {}, + "valid": true }, { - "description": "ignores non-strings", - "data": 100, + "description": "all string formats ignore arrays", + "data": [], "valid": true }, { - "description": "two graphemes is long enough", - "data": "💩💩", + "description": "all string formats ignore booleans", + "data": false, "valid": true - } - ] - }, - { - "description": "maxLength.json: maxLength validation with a decimal", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxLength": 2.0 - }, - "tests": [ + }, { - "description": "shorter is valid", - "data": "f", + "description": "all string formats ignore nulls", + "data": null, "valid": true }, { - "description": "too long is invalid", - "data": "foo", - "valid": false + "description": "invalid ipv6 string is only an annotation by default", + "data": "12345::", + "valid": true } ] }, { - "description": "maxProperties.json: maxProperties validation", + "description": "format.json: idn-hostname format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxProperties": 2 + "format": "idn-hostname" }, "tests": [ { - "description": "shorter is valid", - "data": { - "foo": 1 - }, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "exact length is valid", - "data": { - "foo": 1, - "bar": 2 - }, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "too long is invalid", - "data": { - "foo": 1, - "bar": 2, - "baz": 3 - }, - "valid": false + "description": "all string formats ignore objects", + "data": {}, + "valid": true }, { - "description": "ignores arrays", - "data": [ - 1, - 2, - 3 - ], + "description": "all string formats ignore arrays", + "data": [], "valid": true }, { - "description": "ignores strings", - "data": "foobar", + "description": "all string formats ignore booleans", + "data": false, "valid": true }, { - "description": "ignores other non-objects", - "data": 12, + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid idn-hostname string is only an annotation by default", + "data": "〮실례.테스트", "valid": true } ] }, { - "description": "maxProperties.json: maxProperties validation with a decimal", + "description": "format.json: hostname format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxProperties": 2.0 + "format": "hostname" }, "tests": [ { - "description": "shorter is valid", - "data": { - "foo": 1 - }, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "too long is invalid", - "data": { - "foo": 1, - "bar": 2, - "baz": 3 - }, - "valid": false - } - ] - }, - { - "description": "maxProperties.json: maxProperties = 0 means the object is empty", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "maxProperties": 0 - }, - "tests": [ + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, { - "description": "no properties is valid", + "description": "all string formats ignore objects", "data": {}, "valid": true }, { - "description": "one property is invalid", - "data": { - "foo": 1 - }, - "valid": false + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid hostname string is only an annotation by default", + "data": "-a-host-name-that-starts-with--", + "valid": true } ] }, { - "description": "maximum.json: maximum validation", + "description": "format.json: date format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "maximum": 3.0 + "format": "date" }, "tests": [ { - "description": "below the maximum is valid", - "data": 2.6, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "boundary point is valid", - "data": 3.0, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "above the maximum is invalid", - "data": 3.5, - "valid": false + "description": "all string formats ignore objects", + "data": {}, + "valid": true }, { - "description": "ignores non-numbers", - "data": "x", + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid date string is only an annotation by default", + "data": "06/19/1963", "valid": true } ] }, { - "description": "maximum.json: maximum validation with unsigned integer", + "description": "format.json: date-time format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "maximum": 300 + "format": "date-time" }, "tests": [ { - "description": "below the maximum is valid", - "data": 299.97, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "boundary point integer is valid", - "data": 300, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "boundary point float is valid", - "data": 300.0, + "description": "all string formats ignore objects", + "data": {}, "valid": true }, { - "description": "above the maximum is invalid", - "data": 300.5, - "valid": false + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid date-time string is only an annotation by default", + "data": "1990-02-31T15:59:60.123-08:00", + "valid": true } ] }, { - "description": "minItems.json: minItems validation", + "description": "format.json: time format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minItems": 1 + "format": "time" }, "tests": [ { - "description": "longer is valid", - "data": [ - 1, - 2 - ], + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "exact length is valid", - "data": [ - 1 - ], + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "too short is invalid", + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", "data": [], - "valid": false + "valid": true }, { - "description": "ignores non-arrays", - "data": "", + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid time string is only an annotation by default", + "data": "08:30:06 PST", "valid": true } ] }, { - "description": "minItems.json: minItems validation with a decimal", + "description": "format.json: json-pointer format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minItems": 1.0 + "format": "json-pointer" }, "tests": [ { - "description": "longer is valid", - "data": [ - 1, - 2 - ], + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "too short is invalid", + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", "data": [], - "valid": false + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid json-pointer string is only an annotation by default", + "data": "/foo/bar~", + "valid": true } ] }, { - "description": "minLength.json: minLength validation", + "description": "format.json: relative-json-pointer format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minLength": 2 + "format": "relative-json-pointer" }, "tests": [ { - "description": "longer is valid", - "data": "foo", + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "exact length is valid", - "data": "fo", + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "too short is invalid", - "data": "f", - "valid": false + "description": "all string formats ignore objects", + "data": {}, + "valid": true }, { - "description": "ignores non-strings", - "data": 1, + "description": "all string formats ignore arrays", + "data": [], "valid": true }, { - "description": "one grapheme is not long enough", - "data": "💩", - "valid": false - } - ] - }, - { - "description": "minLength.json: minLength validation with a decimal", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "minLength": 2.0 - }, - "tests": [ + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, { - "description": "longer is valid", - "data": "foo", + "description": "all string formats ignore nulls", + "data": null, "valid": true }, { - "description": "too short is invalid", - "data": "f", - "valid": false + "description": "invalid relative-json-pointer string is only an annotation by default", + "data": "/foo/bar", + "valid": true } ] }, { - "description": "minProperties.json: minProperties validation", + "description": "format.json: iri format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minProperties": 1 + "format": "iri" }, "tests": [ { - "description": "longer is valid", - "data": { - "foo": 1, - "bar": 2 - }, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "exact length is valid", - "data": { - "foo": 1 - }, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "too short is invalid", + "description": "all string formats ignore objects", "data": {}, - "valid": false - }, - { - "description": "ignores arrays", - "data": [], "valid": true }, { - "description": "ignores strings", - "data": "", + "description": "all string formats ignore arrays", + "data": [], "valid": true }, { - "description": "ignores other non-objects", - "data": 12, + "description": "all string formats ignore booleans", + "data": false, "valid": true }, { - "description": "ignores null", + "description": "all string formats ignore nulls", "data": null, "valid": true }, { - "description": "ignores booleans", - "data": true, + "description": "invalid iri string is only an annotation by default", + "data": "http://2001:0db8:85a3:0000:0000:8a2e:0370:7334", "valid": true } ] }, { - "description": "minProperties.json: minProperties validation with a decimal", + "description": "format.json: iri-reference format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minProperties": 1.0 + "format": "iri-reference" }, "tests": [ { - "description": "longer is valid", - "data": { - "foo": 1, - "bar": 2 - }, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "too short is invalid", + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", "data": {}, - "valid": false + "valid": true + }, + { + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid iri-reference string is only an annotation by default", + "data": "\\\\WINDOWS\\filëßåré", + "valid": true } ] }, { - "description": "minimum.json: minimum validation", + "description": "format.json: uri format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minimum": 1.1 + "format": "uri" }, "tests": [ { - "description": "above the minimum is valid", - "data": 2.6, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "boundary point is valid", - "data": 1.1, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "below the minimum is invalid", - "data": 0.6, - "valid": false + "description": "all string formats ignore objects", + "data": {}, + "valid": true }, { - "description": "ignores non-numbers", - "data": "x", + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid uri string is only an annotation by default", + "data": "//foo.bar/?baz=qux#quux", "valid": true } ] }, { - "description": "minimum.json: minimum validation with signed integer", + "description": "format.json: uri-reference format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "minimum": -2 + "format": "uri-reference" }, "tests": [ { - "description": "negative above the minimum is valid", - "data": -1, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "positive above the minimum is valid", - "data": 0, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "boundary point is valid", - "data": -2, + "description": "all string formats ignore objects", + "data": {}, "valid": true }, { - "description": "boundary point with float is valid", - "data": -2.0, + "description": "all string formats ignore arrays", + "data": [], "valid": true }, { - "description": "float below the minimum is invalid", - "data": -2.0001, - "valid": false + "description": "all string formats ignore booleans", + "data": false, + "valid": true }, { - "description": "int below the minimum is invalid", - "data": -3, - "valid": false + "description": "all string formats ignore nulls", + "data": null, + "valid": true }, { - "description": "ignores non-numbers", - "data": "x", + "description": "invalid uri-reference string is only an annotation by default", + "data": "\\\\WINDOWS\\fileshare", "valid": true } ] }, { - "description": "not.json: not", + "description": "format.json: uri-template format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": { - "type": "integer" - } + "format": "uri-template" }, "tests": [ { - "description": "allowed", - "data": "foo", + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "disallowed", - "data": 1, - "valid": false + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid uri-template string is only an annotation by default", + "data": "http://example.com/dictionary/{term:1}/{term", + "valid": true } ] }, { - "description": "not.json: not multiple types", + "description": "format.json: uuid format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": { - "type": [ - "integer", - "boolean" - ] - } + "format": "uuid" }, "tests": [ { - "description": "valid", - "data": "foo", + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "mismatch", - "data": 1, - "valid": false + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true }, { - "description": "other mismatch", - "data": true, - "valid": false + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid uuid string is only an annotation by default", + "data": "2eb8aa08-aa98-11ea-b4aa-73b441d1638", + "valid": true } ] }, { - "description": "not.json: not more complex schema", + "description": "format.json: duration format", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": { - "type": "object", - "properties": { - "foo": { - "type": "string" - } - } - } + "format": "duration" }, "tests": [ { - "description": "match", - "data": 1, + "description": "all string formats ignore integers", + "data": 12, "valid": true }, { - "description": "other match", - "data": { - "foo": 1 - }, + "description": "all string formats ignore floats", + "data": 13.7, "valid": true }, { - "description": "mismatch", - "data": { - "foo": "bar" - }, - "valid": false - } - ] + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "invalid duration string is only an annotation by default", + "data": "PT1D", + "valid": true + } + ] }, { - "description": "not.json: forbidden property", + "description": "if-then-else.json: ignore if without then or else", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": { - "not": {} - } + "if": { + "const": 0 } }, "tests": [ { - "description": "property present", - "data": { - "foo": 1, - "bar": 2 - }, - "valid": false + "description": "valid when valid against lone if", + "data": 0, + "valid": true }, { - "description": "property absent", - "data": { - "bar": 1, - "baz": 2 - }, + "description": "valid when invalid against lone if", + "data": "hello", "valid": true } ] }, { - "description": "not.json: forbid everything with empty schema", + "description": "if-then-else.json: ignore then without if", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": {} + "then": { + "const": 0 + } }, "tests": [ { - "description": "number is invalid", - "data": 1, - "valid": false - }, - { - "description": "string is invalid", - "data": "foo", - "valid": false - }, - { - "description": "boolean true is invalid", - "data": true, - "valid": false + "description": "valid when valid against lone then", + "data": 0, + "valid": true }, { - "description": "boolean false is invalid", - "data": false, - "valid": false - }, + "description": "valid when invalid against lone then", + "data": "hello", + "valid": true + } + ] + }, + { + "description": "if-then-else.json: ignore else without if", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "else": { + "const": 0 + } + }, + "tests": [ { - "description": "null is invalid", - "data": null, - "valid": false + "description": "valid when valid against lone else", + "data": 0, + "valid": true }, { - "description": "object is invalid", - "data": { - "foo": "bar" - }, - "valid": false + "description": "valid when invalid against lone else", + "data": "hello", + "valid": true + } + ] + }, + { + "description": "if-then-else.json: if and then without else", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": { + "exclusiveMaximum": 0 }, + "then": { + "minimum": -10 + } + }, + "tests": [ { - "description": "empty object is invalid", - "data": {}, - "valid": false + "description": "valid through then", + "data": -1, + "valid": true }, { - "description": "array is invalid", - "data": [ - "foo" - ], + "description": "invalid through then", + "data": -100, "valid": false }, { - "description": "empty array is invalid", - "data": [], - "valid": false + "description": "valid when if test fails", + "data": 3, + "valid": true } ] }, { - "description": "not.json: forbid everything with boolean schema true", + "description": "if-then-else.json: if and else without then", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": true + "if": { + "exclusiveMaximum": 0 + }, + "else": { + "multipleOf": 2 + } }, "tests": [ { - "description": "number is invalid", - "data": 1, - "valid": false - }, - { - "description": "string is invalid", - "data": "foo", - "valid": false + "description": "valid when if test passes", + "data": -1, + "valid": true }, { - "description": "boolean true is invalid", - "data": true, - "valid": false + "description": "valid through else", + "data": 4, + "valid": true }, { - "description": "boolean false is invalid", - "data": false, + "description": "invalid through else", + "data": 3, "valid": false + } + ] + }, + { + "description": "if-then-else.json: validate against correct branch, then vs else", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": { + "exclusiveMaximum": 0 }, - { - "description": "null is invalid", - "data": null, - "valid": false + "then": { + "minimum": -10 }, + "else": { + "multipleOf": 2 + } + }, + "tests": [ { - "description": "object is invalid", - "data": { - "foo": "bar" - }, - "valid": false + "description": "valid through then", + "data": -1, + "valid": true }, { - "description": "empty object is invalid", - "data": {}, + "description": "invalid through then", + "data": -100, "valid": false }, { - "description": "array is invalid", - "data": [ - "foo" - ], - "valid": false + "description": "valid through else", + "data": 4, + "valid": true }, { - "description": "empty array is invalid", - "data": [], + "description": "invalid through else", + "data": 3, "valid": false } ] }, { - "description": "not.json: allow everything with boolean schema false", + "description": "if-then-else.json: non-interference across combined schemas", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": false + "allOf": [ + { + "if": { + "exclusiveMaximum": 0 + } + }, + { + "then": { + "minimum": -10 + } + }, + { + "else": { + "multipleOf": 2 + } + } + ] }, "tests": [ { - "description": "number is valid", - "data": 1, + "description": "valid, but would have been invalid through then", + "data": -100, "valid": true }, { - "description": "string is valid", - "data": "foo", + "description": "valid, but would have been invalid through else", + "data": 3, "valid": true + } + ] + }, + { + "description": "if-then-else.json: if with boolean schema true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": true, + "then": { + "const": "then" }, + "else": { + "const": "else" + } + }, + "tests": [ { - "description": "boolean true is valid", - "data": true, + "description": "boolean schema true in if always chooses the then path (valid)", + "data": "then", "valid": true }, { - "description": "boolean false is valid", - "data": false, - "valid": true + "description": "boolean schema true in if always chooses the then path (invalid)", + "data": "else", + "valid": false + } + ] + }, + { + "description": "if-then-else.json: if with boolean schema false", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "if": false, + "then": { + "const": "then" }, + "else": { + "const": "else" + } + }, + "tests": [ { - "description": "null is valid", - "data": null, - "valid": true + "description": "boolean schema false in if always chooses the else path (invalid)", + "data": "then", + "valid": false }, { - "description": "object is valid", - "data": { - "foo": "bar" - }, + "description": "boolean schema false in if always chooses the else path (valid)", + "data": "else", "valid": true + } + ] + }, + { + "description": "if-then-else.json: if appears at the end when serialized (keyword processing sequence)", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "then": { + "const": "yes" + }, + "else": { + "const": "other" }, + "if": { + "maxLength": 4 + } + }, + "tests": [ { - "description": "empty object is valid", - "data": {}, + "description": "yes redirects to then and passes", + "data": "yes", "valid": true }, { - "description": "array is valid", - "data": [ - "foo" - ], + "description": "other redirects to else and passes", + "data": "other", "valid": true }, { - "description": "empty array is valid", - "data": [], - "valid": true + "description": "no redirects to then and fails", + "data": "no", + "valid": false + }, + { + "description": "invalid redirects to else and fails", + "data": "invalid", + "valid": false } ] }, { - "description": "not.json: double negation", + "description": "if-then-else.json: then: false fails when condition matches", "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "not": { - "not": {} - } + "if": { + "const": 1 + }, + "then": false }, "tests": [ { - "description": "any value is valid", - "data": "foo", + "description": "matches if → then=false → invalid", + "data": 1, + "valid": false + }, + { + "description": "does not match if → then ignored → valid", + "data": 2, "valid": true } ] }, { - "description": "oneOf.json: oneOf", + "description": "if-then-else.json: else: false fails when condition does not match", "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - { - "type": "integer" - }, - { - "minimum": 2 - } - ] + "if": { + "const": 1 + }, + "else": false }, "tests": [ { - "description": "first oneOf valid", + "description": "matches if → else ignored → valid", "data": 1, "valid": true }, { - "description": "second oneOf valid", - "data": 2.5, - "valid": true - }, - { - "description": "both oneOf valid", - "data": 3, - "valid": false - }, - { - "description": "neither oneOf valid", - "data": 1.5, + "description": "does not match if → else executes → invalid", + "data": 2, "valid": false } ] }, { - "description": "oneOf.json: oneOf with base schema", + "description": "infinite-loop-detection.json: evaluating the same schema location against the same data location twice is not a sign of an infinite loop", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "string", - "oneOf": [ + "$defs": { + "int": { + "type": "integer" + } + }, + "allOf": [ { - "minLength": 2 + "properties": { + "foo": { + "$ref": "#/$defs/int" + } + } }, { - "maxLength": 4 + "additionalProperties": { + "$ref": "#/$defs/int" + } } ] }, "tests": [ { - "description": "mismatch base schema", - "data": 3, - "valid": false - }, - { - "description": "one oneOf valid", - "data": "foobar", + "description": "passing case", + "data": { + "foo": 1 + }, "valid": true }, { - "description": "both oneOf valid", - "data": "foo", + "description": "failing case", + "data": { + "foo": "a string" + }, "valid": false } ] }, { - "description": "oneOf.json: oneOf with boolean schemas, all true", + "description": "items.json: a schema given for items", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - true, - true, - true - ] + "items": { + "type": "integer" + } }, "tests": [ { - "description": "any value is invalid", - "data": "foo", + "description": "valid items", + "data": [ + 1, + 2, + 3 + ], + "valid": true + }, + { + "description": "wrong type of items", + "data": [ + 1, + "x" + ], "valid": false + }, + { + "description": "ignores non-arrays", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "JavaScript pseudo-array is valid", + "data": { + "0": "invalid", + "length": 1 + }, + "valid": true } ] }, { - "description": "oneOf.json: oneOf with boolean schemas, one true", + "description": "items.json: items with boolean schema (true)", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - true, - false, - false - ] + "items": true }, "tests": [ { - "description": "any value is valid", - "data": "foo", + "description": "any array is valid", + "data": [ + 1, + "foo", + true + ], + "valid": true + }, + { + "description": "empty array is valid", + "data": [], "valid": true } ] }, { - "description": "oneOf.json: oneOf with boolean schemas, more than one true", + "description": "items.json: items with boolean schema (false)", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - true, - true, - false - ] + "items": false }, "tests": [ { - "description": "any value is invalid", - "data": "foo", + "description": "any non-empty array is invalid", + "data": [ + 1, + "foo", + true + ], "valid": false + }, + { + "description": "empty array is valid", + "data": [], + "valid": true } ] }, { - "description": "oneOf.json: oneOf with boolean schemas, all false", + "description": "items.json: nested items", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - false, - false, - false - ] + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + } }, "tests": [ { - "description": "any value is invalid", - "data": "foo", - "valid": false - } - ] - }, - { - "description": "oneOf.json: oneOf complex types", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - { - "properties": { - "bar": { - "type": "integer" - } - }, - "required": [ - "bar" + "description": "valid nested array", + "data": [ + [ + [ + [ + 1 + ] + ], + [ + [ + 2 + ], + [ + 3 + ] + ] + ], + [ + [ + [ + 4 + ], + [ + 5 + ], + [ + 6 + ] + ] ] - }, - { - "properties": { - "foo": { - "type": "string" + ], + "valid": true + }, + { + "description": "nested array with invalid type", + "data": [ + [ + [ + [ + "1" + ] + ], + [ + [ + 2 + ], + [ + 3 + ] + ] + ], + [ + [ + [ + 4 + ], + [ + 5 + ], + [ + 6 + ] + ] + ] + ], + "valid": false + }, + { + "description": "not deep enough", + "data": [ + [ + [ + 1 + ], + [ + 2 + ], + [ + 3 + ] + ], + [ + [ + 4 + ], + [ + 5 + ], + [ + 6 + ] + ] + ], + "valid": false + } + ] + }, + { + "description": "items.json: items with null instance elements", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "type": "null" + } + }, + "tests": [ + { + "description": "allows null elements", + "data": [ + null + ], + "valid": true + } + ] + }, + { + "description": "items.json: items and subitems", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "item": { + "type": "array", + "items": false, + "prefixItems": [ + { + "$ref": "#/$defs/sub-item" + }, + { + "$ref": "#/$defs/sub-item" } - }, + ] + }, + "sub-item": { + "type": "object", "required": [ "foo" ] } + }, + "type": "array", + "items": false, + "prefixItems": [ + { + "$ref": "#/$defs/item" + }, + { + "$ref": "#/$defs/item" + }, + { + "$ref": "#/$defs/item" + } ] }, "tests": [ { - "description": "first oneOf valid (complex)", - "data": { - "bar": 2 - }, - "valid": true + "description": "valid items", + "data": [ + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ] + ], + "valid": true + }, + { + "description": "too many items", + "data": [ + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ] + ], + "valid": false + }, + { + "description": "too many sub-items", + "data": [ + [ + { + "foo": null + }, + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ] + ], + "valid": false + }, + { + "description": "wrong item", + "data": [ + { + "foo": null + }, + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ] + ], + "valid": false + }, + { + "description": "wrong sub-item", + "data": [ + [ + {}, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ], + [ + { + "foo": null + }, + { + "foo": null + } + ] + ], + "valid": false + }, + { + "description": "fewer items is valid", + "data": [ + [ + { + "foo": null + } + ], + [ + { + "foo": null + } + ] + ], + "valid": true + } + ] + }, + { + "description": "items.json: prefixItems with no additional items allowed", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + {}, + {}, + {} + ], + "items": false + }, + "tests": [ + { + "description": "empty array", + "data": [], + "valid": true + }, + { + "description": "fewer number of items present (1)", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "fewer number of items present (2)", + "data": [ + 1, + 2 + ], + "valid": true + }, + { + "description": "equal number of items present", + "data": [ + 1, + 2, + 3 + ], + "valid": true + }, + { + "description": "additional items are not permitted", + "data": [ + 1, + 2, + 3, + 4 + ], + "valid": false + } + ] + }, + { + "description": "items.json: items does not look in applicators, valid case", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "prefixItems": [ + { + "minimum": 3 + } + ] + } + ], + "items": { + "minimum": 5 + } + }, + "tests": [ + { + "description": "prefixItems in allOf does not constrain items, invalid case", + "data": [ + 3, + 5 + ], + "valid": false + }, + { + "description": "prefixItems in allOf does not constrain items, valid case", + "data": [ + 5, + 5 + ], + "valid": true + } + ] + }, + { + "description": "items.json: prefixItems validation adjusts the starting index for items", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + { + "type": "string" + } + ], + "items": { + "type": "integer" + } + }, + "tests": [ + { + "description": "valid items", + "data": [ + "x", + 2, + 3 + ], + "valid": true + }, + { + "description": "wrong type of second item", + "data": [ + "x", + "y" + ], + "valid": false + } + ] + }, + { + "description": "items.json: items with heterogeneous array", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + {} + ], + "items": false + }, + "tests": [ + { + "description": "heterogeneous invalid instance", + "data": [ + "foo", + "bar", + 37 + ], + "valid": false + }, + { + "description": "valid instance", + "data": [ + null + ], + "valid": true + } + ] + }, + { + "description": "maxContains.json: maxContains without contains is ignored", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxContains": 1 + }, + "tests": [ + { + "description": "one item valid against lone maxContains", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "two items still valid against lone maxContains", + "data": [ + 1, + 2 + ], + "valid": true + } + ] + }, + { + "description": "maxContains.json: maxContains with contains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "maxContains": 1 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": false + }, + { + "description": "all elements match, valid maxContains", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "all elements match, invalid maxContains", + "data": [ + 1, + 1 + ], + "valid": false + }, + { + "description": "some elements match, valid maxContains", + "data": [ + 1, + 2 + ], + "valid": true + }, + { + "description": "some elements match, invalid maxContains", + "data": [ + 1, + 2, + 1 + ], + "valid": false + } + ] + }, + { + "description": "maxContains.json: maxContains with contains, value with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "maxContains": 1.0 + }, + "tests": [ + { + "description": "one element matches, valid maxContains", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "too many elements match, invalid maxContains", + "data": [ + 1, + 1 + ], + "valid": false + } + ] + }, + { + "description": "maxContains.json: minContains < maxContains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 1, + "maxContains": 3 + }, + "tests": [ + { + "description": "actual < minContains < maxContains", + "data": [], + "valid": false + }, + { + "description": "minContains < actual < maxContains", + "data": [ + 1, + 1 + ], + "valid": true + }, + { + "description": "minContains < maxContains < actual", + "data": [ + 1, + 1, + 1, + 1 + ], + "valid": false + } + ] + }, + { + "description": "maxContains.json: maxContains = 0 with minContains = 0", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 0, + "maxContains": 0 + }, + "tests": [ + { + "description": "empty array", + "data": [], + "valid": true + }, + { + "description": "one matching item", + "data": [ + 1 + ], + "valid": false + } + ] + }, + { + "description": "maxItems.json: maxItems validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxItems": 2 + }, + "tests": [ + { + "description": "shorter is valid", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "exact length is valid", + "data": [ + 1, + 2 + ], + "valid": true + }, + { + "description": "too long is invalid", + "data": [ + 1, + 2, + 3 + ], + "valid": false + }, + { + "description": "ignores non-arrays", + "data": "foobar", + "valid": true + } + ] + }, + { + "description": "maxItems.json: maxItems validation with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxItems": 2.0 + }, + "tests": [ + { + "description": "shorter is valid", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "too long is invalid", + "data": [ + 1, + 2, + 3 + ], + "valid": false + } + ] + }, + { + "description": "maxLength.json: maxLength validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxLength": 2 + }, + "tests": [ + { + "description": "shorter is valid", + "data": "f", + "valid": true + }, + { + "description": "exact length is valid", + "data": "fo", + "valid": true + }, + { + "description": "too long is invalid", + "data": "foo", + "valid": false + }, + { + "description": "ignores non-strings", + "data": 100, + "valid": true + }, + { + "description": "two graphemes is long enough", + "data": "💩💩", + "valid": true + } + ] + }, + { + "description": "maxLength.json: maxLength validation with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxLength": 2.0 + }, + "tests": [ + { + "description": "shorter is valid", + "data": "f", + "valid": true + }, + { + "description": "too long is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "maxProperties.json: maxProperties validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxProperties": 2 + }, + "tests": [ + { + "description": "shorter is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "exact length is valid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": true + }, + { + "description": "too long is invalid", + "data": { + "foo": 1, + "bar": 2, + "baz": 3 + }, + "valid": false + }, + { + "description": "ignores arrays", + "data": [ + 1, + 2, + 3 + ], + "valid": true + }, + { + "description": "ignores strings", + "data": "foobar", + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + } + ] + }, + { + "description": "maxProperties.json: maxProperties validation with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxProperties": 2.0 + }, + "tests": [ + { + "description": "shorter is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "too long is invalid", + "data": { + "foo": 1, + "bar": 2, + "baz": 3 + }, + "valid": false + } + ] + }, + { + "description": "maxProperties.json: maxProperties = 0 means the object is empty", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maxProperties": 0 + }, + "tests": [ + { + "description": "no properties is valid", + "data": {}, + "valid": true + }, + { + "description": "one property is invalid", + "data": { + "foo": 1 + }, + "valid": false + } + ] + }, + { + "description": "maximum.json: maximum validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maximum": 3.0 + }, + "tests": [ + { + "description": "below the maximum is valid", + "data": 2.6, + "valid": true + }, + { + "description": "boundary point is valid", + "data": 3.0, + "valid": true + }, + { + "description": "above the maximum is invalid", + "data": 3.5, + "valid": false + }, + { + "description": "ignores non-numbers", + "data": "x", + "valid": true + } + ] + }, + { + "description": "maximum.json: maximum validation with unsigned integer", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "maximum": 300 + }, + "tests": [ + { + "description": "below the maximum is valid", + "data": 299.97, + "valid": true + }, + { + "description": "boundary point integer is valid", + "data": 300, + "valid": true + }, + { + "description": "boundary point float is valid", + "data": 300.0, + "valid": true + }, + { + "description": "above the maximum is invalid", + "data": 300.5, + "valid": false + } + ] + }, + { + "description": "minContains.json: minContains without contains is ignored", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minContains": 1 + }, + "tests": [ + { + "description": "one item valid against lone minContains", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "zero items still valid against lone minContains", + "data": [], + "valid": true + } + ] + }, + { + "description": "minContains.json: minContains=1 with contains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 1 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": false + }, + { + "description": "no elements match", + "data": [ + 2 + ], + "valid": false + }, + { + "description": "single element matches, valid minContains", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "some elements match, valid minContains", + "data": [ + 1, + 2 + ], + "valid": true + }, + { + "description": "all elements match, valid minContains", + "data": [ + 1, + 1 + ], + "valid": true + } + ] + }, + { + "description": "minContains.json: minContains=2 with contains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 2 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": false + }, + { + "description": "all elements match, invalid minContains", + "data": [ + 1 + ], + "valid": false + }, + { + "description": "some elements match, invalid minContains", + "data": [ + 1, + 2 + ], + "valid": false + }, + { + "description": "all elements match, valid minContains (exactly as needed)", + "data": [ + 1, + 1 + ], + "valid": true + }, + { + "description": "all elements match, valid minContains (more than needed)", + "data": [ + 1, + 1, + 1 + ], + "valid": true + }, + { + "description": "some elements match, valid minContains", + "data": [ + 1, + 2, + 1 + ], + "valid": true + } + ] + }, + { + "description": "minContains.json: minContains=2 with contains with a decimal value", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 2.0 + }, + "tests": [ + { + "description": "one element matches, invalid minContains", + "data": [ + 1 + ], + "valid": false + }, + { + "description": "both elements match, valid minContains", + "data": [ + 1, + 1 + ], + "valid": true + } + ] + }, + { + "description": "minContains.json: maxContains = minContains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "maxContains": 2, + "minContains": 2 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": false + }, + { + "description": "all elements match, invalid minContains", + "data": [ + 1 + ], + "valid": false + }, + { + "description": "all elements match, invalid maxContains", + "data": [ + 1, + 1, + 1 + ], + "valid": false + }, + { + "description": "all elements match, valid maxContains and minContains", + "data": [ + 1, + 1 + ], + "valid": true + } + ] + }, + { + "description": "minContains.json: maxContains < minContains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "maxContains": 1, + "minContains": 3 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": false + }, + { + "description": "invalid minContains", + "data": [ + 1 + ], + "valid": false + }, + { + "description": "invalid maxContains", + "data": [ + 1, + 1, + 1 + ], + "valid": false + }, + { + "description": "invalid maxContains and minContains", + "data": [ + 1, + 1 + ], + "valid": false + } + ] + }, + { + "description": "minContains.json: minContains = 0", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 0 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": true + }, + { + "description": "minContains = 0 makes contains always pass", + "data": [ + 2 + ], + "valid": true + } + ] + }, + { + "description": "minContains.json: minContains = 0 with maxContains", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "contains": { + "const": 1 + }, + "minContains": 0, + "maxContains": 1 + }, + "tests": [ + { + "description": "empty data", + "data": [], + "valid": true + }, + { + "description": "not more than maxContains", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "too many", + "data": [ + 1, + 1 + ], + "valid": false + } + ] + }, + { + "description": "minItems.json: minItems validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minItems": 1 + }, + "tests": [ + { + "description": "longer is valid", + "data": [ + 1, + 2 + ], + "valid": true + }, + { + "description": "exact length is valid", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "too short is invalid", + "data": [], + "valid": false + }, + { + "description": "ignores non-arrays", + "data": "", + "valid": true + } + ] + }, + { + "description": "minItems.json: minItems validation with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minItems": 1.0 + }, + "tests": [ + { + "description": "longer is valid", + "data": [ + 1, + 2 + ], + "valid": true + }, + { + "description": "too short is invalid", + "data": [], + "valid": false + } + ] + }, + { + "description": "minLength.json: minLength validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minLength": 2 + }, + "tests": [ + { + "description": "longer is valid", + "data": "foo", + "valid": true + }, + { + "description": "exact length is valid", + "data": "fo", + "valid": true + }, + { + "description": "too short is invalid", + "data": "f", + "valid": false + }, + { + "description": "ignores non-strings", + "data": 1, + "valid": true + }, + { + "description": "one grapheme is not long enough", + "data": "💩", + "valid": false + } + ] + }, + { + "description": "minLength.json: minLength validation with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minLength": 2.0 + }, + "tests": [ + { + "description": "longer is valid", + "data": "foo", + "valid": true + }, + { + "description": "too short is invalid", + "data": "f", + "valid": false + } + ] + }, + { + "description": "minProperties.json: minProperties validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minProperties": 1 + }, + "tests": [ + { + "description": "longer is valid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": true + }, + { + "description": "exact length is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "too short is invalid", + "data": {}, + "valid": false + }, + { + "description": "ignores arrays", + "data": [], + "valid": true + }, + { + "description": "ignores strings", + "data": "", + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + }, + { + "description": "ignores null", + "data": null, + "valid": true + }, + { + "description": "ignores booleans", + "data": true, + "valid": true + } + ] + }, + { + "description": "minProperties.json: minProperties validation with a decimal", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minProperties": 1.0 + }, + "tests": [ + { + "description": "longer is valid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": true + }, + { + "description": "too short is invalid", + "data": {}, + "valid": false + } + ] + }, + { + "description": "minimum.json: minimum validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minimum": 1.1 + }, + "tests": [ + { + "description": "above the minimum is valid", + "data": 2.6, + "valid": true + }, + { + "description": "boundary point is valid", + "data": 1.1, + "valid": true + }, + { + "description": "below the minimum is invalid", + "data": 0.6, + "valid": false + }, + { + "description": "ignores non-numbers", + "data": "x", + "valid": true + } + ] + }, + { + "description": "minimum.json: minimum validation with signed integer", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "minimum": -2 + }, + "tests": [ + { + "description": "negative above the minimum is valid", + "data": -1, + "valid": true + }, + { + "description": "positive above the minimum is valid", + "data": 0, + "valid": true + }, + { + "description": "boundary point is valid", + "data": -2, + "valid": true + }, + { + "description": "boundary point with float is valid", + "data": -2.0, + "valid": true + }, + { + "description": "float below the minimum is invalid", + "data": -2.0001, + "valid": false + }, + { + "description": "int below the minimum is invalid", + "data": -3, + "valid": false + }, + { + "description": "ignores non-numbers", + "data": "x", + "valid": true + } + ] + }, + { + "description": "multipleOf.json: by int", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "multipleOf": 2 + }, + "tests": [ + { + "description": "int by int", + "data": 10, + "valid": true + }, + { + "description": "int by int fail", + "data": 7, + "valid": false + }, + { + "description": "ignores non-numbers", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "multipleOf.json: by number", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "multipleOf": 1.5 + }, + "tests": [ + { + "description": "zero is multiple of anything", + "data": 0, + "valid": true + }, + { + "description": "4.5 is multiple of 1.5", + "data": 4.5, + "valid": true + }, + { + "description": "-4.5 is multiple of 1.5", + "data": -4.5, + "valid": true + }, + { + "description": "35 is not multiple of 1.5", + "data": 35, + "valid": false + } + ] + }, + { + "description": "multipleOf.json: by small number", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "multipleOf": 0.0001 + }, + "tests": [ + { + "description": "0.0075 is multiple of 0.0001", + "data": 0.0075, + "valid": true + }, + { + "description": "0.00751 is not multiple of 0.0001", + "data": 0.00751, + "valid": false + } + ] + }, + { + "description": "multipleOf.json: float division = inf", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer", + "multipleOf": 0.123456789 + }, + "tests": [ + { + "description": "always invalid, but naive implementations may raise an overflow error", + "data": 1e+308, + "valid": false + } + ] + }, + { + "description": "multipleOf.json: small multiple of large integer", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer", + "multipleOf": 1e-08 + }, + "tests": [ + { + "description": "any integer is a multiple of 1e-8", + "data": 12391239123, + "valid": true + } + ] + }, + { + "description": "not.json: not", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": { + "type": "integer" + } + }, + "tests": [ + { + "description": "allowed", + "data": "foo", + "valid": true + }, + { + "description": "disallowed", + "data": 1, + "valid": false + } + ] + }, + { + "description": "not.json: not multiple types", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": { + "type": [ + "integer", + "boolean" + ] + } + }, + "tests": [ + { + "description": "valid", + "data": "foo", + "valid": true + }, + { + "description": "mismatch", + "data": 1, + "valid": false + }, + { + "description": "other mismatch", + "data": true, + "valid": false + } + ] + }, + { + "description": "not.json: not more complex schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + } + }, + "tests": [ + { + "description": "match", + "data": 1, + "valid": true + }, + { + "description": "other match", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "mismatch", + "data": { + "foo": "bar" + }, + "valid": false + } + ] + }, + { + "description": "not.json: forbidden property", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "not": {} + } + } + }, + "tests": [ + { + "description": "property present", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": false + }, + { + "description": "property absent", + "data": { + "bar": 1, + "baz": 2 + }, + "valid": true + } + ] + }, + { + "description": "not.json: forbid everything with empty schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": {} + }, + "tests": [ + { + "description": "number is invalid", + "data": 1, + "valid": false + }, + { + "description": "string is invalid", + "data": "foo", + "valid": false + }, + { + "description": "boolean true is invalid", + "data": true, + "valid": false + }, + { + "description": "boolean false is invalid", + "data": false, + "valid": false + }, + { + "description": "null is invalid", + "data": null, + "valid": false + }, + { + "description": "object is invalid", + "data": { + "foo": "bar" + }, + "valid": false + }, + { + "description": "empty object is invalid", + "data": {}, + "valid": false + }, + { + "description": "array is invalid", + "data": [ + "foo" + ], + "valid": false + }, + { + "description": "empty array is invalid", + "data": [], + "valid": false + } + ] + }, + { + "description": "not.json: forbid everything with boolean schema true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": true + }, + "tests": [ + { + "description": "number is invalid", + "data": 1, + "valid": false + }, + { + "description": "string is invalid", + "data": "foo", + "valid": false + }, + { + "description": "boolean true is invalid", + "data": true, + "valid": false + }, + { + "description": "boolean false is invalid", + "data": false, + "valid": false + }, + { + "description": "null is invalid", + "data": null, + "valid": false + }, + { + "description": "object is invalid", + "data": { + "foo": "bar" + }, + "valid": false + }, + { + "description": "empty object is invalid", + "data": {}, + "valid": false + }, + { + "description": "array is invalid", + "data": [ + "foo" + ], + "valid": false + }, + { + "description": "empty array is invalid", + "data": [], + "valid": false + } + ] + }, + { + "description": "not.json: allow everything with boolean schema false", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": false + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "string is valid", + "data": "foo", + "valid": true + }, + { + "description": "boolean true is valid", + "data": true, + "valid": true + }, + { + "description": "boolean false is valid", + "data": false, + "valid": true + }, + { + "description": "null is valid", + "data": null, + "valid": true + }, + { + "description": "object is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true + }, + { + "description": "array is valid", + "data": [ + "foo" + ], + "valid": true + }, + { + "description": "empty array is valid", + "data": [], + "valid": true + } + ] + }, + { + "description": "not.json: double negation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "not": { + "not": {} + } + }, + "tests": [ + { + "description": "any value is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "oneOf.json: oneOf", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "integer" + }, + { + "minimum": 2 + } + ] + }, + "tests": [ + { + "description": "first oneOf valid", + "data": 1, + "valid": true + }, + { + "description": "second oneOf valid", + "data": 2.5, + "valid": true + }, + { + "description": "both oneOf valid", + "data": 3, + "valid": false + }, + { + "description": "neither oneOf valid", + "data": 1.5, + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with base schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "oneOf": [ + { + "minLength": 2 + }, + { + "maxLength": 4 + } + ] + }, + "tests": [ + { + "description": "mismatch base schema", + "data": 3, + "valid": false + }, + { + "description": "one oneOf valid", + "data": "foobar", + "valid": true + }, + { + "description": "both oneOf valid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with boolean schemas, all true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + true, + true, + true + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with boolean schemas, one true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + true, + false, + false + ] + }, + "tests": [ + { + "description": "any value is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "oneOf.json: oneOf with boolean schemas, more than one true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + true, + true, + false + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with boolean schemas, all false", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + false, + false, + false + ] + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf complex types", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "properties": { + "bar": { + "type": "integer" + } + }, + "required": [ + "bar" + ] + }, + { + "properties": { + "foo": { + "type": "string" + } + }, + "required": [ + "foo" + ] + } + ] + }, + "tests": [ + { + "description": "first oneOf valid (complex)", + "data": { + "bar": 2 + }, + "valid": true + }, + { + "description": "second oneOf valid (complex)", + "data": { + "foo": "baz" + }, + "valid": true + }, + { + "description": "both oneOf valid (complex)", + "data": { + "foo": "baz", + "bar": 2 + }, + "valid": false + }, + { + "description": "neither oneOf valid (complex)", + "data": { + "foo": 2, + "bar": "quux" + }, + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with empty schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "number" + }, + {} + ] + }, + "tests": [ + { + "description": "one valid - valid", + "data": "foo", + "valid": true + }, + { + "description": "both valid - invalid", + "data": 123, + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with required", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "oneOf": [ + { + "required": [ + "foo", + "bar" + ] + }, + { + "required": [ + "foo", + "baz" + ] + } + ] + }, + "tests": [ + { + "description": "both invalid - invalid", + "data": { + "bar": 2 + }, + "valid": false + }, + { + "description": "first valid - valid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": true + }, + { + "description": "second valid - valid", + "data": { + "foo": 1, + "baz": 3 + }, + "valid": true + }, + { + "description": "both valid - invalid", + "data": { + "foo": 1, + "bar": 2, + "baz": 3 + }, + "valid": false + } + ] + }, + { + "description": "oneOf.json: oneOf with missing optional property", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "properties": { + "bar": true, + "baz": true + }, + "required": [ + "bar" + ] + }, + { + "properties": { + "foo": true + }, + "required": [ + "foo" + ] + } + ] + }, + "tests": [ + { + "description": "first oneOf valid", + "data": { + "bar": 8 + }, + "valid": true + }, + { + "description": "second oneOf valid", + "data": { + "foo": "foo" + }, + "valid": true + }, + { + "description": "both oneOf valid", + "data": { + "foo": "foo", + "bar": 8 + }, + "valid": false + }, + { + "description": "neither oneOf valid", + "data": { + "baz": "quux" + }, + "valid": false + } + ] + }, + { + "description": "oneOf.json: nested oneOf, to check validation semantics", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "oneOf": [ + { + "type": "null" + } + ] + } + ] + }, + "tests": [ + { + "description": "null is valid", + "data": null, + "valid": true + }, + { + "description": "anything non-null is invalid", + "data": 123, + "valid": false + } + ] + }, + { + "description": "pattern.json: pattern validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "pattern": "^a*$" + }, + "tests": [ + { + "description": "a matching pattern is valid", + "data": "aaa", + "valid": true + }, + { + "description": "a non-matching pattern is invalid", + "data": "abc", + "valid": false + }, + { + "description": "ignores booleans", + "data": true, + "valid": true + }, + { + "description": "ignores integers", + "data": 123, + "valid": true + }, + { + "description": "ignores floats", + "data": 1.0, + "valid": true + }, + { + "description": "ignores objects", + "data": {}, + "valid": true + }, + { + "description": "ignores arrays", + "data": [], + "valid": true + }, + { + "description": "ignores null", + "data": null, + "valid": true + } + ] + }, + { + "description": "pattern.json: pattern is not anchored", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "pattern": "a+" + }, + "tests": [ + { + "description": "matches a substring", + "data": "xxaayy", + "valid": true + } + ] + }, + { + "description": "pattern.json: pattern with Unicode property escape requires unicode mode", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "pattern": "^\\p{Letter}+$" + }, + "tests": [ + { + "description": "ASCII letters match", + "data": "Hello", + "valid": true + }, + { + "description": "Non-ASCII letters match", + "data": "π", + "valid": true + }, + { + "description": "Digits do not match", + "data": "123", + "valid": false + } + ] + }, + { + "description": "patternProperties.json: patternProperties validates properties matching a regex", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "patternProperties": { + "f.*o": { + "type": "integer" + } + } + }, + "tests": [ + { + "description": "a single valid match is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "multiple valid matches is valid", + "data": { + "foo": 1, + "foooooo": 2 + }, + "valid": true + }, + { + "description": "a single invalid match is invalid", + "data": { + "foo": "bar", + "fooooo": 2 + }, + "valid": false + }, + { + "description": "multiple invalid matches is invalid", + "data": { + "foo": "bar", + "foooooo": "baz" + }, + "valid": false + }, + { + "description": "ignores arrays", + "data": [ + "foo" + ], + "valid": true + }, + { + "description": "ignores strings", + "data": "foo", + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + } + ] + }, + { + "description": "patternProperties.json: multiple simultaneous patternProperties are validated", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "patternProperties": { + "a*": { + "type": "integer" + }, + "aaa*": { + "maximum": 20 + } + } + }, + "tests": [ + { + "description": "a single valid match is valid", + "data": { + "a": 21 + }, + "valid": true + }, + { + "description": "a simultaneous match is valid", + "data": { + "aaaa": 18 + }, + "valid": true + }, + { + "description": "multiple matches is valid", + "data": { + "a": 21, + "aaaa": 18 + }, + "valid": true + }, + { + "description": "an invalid due to one is invalid", + "data": { + "a": "bar" + }, + "valid": false + }, + { + "description": "an invalid due to the other is invalid", + "data": { + "aaaa": 31 + }, + "valid": false + }, + { + "description": "an invalid due to both is invalid", + "data": { + "aaa": "foo", + "aaaa": 31 + }, + "valid": false + } + ] + }, + { + "description": "patternProperties.json: regexes are not anchored by default and are case sensitive", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "patternProperties": { + "[0-9]{2,}": { + "type": "boolean" + }, + "X_": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "non recognized members are ignored", + "data": { + "answer 1": "42" + }, + "valid": true + }, + { + "description": "recognized members are accounted for", + "data": { + "a31b": null + }, + "valid": false + }, + { + "description": "regexes are case sensitive", + "data": { + "a_x_3": 3 + }, + "valid": true + }, + { + "description": "regexes are case sensitive, 2", + "data": { + "a_X_3": 3 + }, + "valid": false + } + ] + }, + { + "description": "patternProperties.json: patternProperties with boolean schemas", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "patternProperties": { + "f.*": true, + "b.*": false + } + }, + "tests": [ + { + "description": "object with property matching schema true is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "object with property matching schema false is invalid", + "data": { + "bar": 2 + }, + "valid": false + }, + { + "description": "object with both properties is invalid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": false + }, + { + "description": "object with a property matching both true and false is invalid", + "data": { + "foobar": 1 + }, + "valid": false + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true + } + ] + }, + { + "description": "patternProperties.json: patternProperties with null valued instance properties", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "patternProperties": { + "^.*bar$": { + "type": "null" + } + } + }, + "tests": [ + { + "description": "allows null values", + "data": { + "foobar": null + }, + "valid": true + } + ] + }, + { + "description": "patternProperties.json: patternProperties with Unicode property escape", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "patternProperties": { + "^\\p{Letter}+$": { + "type": "number" + } + } + }, + "tests": [ + { + "description": "Unicode letter property name matches", + "data": { + "π": 1 + }, + "valid": true + }, + { + "description": "Non-letter property name does not match pattern", + "data": { + "123": 1 + }, + "valid": true + } + ] + }, + { + "description": "prefixItems.json: a schema given for prefixItems", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "tests": [ + { + "description": "correct types", + "data": [ + 1, + "foo" + ], + "valid": true + }, + { + "description": "wrong types", + "data": [ + "foo", + 1 + ], + "valid": false + }, + { + "description": "incomplete array of items", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "array with additional items", + "data": [ + 1, + "foo", + true + ], + "valid": true + }, + { + "description": "empty array", + "data": [], + "valid": true + }, + { + "description": "JavaScript pseudo-array is valid", + "data": { + "0": "invalid", + "1": "valid", + "length": 2 + }, + "valid": true + } + ] + }, + { + "description": "prefixItems.json: prefixItems with boolean schemas", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + true, + false + ] + }, + "tests": [ + { + "description": "array with one item is valid", + "data": [ + 1 + ], + "valid": true + }, + { + "description": "array with two items is invalid", + "data": [ + 1, + "foo" + ], + "valid": false + }, + { + "description": "empty array is valid", + "data": [], + "valid": true + } + ] + }, + { + "description": "prefixItems.json: additional items are allowed by default", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + { + "type": "integer" + } + ] + }, + "tests": [ + { + "description": "only the first item is validated", + "data": [ + 1, + "foo", + false + ], + "valid": true + } + ] + }, + { + "description": "prefixItems.json: prefixItems with null instance elements", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "prefixItems": [ + { + "type": "null" + } + ] + }, + "tests": [ + { + "description": "allows null elements", + "data": [ + null + ], + "valid": true + } + ] + }, + { + "description": "properties.json: object properties validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "type": "integer" + }, + "bar": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "both properties present and valid is valid", + "data": { + "foo": 1, + "bar": "baz" + }, + "valid": true + }, + { + "description": "one property invalid is invalid", + "data": { + "foo": 1, + "bar": {} + }, + "valid": false + }, + { + "description": "both properties invalid is invalid", + "data": { + "foo": [], + "bar": {} + }, + "valid": false + }, + { + "description": "doesn't invalidate other properties", + "data": { + "quux": [] + }, + "valid": true + }, + { + "description": "ignores arrays", + "data": [], + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + } + ] + }, + { + "description": "properties.json: properties with boolean schema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": true, + "bar": false + } + }, + "tests": [ + { + "description": "no property present is valid", + "data": {}, + "valid": true + }, + { + "description": "only 'true' property present is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "only 'false' property present is invalid", + "data": { + "bar": 2 + }, + "valid": false + }, + { + "description": "both properties present is invalid", + "data": { + "foo": 1, + "bar": 2 + }, + "valid": false + } + ] + }, + { + "description": "properties.json: properties with escaped characters", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo\nbar": { + "type": "number" + }, + "foo\"bar": { + "type": "number" + }, + "foo\\bar": { + "type": "number" + }, + "foo\rbar": { + "type": "number" + }, + "foo\tbar": { + "type": "number" + }, + "foo\fbar": { + "type": "number" + } + } + }, + "tests": [ + { + "description": "object with all numbers is valid", + "data": { + "foo\nbar": 1, + "foo\"bar": 1, + "foo\\bar": 1, + "foo\rbar": 1, + "foo\tbar": 1, + "foo\fbar": 1 + }, + "valid": true + }, + { + "description": "object with strings is invalid", + "data": { + "foo\nbar": "1", + "foo\"bar": "1", + "foo\\bar": "1", + "foo\rbar": "1", + "foo\tbar": "1", + "foo\fbar": "1" + }, + "valid": false + } + ] + }, + { + "description": "properties.json: properties with null valued instance properties", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "type": "null" + } + } + }, + "tests": [ + { + "description": "allows null values", + "data": { + "foo": null + }, + "valid": true + } + ] + }, + { + "description": "properties.json: properties whose names are Javascript object property names", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "__proto__": { + "type": "number" + }, + "toString": { + "properties": { + "length": { + "type": "string" + } + } + }, + "constructor": { + "type": "number" + } + } + }, + "tests": [ + { + "description": "ignores arrays", + "data": [], + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + }, + { + "description": "none of the properties mentioned", + "data": {}, + "valid": true + }, + { + "description": "__proto__ not valid", + "data": { + "__proto__": "foo" + }, + "valid": false + }, + { + "description": "toString not valid", + "data": { + "toString": { + "length": 37 + } + }, + "valid": false + }, + { + "description": "constructor not valid", + "data": { + "constructor": { + "length": 37 + } + }, + "valid": false + }, + { + "description": "all present and valid", + "data": { + "__proto__": 12, + "toString": { + "length": "foo" + }, + "constructor": 37 + }, + "valid": true + } + ] + }, + { + "description": "propertyNames.json: propertyNames validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": { + "maxLength": 3 + } + }, + "tests": [ + { + "description": "all property names valid", + "data": { + "f": {}, + "foo": {} + }, + "valid": true + }, + { + "description": "some property names invalid", + "data": { + "foo": {}, + "foobar": {} + }, + "valid": false + }, + { + "description": "object without properties is valid", + "data": {}, + "valid": true + }, + { + "description": "ignores arrays", + "data": [ + 1, + 2, + 3, + 4 + ], + "valid": true + }, + { + "description": "ignores strings", + "data": "foobar", + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + }, + { + "description": "ignores null", + "data": null, + "valid": true + }, + { + "description": "ignores booleans", + "data": true, + "valid": true + } + ] + }, + { + "description": "propertyNames.json: propertyNames validation with pattern", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": { + "pattern": "^a+$" + } + }, + "tests": [ + { + "description": "matching property names valid", + "data": { + "a": {}, + "aa": {}, + "aaa": {} + }, + "valid": true + }, + { + "description": "non-matching property name is invalid", + "data": { + "aaA": {} + }, + "valid": false + }, + { + "description": "object without properties is valid", + "data": {}, + "valid": true + } + ] + }, + { + "description": "propertyNames.json: propertyNames with boolean schema true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": true + }, + "tests": [ + { + "description": "object with any properties is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true + } + ] + }, + { + "description": "propertyNames.json: propertyNames with boolean schema false", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": false + }, + "tests": [ + { + "description": "object with any properties is invalid", + "data": { + "foo": 1 + }, + "valid": false + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true + } + ] + }, + { + "description": "propertyNames.json: propertyNames with const", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": { + "const": "foo" + } + }, + "tests": [ + { + "description": "object with property foo is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "object with any other property is invalid", + "data": { + "bar": 1 + }, + "valid": false + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true + } + ] + }, + { + "description": "propertyNames.json: propertyNames with enum", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": { + "enum": [ + "foo", + "bar" + ] + } + }, + "tests": [ + { + "description": "object with property foo is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "object with property foo and bar is valid", + "data": { + "foo": 1, + "bar": 1 + }, + "valid": true + }, + { + "description": "object with any other property is invalid", + "data": { + "baz": 1 + }, + "valid": false + }, + { + "description": "empty object is valid", + "data": {}, + "valid": true + } + ] + }, + { + "description": "ref.json: root pointer ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "$ref": "#" + } + }, + "additionalProperties": false + }, + "tests": [ + { + "description": "match", + "data": { + "foo": false + }, + "valid": true + }, + { + "description": "recursive match", + "data": { + "foo": { + "foo": false + } + }, + "valid": true + }, + { + "description": "mismatch", + "data": { + "bar": false + }, + "valid": false + }, + { + "description": "recursive mismatch", + "data": { + "foo": { + "bar": false + } + }, + "valid": false + } + ] + }, + { + "description": "ref.json: relative pointer ref to object", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "type": "integer" + }, + "bar": { + "$ref": "#/properties/foo" + } + } + }, + "tests": [ + { + "description": "match", + "data": { + "bar": 3 + }, + "valid": true + }, + { + "description": "mismatch", + "data": { + "bar": true + }, + "valid": false + } + ] + }, + { + "description": "ref.json: escaped pointer ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "tilde~field": { + "type": "integer" + }, + "slash/field": { + "type": "integer" + }, + "percent%field": { + "type": "integer" + } + }, + "properties": { + "tilde": { + "$ref": "#/$defs/tilde~0field" + }, + "slash": { + "$ref": "#/$defs/slash~1field" + }, + "percent": { + "$ref": "#/$defs/percent%25field" + } + } + }, + "tests": [ + { + "description": "slash invalid", + "data": { + "slash": "aoeu" + }, + "valid": false + }, + { + "description": "tilde invalid", + "data": { + "tilde": "aoeu" + }, + "valid": false + }, + { + "description": "percent invalid", + "data": { + "percent": "aoeu" + }, + "valid": false + }, + { + "description": "slash valid", + "data": { + "slash": 123 + }, + "valid": true + }, + { + "description": "tilde valid", + "data": { + "tilde": 123 + }, + "valid": true + }, + { + "description": "percent valid", + "data": { + "percent": 123 + }, + "valid": true + } + ] + }, + { + "description": "ref.json: nested refs", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a": { + "type": "integer" + }, + "b": { + "$ref": "#/$defs/a" + }, + "c": { + "$ref": "#/$defs/b" + } + }, + "$ref": "#/$defs/c" + }, + "tests": [ + { + "description": "nested ref valid", + "data": 5, + "valid": true + }, + { + "description": "nested ref invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "ref.json: ref applies alongside sibling keywords", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "reffed": { + "type": "array" + } + }, + "properties": { + "foo": { + "$ref": "#/$defs/reffed", + "maxItems": 2 + } + } + }, + "tests": [ + { + "description": "ref valid, maxItems valid", + "data": { + "foo": [] + }, + "valid": true + }, + { + "description": "ref valid, maxItems invalid", + "data": { + "foo": [ + 1, + 2, + 3 + ] + }, + "valid": false + }, + { + "description": "ref invalid", + "data": { + "foo": "string" + }, + "valid": false + } + ] + }, + { + "description": "ref.json: property named $ref that is not a reference", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "$ref": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "property named $ref valid", + "data": { + "$ref": "a" + }, + "valid": true + }, + { + "description": "property named $ref invalid", + "data": { + "$ref": 2 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: property named $ref, containing an actual $ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "$ref": { + "$ref": "#/$defs/is-string" + } + }, + "$defs": { + "is-string": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "property named $ref valid", + "data": { + "$ref": "a" + }, + "valid": true + }, + { + "description": "property named $ref invalid", + "data": { + "$ref": 2 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: $ref to boolean schema true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$defs/bool", + "$defs": { + "bool": true + } + }, + "tests": [ + { + "description": "any value is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "ref.json: $ref to boolean schema false", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "#/$defs/bool", + "$defs": { + "bool": false + } + }, + "tests": [ + { + "description": "any value is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "ref.json: Recursive references between schemas", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/tree", + "description": "tree of nodes", + "type": "object", + "properties": { + "meta": { + "type": "string" + }, + "nodes": { + "type": "array", + "items": { + "$ref": "node" + } + } + }, + "required": [ + "meta", + "nodes" + ], + "$defs": { + "node": { + "$id": "http://localhost:1234/draft2020-12/node", + "description": "node", + "type": "object", + "properties": { + "value": { + "type": "number" + }, + "subtree": { + "$ref": "tree" + } + }, + "required": [ + "value" + ] + } + } + }, + "tests": [ + { + "description": "valid tree", + "data": { + "meta": "root", + "nodes": [ + { + "value": 1, + "subtree": { + "meta": "child", + "nodes": [ + { + "value": 1.1 + }, + { + "value": 1.2 + } + ] + } + }, + { + "value": 2, + "subtree": { + "meta": "child", + "nodes": [ + { + "value": 2.1 + }, + { + "value": 2.2 + } + ] + } + } + ] + }, + "valid": true + }, + { + "description": "invalid tree", + "data": { + "meta": "root", + "nodes": [ + { + "value": 1, + "subtree": { + "meta": "child", + "nodes": [ + { + "value": "string is invalid" + }, + { + "value": 1.2 + } + ] + } + }, + { + "value": 2, + "subtree": { + "meta": "child", + "nodes": [ + { + "value": 2.1 + }, + { + "value": 2.2 + } + ] + } + } + ] + }, + "valid": false + } + ] + }, + { + "description": "ref.json: refs with quote", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo\"bar": { + "$ref": "#/$defs/foo%22bar" + } + }, + "$defs": { + "foo\"bar": { + "type": "number" + } + } + }, + "tests": [ + { + "description": "object with numbers is valid", + "data": { + "foo\"bar": 1 + }, + "valid": true + }, + { + "description": "object with strings is invalid", + "data": { + "foo\"bar": "1" + }, + "valid": false + } + ] + }, + { + "description": "ref.json: naive replacement of $ref with its destination is not correct", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "a_string": { + "type": "string" + } + }, + "enum": [ + { + "$ref": "#/$defs/a_string" + } + ] + }, + "tests": [ + { + "description": "do not evaluate the $ref inside the enum, matching any string", + "data": "this is a string", + "valid": false + }, + { + "description": "do not evaluate the $ref inside the enum, definition exact match", + "data": { + "type": "string" + }, + "valid": false + }, + { + "description": "match the enum exactly", + "data": { + "$ref": "#/$defs/a_string" + }, + "valid": true + } + ] + }, + { + "description": "ref.json: refs with relative uris and defs", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://example.com/schema-relative-uri-defs1.json", + "properties": { + "foo": { + "$id": "schema-relative-uri-defs2.json", + "$defs": { + "inner": { + "properties": { + "bar": { + "type": "string" + } + } + } + }, + "$ref": "#/$defs/inner" + } + }, + "$ref": "schema-relative-uri-defs2.json" + }, + "tests": [ + { + "description": "invalid on inner field", + "data": { + "foo": { + "bar": 1 + }, + "bar": "a" + }, + "valid": false + }, + { + "description": "invalid on outer field", + "data": { + "foo": { + "bar": "a" + }, + "bar": 1 + }, + "valid": false + }, + { + "description": "valid on both fields", + "data": { + "foo": { + "bar": "a" + }, + "bar": "a" + }, + "valid": true + } + ] + }, + { + "description": "ref.json: relative refs with absolute uris and defs", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://example.com/schema-refs-absolute-uris-defs1.json", + "properties": { + "foo": { + "$id": "http://example.com/schema-refs-absolute-uris-defs2.json", + "$defs": { + "inner": { + "properties": { + "bar": { + "type": "string" + } + } + } + }, + "$ref": "#/$defs/inner" + } + }, + "$ref": "schema-refs-absolute-uris-defs2.json" + }, + "tests": [ + { + "description": "invalid on inner field", + "data": { + "foo": { + "bar": 1 + }, + "bar": "a" + }, + "valid": false + }, + { + "description": "invalid on outer field", + "data": { + "foo": { + "bar": "a" + }, + "bar": 1 + }, + "valid": false + }, + { + "description": "valid on both fields", + "data": { + "foo": { + "bar": "a" + }, + "bar": "a" + }, + "valid": true + } + ] + }, + { + "description": "ref.json: $id must be resolved against nearest parent, not just immediate parent", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://example.com/a.json", + "$defs": { + "x": { + "$id": "http://example.com/b/c.json", + "not": { + "$defs": { + "y": { + "$id": "d.json", + "type": "number" + } + } + } + } + }, + "allOf": [ + { + "$ref": "http://example.com/b/d.json" + } + ] + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "non-number is invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "ref.json: order of evaluation: $id and $ref", + "schema": { + "$comment": "$id must be evaluated before $ref to get the proper $ref destination", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/draft2020-12/ref-and-id1/base.json", + "$ref": "int.json", + "$defs": { + "bigint": { + "$comment": "canonical uri: https://example.com/ref-and-id1/int.json", + "$id": "int.json", + "maximum": 10 + }, + "smallint": { + "$comment": "canonical uri: https://example.com/ref-and-id1-int.json", + "$id": "/draft2020-12/ref-and-id1-int.json", + "maximum": 2 + } + } + }, + "tests": [ + { + "description": "data is valid against first definition", + "data": 5, + "valid": true + }, + { + "description": "data is invalid against first definition", + "data": 50, + "valid": false + } + ] + }, + { + "description": "ref.json: order of evaluation: $id and $anchor and $ref", + "schema": { + "$comment": "$id must be evaluated before $ref to get the proper $ref destination", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/draft2020-12/ref-and-id2/base.json", + "$ref": "#bigint", + "$defs": { + "bigint": { + "$comment": "canonical uri: /ref-and-id2/base.json#/$defs/bigint; another valid uri for this location: /ref-and-id2/base.json#bigint", + "$anchor": "bigint", + "maximum": 10 + }, + "smallint": { + "$comment": "canonical uri: https://example.com/ref-and-id2#/$defs/smallint; another valid uri for this location: https://example.com/ref-and-id2/#bigint", + "$id": "https://example.com/draft2020-12/ref-and-id2/", + "$anchor": "bigint", + "maximum": 2 + } + } + }, + "tests": [ + { + "description": "data is valid against first definition", + "data": 5, + "valid": true + }, + { + "description": "data is invalid against first definition", + "data": 50, + "valid": false + } + ] + }, + { + "description": "ref.json: order of evaluation: $id and $ref on nested schema", + "schema": { + "$comment": "$id must be evaluated before $ref to get the proper $ref destination", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/draft2020-12/ref-and-id3/base.json", + "$ref": "nested/foo.json", + "$defs": { + "foo": { + "$comment": "canonical uri: https://example.com/draft2020-12/ref-and-id3/nested/foo.json", + "$id": "nested/foo.json", + "$ref": "./bar.json" + }, + "bar": { + "$comment": "canonical uri: https://example.com/draft2020-12/ref-and-id3/nested/bar.json", + "$id": "nested/bar.json", + "type": "number" + } + } + }, + "tests": [ + { + "description": "data is valid against nested sibling", + "data": 5, + "valid": true + }, + { + "description": "data is invalid against nested sibling", + "data": "a", + "valid": false + } + ] + }, + { + "description": "ref.json: simple URN base URI with $ref via the URN", + "schema": { + "$comment": "URIs do not have to have HTTP(s) schemes", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:uuid:deadbeef-1234-ffff-ffff-4321feebdaed", + "minimum": 30, + "properties": { + "foo": { + "$ref": "urn:uuid:deadbeef-1234-ffff-ffff-4321feebdaed" + } + } + }, + "tests": [ + { + "description": "valid under the URN IDed schema", + "data": { + "foo": 37 + }, + "valid": true + }, + { + "description": "invalid under the URN IDed schema", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: simple URN base URI with JSON pointer", + "schema": { + "$comment": "URIs do not have to have HTTP(s) schemes", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:uuid:deadbeef-1234-00ff-ff00-4321feebdaed", + "properties": { + "foo": { + "$ref": "#/$defs/bar" + } + }, + "$defs": { + "bar": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "a non-string is invalid", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: URN base URI with NSS", + "schema": { + "$comment": "RFC 8141 §2.2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:example:1/406/47452/2", + "properties": { + "foo": { + "$ref": "#/$defs/bar" + } + }, + "$defs": { + "bar": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "a non-string is invalid", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: URN base URI with r-component", + "schema": { + "$comment": "RFC 8141 §2.3.1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:example:foo-bar-baz-qux?+CCResolve:cc=uk", + "properties": { + "foo": { + "$ref": "#/$defs/bar" + } + }, + "$defs": { + "bar": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "a non-string is invalid", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: URN base URI with q-component", + "schema": { + "$comment": "RFC 8141 §2.3.2", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:example:weather?=op=map&lat=39.56&lon=-104.85&datetime=1969-07-21T02:56:15Z", + "properties": { + "foo": { + "$ref": "#/$defs/bar" + } + }, + "$defs": { + "bar": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "a non-string is invalid", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: URN base URI with URN and JSON pointer ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:uuid:deadbeef-1234-0000-0000-4321feebdaed", + "properties": { + "foo": { + "$ref": "urn:uuid:deadbeef-1234-0000-0000-4321feebdaed#/$defs/bar" + } + }, + "$defs": { + "bar": { + "type": "string" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "a non-string is invalid", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: URN base URI with URN and anchor ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:uuid:deadbeef-1234-ff00-00ff-4321feebdaed", + "properties": { + "foo": { + "$ref": "urn:uuid:deadbeef-1234-ff00-00ff-4321feebdaed#something" + } + }, + "$defs": { + "bar": { + "$anchor": "something", + "type": "string" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": { + "foo": "bar" + }, + "valid": true + }, + { + "description": "a non-string is invalid", + "data": { + "foo": 12 + }, + "valid": false + } + ] + }, + { + "description": "ref.json: URN ref with nested pointer ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "urn:uuid:deadbeef-4321-ffff-ffff-1234feebdaed", + "$defs": { + "foo": { + "$id": "urn:uuid:deadbeef-4321-ffff-ffff-1234feebdaed", + "$defs": { + "bar": { + "type": "string" + } + }, + "$ref": "#/$defs/bar" + } + } + }, + "tests": [ + { + "description": "a string is valid", + "data": "bar", + "valid": true + }, + { + "description": "a non-string is invalid", + "data": 12, + "valid": false + } + ] + }, + { + "description": "ref.json: ref with absolute-path-reference", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://example.com/ref/absref.json", + "$defs": { + "a": { + "$id": "http://example.com/ref/absref/foobar.json", + "type": "number" + }, + "b": { + "$id": "http://example.com/absref/foobar.json", + "type": "string" + } + }, + "$ref": "/absref/foobar.json" + }, + "tests": [ + { + "description": "a string is valid", + "data": "foo", + "valid": true + }, + { + "description": "an integer is invalid", + "data": 12, + "valid": false + } + ] + }, + { + "description": "ref.json: $id with file URI still resolves pointers - *nix", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "file:///folder/file.json", + "$defs": { + "foo": { + "type": "number" + } + }, + "$ref": "#/$defs/foo" + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "non-number is invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "ref.json: $id with file URI still resolves pointers - windows", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "file:///c:/folder/file.json", + "$defs": { + "foo": { + "type": "number" + } + }, + "$ref": "#/$defs/foo" + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "non-number is invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "ref.json: empty tokens in $ref json-pointer", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "": { + "$defs": { + "": { + "type": "number" + } + } + } + }, + "allOf": [ + { + "$ref": "#/$defs//$defs/" + } + ] + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "non-number is invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "refRemote.json: remote ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/integer.json" + }, + "documents": { + "http://localhost:1234/draft2020-12/integer.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + } + }, + "tests": [ + { + "description": "remote ref valid", + "data": 1, + "valid": true + }, + { + "description": "remote ref invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "refRemote.json: fragment within remote ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/subSchemas.json#/$defs/integer" + }, + "documents": { + "http://localhost:1234/draft2020-12/subSchemas.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "integer": { + "type": "integer" + }, + "refToInteger": { + "$ref": "#/$defs/integer" + } + } + } + }, + "tests": [ + { + "description": "remote fragment valid", + "data": 1, + "valid": true + }, + { + "description": "remote fragment invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "refRemote.json: anchor within remote ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json#foo" + }, + "documents": { + "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "refToInteger": { + "$ref": "#foo" + }, + "A": { + "$anchor": "foo", + "type": "integer" + } + } + } + }, + "tests": [ + { + "description": "remote anchor valid", + "data": 1, + "valid": true + }, + { + "description": "remote anchor invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "refRemote.json: ref within remote ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/subSchemas.json#/$defs/refToInteger" + }, + "documents": { + "http://localhost:1234/draft2020-12/subSchemas.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "integer": { + "type": "integer" + }, + "refToInteger": { + "$ref": "#/$defs/integer" + } + } + } + }, + "tests": [ + { + "description": "ref within ref valid", + "data": 1, + "valid": true + }, + { + "description": "ref within ref invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "refRemote.json: base URI change", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/", + "items": { + "$id": "baseUriChange/", + "items": { + "$ref": "folderInteger.json" + } + } + }, + "documents": { + "http://localhost:1234/draft2020-12/baseUriChange/folderInteger.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + } + }, + "tests": [ + { + "description": "base URI change ref valid", + "data": [ + [ + 1 + ] + ], + "valid": true + }, + { + "description": "base URI change ref invalid", + "data": [ + [ + "a" + ] + ], + "valid": false + } + ] + }, + { + "description": "refRemote.json: base URI change - change folder", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/scope_change_defs1.json", + "type": "object", + "properties": { + "list": { + "$ref": "baseUriChangeFolder/" + } + }, + "$defs": { + "baz": { + "$id": "baseUriChangeFolder/", + "type": "array", + "items": { + "$ref": "folderInteger.json" + } + } + } + }, + "documents": { + "http://localhost:1234/draft2020-12/baseUriChangeFolder/folderInteger.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + } + }, + "tests": [ + { + "description": "number is valid", + "data": { + "list": [ + 1 + ] + }, + "valid": true + }, + { + "description": "string is invalid", + "data": { + "list": [ + "a" + ] + }, + "valid": false + } + ] + }, + { + "description": "refRemote.json: base URI change - change folder in subschema", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/scope_change_defs2.json", + "type": "object", + "properties": { + "list": { + "$ref": "baseUriChangeFolderInSubschema/#/$defs/bar" + } + }, + "$defs": { + "baz": { + "$id": "baseUriChangeFolderInSubschema/", + "$defs": { + "bar": { + "type": "array", + "items": { + "$ref": "folderInteger.json" + } + } + } + } + } + }, + "documents": { + "http://localhost:1234/draft2020-12/baseUriChangeFolderInSubschema/folderInteger.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + } + }, + "tests": [ + { + "description": "number is valid", + "data": { + "list": [ + 1 + ] + }, + "valid": true + }, + { + "description": "string is invalid", + "data": { + "list": [ + "a" + ] + }, + "valid": false + } + ] + }, + { + "description": "refRemote.json: root ref in remote ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/object", + "type": "object", + "properties": { + "name": { + "$ref": "name-defs.json#/$defs/orNull" + } + } + }, + "documents": { + "http://localhost:1234/draft2020-12/name-defs.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "orNull": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#" + } + ] + } + }, + "type": "string" + } + }, + "tests": [ + { + "description": "string is valid", + "data": { + "name": "foo" + }, + "valid": true + }, + { + "description": "null is valid", + "data": { + "name": null + }, + "valid": true + }, + { + "description": "object is invalid", + "data": { + "name": { + "name": null + } + }, + "valid": false + } + ] + }, + { + "description": "refRemote.json: remote ref with ref to defs", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/schema-remote-ref-ref-defs1.json", + "$ref": "ref-and-defs.json" + }, + "documents": { + "http://localhost:1234/draft2020-12/ref-and-defs.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/ref-and-defs.json", + "$defs": { + "inner": { + "properties": { + "bar": { + "type": "string" + } + } + } + }, + "$ref": "#/$defs/inner" + } + }, + "tests": [ + { + "description": "invalid", + "data": { + "bar": 1 + }, + "valid": false + }, + { + "description": "valid", + "data": { + "bar": "a" + }, + "valid": true + } + ] + }, + { + "description": "refRemote.json: Location-independent identifier in remote ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json#/$defs/refToInteger" + }, + "documents": { + "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "refToInteger": { + "$ref": "#foo" + }, + "A": { + "$anchor": "foo", + "type": "integer" + } + } + } + }, + "tests": [ + { + "description": "integer is valid", + "data": 1, + "valid": true + }, + { + "description": "string is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "refRemote.json: retrieved nested refs resolve relative to their URI not $id", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "http://localhost:1234/draft2020-12/some-id", + "properties": { + "name": { + "$ref": "nested/foo-ref-string.json" + } + } + }, + "documents": { + "http://localhost:1234/draft2020-12/nested/foo-ref-string.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "foo": { + "$ref": "string.json" + } + } + }, + "http://localhost:1234/draft2020-12/nested/string.json": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string" + } + }, + "tests": [ + { + "description": "number is invalid", + "data": { + "name": { + "foo": 1 + } + }, + "valid": false + }, + { + "description": "string is valid", + "data": { + "name": { + "foo": "a" + } + }, + "valid": true + } + ] + }, + { + "description": "refRemote.json: remote HTTP ref with different $id", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/different-id-ref-string.json" + }, + "documents": { + "http://localhost:1234/draft2020-12/different-id-ref-string.json": { + "$id": "http://localhost:1234/draft2020-12/real-id-ref-string.json", + "$defs": { + "bar": { + "type": "string" + } + }, + "$ref": "#/$defs/bar" + } + }, + "tests": [ + { + "description": "number is invalid", + "data": 1, + "valid": false + }, + { + "description": "string is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "refRemote.json: remote HTTP ref with different URN $id", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/urn-ref-string.json" + }, + "documents": { + "http://localhost:1234/draft2020-12/urn-ref-string.json": { + "$id": "urn:uuid:feebdaed-ffff-0000-2020-1200deadbeef", + "$defs": { + "bar": { + "type": "string" + } + }, + "$ref": "#/$defs/bar" + } + }, + "tests": [ + { + "description": "number is invalid", + "data": 1, + "valid": false + }, + { + "description": "string is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "refRemote.json: remote HTTP ref with nested absolute ref", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/nested-absolute-ref-to-string.json" + }, + "documents": { + "http://localhost:1234/draft2020-12/nested-absolute-ref-to-string.json": { + "$defs": { + "bar": { + "$id": "http://localhost:1234/draft2020-12/the-nested-id.json", + "type": "string" + } + }, + "$ref": "http://localhost:1234/draft2020-12/the-nested-id.json" + } + }, + "tests": [ + { + "description": "number is invalid", + "data": 1, + "valid": false + }, + { + "description": "string is valid", + "data": "foo", + "valid": true + } + ] + }, + { + "description": "refRemote.json: $ref to $ref finds detached $anchor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$ref": "http://localhost:1234/draft2020-12/detached-ref.json#/$defs/foo" + }, + "documents": { + "http://localhost:1234/draft2020-12/detached-ref.json": { + "$id": "http://localhost:1234/draft2020-12/detached-ref.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "foo": { + "$ref": "#detached" + }, + "detached": { + "$anchor": "detached", + "type": "integer" + } + } + } + }, + "tests": [ + { + "description": "number is valid", + "data": 1, + "valid": true + }, + { + "description": "non-number is invalid", + "data": "a", + "valid": false + } + ] + }, + { + "description": "required.json: required validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": {}, + "bar": {} + }, + "required": [ + "foo" + ] + }, + "tests": [ + { + "description": "present required property is valid", + "data": { + "foo": 1 + }, + "valid": true + }, + { + "description": "non-present required property is invalid", + "data": { + "bar": 1 + }, + "valid": false + }, + { + "description": "ignores arrays", + "data": [], + "valid": true + }, + { + "description": "ignores strings", + "data": "", + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + }, + { + "description": "ignores null", + "data": null, + "valid": true + }, + { + "description": "ignores boolean", + "data": true, + "valid": true + } + ] + }, + { + "description": "required.json: required default validation", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": {} + } + }, + "tests": [ + { + "description": "not required by default", + "data": {}, + "valid": true + } + ] + }, + { + "description": "required.json: required with empty array", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": {} + }, + "required": [] + }, + "tests": [ + { + "description": "property not required", + "data": {}, + "valid": true + } + ] + }, + { + "description": "required.json: required with escaped characters", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "foo\nbar", + "foo\"bar", + "foo\\bar", + "foo\rbar", + "foo\tbar", + "foo\fbar" + ] + }, + "tests": [ + { + "description": "object with all properties present is valid", + "data": { + "foo\nbar": 1, + "foo\"bar": 1, + "foo\\bar": 1, + "foo\rbar": 1, + "foo\tbar": 1, + "foo\fbar": 1 + }, + "valid": true + }, + { + "description": "object with some properties missing is invalid", + "data": { + "foo\nbar": "1", + "foo\"bar": "1" + }, + "valid": false + } + ] + }, + { + "description": "required.json: required properties whose names are Javascript object property names", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "__proto__", + "toString", + "constructor" + ] + }, + "tests": [ + { + "description": "ignores arrays", + "data": [], + "valid": true + }, + { + "description": "ignores other non-objects", + "data": 12, + "valid": true + }, + { + "description": "none of the properties mentioned", + "data": {}, + "valid": false + }, + { + "description": "__proto__ present", + "data": { + "__proto__": "foo" + }, + "valid": false + }, + { + "description": "toString present", + "data": { + "toString": { + "length": 37 + } + }, + "valid": false + }, + { + "description": "constructor present", + "data": { + "constructor": { + "length": 37 + } + }, + "valid": false + }, + { + "description": "all present", + "data": { + "__proto__": 12, + "toString": { + "length": "foo" + }, + "constructor": 37 + }, + "valid": true + } + ] + }, + { + "description": "type.json: integer type matches integers", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer" + }, + "tests": [ + { + "description": "an integer is an integer", + "data": 1, + "valid": true + }, + { + "description": "a float with zero fractional part is an integer", + "data": 1.0, + "valid": true + }, + { + "description": "a float is not an integer", + "data": 1.1, + "valid": false + }, + { + "description": "a string is not an integer", + "data": "foo", + "valid": false + }, + { + "description": "a string is still not an integer, even if it looks like one", + "data": "1", + "valid": false }, { - "description": "second oneOf valid (complex)", - "data": { - "foo": "baz" - }, - "valid": true + "description": "an object is not an integer", + "data": {}, + "valid": false }, { - "description": "both oneOf valid (complex)", - "data": { - "foo": "baz", - "bar": 2 - }, + "description": "an array is not an integer", + "data": [], "valid": false }, { - "description": "neither oneOf valid (complex)", - "data": { - "foo": 2, - "bar": "quux" - }, + "description": "a boolean is not an integer", + "data": true, + "valid": false + }, + { + "description": "null is not an integer", + "data": null, "valid": false } ] }, { - "description": "oneOf.json: oneOf with empty schema", + "description": "type.json: number type matches numbers", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - { - "type": "number" - }, - {} - ] + "type": "number" }, "tests": [ { - "description": "one valid - valid", - "data": "foo", + "description": "an integer is a number", + "data": 1, "valid": true }, { - "description": "both valid - invalid", - "data": 123, + "description": "a float with zero fractional part is a number (and an integer)", + "data": 1.0, + "valid": true + }, + { + "description": "a float is a number", + "data": 1.1, + "valid": true + }, + { + "description": "a string is not a number", + "data": "foo", + "valid": false + }, + { + "description": "a string is still not a number, even if it looks like one", + "data": "1", + "valid": false + }, + { + "description": "an object is not a number", + "data": {}, + "valid": false + }, + { + "description": "an array is not a number", + "data": [], + "valid": false + }, + { + "description": "a boolean is not a number", + "data": true, + "valid": false + }, + { + "description": "null is not a number", + "data": null, "valid": false } ] }, { - "description": "oneOf.json: oneOf with required", + "description": "type.json: string type matches strings", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "oneOf": [ - { - "required": [ - "foo", - "bar" - ] - }, - { - "required": [ - "foo", - "baz" - ] - } - ] + "type": "string" }, "tests": [ { - "description": "both invalid - invalid", - "data": { - "bar": 2 - }, + "description": "1 is not a string", + "data": 1, "valid": false }, { - "description": "first valid - valid", - "data": { - "foo": 1, - "bar": 2 - }, + "description": "a float is not a string", + "data": 1.1, + "valid": false + }, + { + "description": "a string is a string", + "data": "foo", "valid": true }, { - "description": "second valid - valid", - "data": { - "foo": 1, - "baz": 3 - }, + "description": "a string is still a string, even if it looks like a number", + "data": "1", "valid": true }, { - "description": "both valid - invalid", - "data": { - "foo": 1, - "bar": 2, - "baz": 3 - }, + "description": "an empty string is still a string", + "data": "", + "valid": true + }, + { + "description": "an object is not a string", + "data": {}, + "valid": false + }, + { + "description": "an array is not a string", + "data": [], + "valid": false + }, + { + "description": "a boolean is not a string", + "data": true, + "valid": false + }, + { + "description": "null is not a string", + "data": null, "valid": false } ] }, { - "description": "oneOf.json: oneOf with missing optional property", + "description": "type.json: object type matches objects", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - { - "properties": { - "bar": true, - "baz": true - }, - "required": [ - "bar" - ] - }, - { - "properties": { - "foo": true - }, - "required": [ - "foo" - ] - } - ] + "type": "object" }, "tests": [ { - "description": "first oneOf valid", - "data": { - "bar": 8 - }, - "valid": true + "description": "an integer is not an object", + "data": 1, + "valid": false }, { - "description": "second oneOf valid", - "data": { - "foo": "foo" - }, + "description": "a float is not an object", + "data": 1.1, + "valid": false + }, + { + "description": "a string is not an object", + "data": "foo", + "valid": false + }, + { + "description": "an object is an object", + "data": {}, "valid": true }, { - "description": "both oneOf valid", - "data": { - "foo": "foo", - "bar": 8 - }, + "description": "an array is not an object", + "data": [], "valid": false }, { - "description": "neither oneOf valid", - "data": { - "baz": "quux" - }, + "description": "a boolean is not an object", + "data": true, + "valid": false + }, + { + "description": "null is not an object", + "data": null, "valid": false } ] }, { - "description": "oneOf.json: nested oneOf, to check validation semantics", + "description": "type.json: array type matches arrays", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "oneOf": [ - { - "oneOf": [ - { - "type": "null" - } - ] - } - ] + "type": "array" }, "tests": [ { - "description": "null is valid", - "data": null, + "description": "an integer is not an array", + "data": 1, + "valid": false + }, + { + "description": "a float is not an array", + "data": 1.1, + "valid": false + }, + { + "description": "a string is not an array", + "data": "foo", + "valid": false + }, + { + "description": "an object is not an array", + "data": {}, + "valid": false + }, + { + "description": "an array is an array", + "data": [], "valid": true }, { - "description": "anything non-null is invalid", - "data": 123, + "description": "a boolean is not an array", + "data": true, + "valid": false + }, + { + "description": "null is not an array", + "data": null, "valid": false } ] }, { - "description": "pattern.json: pattern validation", + "description": "type.json: boolean type matches booleans", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "pattern": "^a*$" + "type": "boolean" }, "tests": [ { - "description": "a matching pattern is valid", - "data": "aaa", - "valid": true + "description": "an integer is not a boolean", + "data": 1, + "valid": false }, { - "description": "a non-matching pattern is invalid", - "data": "abc", + "description": "zero is not a boolean", + "data": 0, "valid": false }, { - "description": "ignores booleans", - "data": true, - "valid": true + "description": "a float is not a boolean", + "data": 1.1, + "valid": false }, { - "description": "ignores integers", - "data": 123, - "valid": true + "description": "a string is not a boolean", + "data": "foo", + "valid": false }, { - "description": "ignores floats", - "data": 1.0, - "valid": true + "description": "an empty string is not a boolean", + "data": "", + "valid": false }, { - "description": "ignores objects", + "description": "an object is not a boolean", "data": {}, - "valid": true + "valid": false }, { - "description": "ignores arrays", + "description": "an array is not a boolean", "data": [], - "valid": true + "valid": false }, { - "description": "ignores null", - "data": null, + "description": "true is a boolean", + "data": true, "valid": true - } - ] - }, - { - "description": "pattern.json: pattern is not anchored", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "pattern": "a+" - }, - "tests": [ + }, { - "description": "matches a substring", - "data": "xxaayy", + "description": "false is a boolean", + "data": false, "valid": true + }, + { + "description": "null is not a boolean", + "data": null, + "valid": false } ] }, { - "description": "pattern.json: pattern with Unicode property escape requires unicode mode", + "description": "type.json: null type matches only the null object", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "string", - "pattern": "^\\p{Letter}+$" + "type": "null" }, "tests": [ { - "description": "ASCII letters match", - "data": "Hello", - "valid": true + "description": "an integer is not null", + "data": 1, + "valid": false }, { - "description": "Non-ASCII letters match", - "data": "π", - "valid": true + "description": "a float is not null", + "data": 1.1, + "valid": false }, { - "description": "Digits do not match", - "data": "123", + "description": "zero is not null", + "data": 0, "valid": false - } - ] - }, - { - "description": "properties.json: object properties validation", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": { - "type": "integer" - }, - "bar": { - "type": "string" - } - } - }, - "tests": [ - { - "description": "both properties present and valid is valid", - "data": { - "foo": 1, - "bar": "baz" - }, - "valid": true }, { - "description": "one property invalid is invalid", - "data": { - "foo": 1, - "bar": {} - }, + "description": "a string is not null", + "data": "foo", "valid": false }, { - "description": "both properties invalid is invalid", - "data": { - "foo": [], - "bar": {} - }, + "description": "an empty string is not null", + "data": "", "valid": false }, { - "description": "doesn't invalidate other properties", - "data": { - "quux": [] - }, - "valid": true + "description": "an object is not null", + "data": {}, + "valid": false }, { - "description": "ignores arrays", + "description": "an array is not null", "data": [], - "valid": true + "valid": false }, { - "description": "ignores other non-objects", - "data": 12, + "description": "true is not null", + "data": true, + "valid": false + }, + { + "description": "false is not null", + "data": false, + "valid": false + }, + { + "description": "null is null", + "data": null, "valid": true } ] }, { - "description": "properties.json: properties with boolean schema", + "description": "type.json: multiple types can be specified in an array", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": true, - "bar": false - } + "type": [ + "integer", + "string" + ] }, "tests": [ { - "description": "no property present is valid", - "data": {}, + "description": "an integer is valid", + "data": 1, "valid": true }, { - "description": "only 'true' property present is valid", - "data": { - "foo": 1 - }, + "description": "a string is valid", + "data": "foo", "valid": true }, { - "description": "only 'false' property present is invalid", - "data": { - "bar": 2 - }, + "description": "a float is invalid", + "data": 1.1, "valid": false }, { - "description": "both properties present is invalid", - "data": { - "foo": 1, - "bar": 2 - }, + "description": "an object is invalid", + "data": {}, "valid": false - } - ] - }, - { - "description": "properties.json: properties with escaped characters", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo\nbar": { - "type": "number" - }, - "foo\"bar": { - "type": "number" - }, - "foo\\bar": { - "type": "number" - }, - "foo\rbar": { - "type": "number" - }, - "foo\tbar": { - "type": "number" - }, - "foo\fbar": { - "type": "number" - } - } - }, - "tests": [ + }, { - "description": "object with all numbers is valid", - "data": { - "foo\nbar": 1, - "foo\"bar": 1, - "foo\\bar": 1, - "foo\rbar": 1, - "foo\tbar": 1, - "foo\fbar": 1 - }, - "valid": true + "description": "an array is invalid", + "data": [], + "valid": false }, { - "description": "object with strings is invalid", - "data": { - "foo\nbar": "1", - "foo\"bar": "1", - "foo\\bar": "1", - "foo\rbar": "1", - "foo\tbar": "1", - "foo\fbar": "1" - }, + "description": "a boolean is invalid", + "data": true, + "valid": false + }, + { + "description": "null is invalid", + "data": null, "valid": false } ] }, { - "description": "properties.json: properties with null valued instance properties", + "description": "type.json: type as array with one item", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": { - "type": "null" - } - } + "type": [ + "string" + ] }, "tests": [ { - "description": "allows null values", - "data": { - "foo": null - }, + "description": "string is valid", + "data": "foo", "valid": true + }, + { + "description": "number is invalid", + "data": 123, + "valid": false } ] }, { - "description": "properties.json: properties whose names are Javascript object property names", + "description": "type.json: type: array or object", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "__proto__": { - "type": "number" - }, - "toString": { - "properties": { - "length": { - "type": "string" - } - } - }, - "constructor": { - "type": "number" - } - } + "type": [ + "array", + "object" + ] }, "tests": [ { - "description": "ignores arrays", - "data": [], - "valid": true - }, - { - "description": "ignores other non-objects", - "data": 12, - "valid": true - }, - { - "description": "none of the properties mentioned", - "data": {}, + "description": "array is valid", + "data": [ + 1, + 2, + 3 + ], "valid": true }, { - "description": "__proto__ not valid", + "description": "object is valid", "data": { - "__proto__": "foo" + "foo": 123 }, - "valid": false + "valid": true }, { - "description": "toString not valid", - "data": { - "toString": { - "length": 37 - } - }, + "description": "number is invalid", + "data": 123, "valid": false }, { - "description": "constructor not valid", - "data": { - "constructor": { - "length": 37 - } - }, + "description": "string is invalid", + "data": "foo", "valid": false }, { - "description": "all present and valid", - "data": { - "__proto__": 12, - "toString": { - "length": "foo" - }, - "constructor": 37 - }, - "valid": true + "description": "null is invalid", + "data": null, + "valid": false } ] }, { - "description": "propertyNames.json: propertyNames validation", + "description": "type.json: type: array, object or null", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "propertyNames": { - "maxLength": 3 - } + "type": [ + "array", + "object", + "null" + ] }, "tests": [ { - "description": "all property names valid", - "data": { - "f": {}, - "foo": {} - }, + "description": "array is valid", + "data": [ + 1, + 2, + 3 + ], "valid": true }, { - "description": "some property names invalid", + "description": "object is valid", "data": { - "foo": {}, - "foobar": {} + "foo": 123 }, - "valid": false + "valid": true }, { - "description": "object without properties is valid", - "data": {}, + "description": "null is valid", + "data": null, "valid": true }, { - "description": "ignores arrays", - "data": [ - 1, - 2, - 3, - 4 - ], - "valid": true + "description": "number is invalid", + "data": 123, + "valid": false }, { - "description": "ignores strings", - "data": "foobar", + "description": "string is invalid", + "data": "foo", + "valid": false + } + ] + }, + { + "description": "unevaluatedItems.json: unevaluatedItems true", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "unevaluatedItems": true + }, + "tests": [ + { + "description": "with no unevaluated items", + "data": [], "valid": true }, { - "description": "ignores other non-objects", - "data": 12, + "description": "with unevaluated items", + "data": [ + "foo" + ], "valid": true - }, + } + ] + }, + { + "description": "unevaluatedItems.json: unevaluatedItems false", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "unevaluatedItems": false + }, + "tests": [ { - "description": "ignores null", - "data": null, + "description": "with no unevaluated items", + "data": [], "valid": true }, { - "description": "ignores booleans", - "data": true, - "valid": true + "description": "with unevaluated items", + "data": [ + "foo" + ], + "valid": false } ] }, { - "description": "propertyNames.json: propertyNames validation with pattern", + "description": "unevaluatedItems.json: unevaluatedItems as schema", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "propertyNames": { - "pattern": "^a+$" + "unevaluatedItems": { + "type": "string" } }, "tests": [ { - "description": "matching property names valid", - "data": { - "a": {}, - "aa": {}, - "aaa": {} - }, + "description": "with no unevaluated items", + "data": [], "valid": true }, { - "description": "non-matching property name is invalid", - "data": { - "aaA": {} - }, + "description": "with valid unevaluated items", + "data": [ + "foo" + ], + "valid": true + }, + { + "description": "with invalid unevaluated items", + "data": [ + 42 + ], "valid": false + } + ] + }, + { + "description": "unevaluatedItems.json: unevaluatedItems with uniform items", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "type": "string" }, + "unevaluatedItems": false + }, + "tests": [ { - "description": "object without properties is valid", - "data": {}, + "description": "unevaluatedItems doesn't apply", + "data": [ + "foo", + "bar" + ], "valid": true } ] }, { - "description": "propertyNames.json: propertyNames with boolean schema true", + "description": "unevaluatedItems.json: unevaluatedItems with tuple", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "propertyNames": true + "prefixItems": [ + { + "type": "string" + } + ], + "unevaluatedItems": false }, "tests": [ { - "description": "object with any properties is valid", - "data": { - "foo": 1 - }, + "description": "with no unevaluated items", + "data": [ + "foo" + ], "valid": true }, { - "description": "empty object is valid", - "data": {}, - "valid": true + "description": "with unevaluated items", + "data": [ + "foo", + "bar" + ], + "valid": false } ] }, { - "description": "propertyNames.json: propertyNames with boolean schema false", + "description": "unevaluatedItems.json: unevaluatedItems with items and prefixItems", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "propertyNames": false + "prefixItems": [ + { + "type": "string" + } + ], + "items": true, + "unevaluatedItems": false }, "tests": [ { - "description": "object with any properties is invalid", - "data": { - "foo": 1 - }, - "valid": false - }, - { - "description": "empty object is valid", - "data": {}, + "description": "unevaluatedItems doesn't apply", + "data": [ + "foo", + 42 + ], "valid": true } ] }, { - "description": "propertyNames.json: propertyNames with const", + "description": "unevaluatedItems.json: unevaluatedItems with items", "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "propertyNames": { - "const": "foo" + "$schema": "https://json-schema.org/draft/2020-12/schema", + "items": { + "type": "number" + }, + "unevaluatedItems": { + "type": "string" } }, "tests": [ { - "description": "object with property foo is valid", - "data": { - "foo": 1 - }, + "description": "valid under items", + "comment": "no elements are considered by unevaluatedItems", + "data": [ + 5, + 6, + 7, + 8 + ], "valid": true }, { - "description": "object with any other property is invalid", - "data": { - "bar": 1 - }, + "description": "invalid under items", + "data": [ + "foo", + "bar", + "baz" + ], "valid": false - }, - { - "description": "empty object is valid", - "data": {}, - "valid": true } ] }, { - "description": "propertyNames.json: propertyNames with enum", + "description": "unevaluatedItems.json: unevaluatedItems with nested tuple", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "propertyNames": { - "enum": [ - "foo", - "bar" - ] - } + "prefixItems": [ + { + "type": "string" + } + ], + "allOf": [ + { + "prefixItems": [ + true, + { + "type": "number" + } + ] + } + ], + "unevaluatedItems": false }, "tests": [ { - "description": "object with property foo is valid", - "data": { - "foo": 1 - }, - "valid": true - }, - { - "description": "object with property foo and bar is valid", - "data": { - "foo": 1, - "bar": 1 - }, + "description": "with no unevaluated items", + "data": [ + "foo", + 42 + ], "valid": true }, { - "description": "object with any other property is invalid", - "data": { - "baz": 1 - }, + "description": "with unevaluated items", + "data": [ + "foo", + 42, + true + ], "valid": false - }, - { - "description": "empty object is valid", - "data": {}, - "valid": true } ] }, { - "description": "ref.json: root pointer ref", + "description": "unevaluatedItems.json: unevaluatedItems with nested items", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": { - "$ref": "#" - } + "unevaluatedItems": { + "type": "boolean" }, - "additionalProperties": false + "anyOf": [ + { + "items": { + "type": "string" + } + }, + true + ] }, "tests": [ { - "description": "match", - "data": { - "foo": false - }, + "description": "with only (valid) additional items", + "data": [ + true, + false + ], "valid": true }, { - "description": "recursive match", - "data": { - "foo": { - "foo": false - } - }, + "description": "with no additional items", + "data": [ + "yes", + "no" + ], "valid": true }, { - "description": "mismatch", - "data": { - "bar": false - }, - "valid": false - }, - { - "description": "recursive mismatch", - "data": { - "foo": { - "bar": false - } - }, + "description": "with invalid additional item", + "data": [ + "yes", + false + ], "valid": false } ] }, { - "description": "ref.json: relative pointer ref to object", + "description": "unevaluatedItems.json: unevaluatedItems with nested prefixItems and items", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": { - "type": "integer" - }, - "bar": { - "$ref": "#/properties/foo" + "allOf": [ + { + "prefixItems": [ + { + "type": "string" + } + ], + "items": true } - } + ], + "unevaluatedItems": false }, "tests": [ { - "description": "match", - "data": { - "bar": 3 - }, + "description": "with no additional items", + "data": [ + "foo" + ], "valid": true }, { - "description": "mismatch", - "data": { - "bar": true - }, - "valid": false + "description": "with additional items", + "data": [ + "foo", + 42, + true + ], + "valid": true } ] }, { - "description": "ref.json: escaped pointer ref", + "description": "unevaluatedItems.json: unevaluatedItems with nested unevaluatedItems", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "tilde~field": { - "type": "integer" - }, - "slash/field": { - "type": "integer" - }, - "percent%field": { - "type": "integer" - } - }, - "properties": { - "tilde": { - "$ref": "#/$defs/tilde~0field" - }, - "slash": { - "$ref": "#/$defs/slash~1field" + "allOf": [ + { + "prefixItems": [ + { + "type": "string" + } + ] }, - "percent": { - "$ref": "#/$defs/percent%25field" + { + "unevaluatedItems": true } - } + ], + "unevaluatedItems": false }, "tests": [ { - "description": "slash invalid", - "data": { - "slash": "aoeu" - }, - "valid": false - }, - { - "description": "tilde invalid", - "data": { - "tilde": "aoeu" - }, - "valid": false - }, - { - "description": "percent invalid", - "data": { - "percent": "aoeu" - }, - "valid": false - }, - { - "description": "slash valid", - "data": { - "slash": 123 - }, - "valid": true - }, - { - "description": "tilde valid", - "data": { - "tilde": 123 - }, + "description": "with no additional items", + "data": [ + "foo" + ], "valid": true }, { - "description": "percent valid", - "data": { - "percent": 123 - }, + "description": "with additional items", + "data": [ + "foo", + 42, + true + ], "valid": true } ] }, { - "description": "ref.json: nested refs", + "description": "unevaluatedItems.json: unevaluatedItems with anyOf", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "a": { - "type": "integer" - }, - "b": { - "$ref": "#/$defs/a" + "prefixItems": [ + { + "const": "foo" + } + ], + "anyOf": [ + { + "prefixItems": [ + true, + { + "const": "bar" + } + ] }, - "c": { - "$ref": "#/$defs/b" + { + "prefixItems": [ + true, + true, + { + "const": "baz" + } + ] } + ], + "unevaluatedItems": false + }, + "tests": [ + { + "description": "when one schema matches and has no unevaluated items", + "data": [ + "foo", + "bar" + ], + "valid": true + }, + { + "description": "when one schema matches and has unevaluated items", + "data": [ + "foo", + "bar", + 42 + ], + "valid": false }, - "$ref": "#/$defs/c" - }, - "tests": [ { - "description": "nested ref valid", - "data": 5, + "description": "when two schemas match and has no unevaluated items", + "data": [ + "foo", + "bar", + "baz" + ], "valid": true }, { - "description": "nested ref invalid", - "data": "a", + "description": "when two schemas match and has unevaluated items", + "data": [ + "foo", + "bar", + "baz", + 42 + ], "valid": false } ] }, { - "description": "ref.json: ref applies alongside sibling keywords", + "description": "unevaluatedItems.json: unevaluatedItems with oneOf", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "reffed": { - "type": "array" + "prefixItems": [ + { + "const": "foo" } - }, - "properties": { - "foo": { - "$ref": "#/$defs/reffed", - "maxItems": 2 + ], + "oneOf": [ + { + "prefixItems": [ + true, + { + "const": "bar" + } + ] + }, + { + "prefixItems": [ + true, + { + "const": "baz" + } + ] } - } + ], + "unevaluatedItems": false }, "tests": [ { - "description": "ref valid, maxItems valid", - "data": { - "foo": [] - }, + "description": "with no unevaluated items", + "data": [ + "foo", + "bar" + ], "valid": true }, { - "description": "ref valid, maxItems invalid", - "data": { - "foo": [ - 1, - 2, - 3 - ] - }, - "valid": false - }, - { - "description": "ref invalid", - "data": { - "foo": "string" - }, + "description": "with unevaluated items", + "data": [ + "foo", + "bar", + 42 + ], "valid": false } ] }, { - "description": "ref.json: property named $ref that is not a reference", + "description": "unevaluatedItems.json: unevaluatedItems with not", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "$ref": { - "type": "string" + "prefixItems": [ + { + "const": "foo" } - } + ], + "not": { + "not": { + "prefixItems": [ + true, + { + "const": "bar" + } + ] + } + }, + "unevaluatedItems": false }, "tests": [ { - "description": "property named $ref valid", - "data": { - "$ref": "a" - }, - "valid": true - }, - { - "description": "property named $ref invalid", - "data": { - "$ref": 2 - }, + "description": "with unevaluated items", + "data": [ + "foo", + "bar" + ], "valid": false } ] }, { - "description": "ref.json: property named $ref, containing an actual $ref", + "description": "unevaluatedItems.json: unevaluatedItems with if/then/else", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "$ref": { - "$ref": "#/$defs/is-string" + "prefixItems": [ + { + "const": "foo" } + ], + "if": { + "prefixItems": [ + true, + { + "const": "bar" + } + ] }, - "$defs": { - "is-string": { - "type": "string" - } - } + "then": { + "prefixItems": [ + true, + true, + { + "const": "then" + } + ] + }, + "else": { + "prefixItems": [ + true, + true, + true, + { + "const": "else" + } + ] + }, + "unevaluatedItems": false }, "tests": [ { - "description": "property named $ref valid", - "data": { - "$ref": "a" - }, + "description": "when if matches and it has no unevaluated items", + "data": [ + "foo", + "bar", + "then" + ], "valid": true }, { - "description": "property named $ref invalid", - "data": { - "$ref": 2 - }, + "description": "when if matches and it has unevaluated items", + "data": [ + "foo", + "bar", + "then", + "else" + ], "valid": false - } - ] - }, - { - "description": "ref.json: $ref to boolean schema true", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "#/$defs/bool", - "$defs": { - "bool": true - } - }, - "tests": [ + }, { - "description": "any value is valid", - "data": "foo", + "description": "when if doesn't match and it has no unevaluated items", + "data": [ + "foo", + 42, + 42, + "else" + ], "valid": true - } - ] - }, - { - "description": "ref.json: $ref to boolean schema false", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "#/$defs/bool", - "$defs": { - "bool": false - } - }, - "tests": [ + }, { - "description": "any value is invalid", - "data": "foo", + "description": "when if doesn't match and it has unevaluated items", + "data": [ + "foo", + 42, + 42, + "else", + 42 + ], "valid": false } ] }, { - "description": "ref.json: Recursive references between schemas", + "description": "unevaluatedItems.json: unevaluatedItems with boolean schemas", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/tree", - "description": "tree of nodes", - "type": "object", - "properties": { - "meta": { - "type": "string" - }, - "nodes": { - "type": "array", - "items": { - "$ref": "node" - } - } - }, - "required": [ - "meta", - "nodes" + "allOf": [ + true ], - "$defs": { - "node": { - "$id": "http://localhost:1234/draft2020-12/node", - "description": "node", - "type": "object", - "properties": { - "value": { - "type": "number" - }, - "subtree": { - "$ref": "tree" - } - }, - "required": [ - "value" - ] - } - } + "unevaluatedItems": false }, "tests": [ { - "description": "valid tree", - "data": { - "meta": "root", - "nodes": [ - { - "value": 1, - "subtree": { - "meta": "child", - "nodes": [ - { - "value": 1.1 - }, - { - "value": 1.2 - } - ] - } - }, - { - "value": 2, - "subtree": { - "meta": "child", - "nodes": [ - { - "value": 2.1 - }, - { - "value": 2.2 - } - ] - } - } - ] - }, + "description": "with no unevaluated items", + "data": [], "valid": true }, { - "description": "invalid tree", - "data": { - "meta": "root", - "nodes": [ - { - "value": 1, - "subtree": { - "meta": "child", - "nodes": [ - { - "value": "string is invalid" - }, - { - "value": 1.2 - } - ] - } - }, - { - "value": 2, - "subtree": { - "meta": "child", - "nodes": [ - { - "value": 2.1 - }, - { - "value": 2.2 - } - ] - } - } - ] - }, + "description": "with unevaluated items", + "data": [ + "foo" + ], "valid": false } ] }, { - "description": "ref.json: refs with quote", + "description": "unevaluatedItems.json: unevaluatedItems with $ref", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo\"bar": { - "$ref": "#/$defs/foo%22bar" + "$ref": "#/$defs/bar", + "prefixItems": [ + { + "type": "string" } - }, + ], + "unevaluatedItems": false, "$defs": { - "foo\"bar": { - "type": "number" + "bar": { + "prefixItems": [ + true, + { + "type": "string" + } + ] } } }, "tests": [ { - "description": "object with numbers is valid", - "data": { - "foo\"bar": 1 - }, + "description": "with no unevaluated items", + "data": [ + "foo", + "bar" + ], "valid": true }, { - "description": "object with strings is invalid", - "data": { - "foo\"bar": "1" - }, + "description": "with unevaluated items", + "data": [ + "foo", + "bar", + "baz" + ], "valid": false } ] }, { - "description": "ref.json: naive replacement of $ref with its destination is not correct", + "description": "unevaluatedItems.json: unevaluatedItems before $ref", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "a_string": { + "unevaluatedItems": false, + "prefixItems": [ + { "type": "string" } - }, - "enum": [ - { - "$ref": "#/$defs/a_string" + ], + "$ref": "#/$defs/bar", + "$defs": { + "bar": { + "prefixItems": [ + true, + { + "type": "string" + } + ] } - ] + } }, "tests": [ { - "description": "do not evaluate the $ref inside the enum, matching any string", - "data": "this is a string", - "valid": false + "description": "with no unevaluated items", + "data": [ + "foo", + "bar" + ], + "valid": true }, { - "description": "do not evaluate the $ref inside the enum, definition exact match", - "data": { - "type": "string" - }, + "description": "with unevaluated items", + "data": [ + "foo", + "bar", + "baz" + ], "valid": false - }, - { - "description": "match the enum exactly", - "data": { - "$ref": "#/$defs/a_string" + } + ] + }, + { + "description": "unevaluatedItems.json: unevaluatedItems can't see inside cousins", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "prefixItems": [ + true + ] }, - "valid": true + { + "unevaluatedItems": false + } + ] + }, + "tests": [ + { + "description": "always fails", + "data": [ + 1 + ], + "valid": false } ] }, { - "description": "ref.json: refs with relative uris and defs", + "description": "unevaluatedItems.json: item is evaluated in an uncle schema to unevaluatedItems", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://example.com/schema-relative-uri-defs1.json", "properties": { "foo": { - "$id": "schema-relative-uri-defs2.json", - "$defs": { - "inner": { - "properties": { - "bar": { + "prefixItems": [ + { + "type": "string" + } + ], + "unevaluatedItems": false + } + }, + "anyOf": [ + { + "properties": { + "foo": { + "prefixItems": [ + true, + { "type": "string" } - } + ] } - }, - "$ref": "#/$defs/inner" + } } - }, - "$ref": "schema-relative-uri-defs2.json" + ] }, "tests": [ { - "description": "invalid on inner field", + "description": "no extra items", "data": { - "foo": { - "bar": 1 - }, - "bar": "a" + "foo": [ + "test" + ] }, - "valid": false + "valid": true }, { - "description": "invalid on outer field", + "description": "uncle keyword evaluation is not significant", "data": { - "foo": { - "bar": "a" - }, - "bar": 1 + "foo": [ + "test", + "test" + ] }, "valid": false - }, - { - "description": "valid on both fields", - "data": { - "foo": { - "bar": "a" - }, - "bar": "a" - }, - "valid": true } ] }, { - "description": "ref.json: relative refs with absolute uris and defs", + "description": "unevaluatedItems.json: unevaluatedItems depends on adjacent contains", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://example.com/schema-refs-absolute-uris-defs1.json", - "properties": { - "foo": { - "$id": "http://example.com/schema-refs-absolute-uris-defs2.json", - "$defs": { - "inner": { - "properties": { - "bar": { - "type": "string" - } - } - } - }, - "$ref": "#/$defs/inner" - } + "prefixItems": [ + true + ], + "contains": { + "type": "string" }, - "$ref": "schema-refs-absolute-uris-defs2.json" + "unevaluatedItems": false }, "tests": [ { - "description": "invalid on inner field", - "data": { - "foo": { - "bar": 1 - }, - "bar": "a" - }, - "valid": false + "description": "second item is evaluated by contains", + "data": [ + 1, + "foo" + ], + "valid": true }, { - "description": "invalid on outer field", - "data": { - "foo": { - "bar": "a" - }, - "bar": 1 - }, + "description": "contains fails, second item is not evaluated", + "data": [ + 1, + 2 + ], "valid": false }, { - "description": "valid on both fields", - "data": { - "foo": { - "bar": "a" - }, - "bar": "a" - }, - "valid": true + "description": "contains passes, second item is not evaluated", + "data": [ + 1, + 2, + "foo" + ], + "valid": false } ] }, { - "description": "ref.json: $id must be resolved against nearest parent, not just immediate parent", + "description": "unevaluatedItems.json: unevaluatedItems depends on multiple nested contains", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://example.com/a.json", - "$defs": { - "x": { - "$id": "http://example.com/b/c.json", - "not": { - "$defs": { - "y": { - "$id": "d.json", - "type": "number" - } - } - } - } - }, "allOf": [ { - "$ref": "http://example.com/b/d.json" + "contains": { + "multipleOf": 2 + } + }, + { + "contains": { + "multipleOf": 3 + } } - ] + ], + "unevaluatedItems": { + "multipleOf": 5 + } }, "tests": [ { - "description": "number is valid", - "data": 1, + "description": "5 not evaluated, passes unevaluatedItems", + "data": [ + 2, + 3, + 4, + 5, + 6 + ], "valid": true }, { - "description": "non-number is invalid", - "data": "a", + "description": "7 not evaluated, fails unevaluatedItems", + "data": [ + 2, + 3, + 4, + 7, + 8 + ], "valid": false } ] }, { - "description": "ref.json: order of evaluation: $id and $ref", + "description": "unevaluatedItems.json: unevaluatedItems and contains interact to control item dependency relationship", "schema": { - "$comment": "$id must be evaluated before $ref to get the proper $ref destination", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://example.com/draft2020-12/ref-and-id1/base.json", - "$ref": "int.json", - "$defs": { - "bigint": { - "$comment": "canonical uri: https://example.com/ref-and-id1/int.json", - "$id": "int.json", - "maximum": 10 + "if": { + "contains": { + "const": "a" + } + }, + "then": { + "if": { + "contains": { + "const": "b" + } }, - "smallint": { - "$comment": "canonical uri: https://example.com/ref-and-id1-int.json", - "$id": "/draft2020-12/ref-and-id1-int.json", - "maximum": 2 + "then": { + "if": { + "contains": { + "const": "c" + } + } } - } + }, + "unevaluatedItems": false }, "tests": [ { - "description": "data is valid against first definition", - "data": 5, + "description": "empty array is valid", + "data": [], "valid": true }, { - "description": "data is invalid against first definition", - "data": 50, + "description": "only a's are valid", + "data": [ + "a", + "a" + ], + "valid": true + }, + { + "description": "a's and b's are valid", + "data": [ + "a", + "b", + "a", + "b", + "a" + ], + "valid": true + }, + { + "description": "a's, b's and c's are valid", + "data": [ + "c", + "a", + "c", + "c", + "b", + "a" + ], + "valid": true + }, + { + "description": "only b's are invalid", + "data": [ + "b", + "b" + ], + "valid": false + }, + { + "description": "only c's are invalid", + "data": [ + "c", + "c" + ], + "valid": false + }, + { + "description": "only b's and c's are invalid", + "data": [ + "c", + "b", + "c", + "b", + "c" + ], + "valid": false + }, + { + "description": "only a's and c's are invalid", + "data": [ + "c", + "a", + "c", + "a", + "c" + ], "valid": false } ] }, { - "description": "ref.json: order of evaluation: $id and $anchor and $ref", + "description": "unevaluatedItems.json: unevaluatedItems with minContains = 0", "schema": { - "$comment": "$id must be evaluated before $ref to get the proper $ref destination", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://example.com/draft2020-12/ref-and-id2/base.json", - "$ref": "#bigint", - "$defs": { - "bigint": { - "$comment": "canonical uri: /ref-and-id2/base.json#/$defs/bigint; another valid uri for this location: /ref-and-id2/base.json#bigint", - "$anchor": "bigint", - "maximum": 10 - }, - "smallint": { - "$comment": "canonical uri: https://example.com/ref-and-id2#/$defs/smallint; another valid uri for this location: https://example.com/ref-and-id2/#bigint", - "$id": "https://example.com/draft2020-12/ref-and-id2/", - "$anchor": "bigint", - "maximum": 2 - } - } + "contains": { + "type": "string" + }, + "minContains": 0, + "unevaluatedItems": false }, "tests": [ { - "description": "data is valid against first definition", - "data": 5, + "description": "empty array is valid", + "data": [], "valid": true }, { - "description": "data is invalid against first definition", - "data": 50, + "description": "no items evaluated by contains", + "data": [ + 0 + ], + "valid": false + }, + { + "description": "some but not all items evaluated by contains", + "data": [ + "foo", + 0 + ], "valid": false + }, + { + "description": "all items evaluated by contains", + "data": [ + "foo", + "bar" + ], + "valid": true } ] }, { - "description": "ref.json: order of evaluation: $id and $ref on nested schema", + "description": "unevaluatedItems.json: non-array instances are valid", "schema": { - "$comment": "$id must be evaluated before $ref to get the proper $ref destination", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://example.com/draft2020-12/ref-and-id3/base.json", - "$ref": "nested/foo.json", - "$defs": { - "foo": { - "$comment": "canonical uri: https://example.com/draft2020-12/ref-and-id3/nested/foo.json", - "$id": "nested/foo.json", - "$ref": "./bar.json" - }, - "bar": { - "$comment": "canonical uri: https://example.com/draft2020-12/ref-and-id3/nested/bar.json", - "$id": "nested/bar.json", - "type": "number" - } - } + "unevaluatedItems": false }, "tests": [ { - "description": "data is valid against nested sibling", - "data": 5, + "description": "ignores booleans", + "data": true, "valid": true }, { - "description": "data is invalid against nested sibling", - "data": "a", - "valid": false + "description": "ignores integers", + "data": 123, + "valid": true + }, + { + "description": "ignores floats", + "data": 1.0, + "valid": true + }, + { + "description": "ignores objects", + "data": {}, + "valid": true + }, + { + "description": "ignores strings", + "data": "foo", + "valid": true + }, + { + "description": "ignores null", + "data": null, + "valid": true } ] }, { - "description": "ref.json: simple URN base URI with $ref via the URN", + "description": "unevaluatedItems.json: unevaluatedItems with null instance elements", "schema": { - "$comment": "URIs do not have to have HTTP(s) schemes", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:uuid:deadbeef-1234-ffff-ffff-4321feebdaed", - "minimum": 30, - "properties": { - "foo": { - "$ref": "urn:uuid:deadbeef-1234-ffff-ffff-4321feebdaed" - } + "unevaluatedItems": { + "type": "null" } }, "tests": [ { - "description": "valid under the URN IDed schema", - "data": { - "foo": 37 - }, + "description": "allows null elements", + "data": [ + null + ], "valid": true - }, - { - "description": "invalid under the URN IDed schema", - "data": { - "foo": 12 - }, - "valid": false } ] }, { - "description": "ref.json: simple URN base URI with JSON pointer", + "description": "unevaluatedItems.json: unevaluatedItems can see annotations from if without then and else", "schema": { - "$comment": "URIs do not have to have HTTP(s) schemes", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:uuid:deadbeef-1234-00ff-ff00-4321feebdaed", - "properties": { - "foo": { - "$ref": "#/$defs/bar" - } + "if": { + "prefixItems": [ + { + "const": "a" + } + ] }, - "$defs": { - "bar": { - "type": "string" - } - } + "unevaluatedItems": false }, "tests": [ { - "description": "a string is valid", - "data": { - "foo": "bar" - }, + "description": "valid in case if is evaluated", + "data": [ + "a" + ], "valid": true }, { - "description": "a non-string is invalid", - "data": { - "foo": 12 - }, + "description": "invalid in case if is evaluated", + "data": [ + "b" + ], "valid": false } ] }, { - "description": "ref.json: URN base URI with NSS", + "description": "unevaluatedItems.json: Evaluated items collection needs to consider instance location", "schema": { - "$comment": "RFC 8141 §2.2", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:example:1/406/47452/2", - "properties": { - "foo": { - "$ref": "#/$defs/bar" - } - }, - "$defs": { - "bar": { - "type": "string" + "prefixItems": [ + { + "prefixItems": [ + true, + { + "type": "string" + } + ] } - } + ], + "unevaluatedItems": false }, "tests": [ { - "description": "a string is valid", - "data": { - "foo": "bar" - }, - "valid": true - }, - { - "description": "a non-string is invalid", - "data": { - "foo": 12 - }, + "description": "with an unevaluated item that exists at another location", + "data": [ + [ + "foo", + "bar" + ], + "bar" + ], "valid": false } ] }, { - "description": "ref.json: URN base URI with r-component", + "description": "unevaluatedProperties.json: unevaluatedProperties true", "schema": { - "$comment": "RFC 8141 §2.3.1", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:example:foo-bar-baz-qux?+CCResolve:cc=uk", - "properties": { - "foo": { - "$ref": "#/$defs/bar" - } - }, - "$defs": { - "bar": { - "type": "string" - } - } + "unevaluatedProperties": true }, "tests": [ { - "description": "a string is valid", - "data": { - "foo": "bar" - }, + "description": "with no unevaluated properties", + "data": {}, "valid": true }, { - "description": "a non-string is invalid", + "description": "with unevaluated properties", "data": { - "foo": 12 + "foo": "foo" }, - "valid": false + "valid": true } ] }, { - "description": "ref.json: URN base URI with q-component", + "description": "unevaluatedProperties.json: unevaluatedProperties schema", "schema": { - "$comment": "RFC 8141 §2.3.2", "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:example:weather?=op=map&lat=39.56&lon=-104.85&datetime=1969-07-21T02:56:15Z", - "properties": { - "foo": { - "$ref": "#/$defs/bar" - } - }, - "$defs": { - "bar": { - "type": "string" - } + "unevaluatedProperties": { + "type": "string", + "minLength": 3 } }, "tests": [ { - "description": "a string is valid", + "description": "with no unevaluated properties", + "data": {}, + "valid": true + }, + { + "description": "with valid unevaluated properties", "data": { - "foo": "bar" + "foo": "foo" }, "valid": true }, { - "description": "a non-string is invalid", + "description": "with invalid unevaluated properties", "data": { - "foo": 12 + "foo": "fo" }, "valid": false } ] }, { - "description": "ref.json: URN base URI with URN and JSON pointer ref", + "description": "unevaluatedProperties.json: unevaluatedProperties false", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:uuid:deadbeef-1234-0000-0000-4321feebdaed", - "properties": { - "foo": { - "$ref": "urn:uuid:deadbeef-1234-0000-0000-4321feebdaed#/$defs/bar" - } - }, - "$defs": { - "bar": { - "type": "string" - } - } + "unevaluatedProperties": false }, "tests": [ { - "description": "a string is valid", - "data": { - "foo": "bar" - }, + "description": "with no unevaluated properties", + "data": {}, "valid": true }, { - "description": "a non-string is invalid", + "description": "with unevaluated properties", "data": { - "foo": 12 + "foo": "foo" }, "valid": false } ] }, { - "description": "ref.json: URN base URI with URN and anchor ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with adjacent properties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:uuid:deadbeef-1234-ff00-00ff-4321feebdaed", "properties": { "foo": { - "$ref": "urn:uuid:deadbeef-1234-ff00-00ff-4321feebdaed#something" - } - }, - "$defs": { - "bar": { - "$anchor": "something", "type": "string" } - } + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "a string is valid", + "description": "with no unevaluated properties", "data": { - "foo": "bar" + "foo": "foo" }, "valid": true }, { - "description": "a non-string is invalid", + "description": "with unevaluated properties", "data": { - "foo": 12 + "foo": "foo", + "bar": "bar" }, "valid": false } ] }, { - "description": "ref.json: URN ref with nested pointer ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with adjacent patternProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "urn:uuid:deadbeef-4321-ffff-ffff-1234feebdaed", - "$defs": { - "foo": { - "$id": "urn:uuid:deadbeef-4321-ffff-ffff-1234feebdaed", - "$defs": { - "bar": { - "type": "string" - } - }, - "$ref": "#/$defs/bar" + "patternProperties": { + "^foo": { + "type": "string" } - } - }, - "tests": [ - { - "description": "a string is valid", - "data": "bar", - "valid": true }, - { - "description": "a non-string is invalid", - "data": 12, - "valid": false - } - ] - }, - { - "description": "ref.json: ref to if", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://example.com/ref/if", - "if": { - "$id": "http://example.com/ref/if", - "type": "integer" - } + "unevaluatedProperties": false }, "tests": [ { - "description": "a non-integer is invalid due to the $ref", - "data": "foo", - "valid": false - }, - { - "description": "an integer is valid", - "data": 12, + "description": "with no unevaluated properties", + "data": { + "foo": "foo" + }, "valid": true - } - ] - }, - { - "description": "ref.json: ref to then", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://example.com/ref/then", - "then": { - "$id": "http://example.com/ref/then", - "type": "integer" - } - }, - "tests": [ - { - "description": "a non-integer is invalid due to the $ref", - "data": "foo", - "valid": false }, { - "description": "an integer is valid", - "data": 12, - "valid": true - } - ] - }, - { - "description": "ref.json: ref to else", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://example.com/ref/else", - "else": { - "$id": "http://example.com/ref/else", - "type": "integer" - } - }, - "tests": [ - { - "description": "a non-integer is invalid due to the $ref", - "data": "foo", + "description": "with unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": false - }, - { - "description": "an integer is valid", - "data": 12, - "valid": true } ] }, { - "description": "ref.json: ref with absolute-path-reference", + "description": "unevaluatedProperties.json: unevaluatedProperties with adjacent bool additionalProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://example.com/ref/absref.json", - "$defs": { - "a": { - "$id": "http://example.com/ref/absref/foobar.json", - "type": "number" - }, - "b": { - "$id": "http://example.com/absref/foobar.json", + "type": "object", + "properties": { + "foo": { "type": "string" } }, - "$ref": "/absref/foobar.json" + "additionalProperties": true, + "unevaluatedProperties": false }, "tests": [ { - "description": "a string is valid", - "data": "foo", + "description": "with no additional properties", + "data": { + "foo": "foo" + }, "valid": true }, { - "description": "an integer is invalid", - "data": 12, - "valid": false + "description": "with additional properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true } ] }, { - "description": "ref.json: $id with file URI still resolves pointers - *nix", + "description": "unevaluatedProperties.json: unevaluatedProperties with adjacent non-bool additionalProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "file:///folder/file.json", - "$defs": { + "type": "object", + "properties": { "foo": { - "type": "number" + "type": "string" } }, - "$ref": "#/$defs/foo" + "additionalProperties": { + "type": "string" + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "number is valid", - "data": 1, + "description": "with no additional properties", + "data": { + "foo": "foo" + }, "valid": true }, { - "description": "non-number is invalid", - "data": "a", - "valid": false + "description": "with additional properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true } ] }, { - "description": "ref.json: $id with file URI still resolves pointers - windows", + "description": "unevaluatedProperties.json: unevaluatedProperties with nested properties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "file:///c:/folder/file.json", - "$defs": { + "properties": { "foo": { - "type": "number" + "type": "string" } }, - "$ref": "#/$defs/foo" + "allOf": [ + { + "properties": { + "bar": { + "type": "string" + } + } + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "number is valid", - "data": 1, + "description": "with no additional properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": true }, { - "description": "non-number is invalid", - "data": "a", + "description": "with additional properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + }, "valid": false } ] }, { - "description": "ref.json: empty tokens in $ref json-pointer", + "description": "unevaluatedProperties.json: unevaluatedProperties with nested patternProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "": { - "$defs": { - "": { - "type": "number" - } - } + "properties": { + "foo": { + "type": "string" } }, "allOf": [ { - "$ref": "#/$defs//$defs/" + "patternProperties": { + "^bar": { + "type": "string" + } + } } - ] + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "number is valid", - "data": 1, + "description": "with no additional properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": true }, { - "description": "non-number is invalid", - "data": "a", + "description": "with additional properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + }, "valid": false } ] }, { - "description": "refRemote.json: remote ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with nested additionalProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/integer.json" - }, - "documents": { - "http://localhost:1234/draft2020-12/integer.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "integer" - } + "properties": { + "foo": { + "type": "string" + } + }, + "allOf": [ + { + "additionalProperties": true + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "remote ref valid", - "data": 1, + "description": "with no additional properties", + "data": { + "foo": "foo" + }, "valid": true }, { - "description": "remote ref invalid", - "data": "a", - "valid": false + "description": "with additional properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true } ] }, { - "description": "refRemote.json: fragment within remote ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with nested unevaluatedProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/subSchemas.json#/$defs/integer" - }, - "documents": { - "http://localhost:1234/draft2020-12/subSchemas.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "integer": { - "type": "integer" - }, - "refToInteger": { - "$ref": "#/$defs/integer" - } + "properties": { + "foo": { + "type": "string" + } + }, + "allOf": [ + { + "unevaluatedProperties": true } + ], + "unevaluatedProperties": { + "type": "string", + "maxLength": 2 } }, "tests": [ { - "description": "remote fragment valid", - "data": 1, + "description": "with no nested unevaluated properties", + "data": { + "foo": "foo" + }, "valid": true }, { - "description": "remote fragment invalid", - "data": "a", - "valid": false + "description": "with nested unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true } ] }, { - "description": "refRemote.json: anchor within remote ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with anyOf", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json#foo" - }, - "documents": { - "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "refToInteger": { - "$ref": "#foo" + "properties": { + "foo": { + "type": "string" + } + }, + "anyOf": [ + { + "properties": { + "bar": { + "const": "bar" + } }, - "A": { - "$anchor": "foo", - "type": "integer" - } + "required": [ + "bar" + ] + }, + { + "properties": { + "baz": { + "const": "baz" + } + }, + "required": [ + "baz" + ] + }, + { + "properties": { + "quux": { + "const": "quux" + } + }, + "required": [ + "quux" + ] } - } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "remote anchor valid", - "data": 1, + "description": "when one matches and has no unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": true }, { - "description": "remote anchor invalid", - "data": "a", + "description": "when one matches and has unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "not-baz" + }, "valid": false - } - ] - }, - { - "description": "refRemote.json: ref within remote ref", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/subSchemas.json#/$defs/refToInteger" - }, - "documents": { - "http://localhost:1234/draft2020-12/subSchemas.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "integer": { - "type": "integer" - }, - "refToInteger": { - "$ref": "#/$defs/integer" - } - } - } - }, - "tests": [ + }, { - "description": "ref within ref valid", - "data": 1, + "description": "when two match and has no unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + }, "valid": true }, { - "description": "ref within ref invalid", - "data": "a", + "description": "when two match and has unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "baz", + "quux": "not-quux" + }, "valid": false } ] }, { - "description": "refRemote.json: base URI change", + "description": "unevaluatedProperties.json: unevaluatedProperties with oneOf", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/", - "items": { - "$id": "baseUriChange/", - "items": { - "$ref": "folderInteger.json" + "properties": { + "foo": { + "type": "string" } - } - }, - "documents": { - "http://localhost:1234/draft2020-12/baseUriChange/folderInteger.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "integer" - } + }, + "oneOf": [ + { + "properties": { + "bar": { + "const": "bar" + } + }, + "required": [ + "bar" + ] + }, + { + "properties": { + "baz": { + "const": "baz" + } + }, + "required": [ + "baz" + ] + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "base URI change ref valid", - "data": [ - [ - 1 - ] - ], + "description": "with no unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": true }, { - "description": "base URI change ref invalid", - "data": [ - [ - "a" - ] - ], + "description": "with unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar", + "quux": "quux" + }, "valid": false } ] }, { - "description": "refRemote.json: base URI change - change folder", + "description": "unevaluatedProperties.json: unevaluatedProperties with not", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/scope_change_defs1.json", - "type": "object", "properties": { - "list": { - "$ref": "baseUriChangeFolder/" + "foo": { + "type": "string" } }, - "$defs": { - "baz": { - "$id": "baseUriChangeFolder/", - "type": "array", - "items": { - "$ref": "folderInteger.json" - } - } - } - }, - "documents": { - "http://localhost:1234/draft2020-12/baseUriChangeFolder/folderInteger.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "integer" - } - }, - "tests": [ - { - "description": "number is valid", - "data": { - "list": [ - 1 + "not": { + "not": { + "properties": { + "bar": { + "const": "bar" + } + }, + "required": [ + "bar" ] - }, - "valid": true + } }, + "unevaluatedProperties": false + }, + "tests": [ { - "description": "string is invalid", + "description": "with unevaluated properties", "data": { - "list": [ - "a" - ] + "foo": "foo", + "bar": "bar" }, "valid": false } ] }, { - "description": "refRemote.json: base URI change - change folder in subschema", + "description": "unevaluatedProperties.json: unevaluatedProperties with if/then/else", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/scope_change_defs2.json", - "type": "object", - "properties": { - "list": { - "$ref": "baseUriChangeFolderInSubschema/#/$defs/bar" - } + "if": { + "properties": { + "foo": { + "const": "then" + } + }, + "required": [ + "foo" + ] }, - "$defs": { - "baz": { - "$id": "baseUriChangeFolderInSubschema/", - "$defs": { - "bar": { - "type": "array", - "items": { - "$ref": "folderInteger.json" - } - } + "then": { + "properties": { + "bar": { + "type": "string" } - } - } - }, - "documents": { - "http://localhost:1234/draft2020-12/baseUriChangeFolderInSubschema/folderInteger.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "integer" - } + }, + "required": [ + "bar" + ] + }, + "else": { + "properties": { + "baz": { + "type": "string" + } + }, + "required": [ + "baz" + ] + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "number is valid", + "description": "when if is true and has no unevaluated properties", "data": { - "list": [ - 1 - ] + "foo": "then", + "bar": "bar" }, "valid": true }, { - "description": "string is invalid", + "description": "when if is true and has unevaluated properties", "data": { - "list": [ - "a" - ] + "foo": "then", + "bar": "bar", + "baz": "baz" + }, + "valid": false + }, + { + "description": "when if is false and has no unevaluated properties", + "data": { + "baz": "baz" + }, + "valid": true + }, + { + "description": "when if is false and has unevaluated properties", + "data": { + "foo": "else", + "baz": "baz" }, "valid": false } ] }, { - "description": "refRemote.json: root ref in remote ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with if/then/else, then not defined", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/object", - "type": "object", - "properties": { - "name": { - "$ref": "name-defs.json#/$defs/orNull" - } - } - }, - "documents": { - "http://localhost:1234/draft2020-12/name-defs.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "orNull": { - "anyOf": [ - { - "type": "null" - }, - { - "$ref": "#" - } - ] + "if": { + "properties": { + "foo": { + "const": "then" } }, - "type": "string" - } + "required": [ + "foo" + ] + }, + "else": { + "properties": { + "baz": { + "type": "string" + } + }, + "required": [ + "baz" + ] + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "string is valid", + "description": "when if is true and has no unevaluated properties", "data": { - "name": "foo" + "foo": "then" }, "valid": true }, { - "description": "null is valid", + "description": "when if is true and has unevaluated properties", "data": { - "name": null + "foo": "then", + "bar": "bar" + }, + "valid": false + }, + { + "description": "when if is false and has no unevaluated properties", + "data": { + "baz": "baz" }, "valid": true }, { - "description": "object is invalid", + "description": "when if is false and has unevaluated properties", "data": { - "name": { - "name": null - } + "foo": "else", + "baz": "baz" }, "valid": false } ] }, { - "description": "refRemote.json: remote ref with ref to defs", + "description": "unevaluatedProperties.json: unevaluatedProperties with if/then/else, else not defined", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/schema-remote-ref-ref-defs1.json", - "$ref": "ref-and-defs.json" - }, - "documents": { - "http://localhost:1234/draft2020-12/ref-and-defs.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/ref-and-defs.json", - "$defs": { - "inner": { - "properties": { - "bar": { - "type": "string" - } - } + "if": { + "properties": { + "foo": { + "const": "then" } }, - "$ref": "#/$defs/inner" - } + "required": [ + "foo" + ] + }, + "then": { + "properties": { + "bar": { + "type": "string" + } + }, + "required": [ + "bar" + ] + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "invalid", + "description": "when if is true and has no unevaluated properties", "data": { - "bar": 1 + "foo": "then", + "bar": "bar" + }, + "valid": true + }, + { + "description": "when if is true and has unevaluated properties", + "data": { + "foo": "then", + "bar": "bar", + "baz": "baz" }, "valid": false }, { - "description": "valid", + "description": "when if is false and has no unevaluated properties", "data": { - "bar": "a" + "baz": "baz" }, - "valid": true + "valid": false + }, + { + "description": "when if is false and has unevaluated properties", + "data": { + "foo": "else", + "baz": "baz" + }, + "valid": false } ] }, { - "description": "refRemote.json: Location-independent identifier in remote ref", + "description": "unevaluatedProperties.json: unevaluatedProperties with dependentSchemas", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json#/$defs/refToInteger" - }, - "documents": { - "http://localhost:1234/draft2020-12/locationIndependentIdentifier.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "refToInteger": { - "$ref": "#foo" + "properties": { + "foo": { + "type": "string" + } + }, + "dependentSchemas": { + "foo": { + "properties": { + "bar": { + "const": "bar" + } }, - "A": { - "$anchor": "foo", - "type": "integer" - } + "required": [ + "bar" + ] } - } + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "integer is valid", - "data": 1, + "description": "with no unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": true }, { - "description": "string is invalid", - "data": "foo", + "description": "with unevaluated properties", + "data": { + "bar": "bar" + }, "valid": false } ] }, { - "description": "refRemote.json: retrieved nested refs resolve relative to their URI not $id", + "description": "unevaluatedProperties.json: unevaluatedProperties with boolean schemas", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "http://localhost:1234/draft2020-12/some-id", "properties": { - "name": { - "$ref": "nested/foo-ref-string.json" - } - } - }, - "documents": { - "http://localhost:1234/draft2020-12/nested/foo-ref-string.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "foo": { - "$ref": "string.json" - } + "foo": { + "type": "string" } }, - "http://localhost:1234/draft2020-12/nested/string.json": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "string" - } + "allOf": [ + true + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "number is invalid", + "description": "with no unevaluated properties", "data": { - "name": { - "foo": 1 - } + "foo": "foo" }, - "valid": false + "valid": true }, { - "description": "string is valid", + "description": "with unevaluated properties", "data": { - "name": { - "foo": "a" - } + "bar": "bar" }, - "valid": true + "valid": false } ] }, { - "description": "refRemote.json: remote HTTP ref with different $id", + "description": "unevaluatedProperties.json: unevaluatedProperties with $ref", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/different-id-ref-string.json" - }, - "documents": { - "http://localhost:1234/draft2020-12/different-id-ref-string.json": { - "$id": "http://localhost:1234/draft2020-12/real-id-ref-string.json", - "$defs": { - "bar": { - "type": "string" + "$ref": "#/$defs/bar", + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": false, + "$defs": { + "bar": { + "properties": { + "bar": { + "type": "string" + } } - }, - "$ref": "#/$defs/bar" + } } }, "tests": [ { - "description": "number is invalid", - "data": 1, - "valid": false + "description": "with no unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true }, { - "description": "string is valid", - "data": "foo", - "valid": true + "description": "with unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + }, + "valid": false } ] }, { - "description": "refRemote.json: remote HTTP ref with different URN $id", + "description": "unevaluatedProperties.json: unevaluatedProperties before $ref", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/urn-ref-string.json" - }, - "documents": { - "http://localhost:1234/draft2020-12/urn-ref-string.json": { - "$id": "urn:uuid:feebdaed-ffff-0000-2020-1200deadbeef", - "$defs": { - "bar": { - "type": "string" + "unevaluatedProperties": false, + "properties": { + "foo": { + "type": "string" + } + }, + "$ref": "#/$defs/bar", + "$defs": { + "bar": { + "properties": { + "bar": { + "type": "string" + } } - }, - "$ref": "#/$defs/bar" + } } }, "tests": [ { - "description": "number is invalid", - "data": 1, - "valid": false + "description": "with no unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": true }, { - "description": "string is valid", - "data": "foo", - "valid": true + "description": "with unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + }, + "valid": false } ] }, { - "description": "refRemote.json: remote HTTP ref with nested absolute ref", + "description": "unevaluatedProperties.json: unevaluatedProperties can't see inside cousins", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/nested-absolute-ref-to-string.json" - }, - "documents": { - "http://localhost:1234/draft2020-12/nested-absolute-ref-to-string.json": { - "$defs": { - "bar": { - "$id": "http://localhost:1234/draft2020-12/the-nested-id.json", - "type": "string" + "allOf": [ + { + "properties": { + "foo": true } }, - "$ref": "http://localhost:1234/draft2020-12/the-nested-id.json" - } + { + "unevaluatedProperties": false + } + ] }, "tests": [ { - "description": "number is invalid", - "data": 1, + "description": "always fails", + "data": { + "foo": 1 + }, "valid": false - }, - { - "description": "string is valid", - "data": "foo", - "valid": true } ] }, { - "description": "refRemote.json: $ref to $ref finds detached $anchor", + "description": "unevaluatedProperties.json: unevaluatedProperties can't see inside cousins (reverse order)", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "http://localhost:1234/draft2020-12/detached-ref.json#/$defs/foo" - }, - "documents": { - "http://localhost:1234/draft2020-12/detached-ref.json": { - "$id": "http://localhost:1234/draft2020-12/detached-ref.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "foo": { - "$ref": "#detached" - }, - "detached": { - "$anchor": "detached", - "type": "integer" + "allOf": [ + { + "unevaluatedProperties": false + }, + { + "properties": { + "foo": true } } - } + ] }, "tests": [ { - "description": "number is valid", - "data": 1, - "valid": true - }, - { - "description": "non-number is invalid", - "data": "a", + "description": "always fails", + "data": { + "foo": 1 + }, "valid": false } ] }, { - "description": "required.json: required validation", + "description": "unevaluatedProperties.json: nested unevaluatedProperties, outer false, inner true, properties outside", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { - "foo": {}, - "bar": {} + "foo": { + "type": "string" + } }, - "required": [ - "foo" - ] + "allOf": [ + { + "unevaluatedProperties": true + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "present required property is valid", + "description": "with no nested unevaluated properties", "data": { - "foo": 1 + "foo": "foo" }, "valid": true }, { - "description": "non-present required property is invalid", + "description": "with nested unevaluated properties", "data": { - "bar": 1 + "foo": "foo", + "bar": "bar" }, - "valid": false - }, - { - "description": "ignores arrays", - "data": [], - "valid": true - }, - { - "description": "ignores strings", - "data": "", - "valid": true - }, - { - "description": "ignores other non-objects", - "data": 12, - "valid": true - }, - { - "description": "ignores null", - "data": null, - "valid": true - }, - { - "description": "ignores boolean", - "data": true, "valid": true } ] }, { - "description": "required.json: required default validation", + "description": "unevaluatedProperties.json: nested unevaluatedProperties, outer false, inner true, properties inside", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "properties": { - "foo": {} - } + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": true + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "not required by default", - "data": {}, + "description": "with no nested unevaluated properties", + "data": { + "foo": "foo" + }, + "valid": true + }, + { + "description": "with nested unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": true } ] }, { - "description": "required.json: required with empty array", + "description": "unevaluatedProperties.json: nested unevaluatedProperties, outer true, inner false, properties outside", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { - "foo": {} + "foo": { + "type": "string" + } }, - "required": [] + "allOf": [ + { + "unevaluatedProperties": false + } + ], + "unevaluatedProperties": true }, "tests": [ { - "description": "property not required", - "data": {}, - "valid": true + "description": "with no nested unevaluated properties", + "data": { + "foo": "foo" + }, + "valid": false + }, + { + "description": "with nested unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, + "valid": false } ] }, { - "description": "required.json: required with escaped characters", + "description": "unevaluatedProperties.json: nested unevaluatedProperties, outer true, inner false, properties inside", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "required": [ - "foo\nbar", - "foo\"bar", - "foo\\bar", - "foo\rbar", - "foo\tbar", - "foo\fbar" - ] + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + ], + "unevaluatedProperties": true }, "tests": [ { - "description": "object with all properties present is valid", + "description": "with no nested unevaluated properties", "data": { - "foo\nbar": 1, - "foo\"bar": 1, - "foo\\bar": 1, - "foo\rbar": 1, - "foo\tbar": 1, - "foo\fbar": 1 + "foo": "foo" }, "valid": true }, { - "description": "object with some properties missing is invalid", + "description": "with nested unevaluated properties", "data": { - "foo\nbar": "1", - "foo\"bar": "1" + "foo": "foo", + "bar": "bar" }, "valid": false } ] }, { - "description": "required.json: required properties whose names are Javascript object property names", + "description": "unevaluatedProperties.json: cousin unevaluatedProperties, true and false, true with properties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "required": [ - "__proto__", - "toString", - "constructor" + "allOf": [ + { + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": true + }, + { + "unevaluatedProperties": false + } ] }, "tests": [ { - "description": "ignores arrays", - "data": [], - "valid": true - }, - { - "description": "ignores other non-objects", - "data": 12, - "valid": true - }, - { - "description": "none of the properties mentioned", - "data": {}, - "valid": false - }, - { - "description": "__proto__ present", - "data": { - "__proto__": "foo" - }, - "valid": false - }, - { - "description": "toString present", + "description": "with no nested unevaluated properties", "data": { - "toString": { - "length": 37 - } + "foo": "foo" }, "valid": false }, { - "description": "constructor present", + "description": "with nested unevaluated properties", "data": { - "constructor": { - "length": 37 - } + "foo": "foo", + "bar": "bar" }, "valid": false - }, - { - "description": "all present", - "data": { - "__proto__": 12, - "toString": { - "length": "foo" - }, - "constructor": 37 - }, - "valid": true } ] }, { - "description": "type.json: integer type matches integers", + "description": "unevaluatedProperties.json: cousin unevaluatedProperties, true and false, false with properties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "integer" + "allOf": [ + { + "unevaluatedProperties": true + }, + { + "properties": { + "foo": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + ] }, "tests": [ { - "description": "an integer is an integer", - "data": 1, - "valid": true - }, - { - "description": "a float with zero fractional part is an integer", - "data": 1.0, + "description": "with no nested unevaluated properties", + "data": { + "foo": "foo" + }, "valid": true }, { - "description": "a float is not an integer", - "data": 1.1, - "valid": false - }, - { - "description": "a string is not an integer", - "data": "foo", - "valid": false - }, - { - "description": "a string is still not an integer, even if it looks like one", - "data": "1", - "valid": false - }, - { - "description": "an object is not an integer", - "data": {}, - "valid": false - }, - { - "description": "an array is not an integer", - "data": [], - "valid": false - }, - { - "description": "a boolean is not an integer", - "data": true, - "valid": false - }, - { - "description": "null is not an integer", - "data": null, + "description": "with nested unevaluated properties", + "data": { + "foo": "foo", + "bar": "bar" + }, "valid": false } ] }, { - "description": "type.json: number type matches numbers", + "description": "unevaluatedProperties.json: property is evaluated in an uncle schema to unevaluatedProperties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "number" + "properties": { + "foo": { + "properties": { + "bar": { + "type": "string" + } + }, + "unevaluatedProperties": false + } + }, + "anyOf": [ + { + "properties": { + "foo": { + "properties": { + "faz": { + "type": "string" + } + } + } + } + } + ] }, "tests": [ { - "description": "an integer is a number", - "data": 1, - "valid": true - }, - { - "description": "a float with zero fractional part is a number (and an integer)", - "data": 1.0, - "valid": true - }, - { - "description": "a float is a number", - "data": 1.1, + "description": "no extra properties", + "data": { + "foo": { + "bar": "test" + } + }, "valid": true }, { - "description": "a string is not a number", - "data": "foo", - "valid": false - }, - { - "description": "a string is still not a number, even if it looks like one", - "data": "1", - "valid": false - }, - { - "description": "an object is not a number", - "data": {}, - "valid": false - }, - { - "description": "an array is not a number", - "data": [], - "valid": false - }, - { - "description": "a boolean is not a number", - "data": true, - "valid": false - }, - { - "description": "null is not a number", - "data": null, + "description": "uncle keyword evaluation is not significant", + "data": { + "foo": { + "bar": "test", + "faz": "test" + } + }, "valid": false } ] }, { - "description": "type.json: string type matches strings", + "description": "unevaluatedProperties.json: in-place applicator siblings, allOf has unevaluated", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "string" + "allOf": [ + { + "properties": { + "foo": true + }, + "unevaluatedProperties": false + } + ], + "anyOf": [ + { + "properties": { + "bar": true + } + } + ] }, "tests": [ { - "description": "1 is not a string", - "data": 1, - "valid": false - }, - { - "description": "a float is not a string", - "data": 1.1, + "description": "base case: both properties present", + "data": { + "foo": 1, + "bar": 1 + }, "valid": false }, { - "description": "a string is a string", - "data": "foo", - "valid": true - }, - { - "description": "a string is still a string, even if it looks like a number", - "data": "1", - "valid": true - }, - { - "description": "an empty string is still a string", - "data": "", + "description": "in place applicator siblings, bar is missing", + "data": { + "foo": 1 + }, "valid": true }, { - "description": "an object is not a string", - "data": {}, + "description": "in place applicator siblings, foo is missing", + "data": { + "bar": 1 + }, "valid": false - }, + } + ] + }, + { + "description": "unevaluatedProperties.json: in-place applicator siblings, anyOf has unevaluated", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "properties": { + "foo": true + } + } + ], + "anyOf": [ + { + "properties": { + "bar": true + }, + "unevaluatedProperties": false + } + ] + }, + "tests": [ { - "description": "an array is not a string", - "data": [], + "description": "base case: both properties present", + "data": { + "foo": 1, + "bar": 1 + }, "valid": false }, { - "description": "a boolean is not a string", - "data": true, + "description": "in place applicator siblings, bar is missing", + "data": { + "foo": 1 + }, "valid": false }, { - "description": "null is not a string", - "data": null, - "valid": false + "description": "in place applicator siblings, foo is missing", + "data": { + "bar": 1 + }, + "valid": true } ] }, { - "description": "type.json: object type matches objects", + "description": "unevaluatedProperties.json: unevaluatedProperties + single cyclic ref", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object" + "properties": { + "x": { + "$ref": "#" + } + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "an integer is not an object", - "data": 1, - "valid": false + "description": "Empty is valid", + "data": {}, + "valid": true }, { - "description": "a float is not an object", - "data": 1.1, - "valid": false + "description": "Single is valid", + "data": { + "x": {} + }, + "valid": true }, { - "description": "a string is not an object", - "data": "foo", + "description": "Unevaluated on 1st level is invalid", + "data": { + "x": {}, + "y": {} + }, "valid": false }, { - "description": "an object is an object", - "data": {}, + "description": "Nested is valid", + "data": { + "x": { + "x": {} + } + }, "valid": true }, { - "description": "an array is not an object", - "data": [], + "description": "Unevaluated on 2nd level is invalid", + "data": { + "x": { + "x": {}, + "y": {} + } + }, "valid": false }, { - "description": "a boolean is not an object", - "data": true, - "valid": false + "description": "Deep nested is valid", + "data": { + "x": { + "x": { + "x": {} + } + } + }, + "valid": true }, { - "description": "null is not an object", - "data": null, + "description": "Unevaluated on 3rd level is invalid", + "data": { + "x": { + "x": { + "x": {}, + "y": {} + } + } + }, "valid": false } ] }, { - "description": "type.json: array type matches arrays", + "description": "unevaluatedProperties.json: unevaluatedProperties + ref inside allOf / oneOf", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "array" + "$defs": { + "one": { + "properties": { + "a": true + } + }, + "two": { + "required": [ + "x" + ], + "properties": { + "x": true + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/one" + }, + { + "properties": { + "b": true + } + }, + { + "oneOf": [ + { + "$ref": "#/$defs/two" + }, + { + "required": [ + "y" + ], + "properties": { + "y": true + } + } + ] + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "an integer is not an array", - "data": 1, + "description": "Empty is invalid (no x or y)", + "data": {}, "valid": false }, { - "description": "a float is not an array", - "data": 1.1, + "description": "a and b are invalid (no x or y)", + "data": { + "a": 1, + "b": 1 + }, "valid": false }, { - "description": "a string is not an array", - "data": "foo", + "description": "x and y are invalid", + "data": { + "x": 1, + "y": 1 + }, "valid": false }, { - "description": "an object is not an array", - "data": {}, - "valid": false + "description": "a and x are valid", + "data": { + "a": 1, + "x": 1 + }, + "valid": true }, { - "description": "an array is an array", - "data": [], + "description": "a and y are valid", + "data": { + "a": 1, + "y": 1 + }, "valid": true }, { - "description": "a boolean is not an array", - "data": true, - "valid": false + "description": "a and b and x are valid", + "data": { + "a": 1, + "b": 1, + "x": 1 + }, + "valid": true }, { - "description": "null is not an array", - "data": null, + "description": "a and b and y are valid", + "data": { + "a": 1, + "b": 1, + "y": 1 + }, + "valid": true + }, + { + "description": "a and b and x and y are invalid", + "data": { + "a": 1, + "b": 1, + "x": 1, + "y": 1 + }, "valid": false } ] }, { - "description": "type.json: boolean type matches booleans", + "description": "unevaluatedProperties.json: dynamic evalation inside nested refs", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "boolean" + "$defs": { + "one": { + "oneOf": [ + { + "$ref": "#/$defs/two" + }, + { + "required": [ + "b" + ], + "properties": { + "b": true + } + }, + { + "required": [ + "xx" + ], + "patternProperties": { + "x": true + } + }, + { + "required": [ + "all" + ], + "unevaluatedProperties": true + } + ] + }, + "two": { + "oneOf": [ + { + "required": [ + "c" + ], + "properties": { + "c": true + } + }, + { + "required": [ + "d" + ], + "properties": { + "d": true + } + } + ] + } + }, + "oneOf": [ + { + "$ref": "#/$defs/one" + }, + { + "required": [ + "a" + ], + "properties": { + "a": true + } + } + ], + "unevaluatedProperties": false }, "tests": [ { - "description": "an integer is not a boolean", - "data": 1, + "description": "Empty is invalid", + "data": {}, "valid": false }, { - "description": "zero is not a boolean", - "data": 0, - "valid": false + "description": "a is valid", + "data": { + "a": 1 + }, + "valid": true }, { - "description": "a float is not a boolean", - "data": 1.1, - "valid": false + "description": "b is valid", + "data": { + "b": 1 + }, + "valid": true }, { - "description": "a string is not a boolean", - "data": "foo", - "valid": false + "description": "c is valid", + "data": { + "c": 1 + }, + "valid": true }, { - "description": "an empty string is not a boolean", - "data": "", - "valid": false + "description": "d is valid", + "data": { + "d": 1 + }, + "valid": true }, { - "description": "an object is not a boolean", - "data": {}, + "description": "a + b is invalid", + "data": { + "a": 1, + "b": 1 + }, "valid": false }, { - "description": "an array is not a boolean", - "data": [], + "description": "a + c is invalid", + "data": { + "a": 1, + "c": 1 + }, "valid": false }, { - "description": "true is a boolean", - "data": true, - "valid": true + "description": "a + d is invalid", + "data": { + "a": 1, + "d": 1 + }, + "valid": false }, { - "description": "false is a boolean", - "data": false, - "valid": true + "description": "b + c is invalid", + "data": { + "b": 1, + "c": 1 + }, + "valid": false }, { - "description": "null is not a boolean", - "data": null, + "description": "b + d is invalid", + "data": { + "b": 1, + "d": 1 + }, "valid": false - } - ] - }, - { - "description": "type.json: null type matches only the null object", - "schema": { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "null" - }, - "tests": [ + }, { - "description": "an integer is not null", - "data": 1, + "description": "c + d is invalid", + "data": { + "c": 1, + "d": 1 + }, "valid": false }, { - "description": "a float is not null", - "data": 1.1, - "valid": false + "description": "xx is valid", + "data": { + "xx": 1 + }, + "valid": true }, { - "description": "zero is not null", - "data": 0, - "valid": false + "description": "xx + foox is valid", + "data": { + "xx": 1, + "foox": 1 + }, + "valid": true }, { - "description": "a string is not null", - "data": "foo", + "description": "xx + foo is invalid", + "data": { + "xx": 1, + "foo": 1 + }, "valid": false }, { - "description": "an empty string is not null", - "data": "", + "description": "xx + a is invalid", + "data": { + "xx": 1, + "a": 1 + }, "valid": false }, { - "description": "an object is not null", - "data": {}, + "description": "xx + b is invalid", + "data": { + "xx": 1, + "b": 1 + }, "valid": false }, { - "description": "an array is not null", - "data": [], + "description": "xx + c is invalid", + "data": { + "xx": 1, + "c": 1 + }, "valid": false }, { - "description": "true is not null", - "data": true, + "description": "xx + d is invalid", + "data": { + "xx": 1, + "d": 1 + }, "valid": false }, { - "description": "false is not null", - "data": false, - "valid": false + "description": "all is valid", + "data": { + "all": 1 + }, + "valid": true }, { - "description": "null is null", - "data": null, + "description": "all + foo is valid", + "data": { + "all": 1, + "foo": 1 + }, "valid": true + }, + { + "description": "all + a is invalid", + "data": { + "all": 1, + "a": 1 + }, + "valid": false } ] }, { - "description": "type.json: multiple types can be specified in an array", + "description": "unevaluatedProperties.json: non-object instances are valid", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": [ - "integer", - "string" - ] + "unevaluatedProperties": false }, "tests": [ { - "description": "an integer is valid", - "data": 1, + "description": "ignores booleans", + "data": true, "valid": true }, { - "description": "a string is valid", - "data": "foo", + "description": "ignores integers", + "data": 123, "valid": true }, { - "description": "a float is invalid", - "data": 1.1, - "valid": false - }, - { - "description": "an object is invalid", - "data": {}, - "valid": false + "description": "ignores floats", + "data": 1.0, + "valid": true }, { - "description": "an array is invalid", + "description": "ignores arrays", "data": [], - "valid": false + "valid": true }, { - "description": "a boolean is invalid", - "data": true, - "valid": false + "description": "ignores strings", + "data": "foo", + "valid": true }, { - "description": "null is invalid", + "description": "ignores null", "data": null, - "valid": false + "valid": true } ] }, { - "description": "type.json: type as array with one item", + "description": "unevaluatedProperties.json: unevaluatedProperties with null valued instance properties", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": [ - "string" - ] + "unevaluatedProperties": { + "type": "null" + } }, "tests": [ { - "description": "string is valid", - "data": "foo", + "description": "allows null valued properties", + "data": { + "foo": null + }, + "valid": true + } + ] + }, + { + "description": "unevaluatedProperties.json: unevaluatedProperties not affected by propertyNames", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "propertyNames": { + "maxLength": 1 + }, + "unevaluatedProperties": { + "type": "number" + } + }, + "tests": [ + { + "description": "allows only number properties", + "data": { + "a": 1 + }, "valid": true }, { - "description": "number is invalid", - "data": 123, + "description": "string property is invalid", + "data": { + "a": "b" + }, "valid": false } ] }, { - "description": "type.json: type: array or object", + "description": "unevaluatedProperties.json: unevaluatedProperties can see annotations from if without then and else", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": [ - "array", - "object" - ] + "if": { + "patternProperties": { + "foo": { + "type": "string" + } + } + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "array is valid", - "data": [ - 1, - 2, - 3 - ], + "description": "valid in case if is evaluated", + "data": { + "foo": "a" + }, "valid": true }, { - "description": "object is valid", + "description": "invalid in case if is evaluated", "data": { - "foo": 123 + "bar": "a" }, - "valid": true + "valid": false + } + ] + }, + { + "description": "unevaluatedProperties.json: dependentSchemas with unevaluatedProperties", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo2": {} + }, + "dependentSchemas": { + "foo": {}, + "foo2": { + "properties": { + "bar": {} + } + } }, + "unevaluatedProperties": false + }, + "tests": [ { - "description": "number is invalid", - "data": 123, + "description": "unevaluatedProperties doesn't consider dependentSchemas", + "data": { + "foo": "" + }, "valid": false }, { - "description": "string is invalid", - "data": "foo", + "description": "unevaluatedProperties doesn't see bar when foo2 is absent", + "data": { + "bar": "" + }, "valid": false }, { - "description": "null is invalid", - "data": null, - "valid": false + "description": "unevaluatedProperties sees bar when foo2 is present", + "data": { + "foo2": "", + "bar": "" + }, + "valid": true } ] }, { - "description": "type.json: type: array, object or null", + "description": "unevaluatedProperties.json: Evaluated properties collection needs to consider instance location", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": [ - "array", - "object", - "null" - ] + "properties": { + "foo": { + "properties": { + "bar": { + "type": "string" + } + } + } + }, + "unevaluatedProperties": false }, "tests": [ { - "description": "array is valid", - "data": [ - 1, - 2, - 3 - ], - "valid": true + "description": "with an unevaluated property that exists at another location", + "data": { + "foo": { + "bar": "foo" + }, + "bar": "bar" + }, + "valid": false + } + ] + }, + { + "description": "unevaluatedProperties.json: Evaluated properties collection needs to consider instance location with patternProperties", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "patternProperties": { + "^bar$": { + "type": "string" + } + } + } }, + "unevaluatedProperties": false + }, + "tests": [ { - "description": "object is valid", + "description": "with only the nested evaluated property", "data": { - "foo": 123 + "foo": { + "bar": "foo" + } }, "valid": true }, { - "description": "null is valid", - "data": null, - "valid": true + "description": "with an unevaluated property that exists at another location", + "data": { + "foo": { + "bar": "foo" + }, + "bar": "bar" + }, + "valid": false + } + ] + }, + { + "description": "unevaluatedProperties.json: Evaluated properties collection needs to consider instance location with additionalProperties", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "foo": { + "additionalProperties": { + "type": "string" + } + } }, + "unevaluatedProperties": false + }, + "tests": [ { - "description": "number is invalid", - "data": 123, - "valid": false + "description": "with only the nested evaluated property", + "data": { + "foo": { + "bar": "foo" + } + }, + "valid": true }, { - "description": "string is invalid", - "data": "foo", + "description": "with an unevaluated property that exists at another location", + "data": { + "foo": { + "bar": "foo" + }, + "bar": "bar" + }, "valid": false } ] @@ -7463,4 +13268,4 @@ } ] } -] \ No newline at end of file +] diff --git a/packages/ack/test/json_schema_2020_12_full_conformance_test.dart b/packages/ack/test/json_schema_2020_12_full_conformance_test.dart new file mode 100644 index 00000000..0deefadb --- /dev/null +++ b/packages/ack/test/json_schema_2020_12_full_conformance_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack/ack.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + const fixtureRoot = 'test/fixtures/json_schema_2020_12_full'; + final remoteRoot = Directory('$fixtureRoot/remotes/draft2020-12'); + final documents = { + for (final file in remoteRoot.listSync(recursive: true).whereType()) + if (file.path.endsWith('.json')) + Uri.parse( + 'http://localhost:1234/draft2020-12/' + '${p.relative(file.path, from: remoteRoot.path).replaceAll('\\', '/')}', + ): jsonDecode( + file.readAsStringSync(), + )!, + }; + final corpus = Directory('$fixtureRoot/draft2020-12'); + + group('official draft 2020-12 complete required suite', () { + for (final file in corpus.listSync().whereType()) { + if (!file.path.endsWith('.json')) continue; + final filename = p.basename(file.path); + final groups = (jsonDecode(file.readAsStringSync()) as List) + .cast>(); + for (final group in groups) { + test('$filename: ${group['description']}', () { + final schema = Ack.fromJsonSchema( + group['schema']!, + baseUri: Uri.parse( + 'http://localhost:1234/draft2020-12/__case__.json', + ), + documents: documents, + ); + final roundTrip = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + for (final example in group['tests']! as List) { + final testCase = example as Map; + final expected = testCase['valid'] as bool; + expect( + schema.safeParse(testCase['data']).isOk, + expected, + reason: testCase['description'] as String, + ); + expect( + roundTrip.safeParse(testCase['data']).isOk, + expected, + reason: 'export: ${testCase['description']}', + ); + } + }); + } + } + }); +} diff --git a/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart new file mode 100644 index 00000000..28e923bd --- /dev/null +++ b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack/ack.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + const fixtureRoot = 'test/fixtures/json_schema_2020_12_full'; + final groups = >[ + for (final filename in [ + 'dynamicRef.json', + 'unevaluatedProperties.json', + 'unevaluatedItems.json', + ]) + for (final group + in (jsonDecode( + File( + '$fixtureRoot/draft2020-12/$filename', + ).readAsStringSync(), + ) + as List) + .cast>()) + if (filename == 'dynamicRef.json' || + (group['description'] as String).contains(r'$dynamicRef')) + {...group, 'description': '$filename: ${group['description']}'}, + ]; + final remotes = Directory('$fixtureRoot/remotes/draft2020-12'); + final documents = { + for (final file in remotes.listSync(recursive: true).whereType()) + if (file.path.endsWith('.json')) + Uri.parse( + 'http://localhost:1234/draft2020-12/' + '${p.relative(file.path, from: remotes.path).replaceAll('\\', '/')}', + ): jsonDecode( + file.readAsStringSync(), + )!, + }; + + group('official 2020-12 dynamicRef validation', () { + for (final group in groups) { + test(group['description']! as String, () { + final schema = Ack.fromJsonSchema( + group['schema']!, + baseUri: Uri.parse( + 'http://localhost:1234/draft2020-12/__dynamic_ref_test__.json', + ), + documents: documents, + ); + final exported = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + for (final example in group['tests']! as List) { + final value = example['data']; + final expected = example['valid'] as bool; + final reason = example['description'] as String; + expect(schema.safeParse(value).isOk, expected, reason: reason); + expect(schema.safeEncode(value).isOk, expected, reason: reason); + expect(exported.safeParse(value).isOk, expected, reason: reason); + } + }); + } + }); +} diff --git a/packages/ack/test/json_schema_format_conformance_test.dart b/packages/ack/test/json_schema_format_conformance_test.dart new file mode 100644 index 00000000..ca7e5504 --- /dev/null +++ b/packages/ack/test/json_schema_format_conformance_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack/ack.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + const root = 'test/fixtures/json_schema_2020_12_full/draft2020-12'; + final formatDirectory = Directory('$root/optional/format'); + + group('official 2020-12 optional format assertion', () { + for (final file in formatDirectory.listSync().whereType()) { + if (!file.path.endsWith('.json')) continue; + final filename = p.basename(file.path); + final groups = (jsonDecode(file.readAsStringSync()) as List) + .cast>(); + for (final group in groups) { + test('$filename: ${group['description']}', () { + if (filename == 'unknown.json') { + expect( + () => Ack.fromJsonSchema(group['schema']!, assertFormats: true), + throwsA(isA()), + ); + return; + } + final schema = Ack.fromJsonSchema( + group['schema']!, + assertFormats: true, + ); + final roundTrip = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + final mismatches = []; + for (final example in group['tests']! as List) { + final testCase = example as Map; + final actual = schema.safeParse(testCase['data']).isOk; + if (actual != testCase['valid']) { + mismatches.add( + '${testCase['description']}: expected ${testCase['valid']}, got $actual', + ); + } + final exported = roundTrip.safeParse(testCase['data']).isOk; + if (exported != testCase['valid']) { + mismatches.add( + 'export: ${testCase['description']}: expected ${testCase['valid']}, got $exported', + ); + } + } + expect(mismatches, isEmpty); + }); + } + } + }); + + group('official 2020-12 format-assertion vocabulary', () { + final groups = + (jsonDecode( + File( + '$root/optional/format-assertion.json', + ).readAsStringSync(), + ) + as List) + .cast>(); + final documents = { + for (final value in ['true', 'false']) + Uri.parse( + 'http://localhost:1234/draft2020-12/format-assertion-$value.json', + ): jsonDecode( + File( + '$root/../remotes/draft2020-12/format-assertion-$value.json', + ).readAsStringSync(), + )!, + }; + for (final group in groups) { + test(group['description']! as String, () { + final schema = Ack.fromJsonSchema( + group['schema']!, + documents: documents, + ); + final roundTrip = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + for (final example in group['tests']! as List) { + final testCase = example as Map; + expect( + schema.safeParse(testCase['data']).isOk, + testCase['valid'], + reason: testCase['description'] as String, + ); + expect( + roundTrip.safeParse(testCase['data']).isOk, + testCase['valid'], + reason: 'export: ${testCase['description']}', + ); + } + }); + } + }); +} diff --git a/packages/ack/test/json_schema_format_import_test.dart b/packages/ack/test/json_schema_format_import_test.dart deleted file mode 100644 index a5cffede..00000000 --- a/packages/ack/test/json_schema_format_import_test.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -void main() { - group('JSON Schema date-time format import', () { - test('strict import asserts date-time without an option', () { - final schema = Ack.fromJsonSchema({'format': 'date-time'}); - expect(schema.safeParse('2024-02-29T00:00:00Z').isOk, isTrue); - expect(schema.safeParse('2024-02-30T00:00:00Z').isFail, isTrue); - }); - - test('unsupported assertion reports its source pointer', () { - final base = Uri.parse('https://example.test/root.json'); - try { - Ack.fromJsonSchema( - { - 'properties': { - 'stamp': {r'$ref': r'formats.json#/$defs/stamp'}, - }, - }, - baseUri: base, - documents: { - Uri.parse('formats.json'): { - r'$defs': { - 'stamp': {'format': 'email'}, - }, - }, - }, - ); - fail('Expected an unsupported format diagnostic.'); - } on JsonSchemaImportException catch (error) { - final issue = error.diagnostics.single; - expect(issue.code, 'unsupported_keyword'); - expect(issue.keyword, 'format'); - expect(issue.pointer, r'#/$defs/stamp/format'); - expect(issue.documentUri, base.resolve('formats.json')); - } - - // Only reachable definitions are compiled. - expect( - Ack.fromJsonSchema({ - r'$defs': { - 'unused': {'format': 'email'}, - }, - }).safeParse('anything').isOk, - isTrue, - ); - }); - - test('non-string format values are invalid schema input', () { - expect( - () => Ack.fromJsonSchema({'format': 17}), - throwsA( - isA().having( - (error) => error.diagnostics.single.pointer, - 'pointer', - '#/format', - ), - ), - ); - }); - - test('format applies only to strings and preserves valid input', () { - final schema = Ack.fromJsonSchema({'format': 'date-time'}); - for (final value in [ - null, - true, - 4, - 1.25, - [], - {}, - ]) { - expect(schema.safeParse(value).isOk, isTrue, reason: '$value'); - } - const original = '1998-12-31t15:59:60.123456789-08:00'; - expect(schema.parse(original), original); - expect(schema.safeParse('2024-02-30T01:02:03Z').isFail, isTrue); - - final typed = Ack.fromJsonSchema({ - 'type': 'string', - 'format': 'date-time', - }); - expect(typed.safeParse(4).isFail, isTrue); - expect(typed.safeParse(original).isOk, isTrue); - }); - - test('assertion survives references, siblings, arrays and export', () { - final schema = Ack.fromJsonSchema({ - r'$defs': { - 'stamp': {'format': 'date-time'}, - }, - 'properties': { - 'stamps': { - 'type': 'array', - 'items': { - r'$ref': r'#/$defs/stamp', - 'allOf': [ - {'type': 'string'}, - ], - }, - }, - }, - }); - final exported = schema.toJsonSchema(); - final roundTrip = Ack.fromJsonSchema(exported); - for (final imported in [schema, roundTrip]) { - expect( - imported.safeParse({ - 'stamps': ['2024-02-29T00:00:00Z'], - }).isOk, - isTrue, - ); - expect( - imported.safeParse({ - 'stamps': ['2024-02-30T00:00:00Z'], - }).isFail, - isTrue, - ); - expect( - imported.safeParse({ - 'stamps': [7], - }).isFail, - isTrue, - ); - } - }); - - test('native date-time export can be asserted on import', () { - final native = Ack.string().datetime(); - final imported = Ack.fromJsonSchema(native.toJsonSchema()); - expect(imported.safeParse('2024-02-29T00:00:00Z').isOk, isTrue); - expect(imported.safeParse('2024-02-30T00:00:00Z').isFail, isTrue); - expect(imported.safeParse(4).isFail, isTrue); - }); - - test('checks dates, offsets and leap seconds in UTC', () { - final schema = Ack.fromJsonSchema({'format': 'date-time'}); - for (final value in [ - '2023-02-29T00:00:00Z', - '2024-02-29T00:00:00', - '2024-02-29T00:00:00+24:00', - '2024-02-29T00:00:00+01:60', - '2030-06-30T23:59:60Z', - '1998-12-31T15:58:60-08:00', - '1999-01-01T23:59:60Z', - '2024-02-29T00:00:00Z😀', - ]) { - expect(schema.safeParse(value).isFail, isTrue, reason: value); - } - for (final value in [ - '2024-02-29T00:00:00Z', - '2024-02-29t00:00:00.123456789z', - '1998-12-31T23:59:60Z', - '1998-12-31T15:59:60-08:00', - '1998-12-31t23:59:60z', - '1999-01-01t00:59:60+01:00', - ]) { - expect(schema.safeParse(value).isOk, isTrue, reason: value); - } - }); - }); -} diff --git a/packages/ack/test/json_schema_import_a2ui_test.dart b/packages/ack/test/json_schema_import_a2ui_test.dart index ab88b766..4834165b 100644 --- a/packages/ack/test/json_schema_import_a2ui_test.dart +++ b/packages/ack/test/json_schema_import_a2ui_test.dart @@ -33,42 +33,44 @@ void main() { } }); - test( - 'client capabilities reject meta-schema references with diagnostics', - () { - expect( - () => load('client_capabilities.json'), - throwsA( - isA().having( - (error) => error.diagnostics.map((issue) => issue.code), - 'diagnostic codes', - everyElement('unsupported_reference'), - ), - ), - ); - }, - ); + test('client capabilities validate embedded schemas offline', () { + final schema = load('client_capabilities.json'); + Map withComponentType(String type) => { + 'v0.9': { + 'supportedCatalogIds': [], + 'inlineCatalogs': [ + { + 'catalogId': 'example', + 'components': { + 'Button': {'type': type}, + }, + }, + ], + }, + }; + expect(schema.safeParse(withComponentType('string')).isOk, isTrue); + expect(schema.safeParse(withComponentType('invalid')).isFail, isTrue); + }); - test('client events assert the action timestamp format', () { + test('client event date-time format is an annotation by default', () { final schema = load('client_to_server.json'); - final event = { + final message = { 'version': 'v0.9', 'action': { - 'name': 'submit', - 'surfaceId': 'surface-1', - 'sourceComponentId': 'button-1', - 'timestamp': '2024-02-29T01:02:03Z', + 'name': 'click', + 'surfaceId': 'surface', + 'sourceComponentId': 'button', + 'timestamp': 'not-a-date', 'context': {}, }, }; - expect(schema.safeParse(event).isOk, isTrue); - final invalid = { - ...event, - 'action': { - ...(event['action']! as Map), - 'timestamp': '2024-02-30T01:02:03Z', - }, - }; - expect(schema.safeParse(invalid).isFail, isTrue); + expect(schema.safeParse(message).isOk, isTrue); + expect( + schema.safeParse({ + ...message, + 'action': {...message['action']! as Map, 'timestamp': 1}, + }).isFail, + isTrue, + ); }); } diff --git a/packages/ack/test/json_schema_import_api_test.dart b/packages/ack/test/json_schema_import_api_test.dart index 190e2790..1c9c28fc 100644 --- a/packages/ack/test/json_schema_import_api_test.dart +++ b/packages/ack/test/json_schema_import_api_test.dart @@ -12,7 +12,17 @@ void main() { .toJsonSchema(); expect(exported['type'], isNull); - expect(exported['definitions'], isA>()); + expect(exported['allOf'], [ + {r'$ref': '#/definitions/_ack_import_0_0'}, + ]); + expect((exported['definitions'] as Map)['_ack_import_0_0'], { + 'type': 'string', + 'minLength': 2, + }); + expect( + schema.toJsonSchemaPreservingImportedDialect()[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); expect(modelExport, exported); expect(schema.safeParse('Ada').isOk, isTrue); expect(schema.safeParse('A').isFail, isTrue); @@ -90,17 +100,19 @@ void main() { } }); - test('unsupported assertions expose immutable diagnostics', () { - final document = {'type': 'string', 'format': 'email'}; + test('unknown required vocabularies expose immutable diagnostics', () { + final document = { + r'$vocabulary': {'https://example.test/required': true}, + }; late JsonSchemaImportException failure; try { Ack.fromJsonSchema(document); - fail('Expected an unsupported keyword failure.'); + fail('Expected an unsupported vocabulary failure.'); } on JsonSchemaImportException catch (error) { failure = error; } - expect(failure.diagnostics.single.code, 'unsupported_keyword'); - expect(failure.diagnostics.single.pointer, '#/format'); + expect(failure.diagnostics.single.code, 'unsupported_vocabulary'); + expect(failure.diagnostics.single.pointer, r'#/$vocabulary'); expect(() => failure.diagnostics.clear(), throwsUnsupportedError); }); @@ -122,4 +134,19 @@ void main() { expect(never.safeParse(null).isFail, isTrue); expect(never.safeParse('anything').isFail, isTrue); }); + + test('dynamic references require the explicit preserving export', () { + final schema = Ack.fromJsonSchema({ + r'$dynamicAnchor': 'node', + 'type': 'object', + 'properties': { + 'next': {r'$dynamicRef': '#node'}, + }, + }); + expect(schema.toJsonSchema, throwsUnsupportedError); + expect( + schema.toJsonSchemaPreservingImportedDialect()[r'$dynamicAnchor'], + 'node', + ); + }); } diff --git a/packages/ack/test/json_schema_import_conformance_test.dart b/packages/ack/test/json_schema_import_conformance_test.dart index 6ea7ac15..ad796f84 100644 --- a/packages/ack/test/json_schema_import_conformance_test.dart +++ b/packages/ack/test/json_schema_import_conformance_test.dart @@ -23,7 +23,9 @@ void main() { (uri, value) => MapEntry(Uri.parse(uri), value!), ), ); - final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); + final roundTrip = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); for (final example in group['tests']! as List) { final value = example['data']; final expected = example['valid'] as bool; @@ -35,32 +37,4 @@ void main() { }); } }); - - test('asserted date-time matches the official format fixture', () { - final fixture = - jsonDecode( - File( - 'test/fixtures/json_schema_2020_12_full/' - 'draft2020-12/optional/format/date-time.json', - ).readAsStringSync(), - ) - as List; - final imported = Ack.fromJsonSchema({'format': 'date-time'}); - final native = Ack.string().datetime(); - var count = 0; - for (final group in fixture.cast>()) { - for (final probe - in (group['tests'] as List).cast>()) { - final value = probe['data']; - final expected = probe['valid'] as bool; - final reason = probe['description'] as String; - expect(imported.safeParse(value).isOk, expected, reason: reason); - if (value is String) { - expect(native.safeParse(value).isOk, expected, reason: reason); - } - count++; - } - } - expect(count, 43); - }); } diff --git a/packages/ack/test/json_schema_import_test.dart b/packages/ack/test/json_schema_import_test.dart index 3250efad..76dd6603 100644 --- a/packages/ack/test/json_schema_import_test.dart +++ b/packages/ack/test/json_schema_import_test.dart @@ -52,6 +52,23 @@ void main() { } }); + test('rejects a supplied boolean dialect with an import diagnostic', () { + final dialect = Uri.parse('https://example.test/boolean-dialect'); + expect( + () => Ack.fromJsonSchema( + {r'$schema': dialect.toString()}, + documents: {dialect: true}, + ), + throwsA( + isA().having( + (error) => error.diagnostics.single.code, + 'diagnostic code', + 'unsupported_dialect', + ), + ), + ); + }); + test( 'rejects invalid annotation shapes instead of emitting invalid schemas', () { @@ -73,34 +90,29 @@ void main() { ); test( - 'added constraints do not become ignored Draft-7 reference siblings', + 'added constraints remain effective beside 2020-12 source schemas', () { final schema = Ack.fromJsonSchema(true).constrain(_OnlyStrings()); expect(schema.safeParse('ok').isOk, isTrue); expect(schema.safeParse(1).isFail, isTrue); expect(schema.safeParse(null).isOk, isTrue); - final exported = schema.toJsonSchema(); - // Draft-7 ignores every sibling of $ref. A root reference here would - // silently discard the additional, exported `type: string` assertion. + final exported = schema.toJsonSchemaPreservingImportedDialect(); expect(exported.containsKey(r'$ref'), isFalse); + expect( + exported[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); final branches = exported['anyOf'] as List; final constrained = branches .whereType>() .singleWhere((branch) => branch['type'] == 'string'); expect(constrained, isNot(contains(r'$ref'))); - final referenceEnvelope = constrained['allOf'] as List; - expect(referenceEnvelope, hasLength(1)); - expect( - referenceEnvelope.single, - isA>().having( - (reference) => reference[r'$ref'], - r'$ref', - isA(), - ), - ); expect(Ack.fromJsonSchema(exported).safeParse(1).isFail, isTrue); expect(Ack.fromJsonSchema(exported).safeParse(null).isOk, isTrue); - expect(schema.toSchemaModel().toJsonSchema(), exported); + expect( + schema.toSchemaModelPreservingImportedDialect().toJsonSchema(), + exported, + ); }, ); @@ -113,6 +125,76 @@ void main() { expect(schema.describe('Override').description, 'Override'); }); + test('nullable export retains local pointers into the source resource', () { + final schema = Ack.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'alias': {r'$ref': '#/properties/name'}, + }, + }).nullable(); + final restored = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + for (final value in [ + null, + {'name': 'Ada', 'alias': 'Ada'}, + {'name': 'Ada', 'alias': 1}, + ]) { + expect(restored.safeParse(value).isOk, schema.safeParse(value).isOk); + } + }); + + test('nullable export keeps recursive references scoped to the source', () { + final schema = Ack.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'child': {r'$ref': '#'}, + }, + }).nullable(); + final restored = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + for (final value in [ + null, + {}, + {'child': {}}, + {'child': null}, + ]) { + expect(restored.safeParse(value).isOk, schema.safeParse(value).isOk); + } + }); + + test('nullable export keeps root anchors scoped to the source', () { + for (final (anchor, reference) in [ + (r'$anchor', r'$ref'), + (r'$dynamicAnchor', r'$dynamicRef'), + ]) { + final schema = Ack.fromJsonSchema({ + anchor: 'node', + 'type': 'object', + 'properties': { + 'child': {reference: '#node'}, + }, + }).nullable(); + final restored = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + for (final value in [ + null, + {}, + {'child': {}}, + {'child': null}, + ]) { + expect( + restored.safeParse(value).isOk, + schema.safeParse(value).isOk, + reason: '$anchor: $value', + ); + } + } + }); + test( 'resolves catalog schemas stored outside standard schema containers', () { @@ -142,7 +224,7 @@ void main() { expect(schema.safeParse({'text': 1}).isFail, isTrue); expect( Ack.fromJsonSchema( - schema.toJsonSchema(), + schema.toJsonSchemaPreservingImportedDialect(), ).safeParse({'text': 1}).isFail, isTrue, ); @@ -164,12 +246,14 @@ void main() { expect(schema.safeParse('hello').isOk, isTrue); }); - test('empty and repeated enum values export as valid Draft-7 schemas', () { + test('empty and repeated enum values retain 2020-12 semantics', () { final never = Ack.fromJsonSchema({'enum': []}); for (final value in [null, 1, 'x', [], {}]) { expect(never.safeParse(value).isFail, isTrue); expect( - Ack.fromJsonSchema(never.toJsonSchema()).safeParse(value).isFail, + Ack.fromJsonSchema( + never.toJsonSchemaPreservingImportedDialect(), + ).safeParse(value).isFail, isTrue, ); } @@ -177,10 +261,13 @@ void main() { 'enum': [1, 1.0, 'x', 'x'], }); expect(repeated.safeParse(1).isOk, isTrue); - final definitions = repeated.toJsonSchema()['definitions'] as Map; - expect((definitions.values.first as Map)['enum'], [1, 'x']); - final falseDefinitions = never.toJsonSchema()['definitions'] as Map; - expect(falseDefinitions.values.first, {'not': {}}); + expect(repeated.toJsonSchemaPreservingImportedDialect()['enum'], [ + 1, + 1.0, + 'x', + 'x', + ]); + expect(never.toJsonSchemaPreservingImportedDialect()['enum'], isEmpty); }); test('pattern and propertyNames validate and export verbatim', () { @@ -199,36 +286,52 @@ void main() { // "pattern" is skipped rather than failed for non-strings. expect(schema.safeParse({'name': 42}).isOk, isTrue); - final export = schema.toJsonSchema(); - final definitions = (export['definitions'] as Map).values.cast(); - expect(definitions.any((d) => d['pattern'] == r'^a+$'), isTrue); - expect( - definitions.any((d) => (d['propertyNames'] as Map?)?[r'$ref'] != null), - isTrue, - ); + final export = schema.toJsonSchemaPreservingImportedDialect(); + expect((export['properties'] as Map)['name'], {'pattern': r'^a+$'}); + expect(export['propertyNames'], {'maxLength': 4}); final roundTrip = Ack.fromJsonSchema(export); expect(roundTrip.safeParse(accepted).isOk, isTrue); expect(roundTrip.safeParse(badPattern).isFail, isTrue); expect(roundTrip.safeParse(badPropertyName).isFail, isTrue); }); - test('pattern compiles in ECMA-262 Unicode mode', () { - final single = Ack.fromJsonSchema({'pattern': r'^.$'}); - expect(single.safeParse('😀').isOk, isTrue); - expect(single.safeParse('😀😀').isFail, isTrue); - + test('Unicode property escapes use ECMA-262 Unicode matching', () { + final schema = Ack.fromJsonSchema({ + 'type': 'string', + 'pattern': r'^\p{Letter}+$', + }); + expect(schema.safeParse('Éclair').isOk, isTrue); + expect(schema.safeParse('東京').isOk, isTrue); + expect(schema.safeParse('123').isFail, isTrue); expect( - () => Ack.fromJsonSchema({'pattern': r'^\d{3}\-\d{4}$'}), - throwsA( - isA().having( - (e) => e.diagnostics.single.pointer, - 'pointer', - '#/pattern', - ), - ), + Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ).safeParse('東京').isOk, + isTrue, ); }); + test('multipleOf compares decimal values exactly', () { + final schema = Ack.fromJsonSchema({'type': 'number', 'multipleOf': 0.1}); + for (final value in [0, 0.3, -0.3, 1.0, 1e2]) { + expect(schema.safeParse(value).isOk, isTrue, reason: '$value'); + } + for (final value in [0.31, 1.2000000000000002, 1e-7]) { + expect(schema.safeParse(value).isFail, isTrue, reason: '$value'); + } + final restored = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + expect(restored.safeParse(0.3).isOk, isTrue); + expect(restored.safeParse(0.31).isFail, isTrue); + for (final invalid in [0, -0.1, '0.1']) { + expect( + () => Ack.fromJsonSchema({'multipleOf': invalid}), + throwsA(isA()), + ); + } + }); + test('reference failures include source URI and keyword location', () { try { Ack.fromJsonSchema({ @@ -345,80 +448,357 @@ void main() { expect(error.toMap()['value'], 'foobar'); }); - test('runtime errors expose the failing keyword and its location', () { + test('recursive validation has no hidden Ack.lazy depth limit', () { final schema = Ack.fromJsonSchema({ 'type': 'object', 'properties': { - 'tags': { - 'type': 'array', - 'items': { - 'enum': ['x', 'y'], + 'child': {r'$ref': '#'}, + }, + }); + var value = {}; + for (var i = 0; i < 150; i++) { + value = {'child': value}; + } + expect(schema.safeParse(value).isOk, isTrue); + }); + + test( + 'URI fragments are decoded once, including percent and tilde tokens', + () { + for (final entry in { + r'#/$defs/a%20b': 'a b', + r'#/$defs/a%2520b': 'a%20b', + r'#/$defs/%E2%98%83': '☃', + r'#/$defs/~01': '~1', + }.entries) { + final schema = Ack.fromJsonSchema({ + r'$defs': { + entry.value: {'const': 'yes'}, + }, + r'$ref': entry.key, + }); + expect(schema.safeParse('yes').isOk, isTrue, reason: entry.key); + expect(schema.safeParse('no').isFail, isTrue, reason: entry.key); + } + }, + ); + + test( + 'nested IDs change ref scope without scanning literal data for IDs', + () { + final schema = Ack.fromJsonSchema( + { + r'$id': 'https://example.test/root.json', + r'$defs': { + 'nested': { + r'$id': 'child/index.json', + 'properties': { + 'x': {r'$ref': 'value.json'}, + }, + 'required': ['x'], + }, }, + r'$ref': 'child/index.json', + 'default': {r'$id': 'child/index.json'}, }, - }, + documents: { + Uri.parse('https://example.test/child/value.json'): { + 'type': 'boolean', + }, + }, + ); + expect(schema.safeParse({'x': true}).isOk, isTrue); + expect(schema.safeParse({'x': 1}).isFail, isTrue); + }, + ); + + test( + 'the supplied bundle may also contain the identical root document', + () { + final uri = Uri.parse('https://example.test/root.json'); + final document = {'type': 'string'}; + expect( + Ack.fromJsonSchema( + document, + baseUri: uri, + documents: {uri: document}, + ).safeParse('ok').isOk, + isTrue, + ); + }, + ); + + test('schema documents and parsed values are detached snapshots', () { + final types = ['string']; + final document = {'type': types}; + final schema = Ack.fromJsonSchema(document); + types.add('number'); + expect(schema.safeParse(1).isFail, isTrue); + final input = { + 'a': [1], + }; + final parsed = Ack.fromJsonSchema(true).parse(input) as Map; + input['a']!.add(2); + expect(parsed, { + 'a': [1], }); - final error = - schema.safeParse({ - 'tags': ['x', 'z'], - }).getError() - as JsonSchemaValidationError; - expect(error.keyword, 'enum'); - expect(error.documentUri, Uri.parse('ack-import:///root.json')); - expect(error.pointer, '#/properties/tags/items/enum'); - expect(error.path, '#/tags/1'); + expect(() => (parsed['a'] as List).add(2), throwsUnsupportedError); + final cycle = []; + cycle.add(cycle); + expect(Ack.fromJsonSchema(true).safeParse(cycle).isFail, isTrue); + expect(Ack.fromJsonSchema(true).safeParse(double.nan).isFail, isTrue); + }); + + test( + 'fluent nullability overrides preserve parse/encode/export parity', + () { + for (final schema in [ + Ack.fromJsonSchema({'type': 'string'}).nullable(), + Ack.fromJsonSchema(true).nullable(value: false), + ]) { + final roundTrip = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + expect(schema.safeParse(null).isOk, schema.isNullable); + expect(schema.safeEncode(null).isOk, schema.isNullable); + expect(roundTrip.safeParse(null).isOk, schema.isNullable); + } + }, + ); + + test( + 'resolves cross-file, escaped pointers, anchors, and recursive refs', + () { + final schema = Ack.fromJsonSchema( + { + r'$id': 'https://example.test/root.json', + r'$ref': 'types.json#node', + }, + documents: { + Uri.parse('https://example.test/types.json'): { + r'$defs': { + 'a/b~c': { + r'$anchor': 'node', + 'type': 'object', + 'properties': { + 'value': {'type': 'integer'}, + 'next': {r'$ref': r'#/$defs/a~1b~0c'}, + }, + 'required': ['value'], + 'additionalProperties': false, + }, + }, + }, + }, + ); + final value = { + 'value': 1, + 'next': {'value': 2}, + }; + expect(schema.parse(value), value); + expect(schema.safeParse({'value': 1, 'next': {}}).isFail, isTrue); + final roundTrip = Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ); + expect(roundTrip.safeParse(value).isOk, isTrue); + expect(roundTrip.safeParse({'value': 1, 'next': {}}).isFail, isTrue); + }, + ); + + test( + 'reports missing refs and nonproductive cycles instead of overflowing', + () { + for (final document in [ + {r'$ref': 'missing.json'}, + {r'$ref': '#'}, + { + r'$defs': { + 'a': {r'$ref': '#'}, + }, + r'$ref': r'#/$defs/a', + }, + ]) { + expect( + () => Ack.fromJsonSchema(document), + throwsA(isA()), + ); + } + }, + ); + + test('bundled 2020-12 meta-schema validates offline', () { + final meta = Ack.fromJsonSchema({ + r'$ref': 'https://json-schema.org/draft/2020-12/schema', + }); + expect(meta.safeParse({'type': 'string'}).isOk, isTrue); + expect(meta.safeParse({'type': 'not-a-json-type'}).isFail, isTrue); + expect(meta.safeParse({'minimum': 'zero'}).isFail, isTrue); + }); + + test('all standard meta-schema resources resolve offline', () { + for (final name in [ + 'schema', + 'meta/core', + 'meta/applicator', + 'meta/unevaluated', + 'meta/validation', + 'meta/meta-data', + 'meta/format-annotation', + 'meta/format-assertion', + 'meta/content', + ]) { + final resource = Ack.fromJsonSchema({ + r'$ref': 'https://json-schema.org/draft/2020-12/$name', + }); + expect( + resource.safeParse({'type': 'string'}).isOk, + isTrue, + reason: name, + ); + } + }); + + test('unsupported standard keywords report escaped locations', () { + final document = { + 'properties': { + 'a/b': { + r'$vocabulary': {'https://example.test/required': true}, + }, + }, + }; + try { + Ack.fromJsonSchema(document); + fail('Expected unsupported keyword diagnostics.'); + } on JsonSchemaImportException catch (error) { + expect( + error.diagnostics.map((diagnostic) => diagnostic.pointer), + contains(r'#/properties/a~1b/$vocabulary'), + ); + } + }); + + test( + 'format, content, and unknown keywords are annotations by default', + () { + final document = { + 'type': 'string', + 'format': 'email', + 'contentEncoding': 'base64', + 'contentMediaType': 'application/json', + 'contentSchema': {'type': 'object'}, + 'x-custom': {'expected': false}, + }; + final schema = Ack.fromJsonSchema(document); + expect(schema.safeParse('not an email or base64').isOk, isTrue); + expect(schema.safeParse(1).isFail, isTrue); + final imported = schema.toJsonSchemaPreservingImportedDialect(); + for (final key in [ + 'format', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', + 'x-custom', + ]) { + expect(imported[key], document[key], reason: key); + } + expect( + Ack.fromJsonSchema( + schema.toJsonSchemaPreservingImportedDialect(), + ).safeParse('invalid').isOk, + isTrue, + ); + }, + ); + + test('unknown required vocabularies remain unsupported', () { expect( - error.toMap(), - allOf( - containsPair('keyword', 'enum'), - containsPair('documentUri', 'ack-import:///root.json'), - containsPair('pointer', '#/properties/tags/items/enum'), - ), + () => Ack.fromJsonSchema({ + r'$vocabulary': {'https://example.test/required': true}, + }), + throwsA(isA()), ); }); - test('runtime error locations follow references across documents', () { - final schema = Ack.fromJsonSchema( - { - 'properties': { - 'a': {r'$ref': r'defs.json#/$defs/a~1b'}, - 'b': { - 'properties': {'c': false}, + test('unknown optional vocabularies are retained but inert', () { + final schema = Ack.fromJsonSchema({ + r'$vocabulary': {'https://example.test/optional': false}, + 'type': 'string', + }); + expect(schema.safeParse('accepted').isOk, isTrue); + expect(schema.safeParse(1).isFail, isTrue); + expect(schema.toJsonSchemaPreservingImportedDialect()[r'$vocabulary'], { + 'https://example.test/optional': false, + }); + }); + + test('rejects malformed supported keywords', () { + for (final document in [ + {'type': 'made-up'}, + {'required': 'a'}, + {'minItems': -1}, + {'anyOf': []}, + {'enum': 1}, + {'items': 3}, + {'pattern': 1}, + {'pattern': '('}, + ]) { + expect( + () => Ack.fromJsonSchema(document), + throwsA(isA()), + reason: '$document', + ); + } + }); + + test( + 'runtime failures identify the failing keyword and source location', + () { + final schema = Ack.fromJsonSchema( + { + 'properties': { + 'a': {r'$ref': r'defs.json#/$defs/a~1b'}, + 'b': { + 'properties': {'c': false}, + }, }, + 'required': ['a'], }, - 'required': ['a'], - }, - baseUri: Uri.parse('https://example.test/root.json'), - documents: { - Uri.parse('https://example.test/defs.json'): { - r'$defs': { - 'a/b': {'type': 'string'}, + baseUri: Uri.parse('https://example.test/root.json'), + documents: { + Uri.parse('https://example.test/defs.json'): { + r'$defs': { + 'a/b': {'type': 'string'}, + }, }, }, - }, - ); - JsonSchemaValidationError failure(Object value) => - schema.safeParse(value).getError() as JsonSchemaValidationError; - - final type = failure({'a': 1}); - expect(type.keyword, 'type'); - expect(type.documentUri, Uri.parse('https://example.test/defs.json')); - expect(type.pointer, r'#/$defs/a~1b/type'); - expect(type.path, '#/a'); - - final rejected = failure({ - 'a': 'ok', - 'b': {'c': 1}, - }); - expect(rejected.keyword, isEmpty); - expect(rejected.documentUri, Uri.parse('https://example.test/root.json')); - expect(rejected.pointer, '#/properties/b/properties/c'); - expect(rejected.message, contains('"false" failed')); - - final missing = failure({}); - expect(missing.keyword, 'required'); - expect(missing.pointer, '#/required'); - expect(missing.path, '#/a'); - }); + ); + JsonSchemaValidationError failure(Object value) => + schema.safeParse(value).getError() as JsonSchemaValidationError; + + final type = failure({'a': 1}); + expect(type.keyword, 'type'); + expect(type.documentUri, Uri.parse('https://example.test/defs.json')); + expect(type.pointer, r'#/$defs/a~1b/type'); + expect(type.path, '#/a'); + + final rejected = failure({ + 'a': 'ok', + 'b': {'c': 1}, + }); + expect(rejected.keyword, isEmpty); + expect( + rejected.documentUri, + Uri.parse('https://example.test/root.json'), + ); + expect(rejected.pointer, '#/properties/b/properties/c'); + expect(rejected.message, contains('"false" failed')); + + final missing = failure({}); + expect(missing.keyword, 'required'); + expect(missing.pointer, '#/required'); + expect(missing.path, '#/a'); + }, + ); group('error locations use URI fragment encoding', () { for (final entry in { @@ -655,207 +1035,6 @@ void main() { }); }); - test('recursive validation has no hidden Ack.lazy depth limit', () { - final schema = Ack.fromJsonSchema({ - 'type': 'object', - 'properties': { - 'child': {r'$ref': '#'}, - }, - }); - var value = {}; - for (var i = 0; i < 150; i++) { - value = {'child': value}; - } - expect(schema.safeParse(value).isOk, isTrue); - }); - - test( - 'URI fragments are decoded once, including percent and tilde tokens', - () { - for (final entry in { - r'#/$defs/a%20b': 'a b', - r'#/$defs/a%2520b': 'a%20b', - r'#/$defs/%E2%98%83': '☃', - r'#/$defs/~01': '~1', - }.entries) { - final schema = Ack.fromJsonSchema({ - r'$defs': { - entry.value: {'const': 'yes'}, - }, - r'$ref': entry.key, - }); - expect(schema.safeParse('yes').isOk, isTrue, reason: entry.key); - expect(schema.safeParse('no').isFail, isTrue, reason: entry.key); - } - }, - ); - - test( - 'nested IDs change ref scope without scanning literal data for IDs', - () { - final schema = Ack.fromJsonSchema( - { - r'$id': 'https://example.test/root.json', - r'$defs': { - 'nested': { - r'$id': 'child/index.json', - 'properties': { - 'x': {r'$ref': 'value.json'}, - }, - 'required': ['x'], - }, - }, - r'$ref': 'child/index.json', - 'default': {r'$id': 'child/index.json'}, - }, - documents: { - Uri.parse('https://example.test/child/value.json'): { - 'type': 'boolean', - }, - }, - ); - expect(schema.safeParse({'x': true}).isOk, isTrue); - expect(schema.safeParse({'x': 1}).isFail, isTrue); - }, - ); - - test( - 'the supplied bundle may also contain the identical root document', - () { - final uri = Uri.parse('https://example.test/root.json'); - final document = {'type': 'string'}; - expect( - Ack.fromJsonSchema( - document, - baseUri: uri, - documents: {uri: document}, - ).safeParse('ok').isOk, - isTrue, - ); - }, - ); - - test('schema documents and parsed values are detached snapshots', () { - final types = ['string']; - final document = {'type': types}; - final schema = Ack.fromJsonSchema(document); - types.add('number'); - expect(schema.safeParse(1).isFail, isTrue); - final input = { - 'a': [1], - }; - final parsed = Ack.fromJsonSchema(true).parse(input) as Map; - input['a']!.add(2); - expect(parsed, { - 'a': [1], - }); - expect(() => (parsed['a'] as List).add(2), throwsUnsupportedError); - final cycle = []; - cycle.add(cycle); - expect(Ack.fromJsonSchema(true).safeParse(cycle).isFail, isTrue); - expect(Ack.fromJsonSchema(true).safeParse(double.nan).isFail, isTrue); - }); - - test('parse and encode reject the same non-JSON values', () { - final schema = Ack.fromJsonSchema(true); - final cycle = {}; - cycle['self'] = [cycle]; - for (final value in [ - {1: 'a'}, - { - 'a': [double.infinity], - }, - cycle, - [DateTime(2026)], - ]) { - final parsed = schema.safeParse(value); - final encoded = schema.safeEncode(value); - expect(parsed.isFail, isTrue, reason: '$value'); - expect(encoded.isFail, isTrue, reason: '$value'); - expect(parsed.getError().message, encoded.getError().message); - } - final shared = ['x']; - final parsed = schema.parse({'a': shared, 'b': shared}) as Map; - expect(parsed, { - 'a': ['x'], - 'b': ['x'], - }); - expect(() => parsed['c'] = 1, throwsUnsupportedError); - }); - - test( - 'fluent nullability overrides preserve parse/encode/export parity', - () { - for (final schema in [ - Ack.fromJsonSchema({'type': 'string'}).nullable(), - Ack.fromJsonSchema(true).nullable(value: false), - ]) { - final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); - expect(schema.safeParse(null).isOk, schema.isNullable); - expect(schema.safeEncode(null).isOk, schema.isNullable); - expect(roundTrip.safeParse(null).isOk, schema.isNullable); - } - }, - ); - - test( - 'resolves cross-file, escaped pointers, anchors, and recursive refs', - () { - final schema = Ack.fromJsonSchema( - { - r'$id': 'https://example.test/root.json', - r'$ref': 'types.json#node', - }, - documents: { - Uri.parse('https://example.test/types.json'): { - r'$defs': { - 'a/b~c': { - r'$anchor': 'node', - 'type': 'object', - 'properties': { - 'value': {'type': 'integer'}, - 'next': {r'$ref': r'#/$defs/a~1b~0c'}, - }, - 'required': ['value'], - 'additionalProperties': false, - }, - }, - }, - }, - ); - final value = { - 'value': 1, - 'next': {'value': 2}, - }; - expect(schema.parse(value), value); - expect(schema.safeParse({'value': 1, 'next': {}}).isFail, isTrue); - final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); - expect(roundTrip.safeParse(value).isOk, isTrue); - expect(roundTrip.safeParse({'value': 1, 'next': {}}).isFail, isTrue); - }, - ); - - test( - 'reports missing refs and nonproductive cycles instead of overflowing', - () { - for (final document in [ - {r'$ref': 'missing.json'}, - {r'$ref': '#'}, - { - r'$defs': { - 'a': {r'$ref': '#'}, - }, - r'$ref': r'#/$defs/a', - }, - ]) { - expect( - () => Ack.fromJsonSchema(document), - throwsA(isA()), - ); - } - }, - ); - test('conditional reference cycles are nonproductive', () { for (final keyword in ['if', 'then', 'else']) { expect( @@ -953,63 +1132,6 @@ void main() { expect(nested.value, ['a', 'b', 'a']); }, ); - - test( - 'reports meta-schema references as unsupported, not as any schema', - () { - expect( - () => Ack.fromJsonSchema({ - r'$ref': 'https://json-schema.org/draft/2020-12/schema', - }), - throwsA( - isA().having( - (error) => error.diagnostics.single.keyword, - 'keyword', - r'$ref', - ), - ), - ); - }, - ); - - test('strict mode reports unsupported keywords at escaped locations', () { - final document = { - 'properties': { - 'a/b': {'format': 'email', 'x-custom': true}, - }, - }; - try { - Ack.fromJsonSchema(document); - fail('Expected unsupported keyword diagnostics.'); - } on JsonSchemaImportException catch (error) { - expect( - error.diagnostics.map((diagnostic) => diagnostic.pointer), - containsAll([ - '#/properties/a~1b/format', - '#/properties/a~1b/x-custom', - ]), - ); - } - }); - - test('rejects malformed supported keywords', () { - for (final document in [ - {'type': 'made-up'}, - {'required': 'a'}, - {'minItems': -1}, - {'anyOf': []}, - {'enum': 1}, - {'items': 3}, - {'pattern': 1}, - {'pattern': '('}, - ]) { - expect( - () => Ack.fromJsonSchema(document), - throwsA(isA()), - reason: '$document', - ); - } - }); }); } diff --git a/packages/ack/test/reference_semantics_test.dart b/packages/ack/test/reference_semantics_test.dart index 77c48c16..607774fd 100644 --- a/packages/ack/test/reference_semantics_test.dart +++ b/packages/ack/test/reference_semantics_test.dart @@ -99,7 +99,6 @@ void main() { expect(model.toJsonSchema(), { 'type': 'string', 'minLength': 10, - 'x-transformed': true, }); expect(model.warnings.map((warning) => warning.code), [ 'codec_runtime_constraint_not_exported', diff --git a/packages/ack/test/runtime_dependencies_test.dart b/packages/ack/test/runtime_dependencies_test.dart index de763ed8..13455090 100644 --- a/packages/ack/test/runtime_dependencies_test.dart +++ b/packages/ack/test/runtime_dependencies_test.dart @@ -6,7 +6,7 @@ import 'dart:io'; import 'package:test/test.dart'; void main() { - test('ack depends only on meta and collection at runtime', () { + test('ack depends only on lightweight runtime packages (no json_annotation)', () { final lines = File('pubspec.yaml').readAsLinesSync(); final start = lines.indexOf('dependencies:'); final dependencies = [ @@ -18,6 +18,9 @@ void main() { match.group(1), ]; - expect(dependencies, unorderedEquals(['meta', 'collection'])); + expect( + dependencies, + unorderedEquals(['meta', 'collection', 'punycoder', 'unorm_dart']), + ); }); } diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index ccfa5d9b..4a6337cd 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -174,6 +174,19 @@ void main() { expect(model.toJsonSchema(), schema.toJsonSchema()); }); + test('uses a resource-preserving model for imported JSON Schema', () { + final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + + final model = schema.toSchemaModelPreservingImportedDialect(); + + expect(model, isA()); + expect(model.toJsonSchema()['type'], 'string'); + expect( + model.toJsonSchema(), + schema.toJsonSchemaPreservingImportedDialect(), + ); + }); + test('rejects an imported definition reused as a lazy target', () { final imported = Ack.fromJsonSchema(true).nullable(value: false); final schema = Ack.object({ @@ -325,7 +338,7 @@ void main() { branch.properties!['type'] as AckStringSchemaModel; expect(discriminator.constValue, 'cat'); - expect(branch.extensions['x-transformed'], isTrue); + expect(branch.extensions, isEmpty); expect(branch.required, ['type', 'name']); }, ); @@ -339,7 +352,6 @@ void main() { expect(model.toJsonSchema(), { 'type': 'string', 'format': 'date', - 'x-transformed': true, }); expect( model.warnings.map((warning) => warning.code), @@ -396,7 +408,6 @@ void main() { expect(model.toJsonSchema(), { 'type': 'string', 'minLength': 1, - 'x-transformed': true, }); expect(model.warnings, isEmpty); }); @@ -412,7 +423,7 @@ void main() { ); final model = schema.toSchemaModel(); - expect(model.toJsonSchema(), {'type': 'integer', 'x-transformed': true}); + expect(model.toJsonSchema(), {'type': 'integer'}); expect(model.warnings, isEmpty); }); diff --git a/packages/ack/test/schemas/datetime_validation_test.dart b/packages/ack/test/schemas/datetime_validation_test.dart index 52f7230e..dac3d92a 100644 --- a/packages/ack/test/schemas/datetime_validation_test.dart +++ b/packages/ack/test/schemas/datetime_validation_test.dart @@ -356,12 +356,12 @@ void main() { }); group('JSON Schema', () { - test('emits format: date and x-transformed', () { + test('emits format: date without x-transformed', () { final schema = Ack.date(); final jsonSchema = schema.toJsonSchema(); expect(jsonSchema['format'], 'date'); - expect(jsonSchema['x-transformed'], isTrue); + expect(jsonSchema.containsKey('x-transformed'), isFalse); }); test('datetime emits format: date-time', () { @@ -369,7 +369,7 @@ void main() { final jsonSchema = schema.toJsonSchema(); expect(jsonSchema['format'], 'date-time'); - expect(jsonSchema['x-transformed'], isTrue); + expect(jsonSchema.containsKey('x-transformed'), isFalse); }); test('base schema includes type: string', () { diff --git a/packages/ack/test/schemas/discriminated_object_schema_test.dart b/packages/ack/test/schemas/discriminated_object_schema_test.dart index b973e3ad..c932f1d9 100644 --- a/packages/ack/test/schemas/discriminated_object_schema_test.dart +++ b/packages/ack/test/schemas/discriminated_object_schema_test.dart @@ -779,7 +779,7 @@ void main() { final properties = (branch['properties'] as Map) .cast(); - expect(branch['x-transformed'], isTrue); + expect(branch.containsKey('x-transformed'), isFalse); expect(properties['type'], equals({'type': 'string', 'const': 'cat'})); expect(branch['required'], equals(['type', 'name'])); }); diff --git a/packages/ack/test/schemas/duration_validation_test.dart b/packages/ack/test/schemas/duration_validation_test.dart index 7c159628..07385e30 100644 --- a/packages/ack/test/schemas/duration_validation_test.dart +++ b/packages/ack/test/schemas/duration_validation_test.dart @@ -182,12 +182,12 @@ void main() { }); group('JSON Schema', () { - test('emits type integer and x-transformed', () { + test('emits type integer without x-transformed', () { final schema = Ack.duration(); final jsonSchema = schema.toJsonSchema(); expect(jsonSchema['type'], 'integer'); - expect(jsonSchema['x-transformed'], isTrue); + expect(jsonSchema.containsKey('x-transformed'), isFalse); }); test('emits minimum with milliseconds value', () { @@ -212,7 +212,6 @@ void main() { expect(model.toJsonSchema(), { 'type': 'integer', - 'x-transformed': true, 'minimum': 5000, 'maximum': 10000, }); @@ -228,7 +227,7 @@ void main() { final model = schema.toSchemaModel(); expect(model.toJsonSchema()['anyOf'], [ - {'type': 'integer', 'minimum': 5000, 'x-transformed': true}, + {'type': 'integer', 'minimum': 5000}, {'type': 'null'}, ]); expect(model.warnings, isEmpty); diff --git a/packages/ack/test/schemas/effective_nullability_test.dart b/packages/ack/test/schemas/effective_nullability_test.dart index ab0fcf57..3e9864c9 100644 --- a/packages/ack/test/schemas/effective_nullability_test.dart +++ b/packages/ack/test/schemas/effective_nullability_test.dart @@ -6,6 +6,31 @@ AnyOfSchema _nullableUnion() => Ack.anyOf([Ack.string().nullable(), Ack.integer()]); void main() { + group('nullable wrapper composition', () { + test('keeps optional fields optional and retains their description', () { + final field = Ack.string().optional().describe('Display name').nullable(); + final schema = Ack.object({'name': field}); + + expect(field.isOptional, isTrue); + expect(field.description, 'Display name'); + expect(schema.safeParse({}).isOk, isTrue); + expect(schema.safeEncode({}).isOk, isTrue); + expect(schema.safeParse({'name': null}).isOk, isTrue); + }); + + test('enforces refinements added after nullable on parse and encode', () { + final schema = Ack.string().nullable().refine( + (value) => value != 'blocked', + message: 'Blocked name', + ); + + expect(schema.safeParse('blocked').isFail, isTrue); + expect(schema.safeEncode('blocked').isFail, isTrue); + expect(schema.safeParse('allowed').isOk, isTrue); + expect(schema.safeParse(null).isOk, isTrue); + }); + }); + group('effective nullability — object fields', () { test('required nullable-union field parses and encodes null', () { final schema = Ack.object({'v': _nullableUnion()}); diff --git a/packages/ack/test/schemas/uri_validation_test.dart b/packages/ack/test/schemas/uri_validation_test.dart index b522a822..113f1e76 100644 --- a/packages/ack/test/schemas/uri_validation_test.dart +++ b/packages/ack/test/schemas/uri_validation_test.dart @@ -81,13 +81,13 @@ void main() { }); group('JSON Schema', () { - test('emits type string, format uri, and x-transformed', () { + test('emits type string and format uri without x-transformed', () { final schema = Ack.uri(); final jsonSchema = schema.toJsonSchema(); expect(jsonSchema['type'], 'string'); expect(jsonSchema['format'], 'uri'); - expect(jsonSchema['x-transformed'], isTrue); + expect(jsonSchema.containsKey('x-transformed'), isFalse); }); }); }); diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json index 6f309877..6fc20dd2 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_native_json_schema/manifest.json @@ -387,7 +387,7 @@ "format" ], "status": "generated", - "comparison": "adapter_transform_needed", + "comparison": "exact", "fixture": "ack_schema_date_transform_constraints.json" }, { @@ -399,7 +399,7 @@ "format" ], "status": "generated", - "comparison": "adapter_transform_needed", + "comparison": "exact", "fixture": "ack_schema_datetime_transform.json" }, { @@ -411,7 +411,7 @@ "format" ], "status": "generated", - "comparison": "adapter_transform_needed", + "comparison": "exact", "fixture": "ack_schema_uri_transform.json" }, { @@ -425,7 +425,7 @@ "maximum" ], "status": "generated", - "comparison": "adapter_transform_needed", + "comparison": "exact", "fixture": "ack_schema_duration_transform_constraints.json" }, { diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_date_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_date_transform_constraints.json index e86af8b2..c1a190b8 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_date_transform_constraints.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_date_transform_constraints.json @@ -1,5 +1,4 @@ { "type": "string", - "format": "date", - "x-transformed": true + "format": "date" } diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_datetime_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_datetime_transform.json index 390e855a..fd9a76a4 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_datetime_transform.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_datetime_transform.json @@ -1,5 +1,4 @@ { "type": "string", - "format": "date-time", - "x-transformed": true + "format": "date-time" } diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_duration_transform_constraints.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_duration_transform_constraints.json index 87c6b993..a536867f 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_duration_transform_constraints.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_duration_transform_constraints.json @@ -1,6 +1,5 @@ { "type": "integer", "minimum": 1000, - "maximum": 2000, - "x-transformed": true + "maximum": 2000 } diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_generic_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_generic_transform.json index 16fd42ab..ae56259c 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_generic_transform.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_generic_transform.json @@ -1,5 +1,4 @@ { "type": "string", - "minLength": 1, - "x-transformed": true + "minLength": 1 } diff --git a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_uri_transform.json b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_uri_transform.json index ca95dbf3..3caf8ad3 100644 --- a/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_uri_transform.json +++ b/packages/ack_firebase_ai/test/fixtures/firebase_ai_response_json_schema/ack_schema_uri_transform.json @@ -1,5 +1,4 @@ { "type": "string", - "format": "uri", - "x-transformed": true + "format": "uri" } diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart index e66a89d2..c2cf081c 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_native_schema_cases.dart @@ -418,22 +418,22 @@ FirebaseAiNativeSchemaCase _nativeJsonSchemaCase( ), 'ack_schema_date_transform_constraints' => _generatedJsonSchema( schemaCase, - FirebaseAiSchemaComparison.adapterTransformNeeded, + FirebaseAiSchemaComparison.exact, firebase_ai.JSONSchema.string(format: 'date'), ), 'ack_schema_datetime_transform' => _generatedJsonSchema( schemaCase, - FirebaseAiSchemaComparison.adapterTransformNeeded, + FirebaseAiSchemaComparison.exact, firebase_ai.JSONSchema.string(format: 'date-time'), ), 'ack_schema_uri_transform' => _generatedJsonSchema( schemaCase, - FirebaseAiSchemaComparison.adapterTransformNeeded, + FirebaseAiSchemaComparison.exact, firebase_ai.JSONSchema.string(format: 'uri'), ), 'ack_schema_duration_transform_constraints' => _generatedJsonSchema( schemaCase, - FirebaseAiSchemaComparison.adapterTransformNeeded, + FirebaseAiSchemaComparison.exact, firebase_ai.JSONSchema.integer(minimum: 1000, maximum: 2000), ), 'ack_schema_discriminated_union' => _generatedJsonSchema( diff --git a/packages/ack_generator/build.yaml b/packages/ack_generator/build.yaml index 7358d5b7..e18cadee 100644 --- a/packages/ack_generator/build.yaml +++ b/packages/ack_generator/build.yaml @@ -2,35 +2,18 @@ builders: ack_generator: import: "package:ack_generator/builder.dart" builder_factories: ["ackGenerator"] - build_extensions: {".dart": [".g.dart"]} + build_extensions: {".dart": [".ack.g.part"]} auto_apply: dependents - build_to: source - defaults: - generate_for: - exclude: - - "**/*.ack.dart" - - "**/*.ack.g.dart" - - ack_models: - import: "package:ack_generator/builder.dart" - builder_factories: ["ackModelBuilder"] - build_extensions: {".dart": [".ack.dart"]} - auto_apply: dependents - build_to: source - runs_before: - - ack_generator|ack_model_json - applies_builders: - - ack_generator|ack_model_json - defaults: - generate_for: - exclude: - - "**/*.ack.dart" - - "**/*.ack.g.dart" + build_to: cache + applies_builders: ["source_gen|combining_builder"] - ack_model_json: + ack_json_schema: import: "package:ack_generator/builder.dart" - builder_factories: ["ackModelJsonBuilder"] - build_extensions: {".dart": [".ack.g.dart"]} + builder_factories: ["ackJsonSchemaBuilder"] + build_extensions: {".schema.json": [".schema.dart"]} auto_apply: none build_to: source - required_inputs: [".ack.dart"] + runs_before: ["ack_generator:ack_generator"] + defaults: + options: + unknown_properties: schema diff --git a/packages/ack_generator/lib/ack_generator.dart b/packages/ack_generator/lib/ack_generator.dart index a94e1b51..fac5f9c8 100644 --- a/packages/ack_generator/lib/ack_generator.dart +++ b/packages/ack_generator/lib/ack_generator.dart @@ -1,6 +1,5 @@ // Export the builders for build.yaml -export 'src/builder.dart' - show ackGenerator, ackModelBuilder, ackModelJsonBuilder; +export 'src/builder.dart' show ackGenerator, ackModelBuilder; // Re-exports the annotations for convenience alongside the builder entrypoint. export 'package:ack/annotations.dart'; diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 4f94ee8f..7eaa1e73 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -1,13 +1,10 @@ -import 'package:ack/ack.dart' show AckSchema; +import 'package:ack/ack.dart' show AckModelAdapter, AckSchema; import 'package:ack/annotations.dart' as annotations; -import 'package:ack/ack_generator_support.dart'; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; -import 'package:build/build.dart'; -import 'package:json_annotation/json_annotation.dart'; import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; @@ -141,25 +138,10 @@ final class ClassModelGraphBuilder { 'toUpperCase', }; - static const _ackModelChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - annotations.AckModel, - inPackage: 'ack', - ); static const _schemableChecker = TypeChecker.typeNamed( annotations.Schemable, inPackage: 'ack', ); - static const _ackInferChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - annotations.AckInfer, - inPackage: 'ack', - ); - static const _ackTypeChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - annotations.AckType, - inPackage: 'ack', - ); static const _ackFieldChecker = TypeChecker.typeNamed( annotations.AckField, inPackage: 'ack', @@ -168,20 +150,20 @@ final class ClassModelGraphBuilder { AckSchema, inPackage: 'ack', ); - static const _generatedJsonChecker = TypeChecker.typeNamed( - AckGeneratedJson, + static const _ackModelAdapterChecker = TypeChecker.typeNamed( + AckModelAdapter, inPackage: 'ack', ); - static const _jsonSerializableChecker = TypeChecker.typeNamed( - JsonSerializable, + static const _jsonSerializableChecker = TypeChecker.typeNamedLiterally( + 'JsonSerializable', inPackage: 'json_annotation', ); - static const _jsonKeyChecker = TypeChecker.typeNamed( - JsonKey, + static const _jsonKeyChecker = TypeChecker.typeNamedLiterally( + 'JsonKey', inPackage: 'json_annotation', ); - static const _jsonConverterChecker = TypeChecker.typeNamed( - JsonConverter, + static const _jsonConverterChecker = TypeChecker.typeNamedLiterally( + 'JsonConverter', inPackage: 'json_annotation', ); @@ -294,22 +276,13 @@ final class ClassModelGraphBuilder { claim(ackClassMixinName(node.className), node); claim(ackClassRawObjectName(node.className), node); if (node is! AckObjectModelNode) continue; + claim(ackClassFromRuntimeName(node.className), node); + claim(ackClassToRuntimeName(node.className), node); if (node.fields.any((field) => field.nullable || !field.isRequired)) { claim(ackCopyWithUnsetTypeName(node.className), node); } claim(ackCopyWithInterfaceName(node.className), node); claim(ackCopyWithImplementationName(node.className), node); - claim(ackClassFromRuntimeName(node.className), node); - claim(ackClassToRuntimeName(node.className), node); - claim(jsonFromHelperName(node.className), node); - claim(jsonToHelperName(node.className), node); - for (final fieldName in [ - for (final field in node.fields) field.dartName, - if (node.captureFieldName case final capture?) capture, - ]) { - claim(ackClassFromRuntimeBridgeName(node.className, fieldName), node); - claim(ackClassToRuntimeBridgeName(node.className, fieldName), node); - } } } @@ -596,7 +569,6 @@ final class ClassModelGraphBuilder { for (final field in fields.values) { final name = field.name; if (name == null) continue; - _rejectResolvedLegacyGeneratedType(field.type, field); if (!_containsInvalidType(field.type)) continue; futureTypes[name] = await _futureGeneratedType(field); } @@ -891,30 +863,6 @@ final class ClassModelGraphBuilder { type is InvalidType || (type is InterfaceType && type.typeArguments.any(_containsInvalidType)); - void _rejectResolvedLegacyGeneratedType(DartType type, FieldElement field) { - if (type is! InterfaceType) return; - final element = type.element; - if (element is ExtensionTypeElement) { - final name = element.name; - if (name != null && _isGeneratedAckType(element, name)) { - _rejectLegacyGeneratedType(field, name); - } - } - for (final argument in type.typeArguments) { - _rejectResolvedLegacyGeneratedType(argument, field); - } - } - - bool _isGeneratedAckType(ExtensionTypeElement element, String name) { - for (final candidate in LibraryReader(element.library).allElements) { - final declaration = _ackTypeDeclaration(candidate); - if (declaration != null && _generatedAckTypeName(declaration) == name) { - return true; - } - } - return false; - } - Future<_FutureGeneratedType> _futureGeneratedType(FieldElement field) async { final resolved = await _resolvedLibraryFor(field.library); AstNode? node = resolved.getFragmentDeclaration(field.firstFragment)?.node; @@ -977,14 +925,6 @@ final class ClassModelGraphBuilder { setListSchema: listSchema, ); } else { - final legacyTarget = _futureAckTypeTarget( - generatedTypeName: name, - prefix: prefix, - field: field, - ); - if (legacyTarget != null) { - _rejectLegacyGeneratedType(field, name); - } final target = _futureAckInferTarget( className: name, prefix: prefix, @@ -1094,103 +1034,6 @@ final class ClassModelGraphBuilder { return matches.single; } - Element? _futureAckTypeTarget({ - required String generatedTypeName, - required String? prefix, - required FieldElement field, - }) { - final matches = {}; - String? hiddenDeclaration; - - void consider(Element element, LibraryImport? import) { - final declaration = _ackTypeDeclaration(element); - if (declaration == null || - _generatedAckTypeName(declaration) != generatedTypeName) { - return; - } - if (import != null && !_importAllowsName(import, generatedTypeName)) { - hiddenDeclaration = declaration.name; - return; - } - matches.add(declaration.baseElement); - } - - if (prefix == null) { - for (final element in library.allElements) { - consider(element, null); - } - } - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importPrefix = import.prefix?.element.name; - if (importPrefix != prefix) continue; - final importedLibrary = import.importedLibrary; - final elements = { - if (importedLibrary != null) - ...LibraryReader(importedLibrary).allElements, - ...import.namespace.definedNames2.values, - }; - for (final element in elements) { - consider(element, import); - } - } - if (matches.isEmpty) { - if (hiddenDeclaration != null) { - throw InvalidGenerationSource( - 'Generated legacy type "$generatedTypeName" is hidden by an ' - 'import combinator. Expose $generatedTypeName from the import.', - element: field, - ); - } - return null; - } - if (matches.length > 1) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} resolves future legacy ' - 'generated type "$generatedTypeName" ambiguously.', - element: field, - ); - } - return matches.single; - } - - Never _rejectLegacyGeneratedType(FieldElement field, String name) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} crosses from a modern ' - 'class-first model into legacy @AckType generated type "$name". AckType ' - 'and modern models intentionally use isolated generators; migrate ' - 'this connected graph together.', - element: field, - ); - } - - Element? _ackTypeDeclaration(Element element) { - final declaration = switch (element) { - GetterElement(isOriginVariable: true) => element.variable.baseElement, - GetterElement() => element.baseElement, - TopLevelVariableElement() => element.baseElement, - _ => null, - }; - return declaration != null && - _ackTypeChecker.hasAnnotationOfExact(declaration) - ? declaration - : null; - } - - String _generatedAckTypeName(Element declaration) { - final annotation = _ackTypeChecker.firstAnnotationOfExact(declaration)!; - final configuredName = ConstantReader(annotation).read('name'); - var baseName = configuredName.isNull - ? declaration.name! - : configuredName.stringValue.trim(); - if (configuredName.isNull && baseName.endsWith('Schema')) { - baseName = baseName.substring(0, baseName.length - 'Schema'.length); - } - if (baseName.isEmpty) baseName = 'Type'; - baseName = '${baseName[0].toUpperCase()}${baseName.substring(1)}'; - return '${baseName}Type'; - } - Element? _ackInferDeclaration(Element element) { final declaration = switch (element) { GetterElement(isOriginVariable: true) => element.variable.baseElement, @@ -1198,16 +1041,13 @@ final class ClassModelGraphBuilder { _ => null, }; return declaration != null && - (_ackInferChecker.hasAnnotationOfExact(declaration) || - _schemableChecker.hasAnnotationOfExact(declaration)) + _schemableChecker.hasAnnotationOfExact(declaration) ? declaration : null; } String _generatedAckInferClassName(Element declaration) { - final annotation = - _ackInferChecker.firstAnnotationOfExact(declaration) ?? - _schemableChecker.firstAnnotationOfExact(declaration)!; + final annotation = _schemableChecker.firstAnnotationOfExact(declaration)!; final custom = ConstantReader(annotation).read('name'); return ackInferModelClassName( declaration.name!, @@ -1215,20 +1055,6 @@ final class ClassModelGraphBuilder { ); } - bool _importAllowsName(LibraryImport import, String name) { - for (final combinator in import.combinators) { - if (combinator is ShowElementCombinator && - !combinator.shownNames.contains(name)) { - return false; - } - if (combinator is HideElementCombinator && - combinator.hiddenNames.contains(name)) { - return false; - } - } - return true; - } - Future _escapeHatchExpression( FieldElement field, ConstantReader annotation, @@ -1363,12 +1189,32 @@ final class ClassModelGraphBuilder { return '${prefix == null ? '' : '$prefix.'}$facadeName.schema'; } } - if (_generatedJsonChecker.hasAnnotationOfExact(target)) { + if (_schemaFirstRuntimeRef(target, field) != null) { return '${_visibleTypeName(interfaceType, field)}.\$ack.schema'; } return await _typeOwnedSchema(interfaceType, field); } + AckInferRef? _schemaFirstRuntimeRef( + InterfaceElement target, + FieldElement field, + ) { + final getter = target.getGetter(r'$ack'); + final returnType = getter?.returnType; + if (getter == null || + !getter.isStatic || + returnType is! InterfaceType || + !_ackModelAdapterChecker.isExactlyType(returnType)) { + return null; + } + final runtimeType = returnType.typeArguments.length == 3 + ? returnType.typeArguments[1] + : null; + return runtimeType == null || _containsInvalidType(runtimeType) + ? _jsonMapRef + : _typeRef(runtimeType, field); + } + /// Resolves [type] through a static `schema` that the type declares. /// /// A non-generic type declares a static field or getter. A generic type @@ -1622,16 +1468,15 @@ final class ClassModelGraphBuilder { 'Object', }.contains(interfaceType.element.name)) { result = AckScalarTypeRef(interfaceType.element.name!); - } else if (_generatedJsonChecker.hasAnnotationOfExact( - interfaceType.element, - )) { + } else if (_schemaFirstRuntimeRef(interfaceType.element, field) + case final runtimeRef?) { result = AckModelTypeRef( schemaId: AckSchemaId( libraryUri: interfaceType.element.library.uri, declarationName: interfaceType.element.name!, ), className: interfaceType.element.name!, - runtimeRef: _jsonMapRef, + runtimeRef: runtimeRef, importPrefix: _visiblePrefix(interfaceType.element, field), ); } else { @@ -1715,53 +1560,28 @@ final class ClassModelGraphBuilder { } final annotation = _ackFieldChecker.firstAnnotationOfExact(field); - AckSchemaFieldPresence? legacyOverride; if (annotation != null) { final reader = ConstantReader(annotation); - final schemaMissing = reader.read('schema').isNull; - // AckFieldPresence index: 0 inferred, 1 required, 2 optional. - final presenceIndex = reader - .read('presence') - .objectValue - .getField('index')! - .toIntValue()!; - if (schemaMissing && - reader.read('description').isNull && - presenceIndex == 0) { + if (reader.read('name').isNull && + reader.read('schema').isNull && + reader.read('description').isNull) { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} @AckField() is a ' - 'no-op; set schema, description, or presence.', + 'no-op; set name, schema, or description.', element: field, ); } - legacyOverride = switch (presenceIndex) { - 0 => null, - 1 => AckSchemaFieldPresence.required, - 2 => AckSchemaFieldPresence.optional, - _ => throw StateError('Unknown AckFieldPresence index $presenceIndex.'), - }; } - final AckSchemaFieldPresence? annotationOverride; + final AckSchemaFieldPresence? override; if (hasOptional) { - annotationOverride = AckSchemaFieldPresence.optional; + override = AckSchemaFieldPresence.optional; } else if (hasRequired) { - annotationOverride = AckSchemaFieldPresence.required; + override = AckSchemaFieldPresence.required; } else { - annotationOverride = null; + override = null; } - if (legacyOverride != null && - annotationOverride != null && - legacyOverride != annotationOverride) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} has conflicting ' - 'presence declarations.', - element: field, - ); - } - - final override = annotationOverride ?? legacyOverride; if (override == AckSchemaFieldPresence.optional) { final canBeOptional = isDiscriminator || @@ -1776,14 +1596,6 @@ final class ClassModelGraphBuilder { } } - if (legacyOverride != null) { - log.warning( - '${field.enclosingElement.name}.${field.name} uses ' - '@AckField(presence: ...); use @Optional() or @Required() instead. ' - 'AckField.presence will be removed in 2.0.0.', - ); - } - return override ?? inferred; } @@ -2025,9 +1837,7 @@ final class ClassModelGraphBuilder { } _ModelOptions? _options(ClassElement element) { - final annotation = - _ackModelChecker.firstAnnotationOfExact(element) ?? - _schemableChecker.firstAnnotationOfExact(element); + final annotation = _schemableChecker.firstAnnotationOfExact(element); if (annotation == null) return null; final reader = ConstantReader(annotation); final caseStyle = reader.read('caseStyle').objectValue; @@ -2050,19 +1860,7 @@ final class ClassModelGraphBuilder { } /// The annotation spelling that marks [element] or its sealed base. - String _annotationName(Element element) { - if (element is! ClassElement) return '@Schemable'; - for (final candidate in [ - element, - for (final supertype in element.allSupertypes) supertype.element, - ]) { - if (_ackModelChecker.hasAnnotationOfExact(candidate)) return '@AckModel'; - if (_schemableChecker.hasAnnotationOfExact(candidate)) { - return '@Schemable'; - } - } - return '@Schemable'; - } + String _annotationName(Element element) => '@Schemable'; String? _classDescription(ClassElement element, _ModelOptions options) { final tag = _descriptionTag(element); @@ -2104,8 +1902,7 @@ final class ClassModelGraphBuilder { return base is ClassElement && base.library == element.library && base.isSealed && - (_ackModelChecker.hasAnnotationOfExact(base) || - _schemableChecker.hasAnnotationOfExact(base)); + _schemableChecker.hasAnnotationOfExact(base); }); return isImplicitUnionBranch ? ackClassSchemaFacadeName(element.name!) @@ -2113,8 +1910,23 @@ final class ClassModelGraphBuilder { } String? _jsonKey(FieldElement field) { + String? ackFieldName; + final ackField = _ackFieldChecker.firstAnnotationOfExact(field); + if (ackField != null) { + final nameReader = ConstantReader(ackField).read('name'); + if (!nameReader.isNull) { + ackFieldName = nameReader.stringValue; + if (ackFieldName.trim().isEmpty) { + throw InvalidGenerationSource( + '${field.enclosingElement.name}.${field.name} @AckField.name must ' + 'not be blank.', + element: field, + ); + } + } + } final annotation = _jsonKeyChecker.firstAnnotationOfExact(field); - if (annotation == null) return null; + if (annotation == null) return ackFieldName; final reader = ConstantReader(annotation); const unsupportedOptions = [ 'defaultValue', @@ -2145,7 +1957,16 @@ final class ClassModelGraphBuilder { ); } final value = reader.read('name'); - return value.isNull ? null : value.stringValue; + final jsonKeyName = value.isNull ? null : value.stringValue; + if (ackFieldName != null && jsonKeyName != null) { + throw InvalidGenerationSource( + '${field.enclosingElement.name}.${field.name} sets both ' + '@AckField(name: ...) and @JsonKey(name: ...). Use @AckField(name: ...) ' + 'only.', + element: field, + ); + } + return ackFieldName ?? jsonKeyName; } void _rejectJsonKeyOnParameter( diff --git a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart b/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart deleted file mode 100644 index 6274190c..00000000 --- a/packages/ack_generator/lib/src/analyzer/schema_ast_analyzer.dart +++ /dev/null @@ -1,3784 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:ack/annotations.dart'; -import 'package:analyzer/dart/analysis/results.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/token.dart' show Keyword; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:analyzer/dart/element/type_provider.dart'; -import 'package:logging/logging.dart'; -import 'package:source_gen/source_gen.dart'; - -import '../json/helper_names.dart'; -import '../models/field_info.dart'; -import '../models/model_info.dart'; - -/// Logger for schema AST analysis warnings and diagnostics. -final _log = Logger('SchemaAstAnalyzer'); - -typedef _SchemaReference = ({String name, String? prefix}); -typedef _ListElementRef = ({ - MethodInvocation? invocation, - _SchemaReference? schemaRef, -}); -typedef _SchemaChainInfo = ({ - MethodInvocation? ackBase, - _SchemaReference? schemaReference, - bool isOptional, - bool isNullable, - bool wasTruncated, - MethodInvocation? transformInvocation, - DartType? transformOutputType, - String? transformOutputTypeString, -}); - -typedef _SchemaTypeMapping = ({ - DartType dartType, - String? listElementSchemaRef, - String? listElementDisplayTypeOverride, - String? listElementCastTypeOverride, - bool listElementIsCustomType, -}); -typedef _ListElementAnalysis = ({ - _SchemaTypeMapping mapping, - String elementRepresentationType, -}); -typedef _ResolvedSchemaElement = ({ - Element element, - LibraryImport? importDirective, -}); - -class _ResolvedSchemaReference { - final String schemaName; - final ModelInfo modelInfo; - final String? importPrefix; - final LibraryImport? importDirective; - final bool hasAckTypeAnnotation; - final Element sourceDeclaration; - final Uri? sourceLibraryUri; - - const _ResolvedSchemaReference({ - required this.schemaName, - required this.modelInfo, - required this.importPrefix, - required this.importDirective, - required this.hasAckTypeAnnotation, - required this.sourceDeclaration, - required this.sourceLibraryUri, - }); -} - -/// Analyzes schema variables by walking the AST -/// -/// This analyzer inspects the AST structure of schema definitions -/// (like `Ack.object({...})`) to extract field type information without -/// requiring const evaluation or string parsing. -class SchemaAstAnalyzer { - static const _ackTypeChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckType, - inPackage: 'ack', - ); - static const _classFirstChecker = TypeChecker.any([ - TypeChecker.typeNamed(Schemable, inPackage: 'ack'), - // ignore: deprecated_member_use - TypeChecker.typeNamed(AckModel, inPackage: 'ack'), - ]); - static const _schemaFirstChecker = TypeChecker.any([ - TypeChecker.typeNamed(Schemable, inPackage: 'ack'), - // ignore: deprecated_member_use - TypeChecker.typeNamed(AckInfer, inPackage: 'ack'), - ]); - - final Map _schemaVariableTypeCache = {}; - final Set _schemaVariableTypeStack = {}; - final Map _schemaReferenceCache = {}; - final Set _schemaReferenceResolutionStack = {}; - final Map> _classByNameCache = {}; - final Map> - _schemaVarByNameCache = {}; - final Map> - _schemaGetterByNameCache = {}; - - Map _classesByName(LibraryElement library) { - return _classByNameCache.putIfAbsent(library, () { - final map = {}; - for (final classElement in library.classes) { - final name = classElement.name; - if (name != null) { - map.putIfAbsent(name, () => classElement); - } - } - return map; - }); - } - - Map _schemaVarsByName( - LibraryElement library, - ) { - return _schemaVarByNameCache.putIfAbsent(library, () { - final map = {}; - for (final variable in library.topLevelVariables) { - final name = variable.name; - if (name != null) { - map.putIfAbsent(name, () => variable); - } - } - return map; - }); - } - - Map _schemaGettersByName(LibraryElement library) { - return _schemaGetterByNameCache.putIfAbsent(library, () { - final map = {}; - for (final getter in library.getters) { - if (!getter.isOriginDeclaration) continue; - - final name = getter.name; - if (name != null) { - map.putIfAbsent(name, () => getter); - } - } - return map; - }); - } - - /// Analyzes a schema variable annotated with @AckType - /// - /// Walks the AST to extract type information from the schema definition. - ModelInfo? analyzeSchemaVariable( - TopLevelVariableElement element, { - String? customTypeName, - }) { - // Get the AST node for this variable using the fragment - final fragment = element.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = element.library; - - final parsedLibResult = session.getParsedLibraryByElement(library); - - // getParsedLibraryByElement returns a SomeParsedLibraryResult which might not have getElementDeclaration - // We need to check if it's actually a ParsedLibraryResult - if (parsedLibResult is! ParsedLibraryResult) { - throw InvalidGenerationSource( - 'Could not get parsed library for "${element.name}"', - element: element, - ); - } - - final declaration = parsedLibResult.getFragmentDeclaration(fragment); - if (declaration == null || declaration.node is! VariableDeclaration) { - throw InvalidGenerationSource( - 'Could not find variable declaration for "${element.name}"', - element: element, - ); - } - - final varDecl = declaration.node as VariableDeclaration; - final initializer = varDecl.initializer; - - if (initializer == null) { - throw InvalidGenerationSource( - 'Schema variable "${element.name}" must have an initializer', - element: element, - ); - } - - _rejectAckModelFacadeExpression( - initializer, - element, - diagnosticPath: element.name, - ); - - if (initializer is MethodInvocation) { - final model = _parseSchemaFromAST( - element.name!, - initializer, - element, - customTypeName: customTypeName, - ); - if (model == null) return null; - return _withSchemaIdentity(model, element); - } - - final schemaReference = _extractSchemaReference(initializer); - if (schemaReference != null) { - final model = _parseSchemaAlias( - variableName: element.name!, - reference: schemaReference, - element: element, - customTypeName: customTypeName, - ); - return _withSchemaIdentity(model, element); - } - - throw InvalidGenerationSource( - 'Schema variable "${element.name}" must be initialized with a schema ' - '(e.g., Ack.object({...}))', - element: element, - ); - } - - /// Analyzes a top-level schema getter annotated with @AckType. - /// - /// Supported forms: - /// - `AckSchema get userSchema => Ack.object({...});` - /// - `AckSchema get userSchema { return Ack.object({...}); }` - ModelInfo? analyzeSchemaGetter( - GetterElement element, { - String? customTypeName, - }) { - final fragment = element.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = element.library; - - final parsedLibResult = session.getParsedLibraryByElement(library); - if (parsedLibResult is! ParsedLibraryResult) { - throw InvalidGenerationSource( - 'Could not get parsed library for getter "${element.name}"', - element: element, - ); - } - - final declaration = parsedLibResult.getFragmentDeclaration(fragment); - if (declaration == null || declaration.node is! FunctionDeclaration) { - throw InvalidGenerationSource( - 'Could not find getter declaration for "${element.name}"', - element: element, - ); - } - - final getterDecl = declaration.node as FunctionDeclaration; - if (!getterDecl.isGetter) { - throw InvalidGenerationSource( - '"${element.name}" is not a getter declaration', - element: element, - ); - } - - final body = getterDecl.functionExpression.body; - Expression? schemaExpression; - - if (body is ExpressionFunctionBody) { - schemaExpression = body.expression; - } else if (body is BlockFunctionBody) { - final statements = body.block.statements; - if (statements.length != 1 || statements.first is! ReturnStatement) { - throw InvalidGenerationSource( - 'Schema getter "${element.name}" must return a schema expression', - element: element, - todo: - 'Use an expression body or a single return statement (e.g., return Ack.object({...});).', - ); - } - - final returnStatement = statements.first as ReturnStatement; - schemaExpression = returnStatement.expression; - } - - if (schemaExpression != null) { - _rejectAckModelFacadeExpression( - schemaExpression, - element, - diagnosticPath: element.name, - ); - } - - if (schemaExpression is MethodInvocation) { - final model = _parseSchemaFromAST( - element.name!, - schemaExpression, - element, - customTypeName: customTypeName, - ); - if (model == null) return null; - return _withSchemaIdentity(model, element); - } - - final schemaReference = _extractSchemaReference(schemaExpression); - if (schemaReference != null) { - final model = _parseSchemaAlias( - variableName: element.name!, - reference: schemaReference, - element: element, - customTypeName: customTypeName, - ); - return _withSchemaIdentity(model, element); - } - - throw InvalidGenerationSource( - 'Schema getter "${element.name}" must return an Ack schema invocation or schema reference', - element: element, - todo: - 'Return a schema expression such as Ack.object({...}), Ack.string(), or another @AckType schema variable/getter.', - ); - } - - ModelInfo _parseSchemaAlias({ - required String variableName, - required _SchemaReference reference, - required Element element, - String? customTypeName, - }) { - final resolved = _resolveSchemaReference(reference, element); - if (resolved == null) { - final referenceLabel = _formatSchemaReference(reference); - throw InvalidGenerationSource( - 'Could not resolve schema alias "$variableName" ' - 'to "$referenceLabel"', - element: element, - ); - } - - final aliasTypeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - final sourceModel = resolved.modelInfo; - - return ModelInfo( - className: aliasTypeName, - schemaClassName: variableName, - fields: sourceModel.fields, - additionalProperties: sourceModel.additionalProperties, - discriminatorKey: sourceModel.discriminatorKey, - discriminatorValue: sourceModel.discriminatorValue, - subtypeNames: sourceModel.subtypeNames, - schemaIdentity: - sourceModel.schemaIdentity ?? - _declarationVisitKey(resolved.sourceDeclaration), - discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, - representationType: sourceModel.representationType, - - isNullableSchema: sourceModel.isNullableSchema, - ); - } - - /// Parses a schema from a MethodInvocation AST node - ModelInfo? _parseSchemaFromAST( - String variableName, - MethodInvocation invocation, - Element element, { - String? customTypeName, - }) { - final chain = _analyzeSchemaChain(invocation); - final baseInvocation = chain.ackBase; - final schemaReference = chain.schemaReference; - - if (schemaReference != null) { - return _parseSchemaReferenceChain( - variableName: variableName, - schemaReference: schemaReference, - element: element, - customTypeName: customTypeName, - isNullable: chain.isNullable, - transformOutputTypeString: _requireTransformOutputType( - chain, - element, - contextLabel: 'Schema "$variableName"', - ), - ); - } - - if (baseInvocation == null) { - throw InvalidGenerationSource( - 'Schema must be an Ack.xxx() method call (e.g., Ack.object(), Ack.string()) or a schema reference.', - element: element, - ); - } - - final methodName = baseInvocation.methodName.name; - final isNullable = chain.isNullable; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Schema "$variableName"', - ); - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: methodName, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Schema "$variableName"', - ); - - late final ModelInfo model; - switch (methodName) { - case 'object': - model = _parseObjectSchema( - variableName, - baseInvocation, - invocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'string': - model = _parseStringSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'integer': - model = _parseIntegerSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'double': - model = _parseDoubleSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'boolean': - model = _parseBooleanSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'list': - final typeProvider = element.library?.typeProvider; - if (typeProvider == null) { - throw InvalidGenerationSource( - 'Could not get type provider for library', - element: element, - ); - } - final listElementAnalysis = _analyzeListElement( - baseInvocation, - element, - typeProvider, - ); - model = _parseListSchema( - variableName, - element, - isNullable: isNullable, - listElementAnalysis: listElementAnalysis, - customTypeName: customTypeName, - ); - break; - case 'literal': - model = _parseLiteralSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'enumString': - model = _parseEnumStringSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'enumValues': - model = _parseEnumValuesSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - case 'uri': - model = _parseRepresentationSchema( - variableName, - element, - representationType: 'Uri', - isNullable: isNullable, - - customTypeName: customTypeName, - ); - break; - case 'date': - case 'datetime': - model = _parseRepresentationSchema( - variableName, - element, - representationType: 'DateTime', - isNullable: isNullable, - - customTypeName: customTypeName, - ); - break; - case 'duration': - model = _parseRepresentationSchema( - variableName, - element, - representationType: 'Duration', - isNullable: isNullable, - - customTypeName: customTypeName, - ); - break; - case 'discriminated': - model = _parseDiscriminatedSchema( - variableName, - baseInvocation, - element, - isNullable: isNullable, - customTypeName: customTypeName, - ); - break; - default: - throw InvalidGenerationSource( - 'Unsupported schema type for @AckType: Ack.$methodName(). ' - 'Supported types: object, string, integer, double, boolean, list, literal, enumString, enumValues, uri, date, datetime, duration, discriminated', - element: element, - ); - } - - if (transformOutputTypeString != null) { - return _withRepresentationType(model, transformOutputTypeString); - } - - return model; - } - - ModelInfo _parseSchemaReferenceChain({ - required String variableName, - required _SchemaReference schemaReference, - required Element element, - String? customTypeName, - required bool isNullable, - required String? transformOutputTypeString, - }) { - final resolved = _resolveSchemaReference(schemaReference, element); - if (resolved == null) { - final referenceLabel = _formatSchemaReference(schemaReference); - throw InvalidGenerationSource( - 'Could not resolve schema reference "$referenceLabel" for "$variableName".', - element: element, - ); - } - - if (transformOutputTypeString != null) { - _throwIfUnsupportedTransformedReferencedSchema( - resolved: resolved, - element: element, - contextLabel: 'Schema "$variableName"', - ); - } - - final aliasTypeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - final sourceModel = resolved.modelInfo; - - return ModelInfo( - className: aliasTypeName, - schemaClassName: variableName, - fields: sourceModel.fields, - additionalProperties: sourceModel.additionalProperties, - discriminatorKey: sourceModel.discriminatorKey, - discriminatorValue: sourceModel.discriminatorValue, - subtypeNames: sourceModel.subtypeNames, - schemaIdentity: - sourceModel.schemaIdentity ?? - _declarationVisitKey(resolved.sourceDeclaration), - discriminatedBaseClassName: sourceModel.discriminatedBaseClassName, - representationType: - transformOutputTypeString ?? sourceModel.representationType, - isNullableSchema: isNullable || sourceModel.isNullableSchema, - ); - } - - /// Parses Ack.object() schema - ModelInfo _parseObjectSchema( - String variableName, - MethodInvocation baseInvocation, - MethodInvocation fullInvocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - // Extract the properties map from the first argument - final args = baseInvocation.argumentList.arguments; - if (args.isEmpty) { - throw InvalidGenerationSource( - 'Ack.object() requires a properties map argument', - element: element, - ); - } - - final firstArg = args.first; - if (firstArg is! SetOrMapLiteral) { - throw InvalidGenerationSource( - 'Ack.object() first argument must be a map literal', - element: element, - ); - } - - // Extract fields from the map literal - final fields = _extractFieldsFromMapLiteral(firstArg, element); - - // Check if additionalProperties is enabled via passthrough() or parameter - final hasAdditionalProperties = _hasAdditionalPropertiesFromInvocation( - baseInvocation, - fullInvocation, - ); - - // Generate extension type name from variable name or custom override - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: fields, - additionalProperties: hasAdditionalProperties, - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.discriminated(...) schema for @AckType bases. - /// - /// Current constraints: - /// - Base cannot be nullable - /// - `schemas` must be a non-empty map literal - /// - Branches must be top-level schema variable/getter references - /// - Branches must be @AckType object schemas and non-nullable - /// - Branch discriminator properties may be omitted or compatible - /// - Each branch schema can only appear once per discriminated base - /// - Branches must be declared in the same library - ModelInfo _parseDiscriminatedSchema( - String variableName, - MethodInvocation baseInvocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - if (isNullable) { - throw InvalidGenerationSource( - 'Ack.discriminated(...) cannot be nullable when used with @AckType.', - element: element, - todo: 'Remove `.nullable()` from the discriminated base schema.', - ); - } - - String? discriminatorKey; - SetOrMapLiteral? schemasLiteral; - - for (final argument in baseInvocation.argumentList.arguments) { - if (argument is! NamedExpression) continue; - - final name = argument.name.label.name; - if (name == 'discriminatorKey') { - final expression = argument.expression; - if (expression is! SimpleStringLiteral) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `discriminatorKey` must be a string literal.', - element: element, - ); - } - discriminatorKey = expression.value; - } else if (name == 'schemas') { - final expression = argument.expression; - if (expression is! SetOrMapLiteral) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must be a map literal.', - element: element, - ); - } - // Check actual content: if non-empty, entries must be MapLiteralEntry. - // We avoid relying on `isMap` since it may return false in unresolved - // contexts (e.g., build_test / source_gen pipelines). - if (expression.elements.isNotEmpty && - expression.elements.first is! MapLiteralEntry) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must be a map literal.', - element: element, - ); - } - schemasLiteral = expression; - } - } - - final resolvedDiscriminatorKey = discriminatorKey; - if (resolvedDiscriminatorKey == null || resolvedDiscriminatorKey.isEmpty) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): missing required `discriminatorKey` string literal.', - element: element, - ); - } - - final resolvedSchemasLiteral = schemasLiteral; - if (resolvedSchemasLiteral == null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): missing required `schemas` map literal.', - element: element, - ); - } - if (resolvedSchemasLiteral.elements.isEmpty) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must contain at least one branch.', - element: element, - ); - } - - final currentLibraryUri = element.library?.uri; - final subtypeNames = {}; - - for (final schemaEntry in resolvedSchemasLiteral.elements) { - if (schemaEntry is! MapLiteralEntry) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): `schemas` must contain key/value map entries.', - element: element, - ); - } - - final keyExpression = schemaEntry.key; - if (keyExpression is! SimpleStringLiteral) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): discriminator values in `schemas` must be string literals.', - element: element, - ); - } - - final discriminatorValue = keyExpression.value; - if (subtypeNames.containsKey(discriminatorValue)) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): duplicate discriminator value "$discriminatorValue".', - element: element, - ); - } - - _rejectAckModelFacadeExpression( - schemaEntry.value, - element, - diagnosticPath: '$variableName.$discriminatorValue', - ); - - final branchReference = _extractSchemaReference(schemaEntry.value); - if (branchReference == null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "$discriminatorValue" must reference a top-level schema variable/getter.', - element: element, - todo: - 'Extract inline expressions to a top-level @AckType schema variable/getter and reference it.', - ); - } - - final resolvedBranch = _resolveSchemaReference(branchReference, element); - if (resolvedBranch == null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): could not resolve branch reference "${_formatSchemaReference(branchReference)}".', - element: element, - ); - } - - if (!resolvedBranch.hasAckTypeAnnotation) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be annotated with @AckType.', - element: element, - ); - } - - if (resolvedBranch.modelInfo.representationType != kMapType) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be an object schema (Map representation).', - element: element, - ); - } - - if (resolvedBranch.modelInfo.isNullableSchema) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" cannot be nullable.', - element: element, - ); - } - - if (resolvedBranch.sourceLibraryUri != currentLibraryUri) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" must be declared in the same library as "$variableName".', - element: element, - ); - } - - if (resolvedBranch.modelInfo.isDiscriminatedBase) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" is itself a discriminated base. ' - 'Nested discriminated unions are not supported.', - element: element, - todo: - 'Use a plain Ack.object(...) schema for each branch, not another Ack.discriminated(...).', - ); - } - - final discriminatorCompatibilityError = - _analyzeDiscriminatorPropertyCompatibility( - declaration: resolvedBranch.sourceDeclaration, - discriminatorKey: resolvedDiscriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: {}, - ); - - if (discriminatorCompatibilityError != null) { - throw InvalidGenerationSource( - 'Ack.discriminated(...): branch "${resolvedBranch.schemaName}" ' - '$discriminatorCompatibilityError', - element: element, - todo: - 'Omit "$resolvedDiscriminatorKey" from the branch schema, or make it accept "$discriminatorValue".', - ); - } - - subtypeNames[discriminatorValue] = - resolvedBranch.modelInfo.schemaClassName; - } - - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: const [], - representationType: kMapType, - isNullableSchema: false, - discriminatorKey: resolvedDiscriminatorKey, - subtypeNames: subtypeNames, - ); - } - - String? _analyzeDiscriminatorPropertyCompatibility({ - required Element declaration, - required String discriminatorKey, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - final declarationKey = _declarationVisitKey(declaration); - if (!visitedDeclarations.add(declarationKey)) { - return 'has a recursive discriminator property reference that cannot be analyzed.'; - } - - final schemaExpression = _extractSchemaExpressionForDeclaration( - declaration, - ); - if (schemaExpression == null) { - return 'has a discriminator property that could not be analyzed.'; - } - - return _analyzeDiscriminatorSchemaExpressionCompatibility( - expression: schemaExpression, - contextElement: declaration, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _analyzeDiscriminatorSchemaExpressionCompatibility({ - required Expression expression, - required Element contextElement, - required String discriminatorKey, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - if (expression is MethodInvocation) { - final schemaReferenceBase = _findSchemaVariableBase(expression); - if (schemaReferenceBase != null) { - final resolvedBranch = _resolveSchemaReference( - schemaReferenceBase, - contextElement, - ); - if (resolvedBranch == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertyCompatibility( - declaration: resolvedBranch.sourceDeclaration, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - final baseInvocation = _findBaseAckInvocation(expression); - if (baseInvocation == null || - baseInvocation.methodName.name != 'object') { - return _analyzeDiscriminatorPropertySchemaExpression( - expression: expression, - contextElement: contextElement, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - return _analyzeDiscriminatorObjectInvocation( - objectInvocation: baseInvocation, - contextElement: contextElement, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - final schemaReference = _extractSchemaReference(expression); - if (schemaReference == null) { - return 'has a discriminator property that could not be analyzed.'; - } - final resolvedBranch = _resolveSchemaReference( - schemaReference, - contextElement, - ); - if (resolvedBranch == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertyCompatibility( - declaration: resolvedBranch.sourceDeclaration, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _analyzeDiscriminatorObjectInvocation({ - required MethodInvocation objectInvocation, - required Element contextElement, - required String discriminatorKey, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - final args = objectInvocation.argumentList.arguments; - if (args.isEmpty) return null; - - final firstArg = args.first; - if (firstArg is! SetOrMapLiteral) return null; - - for (final mapElement in firstArg.elements) { - if (mapElement is! MapLiteralEntry) continue; - final keyExpression = mapElement.key; - if (keyExpression is! SimpleStringLiteral || - keyExpression.value != discriminatorKey) { - continue; - } - - return _analyzeDiscriminatorPropertySchemaExpression( - expression: mapElement.value, - contextElement: contextElement, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - return null; - } - - String? _analyzeDiscriminatorPropertySchemaExpression({ - required Expression expression, - required Element contextElement, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - if (expression is MethodInvocation) { - final schemaReferenceBase = _findSchemaVariableBase(expression); - if (schemaReferenceBase != null) { - final resolved = _resolveSchemaReference( - schemaReferenceBase, - contextElement, - ); - if (resolved == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertySchemaDeclaration( - declaration: resolved.sourceDeclaration, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - final baseInvocation = _findBaseAckInvocation(expression); - if (baseInvocation == null) { - return 'has a discriminator property that could not be analyzed.'; - } - - final schemaMethod = baseInvocation.methodName.name; - if (schemaMethod == 'literal') { - if (!_hasOnlyNonRestrictiveDiscriminatorMethods( - expression, - baseInvocation, - baseMethod: 'literal', - )) { - return 'has discriminator property schema ${expression.toSource()} that could not be proven to accept "$discriminatorValue".'; - } - - final literalValue = _extractSingleStringArgument(baseInvocation); - if (literalValue == null) { - return 'has a discriminator literal that is not a string literal.'; - } - if (literalValue == discriminatorValue) { - return null; - } - return 'has discriminator literal "$literalValue", but is mapped as "$discriminatorValue".'; - } - - if (schemaMethod == 'enumString') { - if (!_hasOnlyNonRestrictiveDiscriminatorMethods( - expression, - baseInvocation, - baseMethod: 'enumString', - )) { - return 'has discriminator property schema ${expression.toSource()} that could not be proven to accept "$discriminatorValue".'; - } - - final allowedValues = _extractStringListArgument(baseInvocation); - if (allowedValues == null) { - return 'has an Ack.enumString(...) discriminator that is not a string list literal.'; - } - if (allowedValues.contains(discriminatorValue)) { - return null; - } - return 'has discriminator enum values ${allowedValues.map((v) => '"$v"').join(', ')}, ' - 'which do not include "$discriminatorValue".'; - } - - return 'has discriminator property schema ${expression.toSource()} that could not be proven to accept "$discriminatorValue".'; - } - - final schemaReference = _extractSchemaReference(expression); - if (schemaReference == null) { - return 'has a discriminator property that could not be analyzed.'; - } - final resolved = _resolveSchemaReference(schemaReference, contextElement); - if (resolved == null) { - return 'has a discriminator property reference that could not be resolved.'; - } - - return _analyzeDiscriminatorPropertySchemaDeclaration( - declaration: resolved.sourceDeclaration, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _analyzeDiscriminatorPropertySchemaDeclaration({ - required Element declaration, - required String discriminatorValue, - required Set visitedDeclarations, - }) { - final declarationKey = _declarationVisitKey(declaration); - if (!visitedDeclarations.add(declarationKey)) { - return 'has a recursive discriminator property reference that cannot be analyzed.'; - } - - final schemaExpression = _extractSchemaExpressionForDeclaration( - declaration, - ); - if (schemaExpression == null) { - return 'has a discriminator property reference that could not be analyzed.'; - } - - return _analyzeDiscriminatorPropertySchemaExpression( - expression: schemaExpression, - contextElement: declaration, - discriminatorValue: discriminatorValue, - visitedDeclarations: visitedDeclarations, - ); - } - - String? _extractSingleStringArgument(MethodInvocation invocation) { - final arguments = invocation.argumentList.arguments; - if (arguments.length != 1 || arguments.first is! SimpleStringLiteral) { - return null; - } - return (arguments.first as SimpleStringLiteral).value; - } - - List? _extractStringListArgument(MethodInvocation invocation) { - final arguments = invocation.argumentList.arguments; - if (arguments.length != 1 || arguments.first is! ListLiteral) { - return null; - } - - final values = []; - for (final element in (arguments.first as ListLiteral).elements) { - if (element is! SimpleStringLiteral) return null; - values.add(element.value); - } - return values; - } - - bool _hasOnlyNonRestrictiveDiscriminatorMethods( - MethodInvocation expression, - MethodInvocation baseInvocation, { - required String baseMethod, - }) { - final (chain, _) = _collectMethodChain(expression); - const allowedMethods = {'optional', 'nullable', 'describe'}; - - for (final invocation in chain) { - final methodName = invocation.methodName.name; - if (identical(invocation, baseInvocation)) { - return methodName == baseMethod; - } - if (!allowedMethods.contains(methodName)) { - return false; - } - } - - return true; - } - - String _declarationVisitKey(Element declaration) { - final libraryUri = declaration.library?.uri.toString() ?? 'unknown'; - final name = declaration.name ?? ''; - return '$libraryUri::$name'; - } - - ModelInfo _withSchemaIdentity(ModelInfo model, Element declaration) { - if (model.schemaIdentity != null) { - return model; - } - - return ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: model.fields, - additionalProperties: model.additionalProperties, - discriminatorKey: model.discriminatorKey, - discriminatorValue: model.discriminatorValue, - subtypeNames: model.subtypeNames, - schemaIdentity: _declarationVisitKey(declaration), - discriminatedBaseClassName: model.discriminatedBaseClassName, - representationType: model.representationType, - isNullableSchema: model.isNullableSchema, - ); - } - - Expression? _extractSchemaExpressionForDeclaration(Element declaration) { - if (declaration is TopLevelVariableElement) { - final fragment = declaration.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = declaration.library; - final parsedLibResult = session.getParsedLibraryByElement(library); - if (parsedLibResult is! ParsedLibraryResult) { - return null; - } - - final variableDeclaration = parsedLibResult.getFragmentDeclaration( - fragment, - ); - if (variableDeclaration == null || - variableDeclaration.node is! VariableDeclaration) { - return null; - } - - final variableNode = variableDeclaration.node as VariableDeclaration; - return variableNode.initializer; - } - - if (declaration is GetterElement) { - final fragment = declaration.firstFragment; - final session = fragment.libraryFragment.element.session; - final library = declaration.library; - final parsedLibResult = session.getParsedLibraryByElement(library); - if (parsedLibResult is! ParsedLibraryResult) { - return null; - } - - final getterDeclaration = parsedLibResult.getFragmentDeclaration( - fragment, - ); - if (getterDeclaration == null || - getterDeclaration.node is! FunctionDeclaration) { - return null; - } - - final functionDeclaration = getterDeclaration.node as FunctionDeclaration; - if (!functionDeclaration.isGetter) return null; - - final body = functionDeclaration.functionExpression.body; - if (body is ExpressionFunctionBody) { - return body.expression; - } - - if (body is BlockFunctionBody) { - final statements = body.block.statements; - if (statements.length != 1 || statements.first is! ReturnStatement) { - return null; - } - - final returnStatement = statements.first as ReturnStatement; - return returnStatement.expression; - } - } - - return null; - } - - bool _hasAdditionalPropertiesFromInvocation( - MethodInvocation baseInvocation, - MethodInvocation fullInvocation, - ) { - bool hasAdditionalProperties = false; - - // First check for named parameter in the base Ack.object() call - for (final arg in baseInvocation.argumentList.arguments) { - if (arg is NamedExpression && - arg.name.label.name == 'additionalProperties') { - if (arg.expression is BooleanLiteral) { - hasAdditionalProperties = (arg.expression as BooleanLiteral).value; - } - } - } - - // Then walk forward from fullInvocation to find passthrough() in the chain - // The chain looks like: Ack.object({...}).passthrough() - // fullInvocation is the outermost call (passthrough if present) - // We need to check if passthrough() was called - MethodInvocation? current = fullInvocation; - while (current != null && current != baseInvocation) { - final methodName = current.methodName.name; - - if (methodName == 'passthrough') { - hasAdditionalProperties = true; - break; - } - - // Move down the chain towards the base - final target = current.target; - if (target is MethodInvocation) { - current = target; - } else { - break; - } - } - - return hasAdditionalProperties; - } - - /// Extracts field information from a map literal - List _extractFieldsFromMapLiteral( - SetOrMapLiteral mapLiteral, - Element element, - ) { - final fields = []; - - for (final mapElement in mapLiteral.elements) { - if (mapElement is! MapLiteralEntry) continue; - - final key = mapElement.key; - final value = mapElement.value; - - // Key should be a string literal - if (key is! SimpleStringLiteral) { - throw InvalidGenerationSource( - 'Map keys must be string literals in schema definition', - element: element, - ); - } - - final fieldName = key.value; - - // Validate that the field name is a valid Dart identifier - _validateFieldName(fieldName, element); - - final fieldInfo = _parseFieldValue(fieldName, value, element); - if (fieldInfo == null) { - throw InvalidGenerationSource( - '${element.name}.$fieldName uses unsupported schema expression ' - '`${value.toSource()}`. Use a directly analyzable Ack schema call ' - 'or named schema reference.', - element: element, - ); - } - fields.add(fieldInfo); - } - - return fields; - } - - /// Parses a field's value expression to determine its type - FieldInfo? _parseFieldValue( - String fieldName, - Expression value, - Element element, - ) { - _rejectAckModelFacadeExpression( - value, - element, - diagnosticPath: '${element.name}.$fieldName', - ); - - // Handle Ack.xxx() method calls - if (value is MethodInvocation) { - final schemaReferenceField = _parseSchemaReferenceMethod( - fieldName, - value, - element, - ); - if (schemaReferenceField != null) { - return schemaReferenceField; - } - return _parseSchemaMethod(fieldName, value, element); - } - - // Handle references to other schema variables (for nested objects) - final schemaReference = _extractSchemaReference(value); - if (schemaReference != null) { - return _buildFieldInfoForSchemaReference( - fieldName: fieldName, - schemaReference: schemaReference, - element: element, - ); - } - - return null; - } - - FieldInfo? _parseSchemaReferenceMethod( - String fieldName, - MethodInvocation invocation, - Element element, - ) { - final chain = _analyzeSchemaChain(invocation); - final schemaReference = chain.schemaReference; - if (schemaReference == null) { - return null; - } - - return _buildFieldInfoForSchemaReference( - fieldName: fieldName, - schemaReference: schemaReference, - element: element, - isRequired: !chain.isOptional, - isNullable: chain.isNullable, - transformedOutputType: chain.transformOutputType, - transformedRepresentationType: _requireTransformOutputType( - chain, - element, - contextLabel: 'Field "$fieldName"', - ), - ); - } - - FieldInfo _buildFieldInfoForSchemaReference({ - required String fieldName, - required _SchemaReference schemaReference, - required Element element, - bool isRequired = true, - bool isNullable = false, - DartType? transformedOutputType, - String? transformedRepresentationType, - }) { - final schemaVarName = schemaReference.name; - final library = element.library; - - final typeProvider = library?.typeProvider; - if (typeProvider == null) { - throw InvalidGenerationSource( - 'Could not get type provider for library', - element: element, - ); - } - - final resolvedReference = _resolveSchemaReference( - schemaReference, - element, - diagnosticPath: '${element.name}.$fieldName', - ); - if (resolvedReference == null) { - throw InvalidGenerationSource( - 'Could not resolve schema reference "$schemaVarName" for field ' - '"$fieldName".', - element: element, - todo: - 'Ensure "$schemaVarName" exists, is imported, and is declared as an Ack schema.', - ); - } - - final hasTransformOverride = transformedRepresentationType != null; - if (hasTransformOverride) { - _throwIfUnsupportedTransformedReferencedSchema( - resolved: resolvedReference, - element: element, - contextLabel: 'Field "$fieldName"', - ); - } - - final representationType = - transformedRepresentationType ?? - resolvedReference.modelInfo.representationType; - final visibleRepresentationType = _resolveVisibleRepresentationType( - representationType: representationType, - resolved: resolvedReference, - contextElement: element, - ); - final hasTypedReference = - resolvedReference.hasAckTypeAnnotation && !hasTransformOverride; - final isObjectRepresentation = representationType == kMapType; - if (isObjectRepresentation && !hasTypedReference) { - throw InvalidGenerationSource( - 'Field "$fieldName" references object schema "$schemaVarName" ' - 'without @AckType. This would fall back to Map.', - element: element, - todo: - 'Annotate "$schemaVarName" with @AckType() so the generator can emit a typed wrapper.', - ); - } - - final mappedType = - transformedOutputType ?? - _representationTypeToDartType(representationType, typeProvider); - final typeBaseName = hasTypedReference - ? _qualifyTypeBaseName( - resolvedReference.modelInfo.className, - resolvedReference.importPrefix, - ) - : null; - final rawDisplayTypeOverride = - !hasTypedReference && - !mappedType.isDartCoreString && - !mappedType.isDartCoreInt && - !mappedType.isDartCoreDouble && - !mappedType.isDartCoreBool && - !mappedType.isDartCoreNum && - !mappedType.isDartCoreList && - !mappedType.isDartCoreMap && - !mappedType.isDartCoreSet - ? visibleRepresentationType - : null; - - return FieldInfo( - name: fieldName, - jsonKey: fieldName, - type: mappedType, - isRequired: isRequired, - isNullable: isNullable, - constraints: [], - nestedSchemaRef: hasTypedReference ? schemaVarName : null, - displayTypeOverride: hasTypedReference - ? '${typeBaseName}Type' - : rawDisplayTypeOverride, - nestedSchemaCastTypeOverride: hasTypedReference - ? visibleRepresentationType - : null, - ); - } - - /// Parses a schema method call (e.g., Ack.string(), Ack.integer().optional()) - FieldInfo _parseSchemaMethod( - String fieldName, - MethodInvocation invocation, - Element element, - ) { - final chain = _analyzeSchemaChain(invocation); - final baseInvocation = chain.ackBase; - - if (baseInvocation == null) { - if (chain.wasTruncated) { - throw InvalidGenerationSource( - 'Field "$fieldName" schema method chain exceeded max depth of 20. ' - '@AckType requires statically analyzable schema chains.', - element: element, - todo: - 'Reduce the chaining depth or extract part of the schema into a named variable.', - ); - } - - throw InvalidGenerationSource( - 'Could not determine schema type for field "$fieldName"', - element: element, - ); - } - - final schemaMethod = baseInvocation.methodName.name; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Field "$fieldName"', - ); - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: schemaMethod, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Field "$fieldName"', - ); - - if (schemaMethod == 'object') { - throw InvalidGenerationSource( - 'Field "$fieldName" uses anonymous inline Ack.object(...). ' - 'Strict typed generation requires a named schema reference.', - element: element, - todo: - 'Extract this inline object schema into a top-level @AckType() variable and reference it by name.', - ); - } - - final typeProvider = element.library!.typeProvider; - final listElementAnalysis = - schemaMethod == 'list' && transformOutputTypeString == null - ? _analyzeListElement( - baseInvocation, - element, - typeProvider, - diagnosticPath: '${element.name}.$fieldName', - ) - : null; - // Map schema type to Dart type (passing full invocation for context) - // Also captures schema variable reference and list metadata for typed wrappers. - final mappedType = - listElementAnalysis?.mapping ?? - _mapSchemaTypeToDartType(invocation, element); - - String? displayTypeOverride; - var collectionElementDisplayTypeOverride = - mappedType.listElementDisplayTypeOverride; - - if (schemaMethod == 'enumValues') { - displayTypeOverride = _extractEnumTypeNameFromInvocation(baseInvocation); - } else if (schemaMethod == 'list') { - collectionElementDisplayTypeOverride = - _extractListEnumElementTypeName(baseInvocation) ?? - collectionElementDisplayTypeOverride; - } - - return FieldInfo( - name: fieldName, - jsonKey: fieldName, - type: mappedType.dartType, - isRequired: !chain.isOptional, - isNullable: chain.isNullable, - constraints: [], - listElementSchemaRef: mappedType.listElementSchemaRef, - displayTypeOverride: - displayTypeOverride ?? - (transformOutputTypeString != null && - !mappedType.dartType.isDartCoreString && - !mappedType.dartType.isDartCoreInt && - !mappedType.dartType.isDartCoreDouble && - !mappedType.dartType.isDartCoreBool && - !mappedType.dartType.isDartCoreNum && - !mappedType.dartType.isDartCoreList && - !mappedType.dartType.isDartCoreMap && - !mappedType.dartType.isDartCoreSet - ? transformOutputTypeString - : null), - collectionElementDisplayTypeOverride: - collectionElementDisplayTypeOverride, - collectionElementCastTypeOverride: mappedType.listElementCastTypeOverride, - collectionElementIsCustomType: mappedType.listElementIsCustomType, - ); - } - - /// Maps a schema method invocation to a Dart type and optional schema reference - /// - /// Returns a record containing the field [DartType] plus list metadata used - /// by the type builder for typed list getters. - _SchemaTypeMapping _mapSchemaTypeToDartType( - MethodInvocation invocation, - Element element, - ) { - final chain = _analyzeSchemaChain(invocation); - final schemaReference = chain.schemaReference; - final baseInvocation = chain.ackBase; - - // We need to get the type provider from the element's library - final library = element.library!; - final typeProvider = library.typeProvider; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Schema expression', - ); - - if (schemaReference != null) { - return _resolveSchemaVariableType( - schemaReference, - element, - typeProvider, - transformedOutputType: chain.transformOutputType, - transformedRepresentationType: transformOutputTypeString, - ); - } - - if (baseInvocation == null) { - throw InvalidGenerationSource( - 'Could not determine schema type for "${invocation.toSource()}".', - element: element, - ); - } - - final schemaMethod = baseInvocation.methodName.name; - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: schemaMethod, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Schema expression', - ); - - if (transformOutputTypeString != null) { - return ( - dartType: chain.transformOutputType ?? typeProvider.dynamicType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - } - - switch (schemaMethod) { - case 'string': - return ( - dartType: typeProvider.stringType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'integer': - return ( - dartType: typeProvider.intType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'double': - return ( - dartType: typeProvider.doubleType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'boolean': - return ( - dartType: typeProvider.boolType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'list': - // Extract element type from Ack.list(elementSchema) argument - // This may return a schema variable reference for nested schemas - return _analyzeListElement( - baseInvocation, - element, - typeProvider, - ).mapping; - case 'object': - // Nested objects represented as Map - // Note: Using dynamicType for analyzer; generated code uses Object? - return ( - dartType: typeProvider.mapType( - typeProvider.stringType, - typeProvider.dynamicType, - ), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'enumString': - case 'literal': - return ( - dartType: typeProvider.stringType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'enumValues': - final resolvedType = _resolveEnumValuesType( - baseInvocation, - library: library, - ); - if (resolvedType != null) { - return ( - dartType: resolvedType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - } - // Fallback to `dynamic` if the enum type can't be resolved. - // This avoids incorrectly assuming `String` when EnumSchema.parse() - // returns the enum value type T. - _log.warning( - 'Could not resolve enum type for Ack.enumValues(); falling back to dynamic.', - ); - return ( - dartType: typeProvider.dynamicType, - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'uri': - return ( - dartType: _dartCoreType(typeProvider, 'Uri'), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'date': - case 'datetime': - return ( - dartType: _dartCoreType(typeProvider, 'DateTime'), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - case 'duration': - return ( - dartType: _dartCoreType(typeProvider, 'Duration'), - listElementSchemaRef: null, - listElementDisplayTypeOverride: null, - listElementCastTypeOverride: null, - listElementIsCustomType: false, - ); - default: - throw InvalidGenerationSource( - 'Unsupported schema method: Ack.$schemaMethod()', - element: element, - ); - } - } - - /// Extracts the enum type name from an `Ack.enumValues(...)` invocation. - /// - /// Prefers source text only when it contains a qualifier - /// (e.g., `alias.UserRole`) so import prefixes are preserved in generated - /// part files. - /// - /// For non-qualified names, prefers resolved static types to avoid - /// incorrectly treating arbitrary `.values` receivers as enum type names - /// (for example, `holder.values` should resolve to the list element type). - String? _extractEnumTypeNameFromInvocation(MethodInvocation invocation) { - final sourceTypeName = _extractEnumTypeNameFromSource(invocation); - if (sourceTypeName != null && sourceTypeName.contains('.')) { - return sourceTypeName; - } - - final resolvedType = _resolveEnumValuesType(invocation); - if (resolvedType != null) { - return resolvedType.getDisplayString(); - } - - return sourceTypeName; - } - - String? _extractEnumTypeNameFromSource(MethodInvocation invocation) { - // From type argument: Ack.enumValues(...) or Ack.enumValues(...) - final typeArgs = invocation.typeArguments?.arguments; - if (typeArgs != null && typeArgs.isNotEmpty) { - return typeArgs.first.toSource(); - } - - // From argument pattern: Ack.enumValues(UserRole.values) / Ack.enumValues(alias.UserRole.values) - final args = invocation.argumentList.arguments; - if (args.isNotEmpty) { - final firstArg = args.first; - if (firstArg is PrefixedIdentifier && - firstArg.identifier.name == 'values') { - final targetSource = firstArg.prefix.toSource(); - if (_looksLikeTypeReference(targetSource)) { - return targetSource; - } - } - if (firstArg is PropertyAccess && - firstArg.propertyName.name == 'values') { - final targetSource = firstArg.target?.toSource(); - if (targetSource != null && _looksLikeTypeReference(targetSource)) { - return targetSource; - } - } - } - - return null; - } - - bool _looksLikeTypeReference(String source) { - final trimmed = source.trim(); - if (trimmed.isEmpty) return false; - - final identifier = trimmed.split('.').last; - if (identifier.isEmpty) return false; - - final firstCodeUnit = identifier.codeUnitAt(0); - const uppercaseA = 65; - const uppercaseZ = 90; - const underscore = 95; - return (firstCodeUnit >= uppercaseA && firstCodeUnit <= uppercaseZ) || - firstCodeUnit == underscore; - } - - String? _extractListEnumElementTypeName(MethodInvocation listInvocation) { - final args = listInvocation.argumentList.arguments; - if (args.isEmpty) return null; - - final ref = _resolveListElementRef(args.first); - final elementSchema = ref.invocation == null - ? null - : _analyzeSchemaChain(ref.invocation!).ackBase; - if (elementSchema == null || - elementSchema.methodName.name != 'enumValues') { - return null; - } - - return _extractEnumTypeNameFromInvocation(elementSchema); - } - - /// Resolves enum type `T` from an `Ack.enumValues(...)` invocation. - /// - /// Resolution strategy (in order): - /// 1. Explicit type argument's resolved type (`Ack.enumValues(...)`) - /// 2. Invocation static type argument (`EnumSchema`) - /// 3. First argument static type (`List` from `T.values`) - /// 4. Source name lookup in the library/import scope - DartType? _resolveEnumValuesType( - MethodInvocation invocation, { - LibraryElement? library, - }) { - final typeArgs = invocation.typeArguments?.arguments; - if (typeArgs != null && typeArgs.isNotEmpty) { - final explicitType = typeArgs.first.type; - if (explicitType is InterfaceType) { - return explicitType; - } - } - - final invocationType = invocation.staticType; - if (invocationType is InterfaceType && - invocationType.typeArguments.isNotEmpty) { - final schemaTypeArg = invocationType.typeArguments.first; - if (schemaTypeArg is InterfaceType) { - return schemaTypeArg; - } - } - - final args = invocation.argumentList.arguments; - if (args.isNotEmpty) { - final resolvedFromArgument = _resolveEnumValuesTypeFromArgument( - args.first, - library: library, - ); - if (resolvedFromArgument != null) { - return resolvedFromArgument; - } - } - - if (library != null) { - final enumTypeName = _extractEnumTypeNameFromSource(invocation); - if (enumTypeName != null) { - final resolvedByName = _resolveTypeByName(enumTypeName, library); - if (resolvedByName != null) { - return resolvedByName; - } - } - } - - return null; - } - - DartType? _resolveEnumValuesTypeFromArgument( - Expression argument, { - LibraryElement? library, - }) { - final enumFromStaticType = _extractEnumTypeFromCandidate( - argument.staticType, - ); - if (enumFromStaticType != null) { - return enumFromStaticType; - } - - if (library == null) { - return null; - } - - final resolvedExpressionType = _resolveExpressionType(argument, library); - return _extractEnumTypeFromCandidate(resolvedExpressionType); - } - - DartType? _extractEnumTypeFromCandidate(DartType? candidate) { - if (candidate is! InterfaceType) { - return null; - } - - if (candidate.element is EnumElement) { - return candidate; - } - - if (candidate.isDartCoreList && candidate.typeArguments.isNotEmpty) { - final elementType = candidate.typeArguments.first; - if (elementType is InterfaceType && elementType.element is EnumElement) { - return elementType; - } - } - - return null; - } - - DartType? _resolveExpressionType( - Expression expression, - LibraryElement library, - ) { - final staticType = expression.staticType; - if (staticType != null && staticType is! DynamicType) { - return staticType; - } - - if (expression is SimpleIdentifier) { - final variableType = _schemaVarsByName(library)[expression.name]?.type; - if (variableType != null) { - return variableType; - } - - final getterType = _schemaGettersByName( - library, - )[expression.name]?.returnType; - if (getterType != null) { - return getterType; - } - - return _resolveTypeByName(expression.name, library); - } - - if (expression is PrefixedIdentifier) { - final targetType = _resolveExpressionType(expression.prefix, library); - if (targetType is InterfaceType) { - final memberType = _resolveClassMemberType( - targetType: targetType, - memberName: expression.identifier.name, - library: library, - ); - if (memberType != null) { - return memberType; - } - } - - return _resolveTypeByName(expression.toSource(), library); - } - - if (expression is PropertyAccess) { - final target = expression.target; - if (target != null) { - final targetType = _resolveExpressionType(target, library); - if (targetType is InterfaceType) { - final memberType = _resolveClassMemberType( - targetType: targetType, - memberName: expression.propertyName.name, - library: library, - ); - if (memberType != null) { - return memberType; - } - } - } - } - - return null; - } - - DartType? _resolveClassMemberType({ - required InterfaceType targetType, - required String memberName, - required LibraryElement library, - }) { - final className = targetType.element.name; - if (className == null) return null; - - final classElement = _classesByName(library)[className]; - if (classElement == null) return null; - - final allFields = [ - ...classElement.fields, - ...classElement.allSupertypes.expand((type) => type.element.fields), - ]; - - final field = allFields.firstWhereOrNull( - (current) => current.name == memberName, - ); - if (field != null) { - return field.type; - } - - final allGetters = [ - ...classElement.getters, - ...classElement.allSupertypes.expand((type) => type.element.getters), - ]; - - final getter = allGetters.firstWhereOrNull( - (current) => current.name == memberName, - ); - return getter?.returnType; - } - - DartType? _resolveTypeByName(String typeName, LibraryElement library) { - final normalizedTypeName = typeName.trim(); - if (normalizedTypeName.isEmpty) return null; - - final scopeResult = library.firstFragment.scope.lookup(normalizedTypeName); - final scopeType = _resolveTypeFromElement(scopeResult.getter); - if (scopeType != null) { - return scopeType; - } - - // Try import namespaces directly as a fallback for simple imported names. - for (final import in library.firstFragment.libraryImports) { - final importedElement = import.namespace.get2(normalizedTypeName); - final importedType = _resolveTypeFromElement(importedElement); - if (importedType != null) { - return importedType; - } - } - - // Last-resort local lookup. - for (final enumElement in library.enums) { - if (enumElement.name == normalizedTypeName) { - return enumElement.thisType; - } - } - for (final classElement in library.classes) { - if (classElement.name == normalizedTypeName) { - return classElement.thisType; - } - } - - return null; - } - - DartType? _resolveTypeFromElement(Element? element) { - if (element is EnumElement) { - return element.thisType; - } - - if (element is ClassElement) { - return element.thisType; - } - - if (element is TypeAliasElement) { - final aliasedType = element.aliasedType; - if (aliasedType is InterfaceType) { - return aliasedType; - } - } - - return null; - } - - _ListElementRef _resolveListElementRef(Expression firstArg) { - if (firstArg is MethodInvocation) { - final schemaRef = _findSchemaVariableBase(firstArg); - if (schemaRef != null) { - return (invocation: firstArg, schemaRef: schemaRef); - } - - return (invocation: firstArg, schemaRef: null); - } - - final schemaRef = _extractSchemaReference(firstArg); - if (schemaRef != null) { - return (invocation: null, schemaRef: schemaRef); - } - - return (invocation: null, schemaRef: null); - } - - /// Analyzes the element schema used by Ack.list(...). - /// - /// Returns the generated list mapping and the list element representation - /// type string. - _ListElementAnalysis _analyzeListElement( - MethodInvocation listInvocation, - Element element, - TypeProvider typeProvider, { - String? diagnosticPath, - }) { - final args = listInvocation.argumentList.arguments; - - if (args.isEmpty) { - throw InvalidGenerationSource( - 'Ack.list(...) requires an element schema argument for strict typed generation.', - element: element, - todo: - 'Provide a concrete element schema, e.g. Ack.list(Ack.string()) or Ack.list(namedSchema).', - ); - } - - final firstArg = args.first; - _rejectAckModelFacadeExpression( - firstArg, - element, - diagnosticPath: diagnosticPath, - ); - - final ref = _resolveListElementRef(firstArg); - if (ref.invocation != null) { - final chain = _analyzeSchemaChain(ref.invocation!); - _rejectNullableListElement(chain.isNullable, element); - final baseInvocation = chain.ackBase; - final transformOutputTypeString = _requireTransformOutputType( - chain, - element, - contextLabel: 'Ack.list(...) element schema', - ); - - if (baseInvocation != null && - baseInvocation.methodName.name == 'object') { - throw InvalidGenerationSource( - 'Ack.list(Ack.object(...)) uses an anonymous inline object schema. ' - 'Strict typed generation requires a named schema reference.', - element: element, - todo: - 'Extract the inline object to a top-level @AckType() variable and use Ack.list(namedSchema).', - ); - } - - if (chain.schemaReference != null) { - _rejectIfReferencesNullableSchema(chain.schemaReference!, element); - final mapping = _resolveSchemaVariableType( - chain.schemaReference!, - element, - typeProvider, - transformedOutputType: chain.transformOutputType, - transformedRepresentationType: transformOutputTypeString, - ); - return ( - mapping: mapping, - elementRepresentationType: _resolveSchemaVariableElementTypeString( - chain.schemaReference!, - element, - transformedRepresentationType: transformOutputTypeString, - ), - ); - } - - if (baseInvocation == null) { - final rawExpression = firstArg.toSource(); - throw InvalidGenerationSource( - 'Could not statically resolve Ack.list($rawExpression) element type.', - element: element, - todo: - 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', - ); - } - - final methodName = baseInvocation.methodName.name; - _throwIfUnsupportedTransformedBaseSchema( - schemaMethod: methodName, - transformOutputTypeString: transformOutputTypeString, - element: element, - contextLabel: 'Ack.list(...) element schema', - ); - - if (methodName == 'list') { - final nested = _analyzeListElement( - baseInvocation, - element, - typeProvider, - diagnosticPath: diagnosticPath, - ); - return ( - mapping: _wrapListElementMapping(nested.mapping, typeProvider), - elementRepresentationType: - transformOutputTypeString ?? - 'List<${nested.elementRepresentationType}>', - ); - } - - final elementMapping = _mapSchemaTypeToDartType(ref.invocation!, element); - final elementRepresentationType = - transformOutputTypeString ?? - (methodName == 'enumValues' - ? _extractEnumTypeNameFromInvocation(baseInvocation) ?? 'dynamic' - : _mapSchemaMethodToType(methodName)); - return ( - mapping: _wrapListElementMapping(elementMapping, typeProvider), - elementRepresentationType: elementRepresentationType, - ); - } - - if (ref.schemaRef != null) { - _rejectIfReferencesNullableSchema(ref.schemaRef!, element); - final mapping = _resolveSchemaVariableType( - ref.schemaRef!, - element, - typeProvider, - ); - return ( - mapping: mapping, - elementRepresentationType: _resolveSchemaVariableElementTypeString( - ref.schemaRef!, - element, - ), - ); - } - - final rawExpression = firstArg.toSource(); - throw InvalidGenerationSource( - 'Could not statically resolve Ack.list($rawExpression) element type.', - element: element, - todo: - 'Use Ack.list(Ack.()), Ack.list(enumSchema), or Ack.list(namedSchema) so the generator can infer a concrete element type.', - ); - } - - void _rejectNullableListElement(bool isNullable, Element element) { - if (!isNullable) return; - - throw InvalidGenerationSource( - 'Ack.list(...) does not support nullable element schemas.', - element: element, - todo: - 'Remove `.nullable()` from the element schema. Make the list itself nullable with `Ack.list(item).nullable()` when needed.', - ); - } - - void _rejectIfReferencesNullableSchema( - _SchemaReference reference, - Element element, - ) { - final resolved = _resolveSchemaReference(reference, element); - _rejectNullableListElement( - resolved?.modelInfo.isNullableSchema ?? false, - element, - ); - } - - _SchemaTypeMapping _wrapListElementMapping( - _SchemaTypeMapping elementMapping, - TypeProvider typeProvider, - ) { - return ( - dartType: typeProvider.listType(elementMapping.dartType), - listElementSchemaRef: elementMapping.listElementSchemaRef, - listElementDisplayTypeOverride: - elementMapping.listElementDisplayTypeOverride, - listElementCastTypeOverride: elementMapping.listElementCastTypeOverride, - listElementIsCustomType: elementMapping.listElementIsCustomType, - ); - } - - /// Resolves a schema reference to its list element type. - /// - /// Looks up the schema in local/imported namespaces and returns the - /// appropriate list type plus metadata needed by the type builder. - _SchemaTypeMapping _resolveSchemaVariableType( - _SchemaReference schemaReference, - Element element, - TypeProvider typeProvider, { - DartType? transformedOutputType, - String? transformedRepresentationType, - }) { - final resolved = _resolveSchemaReference(schemaReference, element); - if (resolved == null) { - throw InvalidGenerationSource( - 'Could not resolve schema reference "${schemaReference.name}" ' - 'used in Ack.list(...)', - element: element, - todo: - 'Ensure "${schemaReference.name}" exists, is imported, and is declared as an Ack schema.', - ); - } - - final modelInfo = resolved.modelInfo; - final hasTransformOverride = transformedRepresentationType != null; - if (hasTransformOverride) { - _throwIfUnsupportedTransformedReferencedSchema( - resolved: resolved, - element: element, - contextLabel: 'Ack.list(${schemaReference.name}) element schema', - ); - } - - final representationType = - transformedRepresentationType ?? modelInfo.representationType; - final visibleRepresentationType = _resolveVisibleRepresentationType( - representationType: representationType, - resolved: resolved, - contextElement: element, - ); - final hasTypedReference = - resolved.hasAckTypeAnnotation && !hasTransformOverride; - final isObjectRepresentation = representationType == kMapType; - if (isObjectRepresentation && !hasTypedReference) { - throw InvalidGenerationSource( - 'Ack.list(${schemaReference.name}) references object schema ' - '"${schemaReference.name}" without @AckType. This would fall back to ' - 'Map.', - element: element, - todo: - 'Annotate "${schemaReference.name}" with @AckType() so list getters can emit typed wrappers.', - ); - } - - final elementDartType = - transformedOutputType ?? - _representationTypeToDartType(representationType, typeProvider); - - final typeBaseName = hasTypedReference - ? _qualifyTypeBaseName(modelInfo.className, resolved.importPrefix) - : null; - final listElementDisplayTypeOverride = hasTypedReference - ? typeBaseName - : (!elementDartType.isDartCoreString && - !elementDartType.isDartCoreInt && - !elementDartType.isDartCoreDouble && - !elementDartType.isDartCoreBool && - !elementDartType.isDartCoreNum && - !elementDartType.isDartCoreList && - !elementDartType.isDartCoreMap && - !elementDartType.isDartCoreSet - ? visibleRepresentationType - : null); - - return ( - dartType: typeProvider.listType(elementDartType), - listElementSchemaRef: hasTypedReference ? resolved.schemaName : null, - listElementDisplayTypeOverride: listElementDisplayTypeOverride, - listElementCastTypeOverride: hasTypedReference - ? visibleRepresentationType - : null, - listElementIsCustomType: hasTypedReference, - ); - } - - /// Resolves a schema reference to its representation type string. - /// - /// This is used for top-level list schemas so we can cast to the correct - /// element type (e.g., `String` for `Ack.string()` schema variables). - /// - /// Throws when the schema variable cannot be resolved or if a circular - /// reference is detected. - String _resolveSchemaVariableElementTypeString( - _SchemaReference schemaReference, - Element element, { - String? transformedRepresentationType, - }) { - final library = element.library; - // Use library-scoped cache key to prevent collisions across libraries - final prefix = schemaReference.prefix ?? ''; - final transformKey = transformedRepresentationType ?? ''; - final cacheKey = - '${library?.uri ?? 'unknown'}::$prefix::${schemaReference.name}::$transformKey'; - - final cached = _schemaVariableTypeCache[cacheKey]; - if (cached != null) { - return cached; - } - - if (_schemaVariableTypeStack.contains(cacheKey)) { - throw InvalidGenerationSource( - 'Circular schema variable reference detected for ' - '"${schemaReference.name}" in Ack.list(...).', - element: element, - todo: - 'Break the circular Ack.list(...) schema references so element types ' - 'can be resolved statically.', - ); - } - - _schemaVariableTypeStack.add(cacheKey); - - String? resolvedType; - try { - if (library == null) { - throw InvalidGenerationSource( - 'Could not resolve library while analyzing schema reference ' - '"${schemaReference.name}"', - element: element, - ); - } - - final resolved = _resolveSchemaReference(schemaReference, element); - if (resolved == null) { - throw InvalidGenerationSource( - 'Could not resolve schema reference "${schemaReference.name}" ' - 'used in Ack.list(...)', - element: element, - todo: - 'Ensure "${schemaReference.name}" exists, is imported, and is declared as an Ack schema.', - ); - } - - final representationType = - transformedRepresentationType ?? - resolved.modelInfo.representationType; - final hasTypedReference = - resolved.hasAckTypeAnnotation && - transformedRepresentationType == null; - - if (representationType == kMapType && !hasTypedReference) { - throw InvalidGenerationSource( - 'Ack.list(${schemaReference.name}) references object schema ' - '"${schemaReference.name}" without @AckType. This would fall back to ' - 'Map.', - element: element, - todo: - 'Annotate "${schemaReference.name}" with @AckType() so list getters can emit typed wrappers.', - ); - } - - resolvedType = _resolveVisibleRepresentationType( - representationType: representationType, - resolved: resolved, - contextElement: element, - ); - return resolvedType; - } finally { - _schemaVariableTypeStack.remove(cacheKey); - if (resolvedType != null) { - _schemaVariableTypeCache[cacheKey] = resolvedType; - } - } - } - - _ResolvedSchemaReference? _resolveSchemaReference( - _SchemaReference reference, - Element contextElement, { - String? diagnosticPath, - }) { - final library = contextElement.library; - if (library == null) { - return null; - } - - final cacheKey = _schemaReferenceCacheKey(reference, library); - final cached = _schemaReferenceCache[cacheKey]; - if (cached != null || _schemaReferenceCache.containsKey(cacheKey)) { - return cached; - } - - if (_schemaReferenceResolutionStack.contains(cacheKey)) { - final referenceLabel = _formatSchemaReference(reference); - throw InvalidGenerationSource( - 'Circular schema reference detected for "$referenceLabel".', - element: contextElement, - todo: - 'Break the circular alias/reference chain between @AckType schemas.', - ); - } - - _schemaReferenceResolutionStack.add(cacheKey); - - _ResolvedSchemaReference? resolvedReference; - var shouldCacheResult = false; - - try { - final resolvedElementMatch = _resolveSchemaElement(reference, library); - if (resolvedElementMatch == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - final resolvedElement = resolvedElementMatch.element; - - TopLevelVariableElement? schemaVariable; - GetterElement? schemaGetter; - Element? sourceDeclaration; - - if (resolvedElement is TopLevelVariableElement) { - schemaVariable = resolvedElement; - sourceDeclaration = resolvedElement; - } else if (resolvedElement is GetterElement) { - if (resolvedElement.isOriginVariable) { - final variable = resolvedElement.variable; - if (variable is TopLevelVariableElement) { - schemaVariable = variable; - sourceDeclaration = variable; - } - } else { - schemaGetter = resolvedElement; - sourceDeclaration = resolvedElement; - } - } - - if (schemaVariable == null && schemaGetter != null) { - // Ensure this is top-level only. - if (schemaGetter.enclosingElement is! LibraryElement) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - } - - if (schemaVariable == null && schemaGetter == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - final schemaName = schemaVariable?.name ?? schemaGetter?.name; - if (schemaName == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - final declarationForMetadata = - sourceDeclaration ?? schemaVariable ?? schemaGetter; - if (declarationForMetadata == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - if (_hasAckInferAnnotation(declarationForMetadata)) { - final path = diagnosticPath ?? contextElement.name ?? 'legacy schema'; - throw InvalidGenerationSource( - '$path crosses from legacy @AckType into modern schema-first model ' - '"$schemaName". ' - 'AckType and modern models intentionally use isolated generators; ' - 'migrate this connected graph together.', - element: contextElement, - ); - } - - final hasAckTypeAnnotation = _hasAckTypeAnnotation( - declarationForMetadata, - ); - - final customTypeName = _extractAckTypeName(declarationForMetadata); - - ModelInfo? modelInfo; - if (schemaVariable != null) { - modelInfo = analyzeSchemaVariable( - schemaVariable, - customTypeName: customTypeName, - ); - } else if (schemaGetter != null) { - modelInfo = analyzeSchemaGetter( - schemaGetter, - customTypeName: customTypeName, - ); - } - - if (modelInfo == null) { - shouldCacheResult = true; - resolvedReference = null; - return null; - } - - resolvedReference = _ResolvedSchemaReference( - schemaName: schemaName, - modelInfo: modelInfo, - importPrefix: reference.prefix, - importDirective: resolvedElementMatch.importDirective, - hasAckTypeAnnotation: hasAckTypeAnnotation, - sourceDeclaration: declarationForMetadata, - sourceLibraryUri: declarationForMetadata.library?.uri, - ); - shouldCacheResult = true; - return resolvedReference; - } on InvalidGenerationSource { - rethrow; - } catch (e, st) { - _log.warning( - 'Unexpected error resolving schema reference ' - '"${_formatSchemaReference(reference)}": $e\n$st', - ); - shouldCacheResult = true; - resolvedReference = null; - return null; - } finally { - _schemaReferenceResolutionStack.remove(cacheKey); - if (shouldCacheResult) { - _schemaReferenceCache[cacheKey] = resolvedReference; - } - } - } - - _ResolvedSchemaElement? _resolveSchemaElement( - _SchemaReference reference, - LibraryElement library, - ) { - if (reference.prefix != null) { - // Prefer an exact prefix match when the source used `prefix.symbol`. - for (final import in library.firstFragment.libraryImports) { - final prefixName = _elementName(import.prefix?.element); - if (prefixName != reference.prefix) continue; - - final importedElement = import.namespace.getPrefixed2( - reference.prefix!, - reference.name, - ); - if (importedElement != null) { - return (element: importedElement, importDirective: import); - } - } - - // Strict behavior: when a prefix is specified, never resolve from a - // different namespace. - return null; - } - - final scopeResult = library.firstFragment.scope.lookup(reference.name); - final scopedElement = scopeResult.getter; - if (scopedElement != null) { - return ( - element: scopedElement, - importDirective: _findImportDirectiveForElement( - reference.name, - scopedElement, - library, - ), - ); - } - - for (final import in library.firstFragment.libraryImports) { - final importedElement = import.namespace.get2(reference.name); - if (importedElement != null) { - return (element: importedElement, importDirective: import); - } - } - - return null; - } - - void _rejectAckModelFacadeExpression( - Expression expression, - Element contextElement, { - String? diagnosticPath, - }) { - final reference = _ackModelFacadeReference(expression); - if (reference == null) return; - - final (:prefix, :facadeName) = reference; - final library = contextElement.library; - if (library == null) return; - final matches = {}; - - void consider(ClassElement element, LibraryImport? import) { - if (_classFirstFacadeName(element) != facadeName) return; - if (import != null && - (!_importAllowsName(import, element.name!) || - !_importAllowsName(import, facadeName))) { - return; - } - matches.add(element); - } - - if (prefix == null) { - for (final element in library.classes) { - consider(element, null); - } - } - for (final import in library.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - if (_elementName(import.prefix?.element) != prefix) continue; - final elements = { - ...import.namespace.definedNames2.values, - ...?import.importedLibrary?.classes, - }; - for (final element in elements.whereType()) { - consider(element, import); - } - } - if (matches.isEmpty) return; - if (matches.length > 1) { - throw InvalidGenerationSource( - 'Modern class-first facade reference ${expression.toSource()} is ' - 'ambiguous.', - element: contextElement, - ); - } - - final path = diagnosticPath ?? contextElement.name ?? 'legacy schema'; - throw InvalidGenerationSource( - '$path crosses from legacy @AckType into modern class-first facade ' - '"$facadeName.schema". AckType and modern models intentionally use ' - 'isolated generators; migrate this connected graph together.', - element: contextElement, - ); - } - - ({String? prefix, String facadeName})? _ackModelFacadeReference( - Expression expression, - ) { - final path = _schemaAccessPath(expression); - if (path == null || - path.last != 'schema' || - (path.length != 2 && path.length != 3)) { - return null; - } - - return ( - prefix: path.length == 3 ? path.first : null, - facadeName: path[path.length - 2], - ); - } - - List? _schemaAccessPath(Expression expression) { - if (expression is ParenthesizedExpression) { - return _schemaAccessPath(expression.expression); - } - if (expression is PostfixExpression && expression.operator.lexeme == '!') { - return _schemaAccessPath(expression.operand); - } - if (expression is MethodInvocation) { - final target = expression.target; - return target == null ? null : _schemaAccessPath(target); - } - if (expression is PropertyAccess) { - final target = expression.target; - final targetPath = target == null ? null : _schemaAccessPath(target); - if (targetPath == null) return null; - return [...targetPath, expression.propertyName.name]; - } - if (expression is PrefixedIdentifier) { - return [expression.prefix.name, expression.identifier.name]; - } - if (expression is SimpleIdentifier) { - return [expression.name]; - } - return null; - } - - String? _classFirstFacadeName(ClassElement element) { - final annotation = _classFirstChecker.firstAnnotationOfExact(element); - if (annotation != null) { - final configuredName = ConstantReader(annotation).read('schemaName'); - return ackClassSchemaFacadeName( - element.name!, - override: configuredName.isNull ? null : configuredName.stringValue, - ); - } - final isImplicitUnionBranch = element.allSupertypes.any((supertype) { - final base = supertype.element; - return base is ClassElement && - base.library == element.library && - base.isSealed && - _classFirstChecker.hasAnnotationOfExact(base); - }); - return isImplicitUnionBranch - ? ackClassSchemaFacadeName(element.name!) - : null; - } - - bool _importAllowsName(LibraryImport import, String name) { - for (final combinator in import.combinators) { - if (combinator is ShowElementCombinator && - !combinator.shownNames.contains(name)) { - return false; - } - if (combinator is HideElementCombinator && - combinator.hiddenNames.contains(name)) { - return false; - } - } - return true; - } - - LibraryImport? _findImportDirectiveForElement( - String name, - Element element, - LibraryElement library, - ) { - for (final import in library.firstFragment.libraryImports) { - final importedElement = import.namespace.get2(name); - if (_elementsMatch(importedElement, element)) { - return import; - } - } - return null; - } - - bool _elementsMatch(Element? first, Element? second) { - if (identical(first, second)) { - return true; - } - if (first == null || second == null) { - return false; - } - return first.library?.uri == second.library?.uri && - first.name == second.name; - } - - String? _elementName(Element? element) { - final modernName = element?.name; - if (modernName != null && modernName.isNotEmpty) { - return modernName; - } - return null; - } - - bool _hasAckTypeAnnotation(Element element) { - return _ackTypeChecker.hasAnnotationOfExact(element); - } - - bool _hasAckInferAnnotation(Element element) { - return _schemaFirstChecker.hasAnnotationOfExact(element); - } - - String? _extractAckTypeName(Element element) { - final annotation = _ackTypeChecker.firstAnnotationOfExact(element); - if (annotation == null) return null; - - final nameField = ConstantReader(annotation).peek('name'); - return (nameField != null && !nameField.isNull) - ? nameField.stringValue - : null; - } - - String _qualifyTypeBaseName(String baseTypeName, String? prefix) { - if (prefix == null || prefix.isEmpty) { - return baseTypeName; - } - return '$prefix.$baseTypeName'; - } - - String _resolveVisibleRepresentationType({ - required String representationType, - required _ResolvedSchemaReference resolved, - required Element contextElement, - }) { - final contextLibrary = contextElement.library; - if (contextLibrary == null) { - throw InvalidGenerationSource( - 'Could not resolve libraries while qualifying transformed representation ' - 'type "$representationType".', - element: contextElement, - ); - } - - if (resolved.sourceLibraryUri == contextLibrary.uri) { - return representationType; - } - - if (_containsUnsupportedRepresentationSyntax(representationType)) { - throw InvalidGenerationSource( - 'Transformed representation type "$representationType" for ' - '"${resolved.schemaName}" uses unsupported syntax for cross-file ' - 'generation.', - element: contextElement, - todo: - 'Use a nominal type with optional nested generics/nullability, or keep the schema in the same library.', - ); - } - - final tokenPattern = RegExp(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*'); - final buffer = StringBuffer(); - var lastIndex = 0; - - for (final match in tokenPattern.allMatches(representationType)) { - buffer.write(representationType.substring(lastIndex, match.start)); - final token = match.group(0)!; - buffer.write( - _resolveVisibleRepresentationToken( - token: token, - resolved: resolved, - contextLibrary: contextLibrary, - fullRepresentationType: representationType, - contextElement: contextElement, - ), - ); - lastIndex = match.end; - } - - buffer.write(representationType.substring(lastIndex)); - return buffer.toString(); - } - - String _resolveVisibleRepresentationToken({ - required String token, - required _ResolvedSchemaReference resolved, - required LibraryElement contextLibrary, - required String fullRepresentationType, - required Element contextElement, - }) { - if (_isBuiltInRepresentationIdentifier(token)) { - return token; - } - - if (token.contains('.')) { - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" uses a qualified type that cannot be ' - 'referenced across library boundaries.', - element: contextElement, - todo: - 'Use an unqualified exported representation type, import that type directly into the consuming library, or keep the schema in the same library.', - ); - } - - final importNamespaceType = _resolveImportedType(token, resolved); - final scopedElement = contextLibrary.firstFragment.scope - .lookup(token) - .getter; - final scopedType = _resolveTypeFromElement(scopedElement); - final localContextType = - scopedElement != null && - _findImportDirectiveForElement( - token, - scopedElement, - contextLibrary, - ) == - null - ? scopedType - : null; - final importedContextTypes = _resolveImportedTypesByName( - token, - contextLibrary, - ); - final unqualifiedContextType = - localContextType ?? - (importedContextTypes.length == 1 ? importedContextTypes.single : null); - final hasAmbiguousImportedTypes = - localContextType == null && importedContextTypes.length > 1; - final prefix = resolved.importPrefix; - - if (importNamespaceType != null && prefix != null && prefix.isNotEmpty) { - return '$prefix.$token'; - } - - if (importNamespaceType != null) { - if (unqualifiedContextType != null && - _sameResolvedType(importNamespaceType, unqualifiedContextType)) { - return token; - } - - if (hasAmbiguousImportedTypes || unqualifiedContextType != null) { - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" is ambiguous in this library.', - element: contextElement, - todo: - 'Use a prefixed schema import or rename/import the representation type so the generated cast resolves unambiguously.', - ); - } - } - - if (hasAmbiguousImportedTypes) { - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" is ambiguous in this library.', - element: contextElement, - todo: - 'Use a prefixed schema import or rename/import the representation type so the generated cast resolves unambiguously.', - ); - } - - if (unqualifiedContextType != null) { - return token; - } - - throw InvalidGenerationSource( - 'Transformed representation type "$fullRepresentationType" for ' - '"${resolved.schemaName}" is not visible from this library.', - element: contextElement, - todo: - 'Export the representation type from the referenced schema library or import that type directly into this library.', - ); - } - - bool _sameResolvedType(DartType first, DartType second) { - return _resolvedTypeIdentity(first) == _resolvedTypeIdentity(second); - } - - List _resolveImportedTypesByName( - String token, - LibraryElement library, - ) { - final normalizedToken = token.trim(); - if (normalizedToken.isEmpty) { - return const []; - } - - final importedTypesByIdentity = {}; - for (final import in library.firstFragment.libraryImports) { - final importedElement = import.namespace.get2(normalizedToken); - final importedType = _resolveTypeFromElement(importedElement); - if (importedType == null) { - continue; - } - - importedTypesByIdentity.putIfAbsent( - _resolvedTypeIdentity(importedType), - () => importedType, - ); - } - - return importedTypesByIdentity.values.toList(growable: false); - } - - DartType? _resolveImportedType( - String token, - _ResolvedSchemaReference resolved, - ) { - final importDirective = resolved.importDirective; - if (importDirective == null) { - return null; - } - - final prefix = resolved.importPrefix; - final importedElement = prefix != null && prefix.isNotEmpty - ? importDirective.namespace.getPrefixed2(prefix, token) - : importDirective.namespace.get2(token); - return _resolveTypeFromElement(importedElement); - } - - String _resolvedTypeIdentity(DartType type) { - if (type is InterfaceType) { - final element = type.element; - final libraryUri = element.library.uri.toString(); - final name = element.name ?? type.getDisplayString(); - return '$libraryUri::$name'; - } - - return type.getDisplayString(); - } - - bool _isBuiltInRepresentationIdentifier(String token) { - return token == 'String' || - token == 'int' || - token == 'double' || - token == 'bool' || - token == 'num' || - token == 'dynamic' || - token == 'Object' || - token == 'Null' || - token == 'Never' || - token == 'void' || - token == 'Uri' || - token == 'DateTime' || - token == 'Duration' || - token == 'List' || - token == 'Set' || - token == 'Map'; - } - - bool _containsUnsupportedRepresentationSyntax(String representationType) { - return representationType.contains('(') || - representationType.contains(')') || - representationType.contains('{') || - representationType.contains('}') || - representationType.contains('=>'); - } - - String _schemaReferenceCacheKey( - _SchemaReference reference, - LibraryElement library, - ) { - final prefix = reference.prefix ?? ''; - return '${library.uri}::$prefix::${reference.name}'; - } - - String _formatSchemaReference(_SchemaReference reference) { - final prefix = reference.prefix; - if (prefix == null || prefix.isEmpty) { - return reference.name; - } - return '$prefix.${reference.name}'; - } - - DartType _representationTypeToDartType( - String representationType, - TypeProvider typeProvider, - ) { - return switch (representationType) { - 'String' => typeProvider.stringType, - 'int' => typeProvider.intType, - 'double' => typeProvider.doubleType, - 'bool' => typeProvider.boolType, - 'num' => typeProvider.numType, - 'Uri' => _dartCoreType(typeProvider, 'Uri'), - 'DateTime' => _dartCoreType(typeProvider, 'DateTime'), - 'Duration' => _dartCoreType(typeProvider, 'Duration'), - _ when representationType.startsWith('Map<') => typeProvider.mapType( - typeProvider.stringType, - typeProvider.dynamicType, - ), - _ when representationType.startsWith('List<') => typeProvider.listType( - typeProvider.dynamicType, - ), - _ => typeProvider.dynamicType, - }; - } - - DartType _dartCoreType(TypeProvider typeProvider, String typeName) { - final type = _resolveTypeByName( - typeName, - typeProvider.stringType.element.library, - ); - return type ?? typeProvider.dynamicType; - } - - /// Extracts the identifier name from different expression forms. - /// - /// Supports simple identifiers, prefixed identifiers (`prefix.name`), - /// and property accesses (`expr.name`). - String? _identifierName(Expression? expression) { - if (expression == null) return null; - - if (expression is SimpleIdentifier) { - return expression.name; - } - - if (expression is PrefixedIdentifier) { - return expression.identifier.name; - } - - if (expression is PropertyAccess) { - return expression.propertyName.name; - } - - return null; - } - - bool _isAckTarget(Expression? target) { - return _identifierName(target) == 'Ack'; - } - - _SchemaReference? _extractSchemaReference(Expression? target) { - if (target == null) return null; - - if (target is SimpleIdentifier) { - if (target.name == 'Ack') return null; - return (name: target.name, prefix: null); - } - - if (target is PrefixedIdentifier) { - final name = target.identifier.name; - if (name == 'Ack') return null; - return (name: name, prefix: target.prefix.name); - } - - if (target is PropertyAccess) { - final name = target.propertyName.name; - if (name == 'Ack') return null; - - final targetExpression = target.target; - String? prefix; - if (targetExpression is SimpleIdentifier) { - prefix = targetExpression.name; - } else if (targetExpression is PrefixedIdentifier) { - prefix = targetExpression.identifier.name; - } - - return (name: name, prefix: prefix); - } - - return null; - } - - (List, bool) _collectMethodChain( - MethodInvocation invocation, - ) { - final chain = []; - MethodInvocation? current = invocation; - - // Safety limit to prevent infinite loops on malformed AST - const maxDepth = 20; - var depth = 0; - - while (current != null && depth < maxDepth) { - chain.add(current); - final target = current.target; - if (target is MethodInvocation) { - current = target; - depth++; - } else { - break; - } - } - - return (chain, depth >= maxDepth); - } - - _SchemaChainInfo _analyzeSchemaChain(MethodInvocation invocation) { - final (chain, truncated) = _collectMethodChain(invocation); - MethodInvocation? ackBase; - _SchemaReference? schemaReference; - var isOptional = false; - var isNullable = false; - MethodInvocation? transformInvocation; - DartType? transformOutputType; - String? transformOutputTypeString; - - for (final current in chain) { - final methodName = current.methodName.name; - - if (methodName == 'optional') { - isOptional = true; - } else if (methodName == 'nullable') { - isNullable = true; - } else if (methodName == 'transform' && transformInvocation == null) { - transformInvocation = current; - final typeArgs = current.typeArguments?.arguments; - if (typeArgs != null && typeArgs.isNotEmpty) { - final typeArg = typeArgs.first; - transformOutputType = typeArg.type; - transformOutputTypeString = typeArg.toSource(); - } - } - - final target = current.target; - if (ackBase == null && _isAckTarget(target)) { - ackBase = current; - } - - if (schemaReference == null) { - final reference = _extractSchemaReference(target); - if (reference != null) { - schemaReference = reference; - } - } - } - - if (truncated) { - _log.warning( - 'Schema method chain exceeded max depth of 20. ' - 'Type inference may fall back to dynamic.', - ); - } - - return ( - ackBase: ackBase, - schemaReference: schemaReference, - isOptional: isOptional, - isNullable: isNullable, - wasTruncated: truncated, - transformInvocation: transformInvocation, - transformOutputType: transformOutputType, - transformOutputTypeString: transformOutputTypeString, - ); - } - - String? _requireTransformOutputType( - _SchemaChainInfo chain, - Element element, { - required String contextLabel, - }) { - if (chain.transformInvocation == null) { - return null; - } - - final typeName = chain.transformOutputTypeString; - if (typeName != null && typeName.isNotEmpty) { - return typeName; - } - - throw InvalidGenerationSource( - '$contextLabel uses .transform(...) without an explicit output type. ' - '@AckType requires .transform(...) so the generated type can be inferred.', - element: element, - todo: - 'Add an explicit type argument, for example .transform((value) => ...).', - ); - } - - void _throwIfUnsupportedTransformedBaseSchema({ - required String schemaMethod, - required String? transformOutputTypeString, - required Element element, - required String contextLabel, - }) { - if (transformOutputTypeString == null) { - return; - } - - if (schemaMethod == 'object') { - throw InvalidGenerationSource( - '$contextLabel transforms an Ack.object(...) schema. ' - 'Transformed object schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the object schema or expose the transformed result through a separate non-object schema.', - ); - } - - if (schemaMethod == 'discriminated') { - throw InvalidGenerationSource( - '$contextLabel transforms an Ack.discriminated(...) schema. ' - 'Transformed discriminated schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the discriminated schema or expose the transformed result through a separate non-object schema.', - ); - } - } - - void _throwIfUnsupportedTransformedReferencedSchema({ - required _ResolvedSchemaReference resolved, - required Element element, - required String contextLabel, - }) { - final modelInfo = resolved.modelInfo; - if (modelInfo.isDiscriminatedBaseDefinition) { - throw InvalidGenerationSource( - '$contextLabel transforms referenced discriminated schema ' - '"${resolved.schemaName}". Transformed discriminated schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the referenced discriminated schema or expose a separate non-object schema.', - ); - } - - if (modelInfo.representationType == kMapType) { - throw InvalidGenerationSource( - '$contextLabel transforms referenced object schema ' - '"${resolved.schemaName}". Transformed object schemas are not supported by @AckType.', - element: element, - todo: - 'Remove .transform() from the referenced object schema or expose a separate non-object schema.', - ); - } - } - - /// Walks a method chain to find the base Ack.xxx() invocation. - /// - /// For `Ack.string().describe('...').optional()`, returns `Ack.string()`. - /// For `Ack.integer().min(0).max(100)`, returns `Ack.integer()`. - /// - /// Returns `null` if no Ack.xxx() base is found. - MethodInvocation? _findBaseAckInvocation(MethodInvocation invocation) { - final (chain, truncated) = _collectMethodChain(invocation); - - for (final current in chain) { - final target = current.target; - if (_isAckTarget(target)) { - return current; - } - } - - if (truncated) { - _log.warning( - 'Method chain exceeded max depth of 20. ' - 'List element type will fall back to dynamic.', - ); - } - return null; - } - - /// Walks a method chain to find a schema variable base reference. - /// - /// For `itemSchema.optional().nullable()`, returns `(name: itemSchema)`. - /// For `deck.slideSchema.describe('...')`, returns - /// `(name: slideSchema, prefix: deck)`. - /// - /// Returns `null` if the chain doesn't end with a schema variable identifier - /// (e.g., if it's an Ack.xxx() chain or unknown structure). - /// - _SchemaReference? _findSchemaVariableBase(MethodInvocation invocation) { - final (chain, truncated) = _collectMethodChain(invocation); - - for (final current in chain) { - final target = current.target; - - final schemaReference = _extractSchemaReference(target); - if (schemaReference != null) { - return schemaReference; - } - - // If target resolves to Ack, this is an Ack.xxx() chain - if (_isAckTarget(target)) { - return null; - } - } - - if (truncated) { - _log.warning( - 'Schema variable method chain exceeded max depth of 20. ' - 'List element type will fall back to dynamic.', - ); - } - return null; - } - - /// Resolves the base class name for a schema variable, honoring custom overrides. - String _resolveModelClassName( - String variableName, - Element element, { - String? customTypeName, - }) { - if (customTypeName == null) { - return _generateTypeNameFromVariable(variableName); - } - - final trimmed = customTypeName.trim(); - if (trimmed.isEmpty) { - throw InvalidGenerationSource( - 'Custom @AckType name cannot be empty', - element: element, - todo: 'Provide a non-empty type name in the @AckType annotation.', - ); - } - - const identifierPattern = r'^[A-Za-z_][A-Za-z0-9_]*$'; - if (!RegExp(identifierPattern).hasMatch(trimmed)) { - throw InvalidGenerationSource( - 'Invalid custom @AckType name "$customTypeName". ' - 'Type names must start with a letter or underscore and can only contain letters, numbers, and underscores.', - element: element, - todo: 'Update the @AckType annotation to use a valid Dart identifier.', - ); - } - - // Ensure leading character is uppercase for consistency. - if (trimmed.length == 1) { - return trimmed.toUpperCase(); - } - - return trimmed[0].toUpperCase() + trimmed.substring(1); - } - - /// Generates an extension type name from a schema variable name - /// - /// Examples: - /// - "userSchema" → "User" - /// - "addressSchema" → "Address" - /// - "myDataSchema" → "MyData" - String _generateTypeNameFromVariable(String variableName) { - // Remove "Schema" suffix if present - var name = variableName; - if (name.endsWith('Schema')) { - name = name.substring(0, name.length - 'Schema'.length); - } - - // Capitalize first letter - if (name.isEmpty) return 'Type'; - return name[0].toUpperCase() + name.substring(1); - } - - /// Parses Ack.string() schema - ModelInfo _parseStringSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'String', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.integer() schema - ModelInfo _parseIntegerSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'int', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.double() schema - ModelInfo _parseDoubleSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'double', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.boolean() schema - ModelInfo _parseBooleanSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'bool', - isNullableSchema: isNullable, - ); - } - - /// Parses Ack.list() schema - /// - /// Extracts the element type from list schema definitions to generate - /// correctly typed extension types (e.g., `List` not `List`). - /// - /// Examples: - /// - `Ack.list(Ack.string())` → `List` - /// - `Ack.list(Ack.integer())` → `List` - /// - `Ack.list(Ack.list(Ack.double()))` → `List>` (nested) - /// - `Ack.list(addressSchema)` → `List>` (schema reference) - ModelInfo _parseListSchema( - String variableName, - Element element, { - required bool isNullable, - required _ListElementAnalysis listElementAnalysis, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: - 'List<${listElementAnalysis.elementRepresentationType}>', - isNullableSchema: isNullable, - ); - } - - ModelInfo _parseRepresentationSchema( - String variableName, - Element element, { - required String representationType, - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: const [], - representationType: representationType, - isNullableSchema: isNullable, - ); - } - - ModelInfo _withRepresentationType( - ModelInfo model, - String representationType, - ) { - return ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: model.fields, - additionalProperties: model.additionalProperties, - discriminatorKey: model.discriminatorKey, - discriminatorValue: model.discriminatorValue, - subtypeNames: model.subtypeNames, - schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: model.discriminatedBaseClassName, - representationType: representationType, - isNullableSchema: model.isNullableSchema, - ); - } - - /// Parses Ack.literal() schema - /// - /// Literal schemas are StringSchema with a literal constraint. - /// The constraint is enforced at runtime, not in the extension type. - /// - /// Example: Ack.literal('active') → extension type StatusType(String) - ModelInfo _parseLiteralSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'String', - isNullableSchema: isNullable, - ); - } - - /// Parses `Ack.enumString()` schema. - /// - /// String-enum schemas are `StringSchema` values with an enum constraint. - /// The allowed values are enforced at runtime, not in the extension type. - /// - /// Example: `Ack.enumString(['a', 'b'])` -> `extension type XType(String)` - ModelInfo _parseEnumStringSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: 'String', - isNullableSchema: isNullable, - ); - } - - /// Parses `Ack.enumValues()` schema - /// - /// EnumValues schemas wrap Dart enum types with validation. - /// The representation type is the enum type itself. - /// - /// Example: `Ack.enumValues([...])` → extension type XType(UserRole) - ModelInfo _parseEnumValuesSchema( - String variableName, - MethodInvocation invocation, - Element element, { - required bool isNullable, - String? customTypeName, - }) { - final typeName = _resolveModelClassName( - variableName, - element, - customTypeName: customTypeName, - ); - - final enumTypeName = _extractEnumTypeNameFromInvocation(invocation); - - // If we couldn't extract the enum type, throw an error - if (enumTypeName == null) { - throw InvalidGenerationSource( - 'Could not determine enum type for Ack.enumValues(). ' - 'Use explicit type argument: Ack.enumValues([...]) ' - 'or pass enum.values: Ack.enumValues(YourEnum.values)', - element: element, - ); - } - - return ModelInfo( - className: typeName, - schemaClassName: variableName, - fields: [], - representationType: enumTypeName, - isNullableSchema: isNullable, - ); - } - - /// Maps Ack schema method names to Dart type strings - /// - /// Used for generating string representations of types in list element contexts. - /// For nested lists, this function is called recursively via [_parseListSchema]. - String _mapSchemaMethodToType(String methodName) { - return switch (methodName) { - 'string' || 'enumString' || 'literal' => 'String', - 'integer' => 'int', - 'double' => 'double', - 'boolean' => 'bool', - 'uri' => 'Uri', - 'date' || 'datetime' => 'DateTime', - 'duration' => 'Duration', - 'object' => kMapType, - 'list' => 'List', - _ => 'dynamic', - }; - } - - /// Validates that a field name is a valid Dart identifier - /// - /// Throws [InvalidGenerationSource] if the field name: - /// - Contains invalid characters (must match [a-zA-Z_$][a-zA-Z0-9_$]*) - /// - Is a Dart reserved keyword - void _validateFieldName(String fieldName, Element element) { - // Check if key is a valid Dart identifier - final identifierRegex = RegExp(r'^[a-zA-Z_$][a-zA-Z0-9_$]*$'); - if (!identifierRegex.hasMatch(fieldName)) { - throw InvalidGenerationSource( - 'JSON key "$fieldName" is not a valid Dart identifier. ' - 'Keys must start with a letter, underscore, or dollar sign, and can only ' - 'contain letters, numbers, underscores, and dollar signs.', - element: element, - todo: - 'Use a valid Dart identifier as the key, or consider transforming ' - 'the key to a valid identifier (e.g., "user-id" → "userId").', - ); - } - - // Reject only reserved words. Built-in and pseudo keywords are allowed - // as identifiers in many contexts (for example `of`, `augment`). - final keyword = Keyword.keywords[fieldName]; - if (keyword?.isReservedWord == true) { - throw InvalidGenerationSource( - 'JSON key "$fieldName" is a Dart reserved keyword and cannot be used as a field name.', - element: element, - todo: - 'Use a different key that is not a Dart reserved keyword, or prefix it ' - '(e.g., "class" → "classValue" or "klass").', - ); - } - } -} diff --git a/packages/ack_generator/lib/src/analyzer/schema_first_names.dart b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart new file mode 100644 index 00000000..3873699d --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart @@ -0,0 +1,111 @@ +/// Member names every schema-first model generates, so a field cannot use +/// them. +const ackSchemaFirstReservedMembers = { + r'$ack', + 'parse', + 'safeParse', + 'fromJson', + 'toJson', + 'safeToJson', + 'copyWith', + '_fromAckRuntime', + '_toAckRuntime', + 'hashCode', + 'noSuchMethod', + 'toString', + 'runtimeType', +}; + +/// Dart reserved words, which cannot be field names. +const ackDartReservedWords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; + +/// Why [key] cannot be a schema-first field name, or `null` when it can. +/// +/// [capturesExtras] is whether the model stores unknown properties in its +/// generated `additionalProperties` field. +String? ackSchemaFirstFieldNameProblem( + String key, { + required bool capturesExtras, +}) { + if (key.startsWith('_')) { + return "cannot start with '_' (private Dart member)."; + } + if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(key) || + ackDartReservedWords.contains(key)) { + return 'cannot be represented as a Dart field name.'; + } + if (ackSchemaFirstReservedMembers.contains(key)) { + return 'conflicts with generated/Object member "$key".'; + } + if (capturesExtras && key == 'additionalProperties') { + return 'conflicts with the generated additional-properties member.'; + } + return null; +} diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 129262f9..3ed5c7ab 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -11,6 +11,7 @@ import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import 'generated_companion_visibility.dart'; +import 'schema_first_names.dart'; final class _Declaration { const _Declaration({ @@ -52,90 +53,8 @@ typedef _SchemaTypes = ({AckInferRef boundary, AckInferRef runtime}); final class SchemaModelGraphBuilder { SchemaModelGraphBuilder(this.library); - static const _reservedMembers = { - r'$ack', - 'parse', - 'safeParse', - 'fromJson', - 'toJson', - 'safeToJson', - 'copyWith', - '_fromAckRuntime', - '_toAckRuntime', - 'hashCode', - 'noSuchMethod', - 'toString', - 'runtimeType', - }; - - static const _dartKeywords = { - 'abstract', - 'as', - 'assert', - 'async', - 'await', - 'base', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'covariant', - 'default', - 'deferred', - 'do', - 'dynamic', - 'else', - 'enum', - 'export', - 'extends', - 'extension', - 'external', - 'factory', - 'false', - 'final', - 'finally', - 'for', - 'get', - 'hide', - 'if', - 'implements', - 'import', - 'in', - 'interface', - 'is', - 'late', - 'library', - 'mixin', - 'new', - 'null', - 'of', - 'on', - 'operator', - 'part', - 'required', - 'rethrow', - 'return', - 'sealed', - 'set', - 'show', - 'static', - 'super', - 'switch', - 'sync', - 'this', - 'throw', - 'true', - 'try', - 'typedef', - 'var', - 'void', - 'when', - 'while', - 'with', - 'yield', - }; + static const _reservedMembers = ackSchemaFirstReservedMembers; + static const _dartKeywords = ackDartReservedWords; static const _oneWayTransformMethods = { 'transform', @@ -146,25 +65,10 @@ final class SchemaModelGraphBuilder { static const _maxReferenceDepth = 16; - static const _ackInferChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckInfer, - inPackage: 'ack', - ); static const _schemableChecker = TypeChecker.typeNamed( Schemable, inPackage: 'ack', ); - static const _legacyAckTypeChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckType, - inPackage: 'ack', - ); - static const _ackModelChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckModel, - inPackage: 'ack', - ); static const _ackSchemaChecker = TypeChecker.typeNamed( AckSchema, inPackage: 'ack', @@ -869,14 +773,6 @@ final class SchemaModelGraphBuilder { if (element is! TopLevelVariableElement && element is! GetterElement) { return null; } - if (_hasLegacyAckType(element)) { - throw InvalidGenerationSource( - '$path crosses from a modern Ack model into legacy @AckType. ' - 'AckType and modern models intentionally use isolated generators; ' - 'migrate this connected graph together.', - element: context, - ); - } if (_hasAckInfer(element)) return null; if (depth >= _maxReferenceDepth) { throw InvalidGenerationSource( @@ -1184,9 +1080,7 @@ final class SchemaModelGraphBuilder { } String? _classFirstFacadeName(ClassElement element) { - final annotation = - _ackModelChecker.firstAnnotationOfExact(element) ?? - _schemableChecker.firstAnnotationOfExact(element); + final annotation = _schemableChecker.firstAnnotationOfExact(element); if (annotation != null) { final value = ConstantReader(annotation).read('schemaName'); return ackClassSchemaFacadeName( @@ -1199,8 +1093,7 @@ final class SchemaModelGraphBuilder { return base is ClassElement && base.library == element.library && base.isSealed && - (_ackModelChecker.hasAnnotationOfExact(base) || - _schemableChecker.hasAnnotationOfExact(base)); + _schemableChecker.hasAnnotationOfExact(base); }); return isImplicitUnionBranch ? ackClassSchemaFacadeName(element.name!) @@ -1470,27 +1363,15 @@ final class SchemaModelGraphBuilder { bool _hasAckInfer(Element element) { final declaration = _propertyDeclaration(element); - return _ackInferChecker.hasAnnotationOfExact(declaration) || - _schemableChecker.hasAnnotationOfExact(declaration); + return _schemableChecker.hasAnnotationOfExact(declaration); } /// The annotation spelling that marks the declaration behind [element]. - String _annotationLabel(Element element) => - _ackInferChecker.hasAnnotationOfExact(_propertyDeclaration(element)) - ? '@AckInfer' - : '@Schemable'; - - bool _hasLegacyAckType(Element element) { - return _legacyAckTypeChecker.hasAnnotationOfExact( - _propertyDeclaration(element), - ); - } + String _annotationLabel(Element element) => '@Schemable'; String? _annotationName(Element element) { final declaration = _propertyDeclaration(element); - final annotation = - _ackInferChecker.firstAnnotationOfExact(declaration) ?? - _schemableChecker.firstAnnotationOfExact(declaration); + final annotation = _schemableChecker.firstAnnotationOfExact(declaration); final field = annotation == null ? null : ConstantReader(annotation).peek('name'); @@ -1596,30 +1477,16 @@ final class SchemaModelGraphBuilder { if (declaration == null) continue; switch (node) { case AckUnionModelNode(): - continue; case AckValueModelNode(): - _validateClassHelperNames( - className: node.className, - fieldNames: const ['value'], - needsCopyWithSentinel: false, - path: node.id.declarationName, - element: declaration.element, - localNames: localNames, - ); + continue; case AckObjectModelNode(): _validateClassHelperNames( className: node.className, - fieldNames: [ - for (final field in node.fields) - if (field.jsonKey != node.discriminatorKey) field.dartName, - if (node.additionalProperties) 'additionalProperties', - ], needsCopyWithSentinel: node.fields.any( (field) => field.jsonKey != node.discriminatorKey && (field.nullable || !field.isRequired), ), - path: node.id.declarationName, element: declaration.element, localNames: localNames, ); @@ -1629,48 +1496,10 @@ final class SchemaModelGraphBuilder { void _validateClassHelperNames({ required String className, - required List fieldNames, required bool needsCopyWithSentinel, - required String path, required Element element, required Set localNames, }) { - final ownerByBridge = {}; - for (final fieldName in fieldNames) { - for (final bridgeName in ackFieldBridgeNames(fieldName)) { - final owner = ownerByBridge[bridgeName]; - if (owner != null) { - throw InvalidGenerationSource( - '$path.$fieldName generates helper "$bridgeName" that conflicts ' - 'with $path.$owner.', - element: element, - ); - } - if (fieldNames.contains(bridgeName)) { - throw InvalidGenerationSource( - '$path.$fieldName generates helper "$bridgeName" that conflicts ' - 'with a stored field.', - element: element, - ); - } - if (_reservedMembers.contains(bridgeName)) { - throw InvalidGenerationSource( - '$path.$fieldName generates helper "$bridgeName" that conflicts ' - 'with a generated member.', - element: element, - ); - } - ownerByBridge[bridgeName] = fieldName; - } - } - - for (final helperName in ackJsonHelperNames(className)) { - if (!localNames.contains(helperName)) continue; - throw InvalidGenerationSource( - 'Generated helper "$helperName" conflicts with a local declaration.', - element: element, - ); - } final sentinelType = ackCopyWithUnsetTypeName(className); if (needsCopyWithSentinel && localNames.contains(sentinelType)) { throw InvalidGenerationSource( diff --git a/packages/ack_generator/lib/src/builder.dart b/packages/ack_generator/lib/src/builder.dart index dd98d00b..b4ae06a4 100644 --- a/packages/ack_generator/lib/src/builder.dart +++ b/packages/ack_generator/lib/src/builder.dart @@ -1,43 +1,24 @@ import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; -import 'generator.dart'; -import 'json/ack_json_generator.dart'; +import 'json_schema/json_schema_builder.dart'; +import 'json_schema/json_schema_library.dart'; import 'model_generator.dart'; -/// Header for the modern parts. -/// -/// Generated code keeps consumer lint rules and coverage reports focused on -/// hand-written source, matching other Dart model generators. -const _modernPartHeader = - '$defaultFileHeader\n' - '// ignore_for_file: type=lint\n' - '// coverage:ignore-file'; - -/// Creates the frozen legacy `.g.dart` builder for `@AckType`. +/// Creates the shared `.g.dart` part builder for `@Schemable` models. Builder ackGenerator(BuilderOptions options) { - return LibraryBuilder(AckSchemaGenerator(), generatedExtension: '.g.dart'); + return SharedPartBuilder([AckModelGenerator()], 'ack'); } -/// Creates the dedicated modern model part. -/// -/// Keeping modern output separate prevents changes to the legacy `.g.dart` -/// contract. +/// Creates a direct `.g.dart` part builder for single-step unit tests. Builder ackModelBuilder(BuilderOptions options) { - return PartBuilder( - [AckModelGenerator()], - '.ack.dart', - header: _modernPartHeader, - options: options, - ); + return PartBuilder([AckModelGenerator()], '.g.dart', options: options); } -/// Creates the dedicated modern JSON part after `.ack.dart` exists. -Builder ackModelJsonBuilder(BuilderOptions options) { - return PartBuilder( - [AckJsonSerializableGenerator()], - '.ack.g.dart', - header: _modernPartHeader, - options: options, - ); -} +/// Creates the opt-in builder that generates `@Schemable` schema declarations +/// from `*.schema.json` JSON Schema bundles. +/// +/// Enable it in a consumer's `build.yaml` as `ack_generator:ack_json_schema`. +Builder ackJsonSchemaBuilder(BuilderOptions options) => AckJsonSchemaBuilder( + AckJsonSchemaLibraryOptions.fromConfig(options.config), +); diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index f863af32..5057db66 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -90,8 +90,7 @@ final class AckClassModelEmitter { copyWithSupertypes: metadata.copyWithSupertypes, ), ) - ..writeln() - ..writeln(_fieldBridges(node)); + ..writeln(); } void _emitUnion( @@ -273,126 +272,139 @@ abstract final class ${metadata.facadeName} { } String _fromRuntimeFunction(AckObjectModelNode node) { - final helper = jsonFromHelperName(node.className); final function = ackClassFromRuntimeName(node.className); + final fieldsByName = { + for (final field in node.fields) field.dartName: field, + }; + final arguments = []; + for (final parameter in node.constructorParameters) { + final String expression; + if (parameter.fieldName == node.captureFieldName) { + expression = + '${_ack('deepUnmodifiableJsonMap')}(Map.fromEntries(\n' + ' value.entries.where((entry) => !declared.contains(entry.key)),\n' + ' ))'; + } else { + final field = fieldsByName[parameter.fieldName]; + if (field == null) { + throw StateError( + 'Missing field ${parameter.fieldName} on ${node.className}.', + ); + } + expression = _decodeParameter(parameter, field); + } + arguments.add( + parameter.kind == AckConstructorParameterKind.named + ? '${parameter.name}: $expression' + : expression, + ); + } + if (node.captureFieldName == null) { return ''' ${node.className} $function(Map value) => - $helper(Map.from(value));'''; + ${node.className}(${arguments.join(', ')});'''; } final declared = _declaredKeys(node); - final capture = node.captureFieldName!; - final captureJsonKey = node.captureJsonKey ?? capture; return ''' ${node.className} $function(Map value) { const declared = ${_keySet(declared)}; - return $helper({ - ...value, - ${dartStringLiteral(captureJsonKey)}: Map.fromEntries( - value.entries.where((entry) => !declared.contains(entry.key)), - ), - }); + return ${node.className}( + ${arguments.join(',\n ')}, + ); }'''; } + String _decodeParameter( + AckConstructorParameter parameter, + AckFieldNode field, + ) { + final raw = 'value[${dartStringLiteral(field.jsonKey)}]'; + final defaultExpr = + parameter.defaultExpression != null && + parameter.defaultExpression != 'null' + ? parameter.defaultExpression + : null; + final baseType = _nonNullable(parameter.typeRef); + final isNullableOrDefaulted = + parameter.typeRef is AckNullableTypeRef || defaultExpr != null; + if (!isNullableOrDefaulted) { + return _fromRuntime(baseType, raw); + } + if (baseType is AckScalarTypeRef || baseType is AckExternalTypeRef) { + final cast = baseType is AckScalarTypeRef && baseType.dartType == 'Object' + ? raw + : '$raw as ${_type(baseType)}?'; + return defaultExpr == null ? cast : '$cast ?? $defaultExpr'; + } + final nullBranch = defaultExpr ?? 'null'; + return 'switch ($raw) {' + ' null => $nullBranch,' + ' final fieldValue => ${_fromRuntime(baseType, 'fieldValue')},' + ' }'; + } + String _toRuntimeFunction(AckObjectModelNode node) { final function = ackClassToRuntimeName(node.className); - final jsonHelper = jsonToHelperName(node.className); - final requiredNulls = [ - for (final field in node.fields) - if (field.isRequired && field.acceptsNull) field, - ]; final discriminatorKey = node.discriminatorKey; final discriminatorValue = node.discriminatorValue; final capture = node.captureFieldName; - final captureJsonKey = node.captureJsonKey ?? capture; - final needsBlock = - capture != null || requiredNulls.isNotEmpty || discriminatorKey != null; - if (!needsBlock) { + final fieldEntries = [ + for (final field in node.fields) + if (discriminatorValue == null || field.jsonKey != discriminatorKey) + '${dartStringLiteral(field.jsonKey)}: ' + '${_omitIfNull(field) ? '?' : ''}${_encodeField(field)}', + if (discriminatorKey != null && discriminatorValue != null) + '${dartStringLiteral(discriminatorKey)}: ' + '${dartStringLiteral(discriminatorValue)}', + ]; + if (capture == null) { return ''' Map $function(${node.className} model) => - {...$jsonHelper(model)};'''; - } - - final lines = []; - if (capture != null) { - lines.add('const declared = ${_keySet(_declaredKeys(node))};'); + {${fieldEntries.join(', ')}};'''; } - lines - ..add('final result = {...$jsonHelper(model)};') - ..addAll([ - if (captureJsonKey != null) - 'result.remove(${dartStringLiteral(captureJsonKey)});', - for (final field in requiredNulls) - 'if (model.${field.dartName} == null) { ' - 'result[${dartStringLiteral(field.jsonKey)}] = null; }', - ]); - final entries = [ - if (capture != null) - 'for (final entry in model.$capture.entries)\n' - ' if (!declared.contains(entry.key)) entry.key: entry.value', - '...result', - if (discriminatorKey != null && discriminatorValue != null) - '${dartStringLiteral(discriminatorKey)}: ${dartStringLiteral(discriminatorValue)}', + 'for (final entry in model.$capture.entries)\n' + ' if (!declared.contains(entry.key)) entry.key: entry.value', + ...fieldEntries, ]; - lines.add('return {${entries.join(',\n ')}};'); return ''' Map $function(${node.className} model) { - ${lines.join('\n ')} + const declared = ${_keySet(_declaredKeys(node))}; + return { + ${entries.join(',\n ')}, + }; }'''; } - String _fieldBridges(AckObjectModelNode node) { - final output = StringBuffer(); - final parametersByField = { - for (final parameter in node.constructorParameters) - parameter.fieldName: parameter, - }; - for (final field in node.fields) { - final type = _type(field.runtimeRef); - final parameterType = - parametersByField[field.dartName]?.typeRef ?? field.runtimeRef; - final fromRuntimeType = - field.defaultExpression != null && - parameterType is! AckNullableTypeRef - ? AckNullableTypeRef(parameterType) - : parameterType; - final fromType = _type(fromRuntimeType); - final fromName = ackClassFromRuntimeBridgeName( - node.className, - field.dartName, - ); - final toName = ackClassToRuntimeBridgeName( - node.className, - field.dartName, - ); - output - ..writeln( - '$fromType $fromName(Object? value) => ' - '${_fromRuntime(fromRuntimeType, 'value')};', - ) - ..writeln( - 'Object? $toName($type value) => ' - '${_toRuntime(field.runtimeRef, 'value')};', - ); + bool _omitIfNull(AckFieldNode field) => + field.nullable && !(field.isRequired && field.acceptsNull); + + String _encodeField(AckFieldNode field) { + final baseType = _nonNullable(field.runtimeRef); + final access = 'model.${field.dartName}'; + if (!field.nullable) { + return _toRuntime(baseType, access); } - if (node.captureFieldName case final capture?) { - final fromName = ackClassFromRuntimeBridgeName(node.className, capture); - final toName = ackClassToRuntimeBridgeName(node.className, capture); - output - ..writeln( - 'Map? $fromName(Object? value) => ' - 'value == null ? null : ' - '${_ack('deepUnmodifiableJsonMap')}(' - 'value as Map);', - ) - ..writeln('Object? $toName(Map value) => value;'); + if (baseType is AckScalarTypeRef || baseType is AckExternalTypeRef) { + return access; } - - return output.toString(); + if (baseType is AckListTypeRef || + baseType is AckSetTypeRef || + baseType is AckMapTypeRef) { + return _toRuntime(AckNullableTypeRef(baseType), access); + } + return 'switch ($access) {' + ' null => null,' + ' final fieldValue => ${_toRuntime(baseType, 'fieldValue')},' + ' }'; } + AckInferRef _nonNullable(AckInferRef type) => switch (type) { + AckNullableTypeRef(:final inner) => inner, + _ => type, + }; + String _fromRuntime(AckInferRef type, String expression) => switch (type) { // Any input is already assignable to Object?; a cast would be redundant. AckNullableTypeRef(inner: AckScalarTypeRef(dartType: 'Object')) => diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 3393f339..1a747f0c 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -7,16 +7,11 @@ import '../utils/string_literal.dart'; /// Emits immutable model declarations solely from a normalized model graph. final class AckModelEmitter { - AckModelEmitter({ - this.ackPrefix, - this.schemablePrefix, - this.schemaPrefixInScope = false, - }); + AckModelEmitter({this.ackPrefix, this.schemaPrefixInScope = false}); static const _schemaShorthandName = 'schema'; final String? ackPrefix; - final String? schemablePrefix; /// Whether the annotated library imports a prefix named `schema`. final bool schemaPrefixInScope; @@ -59,7 +54,6 @@ final class AckModelEmitter { (b) => b ..name = node.className ..modifier = ClassModifier.final$ - ..annotations.add(_jsonMarker()) ..docs.addAll(_docs(node, 'Immutable model')) ..fields.addAll([ for (final field in fields) _field(field), @@ -83,20 +77,18 @@ final class AckModelEmitter { ..._valueMembers(node.className, _objectDataClass(node)), _objectFromRuntime(node, fields: fields), _objectToRuntime(node, fields: fields), - ..._fieldBridges(fields), - if (node.additionalProperties) ..._additionalPropertyBridges(), ]), ); } Class _value(AckValueModelNode node) { final runtimeRef = _type(node.runtimeRef); + final schemaRuntimeRef = _schemaRuntimeType(node.runtimeRef); final boundaryType = _type(node.boundaryType); return Class( (b) => b ..name = node.className ..modifier = ClassModifier.final$ - ..annotations.add(_jsonMarker()) ..docs.addAll(_docs(node, 'Immutable value model')) ..fields.addAll([ Field( @@ -146,24 +138,21 @@ final class AckModelEmitter { Parameter( (p) => p ..name = 'value' - ..type = refer(runtimeRef), + ..type = refer(schemaRuntimeRef), ), ) ..lambda = true ..body = Code( - '${jsonFromHelperName(node.className)}({\'value\': value})', + '${node.className}(${_valueFromRuntime(node.runtimeRef, 'value')})', ), ), Method( (m) => m ..name = '_toAckRuntime' - ..returns = refer(runtimeRef) + ..returns = refer(schemaRuntimeRef) ..lambda = true - ..body = Code( - '${jsonToHelperName(node.className)}(this)[\'value\'] as $runtimeRef', - ), + ..body = Code(_toRuntime(node.runtimeRef, 'value')), ), - ..._valueBridges(node), ]), ); } @@ -249,7 +238,6 @@ return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ..name = node.className ..modifier = ClassModifier.final$ ..extend = refer(union.className) - ..annotations.add(_jsonMarker()) ..docs.addAll(_docs(node, 'Discriminated model branch')) ..fields.addAll([ for (final field in fields) _field(field), @@ -293,8 +281,6 @@ return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { leadingEntries: {discriminator: dartStringLiteral(value)}, isOverride: true, ), - ..._fieldBridges(fields), - if (node.additionalProperties) ..._additionalPropertyBridges(), ]), ); } @@ -304,9 +290,7 @@ return switch (value[${dartStringLiteral(node.discriminatorKey)}]) { ..name = field.dartName ..modifier = FieldModifier.final$ ..type = refer(_fieldType(field)) - ..docs.addAll([ - if (field.description != null) '/// ${field.description}', - ]), + ..docs.addAll(_descriptionDocs(field.description)), ); Field _additionalPropertiesField() => Field( @@ -688,7 +672,12 @@ ${_ack('AckModelAdapter')}( List? fields, Set additionalKnownKeys = const {}, }) { - final helper = jsonFromHelperName(node.className); + final effectiveFields = fields ?? _storedFields(node); + final arguments = [ + for (final field in effectiveFields) + '${field.dartName}: ' + '${_decodeField(field, 'value[${dartStringLiteral(field.jsonKey)}]')}', + ]; if (!node.additionalProperties) { return Method( (m) => m @@ -703,15 +692,20 @@ ${_ack('AckModelAdapter')}( ), ) ..lambda = true - ..body = Code('$helper(Map.from(value))'), + ..body = Code('${node.className}(${arguments.join(', ')})'), ); } - final effectiveFields = fields ?? _storedFields(node); final keys = _declaredJsonKeys( effectiveFields, additionalKeys: additionalKnownKeys, ); + final allArguments = [ + ...arguments, + 'additionalProperties: Map.fromEntries(\n' + ' value.entries.where((entry) => !declared.contains(entry.key)),\n' + ' )', + ]; return Method( (m) => m ..name = '_fromAckRuntime' @@ -726,12 +720,9 @@ ${_ack('AckModelAdapter')}( ) ..body = Code(''' const declared = ${_declaredKeysLiteral(keys)}; -return $helper({ - ...value, - 'additionalProperties': Map.fromEntries( - value.entries.where((entry) => !declared.contains(entry.key)), - ), -});'''), +return ${node.className}( + ${allArguments.join(',\n ')}, +);'''), ); } @@ -742,203 +733,78 @@ return $helper({ bool isOverride = false, }) { final effectiveFields = fields ?? _storedFields(node); - final requiredNulls = [ + final entries = [ + for (final entry in leadingEntries.entries) + '${dartStringLiteral(entry.key)}: ${entry.value}', for (final field in effectiveFields) - if (field.isRequired && field.nullable) field, + '${dartStringLiteral(field.jsonKey)}: ' + '${!field.isRequired ? '?' : ''}${_encodeField(field)}', ]; - final helper = jsonToHelperName(node.className); - final needsBlock = node.additionalProperties || requiredNulls.isNotEmpty; - final declaredLiteral = node.additionalProperties - ? _declaredKeysLiteral( - _declaredJsonKeys( - effectiveFields, - additionalKeys: leadingEntries.keys, - ), - ) - : null; - return Method((m) { m ..name = '_toAckRuntime' ..returns = refer(_runtimeMapType); if (isOverride) m.annotations.add(refer('override')); - if (!needsBlock) { - final entries = [ - for (final entry in leadingEntries.entries) - '${dartStringLiteral(entry.key)}: ${entry.value}', - '...$helper(this)', - ]; + if (!node.additionalProperties) { m ..lambda = true ..body = Code('$_runtimeMapLiteral{${entries.join(', ')}}'); return; } - final lines = [ - if (declaredLiteral != null) 'const declared = $declaredLiteral;', - 'final result = $_runtimeMapLiteral{...$helper(this)};', - if (declaredLiteral != null) "result.remove('additionalProperties');", - ]; - for (final field in requiredNulls) { - lines.add( - 'if (${field.dartName} == null) {' - ' result[${dartStringLiteral(field.jsonKey)}] = null;' - ' }', - ); - } - final returnEntries = [ - if (declaredLiteral != null) - 'for (final entry in additionalProperties.entries)\n' - ' if (!declared.contains(entry.key)) entry.key: entry.value', - for (final entry in leadingEntries.entries) - '${dartStringLiteral(entry.key)}: ${entry.value}', - '...result', - ]; - lines.add( - 'return $_runtimeMapLiteral{\n ${returnEntries.join(',\n ')},\n};', + final declaredLiteral = _declaredKeysLiteral( + _declaredJsonKeys( + effectiveFields, + additionalKeys: leadingEntries.keys, + ), ); - m.body = Code(lines.join('\n')); + final allEntries = [ + 'for (final entry in additionalProperties.entries)\n' + ' if (!declared.contains(entry.key)) entry.key: entry.value', + ...entries, + ]; + m.body = Code(''' +const declared = $declaredLiteral; +return $_runtimeMapLiteral{ + ${allEntries.join(',\n ')}, +};'''); }); } - List _fieldBridges(List fields) => [ - for (final field in fields) ...[_fromBridge(field), _toBridge(field)], - ]; - - List _valueBridges(AckValueModelNode node) { - final type = _type(node.runtimeRef); - return [ - Method( - (m) => m - ..name = ackFromRuntimeBridgeName('value') - ..static = true - ..returns = refer(type) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = Code(_fromRuntime(node.runtimeRef, 'value')), - ), - Method( - (m) => m - ..name = ackToRuntimeBridgeName('value') - ..static = true - ..returns = refer('Object?') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer(type), - ), - ) - ..lambda = true - ..body = Code(_toRuntime(node.runtimeRef, 'value')), - ), - ]; - } - - List _additionalPropertyBridges() => [ - Method( - (m) => m - ..name = ackFromRuntimeBridgeName('additionalProperties') - ..static = true - ..returns = refer('$_runtimeMapType?') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = const Code('value as Map?'), - ), - Method( - (m) => m - ..name = ackToRuntimeBridgeName('additionalProperties') - ..static = true - ..returns = refer('Object?') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer(_runtimeMapType), - ), - ) - ..lambda = true - ..body = const Code('value'), - ), - ]; - - Method _fromBridge(AckFieldNode field) { + String _decodeField(AckFieldNode field, String rawExpression) { final runtimeRef = _nonNullable(field.runtimeRef); final needsNullGuard = !field.isRequired || field.nullable; - late final String body; if (!needsNullGuard) { - body = _fromRuntime(runtimeRef, 'value'); - } else if (!_requiresRuntimeConversion(runtimeRef)) { + return _fromRuntime(runtimeRef, rawExpression); + } + if (!_requiresRuntimeConversion(runtimeRef)) { final type = '${_type(runtimeRef)}?'; - // The bridge parameter is already Object?. - body = type == 'Object?' ? 'value' : 'value as $type'; - } else { - body = - 'switch (value) {' - ' null => null,' - ' final fieldValue => ${_fromRuntime(runtimeRef, 'fieldValue')},' - ' }'; + // The runtime map entry is already Object?. + return type == 'Object?' ? rawExpression : '$rawExpression as $type'; } - return Method( - (m) => m - ..name = ackFromRuntimeBridgeName(field.dartName) - ..static = true - ..returns = refer(_fieldType(field)) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = Code(body), - ); + return 'switch ($rawExpression) {' + ' null => null,' + ' final fieldValue => ${_fromRuntime(runtimeRef, 'fieldValue')},' + ' }'; } - Method _toBridge(AckFieldNode field) { + String _encodeField(AckFieldNode field) { final runtimeRef = _nonNullable(field.runtimeRef); + final name = field.dartName; final needsNullGuard = !field.isRequired || field.nullable; - late final String body; - if (!needsNullGuard) { - body = _toRuntime(runtimeRef, 'value'); - } else if (!_requiresRuntimeConversion(runtimeRef)) { - body = 'value'; - } else { - body = - 'switch (value) {' - ' null => null,' - ' final fieldValue => ${_toRuntime(runtimeRef, 'fieldValue')},' - ' }'; + if (!needsNullGuard || !_requiresRuntimeConversion(runtimeRef)) { + return _toRuntime(runtimeRef, name); } - return Method( - (m) => m - ..name = ackToRuntimeBridgeName(field.dartName) - ..static = true - ..returns = refer('Object?') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer(_fieldType(field)), - ), - ) - ..lambda = true - ..body = Code(body), - ); + if (runtimeRef is AckListTypeRef || + runtimeRef is AckSetTypeRef || + runtimeRef is AckMapTypeRef) { + return _toRuntime(AckNullableTypeRef(runtimeRef), name); + } + return 'switch ($name) {' + ' null => null,' + ' final fieldValue => ${_toRuntime(runtimeRef, 'fieldValue')},' + ' }'; } String _fromRuntime(AckInferRef type, String expression) { @@ -1055,6 +921,44 @@ return $helper({ }; } + bool _containsModelRef(AckInferRef type) => switch (type) { + AckNullableTypeRef(:final inner) => _containsModelRef(inner), + AckModelTypeRef() => true, + AckListTypeRef(:final elementType) || + AckSetTypeRef(:final elementType) => _containsModelRef(elementType), + AckMapTypeRef(:final valueType) => _containsModelRef(valueType), + _ => false, + }; + + String _schemaRuntimeType(AckInferRef type) => switch (type) { + AckNullableTypeRef(:final inner) => '${_schemaRuntimeType(inner)}?', + AckModelTypeRef(:final runtimeRef) => _schemaRuntimeType(runtimeRef), + AckListTypeRef(:final elementType) => + 'List<${_schemaRuntimeType(elementType)}>', + AckSetTypeRef(:final elementType) => + 'Set<${_schemaRuntimeType(elementType)}>', + AckMapTypeRef(:final valueType) => + 'Map', + _ => _type(type), + }; + + String _valueFromRuntime(AckInferRef type, String expression) { + if (!_containsModelRef(type)) return expression; + return switch (type) { + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${_valueFromRuntime(inner, expression)}', + AckModelTypeRef(:final visibleName) => + '$visibleName.\$ack.fromRuntime($expression)', + AckListTypeRef(:final elementType) => + '$expression.map((item) => ${_valueFromRuntime(elementType, 'item')}).toList()', + AckSetTypeRef(:final elementType) => + '$expression.map((item) => ${_valueFromRuntime(elementType, 'item')}).toSet()', + AckMapTypeRef(:final valueType) => + '$expression.map((key, item) => MapEntry(key, ${_valueFromRuntime(valueType, 'item')}))', + _ => expression, + }; + } + String _type(AckInferRef type) { return switch (type) { AckNullableTypeRef(:final inner) => '${_type(inner)}?', @@ -1072,16 +976,16 @@ return $helper({ List _docs(AckModelNode node, String kind) => [ '/// $kind generated from `${node.id.declarationName}`.', - if (node.description != null) '/// ${node.description}', + ..._descriptionDocs(node.description), ]; - Expression _jsonMarker() { - final prefix = schemablePrefix; - final typeName = prefix == null || prefix.isEmpty - ? 'Schemable' - : '$prefix.Schemable'; - return refer(typeName).property('generatedJson'); - } + /// One doc-comment line per description line, so multi-line descriptions + /// keep the generated declaration valid. + List _descriptionDocs(String? description) => [ + if (description != null) + for (final line in description.split(RegExp(r'\r?\n'))) + line.trimRight().isEmpty ? '///' : '/// ${line.trimRight()}', + ]; String _ack(String symbol) { final prefix = ackPrefix; @@ -1091,20 +995,18 @@ return $helper({ static const _runtimeMapType = 'Map'; static const _runtimeMapLiteral = ''; static const _objectJsonType = 'Map'; + static const _toJsonDocs = [ + '/// Validates this model and encodes it for JSON.', + '///', + '/// Throws an `AckException` when validation fails.', + ]; + static const _safeToJsonDocs = [ + '/// Validates this model and encodes it for JSON, returning the validation', + '/// failure instead of throwing.', + ]; } typedef _AckDataClass = ({ List fields, List constructorParameters, }); - -const _toJsonDocs = [ - '/// Validates this model and encodes it for JSON.', - '///', - '/// Throws an `AckException` when validation fails.', -]; - -const _safeToJsonDocs = [ - '/// Validates this model and encodes it for JSON, returning the validation', - '/// failure instead of throwing.', -]; diff --git a/packages/ack_generator/lib/src/builders/type_builder.dart b/packages/ack_generator/lib/src/builders/type_builder.dart deleted file mode 100644 index c2f64bd9..00000000 --- a/packages/ack_generator/lib/src/builders/type_builder.dart +++ /dev/null @@ -1,1082 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:build/build.dart' show log; -import 'package:code_builder/code_builder.dart'; - -import '../models/field_info.dart'; -import '../models/model_info.dart'; - -class _ModelLookups { - final Map byClassName; - final Map bySchemaClassName; - - _ModelLookups(List models) - : byClassName = _buildByClassName(models), - bySchemaClassName = _buildBySchemaClassName(models); - - ModelInfo? classByName(String name) => byClassName[name]; - ModelInfo? schemaByName(String name) => bySchemaClassName[name]; - - static Map _buildByClassName(List models) { - final map = {}; - for (final model in models) { - map.putIfAbsent(model.className, () => model); - } - return map; - } - - static Map _buildBySchemaClassName( - List models, - ) { - final map = {}; - for (final model in models) { - map.putIfAbsent(model.schemaClassName, () => model); - } - return map; - } -} - -enum _GeneratedHelper { listCast, setCast } - -/// Builds Dart 3 extension types for models annotated with `@AckType` -/// -/// Extension types provide zero-cost type-safe wrappers over validated -/// `Map` data returned by schema validation. -class TypeBuilder { - String? _ackImportPrefix; - - /// Configures the import prefix used for `package:ack/ack.dart` in the - /// source library that owns the generated part file. - /// - /// When Ack is imported as `import 'package:ack/ack.dart' as ack;`, - /// generated references must use `ack.SchemaResult` in part files. - void setAckImportPrefix(String? prefix) { - _ackImportPrefix = prefix; - } - - /// Builds top-level private helper functions used by generated extension - /// types. - /// - /// Helpers are emitted only when needed to avoid analyzer warnings in - /// generated files. - List buildTopLevelHelpers(List models) { - if (models.isEmpty) return const []; - - final lookups = _ModelLookups(models); - final helpers = <_GeneratedHelper>{}; - - for (final model in models) { - for (final field in model.fields) { - if ((field.isList || field.isSet) && - !_isCustomElementType(field, lookups)) { - helpers.add( - field.isSet ? _GeneratedHelper.setCast : _GeneratedHelper.listCast, - ); - } - } - } - - final result = []; - if (helpers.contains(_GeneratedHelper.listCast)) { - result.add(_buildListCastHelper()); - } - if (helpers.contains(_GeneratedHelper.setCast)) { - result.add(_buildSetCastHelper()); - } - - return result; - } - - /// Builds an extension type for the given model - /// - /// Returns null if the model should not generate an extension type: - /// - Discriminated base schemas (generated separately via - /// [buildDiscriminatedExtensionBase]) - /// - Nullable schema variables (representation is non-nullable) - ExtensionType? buildExtensionType( - ModelInfo model, - List allModels, - ) { - // Discriminated base schemas get dedicated base extension types. - if (model.isDiscriminatedBaseDefinition) { - return null; - } - - // Nullable schema variables can't be safely wrapped (representation is non-nullable). - if (model.isNullableSchema) { - return null; - } - - final typeName = _getExtensionTypeName(model); - final schemaVarName = _toCamelCase(model.schemaClassName); - final lookups = _ModelLookups(allModels); - - final isObjectSchema = model.representationType == kMapType; - final valueVarName = isObjectSchema ? '_data' : '_value'; - - return ExtensionType( - (b) => b - ..name = typeName - ..docs.addAll(_buildDocs(model)) - ..representationDeclaration = RepresentationDeclaration( - (r) => r - ..declaredRepresentationType = refer(model.representationType) - ..name = valueVarName, - ) - ..implements.add(refer(model.representationType)) - ..methods.addAll([ - ..._buildStaticFactories(model, schemaVarName), - ..._buildGetters(model, lookups), - // Only add args for object schemas - if (isObjectSchema) ...[ - if (model.additionalProperties) _buildArgsGetter(model), - ], - ]), - ); - } - - /// Builds an extension type for discriminated @AckType base schemas. - ExtensionType? buildDiscriminatedExtensionBase( - ModelInfo model, - List allModels, - ) { - if (!model.isDiscriminatedBaseDefinition) { - return null; - } - - final typeName = _getExtensionTypeName(model); - final schemaVarName = _toCamelCase(model.schemaClassName); - final subtypeNames = model.subtypeNames; - - if (subtypeNames == null || subtypeNames.isEmpty) { - return null; - } - - // Resolve schemaClassName → className for the switch expression. - // subtypeNames stores schemaClassName for @AckType models; - // _buildDiscriminatorSwitchExpression needs className to emit TypeName. - final resolvedSubtypeNames = {}; - for (final entry in subtypeNames.entries) { - final branchModel = allModels.firstWhere( - (m) => m.schemaClassName == entry.value, - orElse: () => throw StateError( - 'Failed to resolve discriminated subtype "${entry.value}" ' - '(discriminator "${entry.key}") for base ' - '"${model.schemaClassName}" while building ' - 'extension type "$typeName".', - ), - ); - resolvedSubtypeNames[entry.key] = branchModel.className; - } - - return ExtensionType( - (b) => b - ..name = typeName - ..docs.addAll(_buildDocs(model)) - ..representationDeclaration = RepresentationDeclaration( - (r) => r - ..declaredRepresentationType = refer('Map') - ..name = '_data', - ) - ..implements.add(refer('Map')) - ..methods.addAll([ - Method( - (m) => m - ..type = MethodType.getter - ..name = model.discriminatorKey! - ..returns = refer('String') - ..lambda = true - ..body = Code("_data['${model.discriminatorKey}'] as String"), - ), - _buildDiscriminatedFactory( - model, - schemaVarName, - resolvedSubtypeNames, - ), - _buildDiscriminatedSafeParse( - model, - schemaVarName, - resolvedSubtypeNames, - ), - ]), - ); - } - - /// Builds extension type for discriminated subtypes - ExtensionType? buildDiscriminatedSubtype( - ModelInfo model, - ModelInfo baseModel, - List allModels, - ) { - if (!model.isDiscriminatedSubtype) { - return null; - } - - final typeName = _getExtensionTypeName(model); - final baseTypeName = _getExtensionTypeName(baseModel); - final lookups = _ModelLookups(allModels); - final discriminatorKey = baseModel.discriminatorKey!; - - return ExtensionType( - (b) => b - ..name = typeName - ..docs.addAll(_buildDocs(model)) - ..representationDeclaration = RepresentationDeclaration( - (r) => r - ..declaredRepresentationType = refer('Map') - ..name = '_data', - ) - ..implements.addAll([ - refer(baseTypeName), - refer('Map'), - ]) - ..methods.addAll([ - // Subtype factories validate through the effective branch schema. - Method( - (m) => m - ..type = MethodType.getter - ..name = discriminatorKey - ..returns = refer('String') - ..lambda = true - ..body = Code("_data['$discriminatorKey'] as String"), - ), - ..._buildDiscriminatedSubtypeFactories(model, baseModel), - // Add regular field getters - ..._buildGetters(model, lookups, skipJsonKeys: {discriminatorKey}), - if (model.additionalProperties) - _buildArgsGetter(model, additionalKnownKeys: {discriminatorKey}), - ]), - ); - } - - /// Sorts models in topological order (dependencies before dependents). - /// - /// If circular dependencies are detected, logs a warning and falls back to the - /// original input order. Extension types based on `Map` work - /// correctly with cycles since they all wrap the same underlying type. - List topologicalSort(List models) { - final lookups = _ModelLookups(models); - final sorted = []; - final visiting = {}; - final visited = {}; - var hasCycle = false; - final cycleParticipants = {}; - - // Build dependency map - final dependencies = >{}; - for (final model in models) { - dependencies[model.className] = _extractDependencies(model, lookups); - } - - void visit(String className) { - if (visited.contains(className)) return; - - if (visiting.contains(className)) { - // Cycle detected - mark flag but continue - // Extension types with Map representation work fine with cycles - hasCycle = true; - cycleParticipants.add(className); - return; - } - - visiting.add(className); - - // Visit dependencies first - final deps = dependencies[className] ?? {}; - for (final dep in deps) { - if (dependencies.containsKey(dep)) { - visit(dep); - } - } - - visiting.remove(className); - visited.add(className); - - // Add to sorted list - final model = lookups.classByName(className); - if (model == null) { - throw StateError('Missing model for class name: $className'); - } - sorted.add(model); - } - - // Visit all models - for (final model in models) { - visit(model.className); - } - - // If cycle detected, fall back to original order - // This is safe because extension types wrap Map which doesn't - // require declaration order - if (hasCycle) { - log.warning( - 'Circular dependency detected between extension types: ' - '${cycleParticipants.join(', ')}. ' - 'Using original declaration order (safe for Map-based types).', - ); - return models; - } - - return sorted; - } - - // --- Private Helper Methods --- - - String _qualifyAckSymbol(String symbol) { - final prefix = _ackImportPrefix; - if (prefix == null || prefix.isEmpty) return symbol; - return '$prefix.$symbol'; - } - - Reference _schemaResultRef(Reference innerType) { - return TypeReference( - (b) => b - ..symbol = _qualifyAckSymbol('SchemaResult') - ..types.add(innerType), - ); - } - - String _getExtensionTypeName(ModelInfo model) { - return '${model.className}Type'; - } - - String _toCamelCase(String text) { - if (text.isEmpty) return text; - return text[0].toLowerCase() + text.substring(1); - } - - List _buildDocs(ModelInfo model) { - final docs = ['/// Extension type for ${model.className}']; - if (model.description != null) { - docs.add('/// ${model.description}'); - } - return docs; - } - - List _buildStaticFactories(ModelInfo model, String schemaVarName) { - final typeName = _getExtensionTypeName(model); - final castType = model.representationType; - - return [ - // Static parse factory - Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer(typeName) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $schemaVarName.parseAs( - data, - (validated) => $typeName(validated as $castType), -);'''), - ), - // Static safeParse method - Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..returns = _schemaResultRef(refer(typeName)) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $schemaVarName.safeParseAs( - data, - (validated) => $typeName(validated as $castType), -);'''), - ), - ]; - } - - List _buildDiscriminatedSubtypeFactories( - ModelInfo model, - ModelInfo baseModel, - ) { - final typeName = _getExtensionTypeName(model); - final baseSchemaVarName = _toCamelCase(baseModel.schemaClassName); - final discriminatorValue = model.discriminatorValue!; - final effectiveBranch = - "$baseSchemaVarName.effectiveBranch('$discriminatorValue')"; - - return [ - Method( - (m) => m - ..name = 'parse' - ..static = true - ..returns = refer(typeName) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $effectiveBranch.parseAs( - data, - (validated) => $typeName(validated as Map), -);'''), - ), - Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..returns = _schemaResultRef(refer(typeName)) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..body = Code(''' -return $effectiveBranch.safeParseAs( - data, - (validated) => $typeName(validated as Map), -);'''), - ), - ]; - } - - Method _buildDiscriminatedFactory( - ModelInfo model, - String schemaVarName, - Map subtypeNames, - ) { - final typeName = _getExtensionTypeName(model); - final discriminatorKey = model.discriminatorKey!; - final switchExpression = _buildDiscriminatorSwitchExpression( - 'map', - discriminatorKey, - subtypeNames, - ); - - return Method( - (m) => m - ..name = 'parse' - ..static = true - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..returns = refer(typeName) - ..body = Code(''' -return $schemaVarName.parseAs( - data, - (validated) { - final map = validated as Map; - return $switchExpression; - }, -);'''), - ); - } - - Method _buildDiscriminatedSafeParse( - ModelInfo model, - String schemaVarName, - Map subtypeNames, - ) { - final typeName = _getExtensionTypeName(model); - final discriminatorKey = model.discriminatorKey!; - final switchExpression = _buildDiscriminatorSwitchExpression( - 'map', - discriminatorKey, - subtypeNames, - ); - - return Method( - (m) => m - ..name = 'safeParse' - ..static = true - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'data' - ..type = refer('Object?'), - ), - ) - ..returns = _schemaResultRef(refer(typeName)) - ..body = Code(''' -return $schemaVarName.safeParseAs( - data, - (validated) { - final map = validated as Map; - return $switchExpression; - }, -);'''), - ); - } - - String _buildDiscriminatorSwitchExpression( - String mapVarName, - String discriminatorKey, - Map subtypeNames, - ) { - final cases = []; - for (final entry in subtypeNames.entries) { - final discriminatorValue = entry.key; - final subtypeClassName = entry.value; - final subtypeTypeName = '${subtypeClassName}Type'; - - cases.add(" '$discriminatorValue' => $subtypeTypeName($mapVarName)"); - } - - return ''' -switch ($mapVarName['$discriminatorKey']) { -${cases.join(',\n')}, - _ => throw StateError('Unknown $discriminatorKey: \${$mapVarName['$discriminatorKey']}'), -}'''; - } - - List _buildGetters( - ModelInfo model, - _ModelLookups lookups, { - Set skipJsonKeys = const {}, - }) { - return model.fields - .where((field) => !skipJsonKeys.contains(field.jsonKey)) - .map((field) => _buildGetter(field, lookups)) - .toList(); - } - - Method _buildGetter(FieldInfo field, _ModelLookups lookups) { - final baseType = _resolveFieldType(field, lookups); - // Optional fields need nullable return types because the key might be missing. - final needsNullHandling = field.isNullable || !field.isRequired; - final returnType = _applyNullability(baseType, needsNullHandling); - final body = _buildGetterBody( - field, - lookups, - baseType: baseType, - needsNullHandling: needsNullHandling, - ); - - return Method( - (m) => m - ..type = MethodType.getter - ..name = field.name - ..returns = refer(returnType) - ..lambda = true - ..body = Code(body), - ); - } - - String _applyNullability(String baseType, bool shouldBeNullable) { - if (!shouldBeNullable) return baseType; - if (baseType.endsWith('?')) return baseType; - return '$baseType?'; - } - - String _buildGetterBody( - FieldInfo field, - _ModelLookups lookups, { - required String baseType, - required bool needsNullHandling, - }) { - final key = field.jsonKey; - - if (needsNullHandling) { - return _buildNullableGetter(field, lookups, key, baseType: baseType); - } else { - return _buildNonNullableGetter(field, lookups, key, baseType: baseType); - } - } - - String _buildNonNullableGetter( - FieldInfo field, - _ModelLookups lookups, - String key, { - required String baseType, - }) { - // Nested schema variable reference (e.g., 'status': statusSchema). - if (field.nestedSchemaRef != null) { - if (field.displayTypeOverride != null) { - final castType = field.nestedSchemaCastTypeOverride ?? kMapType; - return "${field.displayTypeOverride!}(_data['$key'] as $castType)"; - } - - final referencedModel = _findSchemaModel(field.nestedSchemaRef!, lookups); - if (referencedModel != null) { - final typeName = '${referencedModel.className}Type'; - final castType = referencedModel.representationType; - return "$typeName(_data['$key'] as $castType)"; - } - return "_data['$key'] as Map"; - } - - // Primitive and already-validated core value types. - if (field.isPrimitive || _isSpecialType(field.type)) { - return "_data['$key'] as $baseType"; - } - - // Enums (validated by schema, returns the enum value) - if (field.isEnum) { - return "_data['$key'] as $baseType"; - } - - if (field.displayTypeOverride != null) { - return "_data['$key'] as $baseType"; - } - - // Lists - if (field.isList) { - return _buildListGetter(field, lookups, key); - } - - // Maps - if (field.isMap) { - return "_data['$key'] as Map"; - } - - // Sets - if (field.isSet) { - return _buildSetGetter(field, lookups, key); - } - - // Nested schema - if (field.isNestedSchema && _hasAckType(field, lookups)) { - final typeConstructor = _getTypeConstructor(field); - return "$typeConstructor(_data['$key'] as Map)"; - } - - // Generic or unknown - return as Object? - return "_data['$key']"; - } - - String _buildNullableGetter( - FieldInfo field, - _ModelLookups lookups, - String key, { - required String baseType, - }) { - if (field.nestedSchemaRef != null) { - final nonNullPart = _buildNonNullableGetter( - field, - lookups, - key, - baseType: baseType, - ); - return "_data['$key'] != null ? $nonNullPart : null"; - } - - // For primitives, enums, and already-validated core value types, - // nullable cast works directly on the validated map payload. - if (field.isPrimitive || field.isEnum || _isSpecialType(field.type)) { - return "_data['$key'] as $baseType?"; - } - - if (field.displayTypeOverride != null) { - return "_data['$key'] as $baseType?"; - } - - // For complex types, check null first - final nonNullPart = _buildNonNullableGetter( - field, - lookups, - key, - baseType: baseType, - ); - return "_data['$key'] != null ? $nonNullPart : null"; - } - - String _buildListGetter(FieldInfo field, _ModelLookups lookups, String key) => - _buildCollectionGetter(field, lookups, key, isSet: false); - - String _buildSetGetter(FieldInfo field, _ModelLookups lookups, String key) => - _buildCollectionGetter(field, lookups, key, isSet: true); - - /// Builds getter code for List or Set collections - String _buildCollectionGetter( - FieldInfo field, - _ModelLookups lookups, - String key, { - required bool isSet, - }) { - final elementType = _getCollectionElementType(field, lookups, isSet: isSet); - - final suffix = isSet ? '.toSet()' : ''; - - // Check if element type is a custom type with @AckType - if (_isCustomElementType(field, lookups)) { - // Return eager List for object lists (per requirements: List, not Iterable) - final listSuffix = isSet ? '' : '.toList()'; - final castType = _getCustomElementCastType(field, elementType, lookups); - final constructorName = field.collectionElementIsCustomType - ? _asExtensionTypeName(elementType) - : '${elementType}Type'; - return "(_data['$key'] as List).map((e) => $constructorName(e as $castType))$listSuffix$suffix"; - } - - // Primitive lists/sets - direct cast - if (isSet) { - return "_\$ackSetCast<$elementType>(_data['$key'])"; - } - return "_\$ackListCast<$elementType>(_data['$key'])"; - } - - Method _buildListCastHelper() { - return Method( - (m) => m - ..name = '_\$ackListCast' - ..types.add(refer('T')) - ..returns = TypeReference( - (b) => b - ..symbol = 'List' - ..types.add(refer('T')), - ) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = Code('(value as List).cast()'), - ); - } - - Method _buildSetCastHelper() { - return Method( - (m) => m - ..name = '_\$ackSetCast' - ..types.add(refer('T')) - ..returns = TypeReference( - (b) => b - ..symbol = 'Set' - ..types.add(refer('T')), - ) - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'value' - ..type = refer('Object?'), - ), - ) - ..lambda = true - ..body = Code('(value as List).cast().toSet()'), - ); - } - - String _resolveFieldType(FieldInfo field, _ModelLookups lookups) { - // Nested schema variable reference (e.g., 'status': statusSchema). - if (field.nestedSchemaRef != null) { - if (field.displayTypeOverride != null) { - return field.displayTypeOverride!; - } - - final referencedModel = _findSchemaModel(field.nestedSchemaRef!, lookups); - if (referencedModel != null) { - return '${referencedModel.className}Type'; - } - return kMapType; - } - - // Primitives - if (field.type.isDartCoreString) return 'String'; - if (field.type.isDartCoreInt) return 'int'; - if (field.type.isDartCoreDouble) return 'double'; - if (field.type.isDartCoreBool) return 'bool'; - if (field.type.isDartCoreNum) return 'num'; - - // Special types - if (_isSpecialType(field.type)) { - return field.type.getDisplayString(); - } - - // Enums - if (field.isEnum) { - return field.displayTypeOverride ?? field.type.getDisplayString(); - } - - // Lists - if (field.isList) { - final elementType = _getCollectionElementType( - field, - lookups, - isSet: false, - ); - // Use List for all list types (eager evaluation per requirements) - if (_isCustomElementType(field, lookups)) { - final customElementType = field.collectionElementIsCustomType - ? _asExtensionTypeName(elementType) - : '${elementType}Type'; - return 'List<$customElementType>'; - } - return 'List<$elementType>'; - } - - // Maps - if (field.isMap) { - return 'Map'; - } - - // Sets - if (field.isSet) { - final elementType = _getCollectionElementType( - field, - lookups, - isSet: true, - ); - if (_isCustomElementType(field, lookups)) { - final customElementType = field.collectionElementIsCustomType - ? _asExtensionTypeName(elementType) - : '${elementType}Type'; - return 'Set<$customElementType>'; - } - return 'Set<$elementType>'; - } - - // Generic types - if (field.isGeneric) { - return 'Object?'; - } - - if (field.displayTypeOverride != null) { - return field.displayTypeOverride!; - } - - // Nested schema - if (field.isNestedSchema && _hasAckType(field, lookups)) { - final baseType = field.type.getDisplayString(); - return '${baseType}Type'; - } - - // Fallback to Object? - return 'Object?'; - } - - String _getCollectionElementType( - FieldInfo field, - _ModelLookups lookups, { - required bool isSet, - }) { - if (field.collectionElementDisplayTypeOverride != null) { - return field.collectionElementDisplayTypeOverride!; - } - - // Check for schema variable reference first (e.g., Ack.list(addressSchema)) - if (!isSet && field.listElementSchemaRef != null) { - final referencedModel = _findSchemaModel( - field.listElementSchemaRef!, - lookups, - ); - if (referencedModel != null) { - return referencedModel.className; - } - return kMapType; - } - - if (field.type is! ParameterizedType) return 'dynamic'; - - final paramType = field.type as ParameterizedType; - if (paramType.typeArguments.isEmpty) return 'dynamic'; - - return _resolveTypeReference(paramType.typeArguments[0], lookups); - } - - String _resolveTypeReference(DartType type, _ModelLookups lookups) { - final baseType = type.getDisplayString(); - - // Primitives - if (type.isDartCoreString) return 'String'; - if (type.isDartCoreInt) return 'int'; - if (type.isDartCoreDouble) return 'double'; - if (type.isDartCoreBool) return 'bool'; - if (type.isDartCoreNum) return 'num'; - - // Special types - if (_isSpecialType(type)) return baseType; - - // Check if this is a custom type with @AckType - final element = type.element; - if (element is InterfaceElement) { - if (_hasAckTypeForElement(element, lookups)) { - return baseType; - } - } - - return baseType; - } - - bool _isSpecialType(DartType type) { - final element = type.element; - if (element == null) return false; - - final name = element.name; - final library = element.library?.name; - - return (name == 'DateTime' && library == 'dart.core') || - (name == 'Uri' && library == 'dart.core') || - (name == 'Duration' && library == 'dart.core'); - } - - bool _hasAckType(FieldInfo field, _ModelLookups lookups) { - final element = field.type.element; - if (element is! InterfaceElement) return false; - - return _hasAckTypeForElement(element, lookups); - } - - bool _hasAckTypeForElement(InterfaceElement element, _ModelLookups lookups) { - final name = element.name; - if (name == null) return false; - return lookups.byClassName.containsKey(name); - } - - bool _isCustomElementType(FieldInfo field, _ModelLookups lookups) { - if (field.collectionElementIsCustomType) { - return true; - } - - // Check for schema variable reference first (e.g., Ack.list(addressSchema)) - if (field.listElementSchemaRef != null) { - return _findSchemaModel(field.listElementSchemaRef!, lookups) != null; - } - - if (field.type is! ParameterizedType) return false; - - final paramType = field.type as ParameterizedType; - if (paramType.typeArguments.isEmpty) return false; - - final elementType = paramType.typeArguments[0]; - final element = elementType.element; - - if (element is! InterfaceElement) return false; - - return _hasAckTypeForElement(element, lookups); - } - - String _getTypeConstructor(FieldInfo field) { - final baseType = field.type.getDisplayString(); - return '${baseType}Type'; - } - - String _asExtensionTypeName(String typeName) { - return typeName.endsWith('Type') ? typeName : '${typeName}Type'; - } - - ModelInfo? _findSchemaModel(String schemaVarName, _ModelLookups lookups) { - return lookups.schemaByName(schemaVarName); - } - - String _getCustomElementCastType( - FieldInfo field, - String elementType, - _ModelLookups lookups, - ) { - if (field.collectionElementCastTypeOverride != null) { - return field.collectionElementCastTypeOverride!; - } - - if (field.listElementSchemaRef != null) { - final referencedModel = _findSchemaModel( - field.listElementSchemaRef!, - lookups, - ); - if (referencedModel != null) { - return referencedModel.representationType; - } - } - - final elementModel = lookups.classByName(elementType); - return elementModel?.representationType ?? kMapType; - } - - Set _extractDependencies(ModelInfo model, _ModelLookups lookups) { - final dependencies = {}; - - final discriminatedBaseClassName = model.discriminatedBaseClassName; - if (discriminatedBaseClassName != null && - lookups.byClassName.containsKey(discriminatedBaseClassName)) { - dependencies.add(discriminatedBaseClassName); - } - - for (final field in model.fields) { - if (field.isPrimitive || - field.isEnum || - field.isGeneric || - _isSpecialType(field.type)) { - continue; - } - - // Nested schema - if (field.isNestedSchema) { - final typeName = field.type.getDisplayString(); - - // Skip self-references - circular schemas are valid for Map-based extension types - // Extension types don't require declaration order since they all wrap Map - if (typeName == model.className) { - continue; // Self-reference doesn't create a dependency - } - - if (lookups.byClassName.containsKey(typeName)) { - dependencies.add(typeName); - } - } - - // List/Set of objects - if (field.isList || field.isSet) { - if (field.type is ParameterizedType) { - final paramType = field.type as ParameterizedType; - if (paramType.typeArguments.isNotEmpty) { - final elementType = paramType.typeArguments[0]; - final element = elementType.element; - - if (element is InterfaceElement) { - final name = element.name; - if (name != null && lookups.byClassName.containsKey(name)) { - dependencies.add(name); - } - } - } - } - } - } - - return dependencies; - } - - /// Builds the `args` getter that returns additional properties - /// - /// Returns a Map containing only properties that are not explicitly - /// defined in the schema. This is useful when additionalProperties: true. - Method _buildArgsGetter( - ModelInfo model, { - Set additionalKnownKeys = const {}, - }) { - final knownKeys = { - ...additionalKnownKeys, - ...model.fields.map((f) => f.jsonKey), - }; - - // 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(' && ')}))'; - - return Method( - (m) => m - ..type = MethodType.getter - ..name = 'args' - ..returns = refer('Map') - ..lambda = true - ..body = Code(filterExpr), - ); - } -} diff --git a/packages/ack_generator/lib/src/generator.dart b/packages/ack_generator/lib/src/generator.dart deleted file mode 100644 index f4facb9f..00000000 --- a/packages/ack_generator/lib/src/generator.dart +++ /dev/null @@ -1,450 +0,0 @@ -import 'package:ack/annotations.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:build/build.dart'; -import 'package:code_builder/code_builder.dart'; -import 'package:dart_style/dart_style.dart'; -import 'package:logging/logging.dart'; -import 'package:source_gen/source_gen.dart'; - -import 'analyzer/schema_ast_analyzer.dart'; -import 'builders/type_builder.dart'; -import 'models/model_info.dart'; -import 'validation/code_validator.dart'; - -/// Logger for schema generation warnings and diagnostics. -final _log = Logger('AckSchemaGenerator'); - -/// Generates extension types for top-level schemas annotated with `@AckType`. -class AckSchemaGenerator extends Generator { - static const _ackTypeChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckType, - inPackage: 'ack', - ); - - final _formatter = DartFormatter( - languageVersion: DartFormatter.latestLanguageVersion, - ); - - @override - String generate(LibraryReader library, BuildStep buildStep) { - final annotatedVariables = []; - final annotatedGetters = []; - - for (final element in library.allElements) { - if (element is ClassElement && _hasAckTypeAnnotation(element)) { - throw InvalidGenerationSource( - '@AckType can only be applied to top-level schema variables or getters, not classes.', - element: element, - todo: - 'Remove @AckType from the class and annotate a top-level schema variable or getter instead.', - ); - } - - if (element is TopLevelVariableElement && - _hasAckTypeAnnotation(element)) { - annotatedVariables.add(element); - } else if (element is GetterElement && _hasAckTypeAnnotation(element)) { - final isTopLevel = element.enclosingElement is LibraryElement; - if (!isTopLevel) { - throw InvalidGenerationSource( - '@AckType can only be applied to top-level schema variables or getters.', - element: element, - todo: - 'Move this getter to the library level or annotate a top-level schema variable instead.', - ); - } - - if (element.isOriginDeclaration) { - annotatedGetters.add(element); - } - } - } - - for (final classElement in library.classes) { - for (final getter in classElement.getters) { - if (_hasAckTypeAnnotation(getter)) { - throw InvalidGenerationSource( - '@AckType can only be applied to top-level schema variables or getters.', - element: getter, - todo: - 'Move this getter to the library level or annotate a top-level schema variable instead.', - ); - } - } - } - - if (annotatedVariables.isEmpty && annotatedGetters.isEmpty) { - return ''; - } - - final helperMethods = []; - final extensionTypes = []; - final schemaAstAnalyzer = SchemaAstAnalyzer(); - final typeBuilder = TypeBuilder(); - typeBuilder.setAckImportPrefix(_resolveAckImportPrefix(library)); - - final modelInfos = []; - - for (final variable in annotatedVariables) { - try { - final modelInfo = schemaAstAnalyzer.analyzeSchemaVariable( - variable, - customTypeName: _extractAckTypeName(variable), - ); - if (modelInfo != null) { - modelInfos.add(modelInfo); - } - } catch (e) { - throw InvalidGenerationSource( - 'Failed to analyze schema variable "${variable.name}": $e', - element: variable, - todo: - 'Ensure the variable uses Ack schema syntax such as Ack.object(), Ack.string(), or another @AckType schema reference.', - ); - } - } - - for (final getter in annotatedGetters) { - try { - final modelInfo = schemaAstAnalyzer.analyzeSchemaGetter( - getter, - customTypeName: _extractAckTypeName(getter), - ); - if (modelInfo != null) { - modelInfos.add(modelInfo); - } - } catch (e) { - throw InvalidGenerationSource( - 'Failed to analyze schema getter "${getter.name}": $e', - element: getter, - todo: - 'Ensure the getter returns Ack schema syntax such as Ack.object(), Ack.string(), or another @AckType schema reference.', - ); - } - } - - final linkedModelInfos = _linkDiscriminatedModels(modelInfos); - - _generateExtensionTypes( - annotatedVariables, - annotatedGetters, - linkedModelInfos, - typeBuilder, - helperMethods, - extensionTypes, - ); - - if (extensionTypes.isEmpty) { - return ''; - } - - final inputFileName = buildStep.inputId.pathSegments.last; - final generatedLibrary = Library( - (b) => b - ..directives.add(Directive.partOf(inputFileName)) - ..body.addAll([...helperMethods, ...extensionTypes]), - ); - - final emitter = DartEmitter( - allocator: Allocator.none, - orderDirectives: true, - useNullSafetySyntax: true, - ); - - final generatedCode = generatedLibrary.accept(emitter).toString(); - - String formattedCode; - try { - formattedCode = _formatter.format(generatedCode); - } catch (e) { - _log.warning('Code formatting failed, using unformatted output: $e'); - formattedCode = generatedCode; - } - - final validation = validateGeneratedDartCode(formattedCode); - if (validation.isFailure) { - throw InvalidGenerationSource( - 'Generated code validation failed: ${validation.errorMessage}\n' - 'Generated output:\n$formattedCode', - todo: 'Fix the code generation logic to produce valid Dart syntax.', - ); - } - - return formattedCode; - } - - void _generateExtensionTypes( - List annotatedVariables, - List annotatedGetters, - List models, - TypeBuilder typeBuilder, - List helperMethods, - List extensionTypes, - ) { - final typedElements = [ - for (final model in models) - _findAnnotatedSchemaElement( - model.schemaClassName, - annotatedVariables, - annotatedGetters, - ) ?? - (throw InvalidGenerationSource( - 'Could not find schema declaration "${model.schemaClassName}"', - todo: - 'Ensure the schema variable or getter exists and is annotated with @AckType.', - )), - ]; - - List sortedModels; - try { - sortedModels = typeBuilder.topologicalSort(models); - } catch (e) { - final element = typedElements.first; - throw InvalidGenerationSource( - 'Extension type dependency resolution failed: $e', - element: element, - todo: - 'Check for circular dependencies in the typed schema graph and ensure nested schemas resolve to @AckType declarations.', - ); - } - - final generatedTypeModels = []; - - for (final model in sortedModels) { - final element = _findAnnotatedSchemaElement( - model.schemaClassName, - annotatedVariables, - annotatedGetters, - ); - if (element == null) { - throw InvalidGenerationSource( - 'Could not find schema declaration "${model.schemaClassName}"', - todo: - 'Ensure the schema variable or getter exists and is annotated with @AckType.', - ); - } - - try { - if (model.isDiscriminatedBaseDefinition) { - final baseExtension = typeBuilder.buildDiscriminatedExtensionBase( - model, - sortedModels, - ); - if (baseExtension != null) { - extensionTypes.add(baseExtension); - } - - final subtypeNames = model.subtypeNames; - if (subtypeNames == null) { - continue; - } - - final emittedSubtypeSchemaNames = {}; - for (final subtypeSchemaName in subtypeNames.values) { - if (!emittedSubtypeSchemaNames.add(subtypeSchemaName)) { - throw InvalidGenerationSource( - 'Discriminated base "${model.schemaClassName}" maps multiple discriminator values to subtype "$subtypeSchemaName".', - element: element, - todo: - 'Ensure each discriminator value maps to a unique branch schema.', - ); - } - - final subtypeModel = sortedModels.firstWhere( - (candidate) => candidate.schemaClassName == subtypeSchemaName, - orElse: () => throw InvalidGenerationSource( - 'Subtype "$subtypeSchemaName" was not found while generating "${model.schemaClassName}".', - element: element, - ), - ); - - final subtypeExtension = typeBuilder.buildDiscriminatedSubtype( - subtypeModel, - model, - sortedModels, - ); - if (subtypeExtension != null) { - extensionTypes.add(subtypeExtension); - generatedTypeModels.add(subtypeModel); - } - } - continue; - } - - if (model.isDiscriminatedSubtype) { - continue; - } - - final extensionType = typeBuilder.buildExtensionType( - model, - sortedModels, - ); - if (extensionType != null) { - extensionTypes.add(extensionType); - generatedTypeModels.add(model); - } - } catch (e) { - throw InvalidGenerationSource( - 'Extension type generation failed for ${element.name}: $e', - element: element, - todo: - 'Ensure nested schemas resolve to @AckType declarations and unsupported schema shapes are not annotated.', - ); - } - } - - if (generatedTypeModels.isNotEmpty) { - helperMethods.addAll( - typeBuilder.buildTopLevelHelpers(generatedTypeModels), - ); - } - } - - List _linkDiscriminatedModels(List models) { - final linked = List.from(models); - final modelIndexBySchemaClassName = { - for (var i = 0; i < linked.length; i++) linked[i].schemaClassName: i, - }; - final branchOwnerByCanonicalIdentity = {}; - - for (var i = 0; i < linked.length; i++) { - final baseModel = linked[i]; - if (!baseModel.isDiscriminatedBaseDefinition) { - continue; - } - - final discriminatorKey = baseModel.discriminatorKey; - final subtypeNames = baseModel.subtypeNames; - if (discriminatorKey == null || subtypeNames == null) { - continue; - } - - for (final entry in subtypeNames.entries) { - final discriminatorValue = entry.key; - final branchSchemaClassName = entry.value; - final branchIndex = modelIndexBySchemaClassName[branchSchemaClassName]; - - if (branchIndex == null) { - throw InvalidGenerationSource( - 'Could not resolve discriminated branch "$branchSchemaClassName" for base "${baseModel.schemaClassName}".', - todo: - 'Ensure every Ack.discriminated(...) branch references an @AckType schema declared in the same library.', - ); - } - - final branchModel = linked[branchIndex]; - final canonicalBranchIdentity = - branchModel.schemaIdentity ?? branchSchemaClassName; - final existingOwner = - branchOwnerByCanonicalIdentity[canonicalBranchIdentity]; - if (existingOwner != null && - existingOwner != baseModel.schemaClassName) { - throw InvalidGenerationSource( - 'Branch schema "$branchSchemaClassName" is mapped to multiple discriminated bases: "$existingOwner" and "${baseModel.schemaClassName}".', - todo: - 'A branch schema can only belong to one Ack.discriminated(...) base.', - ); - } - branchOwnerByCanonicalIdentity[canonicalBranchIdentity] = - baseModel.schemaClassName; - - linked[branchIndex] = _copyModelInfo( - branchModel, - discriminatorKey: discriminatorKey, - discriminatorValue: discriminatorValue, - discriminatedBaseClassName: baseModel.className, - ); - } - } - - return linked; - } - - ModelInfo _copyModelInfo( - ModelInfo model, { - String? discriminatorKey, - String? discriminatorValue, - Map? subtypeNames, - String? discriminatedBaseClassName, - }) { - return ModelInfo( - className: model.className, - schemaClassName: model.schemaClassName, - description: model.description, - fields: model.fields, - additionalProperties: model.additionalProperties, - discriminatorKey: discriminatorKey ?? model.discriminatorKey, - discriminatorValue: discriminatorValue ?? model.discriminatorValue, - subtypeNames: subtypeNames ?? model.subtypeNames, - schemaIdentity: model.schemaIdentity, - discriminatedBaseClassName: - discriminatedBaseClassName ?? model.discriminatedBaseClassName, - representationType: model.representationType, - isNullableSchema: model.isNullableSchema, - ); - } - - bool _hasAckTypeAnnotation(Element element) { - return _ackTypeChecker.hasAnnotationOfExact(element); - } - - String? _extractAckTypeName(Element element) { - final annotation = _ackTypeChecker.firstAnnotationOfExact(element); - if (annotation == null) { - return null; - } - - final nameField = ConstantReader(annotation).peek('name'); - return nameField != null && !nameField.isNull - ? nameField.stringValue - : null; - } - - Element? _findAnnotatedSchemaElement( - String schemaName, - List annotatedVariables, - List annotatedGetters, - ) { - for (final variable in annotatedVariables) { - if (variable.name == schemaName) { - return variable; - } - } - - for (final getter in annotatedGetters) { - if (getter.name == schemaName) { - return getter; - } - } - - return null; - } - - String? _resolveAckImportPrefix(LibraryReader library) { - for (final import in library.element.firstFragment.libraryImports) { - if (!_isAckImport(import)) { - continue; - } - - final prefixElement = import.prefix?.element; - final prefix = prefixElement?.name; - if (prefix != null && prefix.isNotEmpty) { - return prefix; - } - return null; - } - - return null; - } - - bool _isAckImport(LibraryImport import) { - final importedLibrary = import.importedLibrary; - if (importedLibrary != null && - importedLibrary.uri.toString() == 'package:ack/ack.dart') { - return true; - } - - return import.uri.toString().contains('package:ack/ack.dart'); - } -} diff --git a/packages/ack_generator/lib/src/json/ack_json_generator.dart b/packages/ack_generator/lib/src/json/ack_json_generator.dart deleted file mode 100644 index 1e3f83ac..00000000 --- a/packages/ack_generator/lib/src/json/ack_json_generator.dart +++ /dev/null @@ -1,124 +0,0 @@ -// ignore: deprecated_member_use -import 'package:ack/annotations.dart' show AckCaseStyle, AckModel, Schemable; -import 'package:ack/ack_generator_support.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:build/build.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:json_serializable/json_serializable.dart'; -import 'package:source_gen/source_gen.dart'; - -import 'ack_runtime_type_helper.dart'; - -/// Delegates Ack-marked model classes to json_serializable. -/// -/// Consumer builder options are ignored. Ack owns the configuration: each -/// class gets the `JsonSerializable` constant from `json_configs.dart` that -/// matches its `caseStyle`, and generated schema-first models use `none`. The -/// Ack annotations carry no json_annotation types. -final class AckJsonSerializableGenerator extends Generator { - AckJsonSerializableGenerator() - : _delegate = JsonSerializableGenerator.withDefaultHelpers(const [ - AckRuntimeTypeHelper(), - ], config: const JsonSerializable(includeIfNull: false)); - - final JsonSerializableGenerator _delegate; - - static final _configs = AssetId( - 'ack_generator', - 'lib/src/json/json_configs.dart', - ); - - static const _marker = TypeChecker.typeNamed( - AckGeneratedJson, - inPackage: 'ack', - ); - // ignore: deprecated_member_use - static const _model = TypeChecker.typeNamed(AckModel, inPackage: 'ack'); - static const _schemable = TypeChecker.typeNamed(Schemable, inPackage: 'ack'); - - @override - Future generate(LibraryReader library, BuildStep buildStep) async { - // A failed model phase leaves helpers this part would call undefined. - if (!await buildStep.canRead( - buildStep.inputId.changeExtension('.ack.dart'), - )) { - return ''; - } - final requests = <({Element element, AckCaseStyle caseStyle})>[]; - final claimed = {}; - for (final item in library.annotatedWith(_marker)) { - requests.add((element: item.element, caseStyle: AckCaseStyle.none)); - claimed.add(item.element.baseElement); - } - - for (final element in library.classes) { - final annotation = - _model.firstAnnotationOfExact(element) ?? - _schemable.firstAnnotationOfExact(element); - if (annotation == null) continue; - final reader = ConstantReader(annotation); - if (!element.isSealed) { - _addModelRequest(requests, claimed, element, reader); - continue; - } - for (final branch in library.classes) { - if (branch == element || branch.isAbstract || !branch.isConstructable) { - continue; - } - final isSubtype = branch.allSupertypes.any( - (type) => type.element.baseElement == element.baseElement, - ); - if (!isSubtype) continue; - final branchAnnotation = - _model.firstAnnotationOfExact(branch) ?? - _schemable.firstAnnotationOfExact(branch); - _addModelRequest( - requests, - claimed, - branch, - branchAnnotation == null ? reader : ConstantReader(branchAnnotation), - ); - } - } - - if (requests.isEmpty) return ''; - - final configs = await buildStep.resolver.libraryFor(_configs); - final output = []; - for (final request in requests) { - final name = request.caseStyle.name; - final config = configs.getTopLevelVariable(name)?.computeConstantValue(); - if (config == null) { - throw InvalidGenerationSource( - 'ack_generator has no JSON configuration named "$name" for ' - 'AckCaseStyle.$name. Declare it as a const JsonSerializable in ' - 'package:ack_generator/src/json/json_configs.dart.', - element: request.element, - ); - } - output.addAll( - _delegate.generateForAnnotatedElement( - request.element, - ConstantReader(config), - buildStep, - ), - ); - } - return output.join('\n\n'); - } - - void _addModelRequest( - List<({Element element, AckCaseStyle caseStyle})> requests, - Set claimed, - ClassElement element, - ConstantReader annotation, - ) { - if (!claimed.add(element.baseElement)) return; - final index = annotation - .read('caseStyle') - .objectValue - .getField('index')! - .toIntValue()!; - requests.add((element: element, caseStyle: AckCaseStyle.values[index])); - } -} diff --git a/packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart b/packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart deleted file mode 100644 index 819446bd..00000000 --- a/packages/ack_generator/lib/src/json/ack_runtime_type_helper.dart +++ /dev/null @@ -1,59 +0,0 @@ -// ignore: deprecated_member_use -import 'package:ack/annotations.dart' show AckModel, Schemable; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:json_serializable/type_helper.dart'; -import 'package:source_gen/source_gen.dart'; - -import 'helper_names.dart'; - -/// Routes every stored Ack field through generated runtime bridge methods. -/// -/// The input is an already-validated Ack runtime value, so this helper must -/// claim scalars as well as nested models. Default json_serializable codecs -/// would otherwise decode DateTime, Uri, enums, or nested JSON a second time. -final class AckRuntimeTypeHelper extends TypeHelper { - const AckRuntimeTypeHelper(); - - // ignore: deprecated_member_use - static const _model = TypeChecker.typeNamed(AckModel, inPackage: 'ack'); - static const _schemable = TypeChecker.typeNamed(Schemable, inPackage: 'ack'); - - @override - Object? serialize( - DartType targetType, - String expression, - TypeHelperContext context, - ) { - final className = context.classElement.name!; - final fieldName = context.fieldElement.name!; - final helper = _isClassFirst(context.classElement) - ? ackClassToRuntimeBridgeName(className, fieldName) - : '$className.${ackToRuntimeBridgeName(fieldName)}'; - return '$helper($expression)'; - } - - @override - Object? deserialize( - DartType targetType, - String expression, - TypeHelperContext context, - bool defaultProvided, - ) { - final className = context.classElement.name!; - final fieldName = context.fieldElement.name!; - final helper = _isClassFirst(context.classElement) - ? ackClassFromRuntimeBridgeName(className, fieldName) - : '$className.${ackFromRuntimeBridgeName(fieldName)}'; - return '$helper($expression)'; - } - - bool _isClassFirst(ClassElement element) => - _model.hasAnnotationOfExact(element) || - _schemable.hasAnnotationOfExact(element) || - element.allSupertypes.any( - (type) => - _model.hasAnnotationOfExact(type.element) || - _schemable.hasAnnotationOfExact(type.element), - ); -} diff --git a/packages/ack_generator/lib/src/json/helper_names.dart b/packages/ack_generator/lib/src/json/helper_names.dart index 6d44aded..cc63db1c 100644 --- a/packages/ack_generator/lib/src/json/helper_names.dart +++ b/packages/ack_generator/lib/src/json/helper_names.dart @@ -1,32 +1,3 @@ -/// Pascal-case first letter used by per-field Ack runtime bridges. -String ackBridgePascal(String fieldName) { - if (fieldName.isEmpty) return fieldName; - return '${fieldName[0].toUpperCase()}${fieldName.substring(1)}'; -} - -/// Decode-side runtime bridge for a stored field. -String ackFromRuntimeBridgeName(String fieldName) => - '_ackFromRuntime${ackBridgePascal(fieldName)}'; - -/// Encode-side runtime bridge for a stored field. -String ackToRuntimeBridgeName(String fieldName) => - '_ackToRuntime${ackBridgePascal(fieldName)}'; - -/// Decode-side top-level bridge for a class-first stored field. -String ackClassFromRuntimeBridgeName(String className, String fieldName) => - '_ack${className}FromRuntime${ackBridgePascal(fieldName)}'; - -/// Encode-side top-level bridge for a class-first stored field. -String ackClassToRuntimeBridgeName(String className, String fieldName) => - '_ack${className}ToRuntime${ackBridgePascal(fieldName)}'; - -/// Runtime-map decoder for a hand-written class-first model. -String ackClassFromRuntimeName(String className) => - '_\$${className}FromRuntime'; - -/// Runtime-map encoder for a hand-written class-first model. -String ackClassToRuntimeName(String className) => '_\$${className}ToRuntime'; - /// Generated mixin that exposes class-first JSON and value members. String ackClassMixinName(String className) => '_\$${className}Ack'; @@ -42,7 +13,13 @@ String ackClassSchemaBackingName(String className) => String ackClassWireSchemaName(String className) => '_${className[0].toLowerCase()}${className.substring(1)}WireSchema'; -/// Model class generated for an `@AckInfer` declaration. +/// Private runtime decoder generated for a class-first object model. +String ackClassFromRuntimeName(String className) => '_\$${className}FromRuntime'; + +/// Private runtime encoder generated for a class-first object model. +String ackClassToRuntimeName(String className) => '_\$${className}ToRuntime'; + +/// Model class generated for a `@Schemable` schema declaration. String ackInferModelClassName(String declarationName, {String? override}) { if (override != null) return override; final stem = declarationName.endsWith('Schema') @@ -66,21 +43,3 @@ String ackCopyWithInterfaceName(String className) => '\$${className}CopyWith'; /// Private implementation behind a generated model's `copyWith` getter. String ackCopyWithImplementationName(String className) => '_\$${className}CopyWith'; - -/// json_serializable `fromJson` helper for a generated model class. -String jsonFromHelperName(String className) => '_\$${className}FromJson'; - -/// json_serializable `toJson` helper for a generated model class. -String jsonToHelperName(String className) => '_\$${className}ToJson'; - -/// All per-field bridges derived from [fieldName]. -Iterable ackFieldBridgeNames(String fieldName) sync* { - yield ackFromRuntimeBridgeName(fieldName); - yield ackToRuntimeBridgeName(fieldName); -} - -/// All top-level JSON helpers derived from [className]. -Iterable ackJsonHelperNames(String className) sync* { - yield jsonFromHelperName(className); - yield jsonToHelperName(className); -} diff --git a/packages/ack_generator/lib/src/json/json_configs.dart b/packages/ack_generator/lib/src/json/json_configs.dart deleted file mode 100644 index 001d4b8d..00000000 --- a/packages/ack_generator/lib/src/json/json_configs.dart +++ /dev/null @@ -1,34 +0,0 @@ -/// The `json_serializable` configuration for each `AckCaseStyle`. -/// -/// `AckJsonSerializableGenerator` resolves this library and passes the -/// constant named after a model's case style to its delegate as the class -/// annotation. Optional null fields stay absent; generated Ack glue restores -/// required nullable keys. -library; - -import 'package:json_annotation/json_annotation.dart'; - -const none = JsonSerializable( - includeIfNull: false, - fieldRename: FieldRename.none, -); - -const snake = JsonSerializable( - includeIfNull: false, - fieldRename: FieldRename.snake, -); - -const kebab = JsonSerializable( - includeIfNull: false, - fieldRename: FieldRename.kebab, -); - -const pascal = JsonSerializable( - includeIfNull: false, - fieldRename: FieldRename.pascal, -); - -const screamingSnake = JsonSerializable( - includeIfNull: false, - fieldRename: FieldRename.screamingSnake, -); diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart b/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart new file mode 100644 index 00000000..cd2657e2 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:build/build.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import 'json_schema_library.dart'; + +/// Generates `.schema.dart` `@AckInfer` declarations from each +/// `.schema.json` draft 2020-12 bundle. +/// +/// The generated library declares the model parts that `ack_models` and +/// `ack_model_json` then fill in the same build. +final class AckJsonSchemaBuilder implements Builder { + /// Creates the builder with its `build.yaml` [options]. + AckJsonSchemaBuilder(this.options); + + /// The parsed builder options. + final AckJsonSchemaLibraryOptions options; + + @override + final buildExtensions = const { + '.schema.json': ['.schema.dart'], + }; + + @override + Future build(BuildStep buildStep) async { + final input = buildStep.inputId; + final Object? document; + try { + document = jsonDecode(await buildStep.readAsString(input)); + } on FormatException catch (error) { + throw FormatException('${input.uri} is not valid JSON: ${error.message}'); + } + final documents = {}; + for (final entry in options.documentPaths.entries) { + final asset = AssetId(input.package, entry.value); + if (!await buildStep.canRead(asset)) { + throw FormatException( + '${input.uri} cannot read document ${entry.key} at ${entry.value}.', + ); + } + try { + final decoded = jsonDecode(await buildStep.readAsString(asset)); + if (decoded == null) { + throw const FormatException('Expected a schema object or boolean.'); + } + documents[input.uri.resolveUri(entry.key)] = decoded; + } on FormatException catch (error) { + throw FormatException( + '${input.uri} document ${entry.key} is not valid JSON: ' + '${error.message}', + ); + } + } + final packageConfig = await buildStep.packageConfig; + final language = packageConfig[input.package]?.languageVersion; + final library = generateAckSchemaLibrary( + document: document, + documentUri: input.uri, + partStem: input.pathSegments.last.replaceFirst(RegExp(r'\.json$'), ''), + options: options, + documents: documents, + languageVersion: language == null + ? null + : Version(language.major, language.minor, 0), + ); + await buildStep.writeAsString( + input.changeExtension('.dart'), + library.source, + ); + } +} diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart b/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart new file mode 100644 index 00000000..b89acb01 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart @@ -0,0 +1,33 @@ +/// One reason a JSON Schema document cannot generate Ack models. +final class AckJsonSchemaDiagnostic { + /// Creates a diagnostic for the node at [pointer]. + const AckJsonSchemaDiagnostic(this.pointer, this.message); + + /// The JSON pointer of the offending node, such as `#/$defs/User/type`. + final String pointer; + + /// What is unsupported and, where possible, how to fix it. + final String message; + + @override + String toString() => '$pointer: $message'; +} + +/// Thrown when a JSON Schema document cannot generate Ack models. +final class AckJsonSchemaGenerationException implements Exception { + /// Creates an exception for [documentUri] with every [diagnostics] entry. + AckJsonSchemaGenerationException(this.documentUri, this.diagnostics) + : assert(diagnostics.isNotEmpty, 'An exception needs a diagnostic.'); + + /// The document that failed. + final Uri documentUri; + + /// Every problem found, in document order. + final List diagnostics; + + @override + String toString() => [ + 'Cannot generate Ack models from $documentUri:', + for (final diagnostic in diagnostics) ' $documentUri$diagnostic', + ].join('\n'); +} diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart new file mode 100644 index 00000000..fdce5389 --- /dev/null +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -0,0 +1,1265 @@ +import 'dart:convert'; + +import 'package:ack/ack.dart'; +import 'package:dart_style/dart_style.dart'; +import 'package:pub_semver/pub_semver.dart'; + +import '../analyzer/schema_first_names.dart'; +import 'json_schema_generation_exception.dart'; + +/// How generated models treat properties a JSON Schema object doesn't declare. +enum AckUnknownPropertiesMode { + /// Follow the schema: `additionalProperties: false` rejects unknown keys. + schema, + + /// Accept and keep unknown keys on every object, for clients that must + /// tolerate additive server changes to strict schemas. + preserve, +} + +/// Whether to prefer typed fields or exact source-schema value wrappers. +enum AckJsonSchemaModelMode { auto, validated } + +/// Options for [generateAckSchemaLibrary]. +final class AckJsonSchemaLibraryOptions { + /// Creates generation options. + const AckJsonSchemaLibraryOptions({ + this.unknownProperties = AckUnknownPropertiesMode.schema, + this.documentPaths = const {}, + this.assertFormats = false, + this.modelMode = AckJsonSchemaModelMode.auto, + }); + + /// Parses builder options from `build.yaml`. + /// + /// Throws an [ArgumentError] for an unknown key or value. + factory AckJsonSchemaLibraryOptions.fromConfig(Map config) { + final unknown = config.keys.toSet().difference({ + 'unknown_properties', + 'documents', + 'assert_formats', + 'model_mode', + }); + if (unknown.isNotEmpty) { + throw ArgumentError( + 'Unknown ack_json_schema options: ${unknown.join(', ')}. ' + 'Supported: unknown_properties, documents, assert_formats, model_mode.', + ); + } + final mode = config['unknown_properties'] ?? 'schema'; + final paths = config['documents'] ?? const {}; + if (paths is! Map || + paths.keys.any((key) => key is! String) || + paths.values.any((value) => value is! String)) { + throw ArgumentError.value( + paths, + 'documents', + 'Must map retrieval URI strings to package asset paths.', + ); + } + final assertFormats = config['assert_formats'] ?? false; + if (assertFormats is! bool) { + throw ArgumentError.value( + assertFormats, + 'assert_formats', + 'Must be a boolean.', + ); + } + return AckJsonSchemaLibraryOptions( + assertFormats: assertFormats, + modelMode: switch (config['model_mode'] ?? 'auto') { + 'auto' => AckJsonSchemaModelMode.auto, + 'validated' => AckJsonSchemaModelMode.validated, + final value => throw ArgumentError.value( + value, + 'model_mode', + 'Must be "auto" or "validated".', + ), + }, + documentPaths: { + for (final entry in paths.entries) + Uri.parse(entry.key as String): entry.value as String, + }, + unknownProperties: switch (mode) { + 'schema' => AckUnknownPropertiesMode.schema, + 'preserve' => AckUnknownPropertiesMode.preserve, + _ => throw ArgumentError.value( + mode, + 'unknown_properties', + 'Must be "schema" or "preserve".', + ), + }, + ); + } + + /// How objects treat undeclared properties. + final AckUnknownPropertiesMode unknownProperties; + + /// External schema resources declared in `build.yaml`, keyed by retrieval + /// URI and stored at package-local asset paths. + final Map documentPaths; + + /// Assert known formats even under the standard 2020-12 dialect. + final bool assertFormats; + + /// `auto` retains typed v1 models where possible; `validated` uses exact + /// source-schema value wrappers for every model. + final AckJsonSchemaModelMode modelMode; +} + +/// A generated Dart library and the document it was checked against. +final class AckJsonSchemaLibrary { + const AckJsonSchemaLibrary._({ + required this.source, + required this.effectiveDocument, + required this.widenedPointers, + }); + + /// The formatted Dart source. + final String source; + + /// The input document with the option widenings applied. + /// + /// Validated value models use `Ack.fromJsonSchema` directly. Typed models + /// retain the documented integer-range and string-length edge cases. + final Map effectiveDocument; + + /// Pointers of objects whose unknown-property rule an option widened. + final List widenedPointers; +} + +/// Generates typed Ack models or validated value models from a draft 2020-12 +/// [document]. +/// +/// [documentUri] identifies the document in diagnostics and resolves its +/// references. [documents] supplies offline external resources. [partStem] +/// names typed model parts (`.ack.dart`); value-model fallback needs no +/// parts. Set [includeParts] to `false` for a plain typed schema library. +/// +/// Throws an [AckJsonSchemaGenerationException] for invalid or unresolvable +/// schema resources, with source pointers where available. +AckJsonSchemaLibrary generateAckSchemaLibrary({ + required Object? document, + required Uri documentUri, + required String partStem, + AckJsonSchemaLibraryOptions options = const AckJsonSchemaLibraryOptions(), + Map documents = const {}, + bool includeParts = true, + Version? languageVersion, +}) { + if (options.modelMode == AckJsonSchemaModelMode.auto) { + final translator = _Translator(documentUri, options, documents); + final effective = translator.prepare(document); + if (effective != null) translator.translate(effective); + if (translator.diagnostics.isEmpty) { + final unformatted = _emit( + translator, + documentUri: documentUri, + partStem: partStem, + includeParts: includeParts, + ); + return AckJsonSchemaLibrary._( + source: DartFormatter( + languageVersion: + languageVersion ?? DartFormatter.latestLanguageVersion, + ).format(unformatted), + effectiveDocument: effective!, + widenedPointers: List.unmodifiable(translator.widened), + ); + } + } + + // The typed translator is intentionally conservative. A valid document it + // cannot lower still generates a value model with the source validator as + // its parse/encode boundary, so no 2020-12 assertion is silently dropped. + final fallbackDocuments = { + for (final entry in documents.entries) entry.key: _deepCopy(entry.value)!, + }; + final fallback = _Translator(documentUri, options, fallbackDocuments); + final fallbackDocument = fallback.prepareFallback(document); + if (fallback.diagnostics.isNotEmpty) { + throw AckJsonSchemaGenerationException(documentUri, fallback.diagnostics); + } + return AckJsonSchemaLibrary._( + source: + DartFormatter( + languageVersion: + languageVersion ?? DartFormatter.latestLanguageVersion, + ).format( + _emitFallback( + fallbackDocument!, + documentUri: documentUri, + documents: fallbackDocuments, + assertFormats: options.assertFormats, + widened: fallback.widened, + ), + ), + effectiveDocument: fallbackDocument, + widenedPointers: List.unmodifiable(fallback.widened), + ); +} + +const _annotations = { + 'title', + 'description', + 'default', + 'examples', + r'$comment', + 'deprecated', + 'readOnly', + 'writeOnly', +}; + +const _typeKeywords = { + 'string': {'type', 'minLength', 'maxLength', 'pattern'}, + 'integer': { + 'type', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + }, + 'number': { + 'type', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + }, + 'boolean': {'type'}, + 'array': {'type', 'items', 'minItems', 'maxItems', 'uniqueItems'}, + 'object': { + 'type', + 'properties', + 'required', + 'additionalProperties', + 'propertyNames', + }, +}; + +// Dart names that a generated library already uses unprefixed. +const _reservedModelNames = { + 'Object', + 'String', + 'List', + 'Map', + 'MapEntry', + 'Set', + 'Iterable', + 'Iterator', + 'Future', + 'Comparable', + 'Null', + 'Never', + 'Function', + 'Type', + 'Record', + 'Enum', + 'Symbol', + 'Duration', + 'DateTime', + 'Uri', + 'RegExp', +}; + +final class _Declaration { + _Declaration(this.name, this.code); + + final String name; + final String code; +} + +final class _Expr { + const _Expr(this.code, {this.nullable = false, this.reference}); + + final String code; + final bool nullable; + + /// The model this expression names, when it is a model reference. + final String? reference; +} + +final class _Translator { + _Translator(this.documentUri, this.options, this.documents); + + final Uri documentUri; + final AckJsonSchemaLibraryOptions options; + final Map documents; + final diagnostics = []; + final declarations = <_Declaration>[]; + final widened = []; + + final _modelNames = {}; + final _edges = >{}; + final _edgePointers = <(String, String), String>{}; + late Map _defs; + + void _fail(String pointer, String message) => + diagnostics.add(AckJsonSchemaDiagnostic(pointer, message)); + + /// Validates the document shape and names, applies widenings, and gates the + /// result through `Ack.fromJsonSchema`. Returns `null` on failure. + Map? prepare(Object? document) { + if (document is! Map) { + _fail('#', 'Expected a JSON object with a "\$defs" map.'); + return null; + } + const rootKeys = { + r'$schema', + r'$defs', + r'$comment', + 'title', + 'description', + }; + for (final key in document.keys) { + if (!rootKeys.contains(key)) { + _fail( + '#/${_token(key)}', + 'Root keyword "$key" is not supported. Put each model under ' + '"\$defs".', + ); + } + } + final defs = document[r'$defs']; + if (defs is! Map || defs.isEmpty) { + _fail('#/\$defs', 'Expected a non-empty "\$defs" map of models.'); + return null; + } + for (final name in defs.keys) { + final pointer = '#/\$defs/${_token(name)}'; + if (!RegExp(r'^[A-Z][A-Za-z0-9]*$').hasMatch(name)) { + _fail( + pointer, + 'Model name "$name" must be UpperCamelCase letters and digits.', + ); + } else if (_reservedModelNames.contains(name)) { + _fail(pointer, 'Model name "$name" shadows a dart:core type.'); + } + } + if (diagnostics.isNotEmpty) return null; + + final effective = _deepCopy(document)! as Map; + if (options.unknownProperties == AckUnknownPropertiesMode.preserve) { + _widen(effective, '#'); + } + _gate(effective, defs.keys); + return diagnostics.isEmpty ? effective : null; + } + + /// Prepares a 2020-12 schema and its supplied resources for value models. + Map? prepareFallback(Object? document) { + if (document is! Map && document is! bool) { + _fail('#', 'Expected a JSON Schema object or boolean.'); + return null; + } + final effective = document is bool + ? { + 'allOf': [document], + } + : _deepCopy(document)! as Map; + final defs = effective[r'$defs']; + if (defs != null && defs is! Map) { + _fail(r'#/$defs', 'Expected a map of schemas.'); + return null; + } + if (options.unknownProperties == AckUnknownPropertiesMode.preserve) { + _widen(effective, '#'); + for (final entry in documents.entries) { + _widen(entry.value, '${entry.key}#'); + } + } + try { + Ack.fromJsonSchema( + effective, + baseUri: documentUri, + documents: documents, + assertFormats: options.assertFormats, + ); + } on JsonSchemaImportException catch (error) { + for (final diagnostic in error.diagnostics) { + _fail(diagnostic.pointer, '${diagnostic.message} (${diagnostic.code})'); + } + } + if (defs case final Map schemas when schemas.isNotEmpty) { + _gate(effective, schemas.keys); + } + return diagnostics.isEmpty ? effective : null; + } + + /// Sets `additionalProperties: false` to `true` on every schema, walking + /// only keywords whose values are schemas. + void _widen(Object? node, String pointer) { + if (node is! Map) return; + if (node['additionalProperties'] == false) { + node['additionalProperties'] = true; + widened.add(pointer); + } + for (final keyword in const [ + r'$defs', + 'definitions', + 'properties', + 'patternProperties', + 'dependentSchemas', + ]) { + if (node[keyword] case final Map schemas) { + for (final MapEntry(:key, :value) in schemas.entries) { + _widen(value, '$pointer/$keyword/${_token(key)}'); + } + } + } + for (final keyword in const [ + 'items', + 'additionalProperties', + 'propertyNames', + 'contains', + 'not', + 'if', + 'then', + 'else', + 'unevaluatedProperties', + 'unevaluatedItems', + ]) { + _widen(node[keyword], '$pointer/$keyword'); + } + for (final keyword in const ['anyOf', 'allOf', 'oneOf', 'prefixItems']) { + if (node[keyword] case final List schemas) { + for (var i = 0; i < schemas.length; i++) { + _widen(schemas[i], '$pointer/$keyword/$i'); + } + } + } + } + + void _gate(Map effective, Iterable names) { + try { + Ack.fromJsonSchema( + { + 'allOf': [ + for (final name in names) + { + r'$ref': documentUri + .replace(fragment: '/\$defs/${_token(name)}') + .toString(), + }, + ], + }, + baseUri: documentUri.resolve('__ack_json_schema_gate__.json'), + documents: {...documents, documentUri: effective}, + assertFormats: options.assertFormats, + ); + } on JsonSchemaImportException catch (error) { + for (final diagnostic in error.diagnostics) { + _fail(diagnostic.pointer, '${diagnostic.message} (${diagnostic.code})'); + } + } + } + + void translate(Map effective) { + _defs = effective[r'$defs']! as Map; + _modelNames.addAll(_defs.keys); + for (final MapEntry(key: name, value: node) in _defs.entries) { + final pointer = '#/\$defs/$name'; + final expr = _expr( + node, + pointer, + owner: name, + hoistName: name, + root: true, + ); + if (expr == null) continue; + if (expr.nullable) { + _fail( + pointer, + 'A "\$defs" model cannot be nullable. Make the properties that ' + 'reference it nullable instead.', + ); + } else if (expr.reference != null) { + _fail( + pointer, + 'A model that only references another model is not ' + 'supported. Reference "${expr.reference}" directly.', + ); + } else { + declarations.add(_Declaration(name, expr.code)); + } + } + _rejectCycles(); + } + + _Expr? _expr( + Object? node, + String pointer, { + required String owner, + required String hoistName, + bool root = false, + }) { + if (node == true) return const _Expr('ack.Ack.any()', nullable: true); + if (node == false) { + _fail(pointer, 'The "false" schema accepts no value.'); + return null; + } + if (node is! Map) { + _fail(pointer, 'Expected a schema object.'); + return null; + } + final keywords = node.keys.where((key) => !_annotations.contains(key)); + final keys = keywords.toSet(); + final description = node['description']; + String describe(String code) => description is String + ? '$code.describe(${_literal(description)})' + : code; + + if (keys.isEmpty) { + return _Expr(describe('ack.Ack.any()'), nullable: true); + } + if (keys.contains(r'$ref')) { + if (keys.length > 1) { + _fail(pointer, 'Keywords next to "\$ref" are not supported.'); + return null; + } + return _ref(node[r'$ref'], '$pointer/\$ref', owner); + } + for (final keyword in ['allOf', 'oneOf', 'not', 'if', 'then', 'else']) { + if (keys.contains(keyword)) { + _fail('$pointer/$keyword', 'Keyword "$keyword" is not supported.'); + return null; + } + } + if (keys.contains('anyOf')) { + if (keys.length > 1) { + _fail(pointer, 'Keywords next to "anyOf" are not supported.'); + return null; + } + return _anyOf( + node['anyOf'], + pointer, + owner, + hoistName, + describe, + root: root, + ); + } + if (keys.contains('enum')) return _enum(node, pointer, keys, describe); + if (keys.contains('const')) return _const(node, pointer, keys, describe); + + final type = node['type']; + if (type == null) { + _fail(pointer, 'Add a "type"; untyped schemas are not supported.'); + return null; + } + final types = type is List ? type : [type]; + final nullable = types.contains('null'); + final nonNull = [ + for (final t in types) + if (t != 'null') t, + ]; + if (nonNull.isEmpty) { + _fail( + '$pointer/type', + 'A schema that only accepts null is not ' + 'supported.', + ); + return null; + } + if (nonNull.length > 1) { + if (keys.length > 1) { + _fail(pointer, 'A multi-type schema cannot also have constraints.'); + return null; + } + final union = _scalarUnion(nonNull, '$pointer/type'); + return union == null ? null : _Expr(describe(union), nullable: nullable); + } + final single = nonNull.single! as String; + final allowed = _typeKeywords[single]; + if (allowed == null) { + _fail('$pointer/type', 'Type "$single" is not supported.'); + return null; + } + for (final keyword in keys.difference(allowed)) { + _fail( + '$pointer/${_token(keyword)}', + 'Keyword "$keyword" cannot be represented in a generated model.', + ); + } + if (!allowed.containsAll(keys)) return null; + + if (single == 'object') { + final object = _object(node, pointer, owner, hoistName, root: root); + if (object == null) return null; + return object.reference == null + ? _Expr(describe(object.code), nullable: nullable) + : _Expr(object.code, nullable: nullable, reference: object.reference); + } + final code = switch (single) { + 'string' => _string(node, pointer), + 'integer' => _integer(node), + 'number' => _number(node), + 'boolean' => 'ack.Ack.boolean()', + _ => _array(node, pointer, owner, hoistName), + }; + if (code == null) return null; + return _Expr(describe(code), nullable: nullable); + } + + _Expr? _ref(Object? ref, String pointer, String owner) { + final match = ref is String + ? RegExp(r'^#/\$defs/([A-Za-z0-9]+)$').firstMatch(ref) + : null; + final name = match?.group(1); + if (name == null || !_defs.containsKey(name)) { + _fail( + pointer, + 'Only "#/\$defs/" references to a model in this ' + 'document are supported.', + ); + return null; + } + _edge(owner, name, pointer); + return _Expr(_variable(name), reference: name); + } + + _Expr? _anyOf( + Object? branches, + String pointer, + String owner, + String hoistName, + String Function(String) describe, { + required bool root, + }) { + final flat = <(Object?, String)>[]; + void flatten(Object? list, String at) { + if (list is! List) return; + for (var i = 0; i < list.length; i++) { + final branch = list[i]; + final branchPointer = '$at/anyOf/$i'; + if (branch is Map && + branch.keys.every( + (k) => k == 'anyOf' || _annotations.contains(k), + ) && + branch.containsKey('anyOf')) { + flatten(branch['anyOf'], branchPointer); + } else { + flat.add((branch, branchPointer)); + } + } + } + + flatten(branches, pointer); + bool isNull(Object? branch) => + branch is Map && + branch.keys.every((k) => k == 'type' || _annotations.contains(k)) && + (branch['type'] == 'null' || + (branch['type'] is List && _listEquals(branch['type'], ['null']))); + final nullable = flat.any((b) => isNull(b.$1)); + final rest = [ + for (final branch in flat) + if (!isNull(branch.$1)) branch, + ]; + if (rest.isEmpty) { + _fail(pointer, 'An "anyOf" that only accepts null is not supported.'); + return null; + } + if (rest.length == 1) { + final (branch, branchPointer) = rest.single; + final inner = _expr( + branch, + branchPointer, + owner: owner, + hoistName: hoistName, + root: root, + ); + if (inner == null) return null; + return _Expr( + inner.reference == null ? describe(inner.code) : inner.code, + nullable: nullable || inner.nullable, + reference: inner.reference, + ); + } + final types = []; + for (final (branch, branchPointer) in rest) { + final bare = + branch is Map && + branch.keys.every((k) => k == 'type' || _annotations.contains(k)) && + branch['type'] is String; + if (!bare) { + _fail( + branchPointer, + 'Only unions of bare string, number, and boolean ' + 'types are supported.', + ); + return null; + } + types.add(branch['type']); + } + final union = _scalarUnion(types, pointer); + return union == null ? null : _Expr(describe(union), nullable: nullable); + } + + String? _scalarUnion(List types, String pointer) { + const checks = {'string': 'String', 'number': 'num', 'boolean': 'bool'}; + final dartTypes = []; + for (final type in types) { + final check = checks[type]; + if (check == null) { + _fail( + pointer, + 'Type "$type" cannot be part of a union; only string, ' + 'number, and boolean can.', + ); + return null; + } + if (!dartTypes.contains(check)) dartTypes.add(check); + } + final test = dartTypes.map((t) => 'value is $t').join(' || '); + final names = [ + for (final type in types.toSet()) + if (type == 'number') 'a number' else 'a $type', + ]; + return 'ack.Ack.any().refine((value) => $test, ' + 'message: ${_literal('Expected ${names.join(' or ')}.')})'; + } + + _Expr? _enum( + Map node, + String pointer, + Set keys, + String Function(String) describe, + ) { + if (!{'enum', 'type'}.containsAll(keys)) { + _fail( + pointer, + 'Keywords next to "enum" other than "type" are not ' + 'supported.', + ); + return null; + } + final type = node['type']; + final types = type == null ? null : (type is List ? type : [type]); + if (types != null && + (types.any((t) => t != 'string' && t != 'null') || + !types.contains('string'))) { + _fail('$pointer/type', 'A string enum needs a string-compatible type.'); + return null; + } + final values = node['enum']; + if (values is! List) return null; + final allowsNull = types == null || types.contains('null'); + final strings = []; + for (var i = 0; i < values.length; i++) { + final value = values[i]; + if (value == null) continue; + if (value is! String) { + _fail('$pointer/enum/$i', 'Only string enum values are supported.'); + return null; + } + if (!strings.contains(value)) strings.add(value); + } + if (strings.isEmpty) { + _fail('$pointer/enum', 'An enum needs at least one string value.'); + return null; + } + return _Expr( + describe('ack.Ack.enumString([${strings.map(_literal).join(', ')}])'), + nullable: allowsNull && values.contains(null), + ); + } + + _Expr? _const( + Map node, + String pointer, + Set keys, + String Function(String) describe, + ) { + final value = node['const']; + final type = node['type']; + final types = type == null ? null : (type is List ? type : [type]); + if (value is! String || + !{'const', 'type'}.containsAll(keys) || + (types != null && + (types.any((t) => t != 'string' && t != 'null') || + !types.contains('string')))) { + _fail( + '$pointer/const', + 'Only a string "const" with an optional string-compatible "type" ' + 'is supported.', + ); + return null; + } + return _Expr(describe('ack.Ack.literal(${_literal(value)})')); + } + + String? _string(Map node, String pointer) { + final buffer = StringBuffer('ack.Ack.string()'); + if (node['minLength'] case final int n) buffer.write('.minLength($n)'); + if (node['maxLength'] case final int n) buffer.write('.maxLength($n)'); + if (node['pattern'] case final String pattern) { + if (RegExp(r'\\[pPu]\{').hasMatch(pattern)) { + _fail( + '$pointer/pattern', + r'Unicode-mode patterns (\p{…}, \P{…}, ' + r'\u{…}) are not supported.', + ); + return null; + } + buffer.write('.matches(${_literal(pattern)})'); + } + return buffer.toString(); + } + + String _integer(Map node) { + final buffer = StringBuffer('ack.Ack.integer()'); + int? integral(Object? value) => + value is num && value == value.truncate() ? value.toInt() : null; + if (node['minimum'] case final num n) { + buffer.write('.min(${integral(n) ?? n.ceil()})'); + } + if (node['exclusiveMinimum'] case final num n) { + final exact = integral(n); + buffer.write( + exact == null ? '.min(${n.ceil()})' : '.greaterThan($exact)', + ); + } + if (node['maximum'] case final num n) { + buffer.write('.max(${integral(n) ?? n.floor()})'); + } + if (node['exclusiveMaximum'] case final num n) { + final exact = integral(n); + buffer.write(exact == null ? '.max(${n.floor()})' : '.lessThan($exact)'); + } + return buffer.toString(); + } + + String _number(Map node) { + final buffer = StringBuffer('ack.Ack.number()'); + for (final (keyword, method) in [ + ('minimum', 'min'), + ('exclusiveMinimum', 'greaterThan'), + ('maximum', 'max'), + ('exclusiveMaximum', 'lessThan'), + ]) { + if (node[keyword] case final num n) buffer.write('.$method(${_num(n)})'); + } + return buffer.toString(); + } + + String? _array( + Map node, + String pointer, + String owner, + String hoistName, + ) { + if (!node.containsKey('items')) { + _fail(pointer, 'Arrays need an "items" schema.'); + return null; + } + final item = _expr( + node['items'], + '$pointer/items', + owner: owner, + hoistName: '${hoistName}Item', + ); + if (item == null) return null; + if (item.nullable) { + _fail( + '$pointer/items', + 'Nullable array items are not supported; Ack ' + 'lists cannot contain null.', + ); + return null; + } + final buffer = StringBuffer('ack.Ack.list(${item.code})'); + if (node['minItems'] case final int n) buffer.write('.minItems($n)'); + if (node['maxItems'] case final int n) buffer.write('.maxItems($n)'); + if (node['uniqueItems'] == true) buffer.write('.unique()'); + return buffer.toString(); + } + + _Expr? _object( + Map node, + String pointer, + String owner, + String hoistName, { + required bool root, + }) { + final properties = node['properties']; + final additional = node['additionalProperties']; + final propertyNames = node['propertyNames']; + final open = + additional == null || + additional == true || + (additional is Map && additional.isEmpty); + final hasProperties = + properties is Map && properties.isNotEmpty; + + final required = { + for (final key in node['required'] as List? ?? const []) + if (key is String) key, + }; + final declared = properties is Map + ? properties.keys.toSet() + : {}; + final missingRequired = required.difference(declared); + for (final missing in missingRequired) { + _fail( + '$pointer/required', + 'Required property "$missing" is not declared in "properties".', + ); + } + if (missingRequired.isNotEmpty) return null; + + if (propertyNames != null && + (hasProperties || !_jsonEquals(propertyNames, {'type': 'string'}))) { + _fail( + '$pointer/propertyNames', + 'Only "propertyNames": {"type": ' + '"string"} on a map-shaped object is supported.', + ); + return null; + } + if (!hasProperties && root && additional is Map && additional.isNotEmpty) { + _fail( + pointer, + 'A "\$defs" model must be an object with "properties"; ' + 'map-shaped models are not supported.', + ); + return null; + } + if (!hasProperties && !root && (open || additional is Map)) { + // A map-shaped object: string keys to one value schema. + if (open) return const _Expr('ack.Ack.map(ack.Ack.any().nullable())'); + final value = _expr( + additional, + '$pointer/additionalProperties', + owner: owner, + hoistName: '${hoistName}Value', + ); + if (value == null) return null; + return _Expr( + 'ack.Ack.map(${value.code}${value.nullable ? '.nullable()' : ''})', + ); + } + if (additional is Map && additional.isNotEmpty) { + _fail( + '$pointer/additionalProperties', + 'An "additionalProperties" ' + 'schema next to "properties" is not supported.', + ); + return null; + } + + final entries = []; + if (properties is Map) { + for (final MapEntry(key: key, value: value) in properties.entries) { + final fieldPointer = '$pointer/properties/${_token(key)}'; + if (key == hoistName) { + _fail( + fieldPointer, + 'Property "$key" would have the same name as its generated ' + 'model class.', + ); + continue; + } + final problem = ackSchemaFirstFieldNameProblem( + key, + capturesExtras: open, + ); + if (problem != null) { + _fail(fieldPointer, 'Property "$key" $problem'); + continue; + } + final field = _expr( + value, + fieldPointer, + owner: owner, + hoistName: '$hoistName${key[0].toUpperCase()}${key.substring(1)}', + ); + if (field == null) continue; + entries.add( + '${_literal(key)}: ${field.code}' + '${field.nullable ? '.nullable()' : ''}' + '${required.contains(key) ? '' : '.optional()'}', + ); + } + } + final extras = open ? ', additionalProperties: true' : ''; + final code = 'ack.Ack.object({${entries.join(', ')}}$extras)'; + if (root) return _Expr(code); + + if (_modelNames.contains(hoistName)) { + _fail( + pointer, + 'The generated model name "$hoistName" for this inline ' + 'object is already used. Move it to "\$defs" under a unique name.', + ); + return null; + } + _modelNames.add(hoistName); + final description = node['description']; + declarations.add( + _Declaration( + hoistName, + description is String + ? '$code.describe(${_literal(description)})' + : code, + ), + ); + _edge(owner, hoistName, pointer); + return _Expr(_variable(hoistName), reference: hoistName); + } + + void _edge(String from, String to, String pointer) { + (_edges[from] ??= {}).add(to); + _edgePointers[(from, to)] ??= pointer; + } + + void _rejectCycles() { + final state = {}; + void visit(String node) { + state[node] = 1; + for (final next in _edges[node] ?? const {}) { + if (state[next] == 1) { + _fail( + _edgePointers[(node, next)]!, + 'Recursive references are not ' + 'supported yet ("$node" → "$next").', + ); + } else if (state[next] == null) { + visit(next); + } + } + state[node] = 2; + } + + for (final name in _defs.keys) { + if (state[name] == null) visit(name); + } + } +} + +String _emit( + _Translator translator, { + required Uri documentUri, + required String partStem, + required bool includeParts, +}) { + final source = documentUri.pathSegments.isEmpty + ? '$documentUri' + : documentUri.pathSegments.last; + final buffer = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND') + ..writeln('// dart format width=80') + ..writeln('// ignore_for_file: type=lint') + ..writeln('// coverage:ignore-file') + ..writeln('//') + ..writeln('// Generated from $source by ack_generator:ack_json_schema.'); + if (translator.widened.isNotEmpty) { + buffer.writeln('// unknown_properties: preserve widened these objects:'); + for (final pointer in translator.widened) { + buffer.writeln('// - ${_commentText(pointer)}'); + } + } + buffer + ..writeln() + ..writeln("import 'package:ack/ack.dart' as ack;") + ..writeln(); + if (includeParts) { + buffer + ..writeln("part '$partStem.g.dart';") + ..writeln(); + } + for (final declaration in translator.declarations) { + buffer + ..writeln('@ack.Schemable(name: ${_literal(declaration.name)})') + ..writeln('final ${_variable(declaration.name)} = ${declaration.code};') + ..writeln(); + } + return buffer.toString(); +} + +String _emitFallback( + Map document, { + required Uri documentUri, + required Map documents, + required bool assertFormats, + required List widened, +}) { + final defs = document[r'$defs'] as Map? ?? const {}; + const rootAnnotations = { + r'$schema', + r'$defs', + r'$comment', + 'title', + 'description', + }; + final hasRootModel = + defs.isEmpty || + document.keys.any((key) => !rootAnnotations.contains(key)); + final names = {}; + String uniqueName(String key) { + final words = key + .split(RegExp(r'[^A-Za-z0-9]+')) + .where((word) => word.isNotEmpty) + .toList(); + var base = words.isEmpty + ? 'Definition' + : words + .map((word) => '${word[0].toUpperCase()}${word.substring(1)}') + .join(); + if (RegExp(r'^[0-9]').hasMatch(base)) base = 'Definition$base'; + if (_reservedModelNames.contains(base)) base = '${base}Model'; + var name = base; + var suffix = 2; + while (!names.add(name)) { + name = '$base${suffix++}'; + } + return name; + } + + final definitions = <(String, String)>[ + for (final key in defs.keys) (uniqueName(key), key), + ]; + final rootName = hasRootModel + ? uniqueName(names.contains('Root') ? 'DocumentRoot' : 'Root') + : null; + final models = <(String, String?)>[ + if (rootName != null) (rootName, null), + ...definitions, + ]; + final uri = _literal(documentUri.toString()); + final lookupUri = _literal( + documentUri.resolve('__ack_json_schema_generated__.json').toString(), + ); + final buffer = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT MODIFY BY HAND') + ..writeln('// dart format width=80') + ..writeln('// ignore_for_file: type=lint') + ..writeln('// coverage:ignore-file') + ..writeln('//') + ..writeln('// Draft 2020-12 value models retain source-schema validation.') + ..writeln( + '// Complex shapes use Object? values, not inferred typed fields.', + ); + if (widened.isNotEmpty) { + buffer.writeln('// unknown_properties: preserve widened these objects:'); + for (final pointer in widened) { + buffer.writeln('// - ${_commentText(pointer)}'); + } + } + buffer + ..writeln() + ..writeln("import 'dart:convert';") + ..writeln("import 'package:ack/ack.dart' as ack;") + ..writeln() + ..writeln('final Object _jsonSchemaDocument =') + ..writeln(' jsonDecode(${_literal(jsonEncode(document))}) as Object;') + ..writeln('final Map _jsonSchemaDocuments = {') + ..writeln(' Uri.parse($uri): _jsonSchemaDocument,'); + for (final entry in documents.entries) { + buffer.writeln( + ' Uri.parse(${_literal(entry.key.toString())}): ' + 'jsonDecode(${_literal(jsonEncode(entry.value))}) as Object,', + ); + } + buffer + ..writeln('};') + ..writeln(); + for (final (name, definition) in models) { + final variable = _variable(name); + final formatOption = assertFormats ? ', assertFormats: true' : ''; + final schemaExpression = definition == null + ? 'ack.Ack.fromJsonSchema(_jsonSchemaDocument, ' + 'baseUri: Uri.parse($uri), documents: {' + 'for (final entry in _jsonSchemaDocuments.entries) ' + 'if (entry.key != Uri.parse($uri)) entry.key: entry.value}' + '$formatOption)' + : 'ack.Ack.fromJsonSchema(' + "{r'\$ref': ${_literal(documentUri.replace(fragment: '/\$defs/${_token(definition)}').toString())}}, " + 'baseUri: Uri.parse($lookupUri), ' + 'documents: _jsonSchemaDocuments$formatOption)'; + buffer + ..writeln('final $variable = $schemaExpression;') + ..writeln() + ..writeln('/// A validated Draft 2020-12 value model.') + ..writeln('final class $name {') + ..writeln(' const $name._(this.value);') + ..writeln() + ..writeln(' /// The parsed, immutable JSON value.') + ..writeln(' final Object? value;') + ..writeln() + ..writeln(' /// The exact source-schema validator for this model.') + ..writeln( + ' static ack.AckSchema get schema => $variable;', + ) + ..writeln() + ..writeln(' static $name parse(Object? value) =>') + ..writeln(' safeParse(value).getOrThrow()!;') + ..writeln() + ..writeln(' static ack.SchemaResult<$name> safeParse(Object? value) =>') + ..writeln( + ' schema.safeParse(value).map((parsed) => $name._(parsed));', + ) + ..writeln() + ..writeln(' static Object? encode($name model) =>') + ..writeln(' safeEncode(model).getOrThrow();') + ..writeln() + ..writeln(' static ack.SchemaResult safeEncode($name model) =>') + ..writeln(' schema.safeEncode(model.value);') + ..writeln() + ..writeln(' Object? toJson() => encode(this);') + ..writeln() + ..writeln(' static Map toJsonSchema() =>') + ..writeln(' schema.toJsonSchemaPreservingImportedDialect();') + ..writeln('}') + ..writeln(); + } + return buffer.toString(); +} + +String _variable(String modelName) => + '${modelName[0].toLowerCase()}${modelName.substring(1)}Schema'; + +String _commentText(String value) => value + .replaceAll('\\', r'\\') + .replaceAll('\r', r'\r') + .replaceAll('\n', r'\n') + .replaceAll('\t', r'\t') + .replaceAll('\u2028', r'\u2028') + .replaceAll('\u2029', r'\u2029'); + +/// A single-quoted Dart string literal with every special character escaped, +/// so descriptions, patterns, and keys stay one simple literal. +String _literal(String value) { + final buffer = StringBuffer("'"); + for (final rune in value.runes) { + buffer.write(switch (rune) { + 0x5C => r'\\', + 0x27 => r"\'", + 0x24 => r'\$', + 0x0A => r'\n', + 0x0D => r'\r', + 0x09 => r'\t', + < 0x20 => '\\u{${rune.toRadixString(16)}}', + _ => String.fromCharCode(rune), + }); + } + buffer.write("'"); + return buffer.toString(); +} + +String _num(num value) => + value is int || value != value.truncate() ? '$value' : '${value.toInt()}'; + +String _token(String key) => key.replaceAll('~', '~0').replaceAll('/', '~1'); + +Object? _deepCopy(Object? value) => switch (value) { + final Map map => { + for (final MapEntry(:key, :value) in map.entries) key: _deepCopy(value), + }, + final List list => [for (final item in list) _deepCopy(item)], + _ => value, +}; + +bool _jsonEquals(Object? a, Object? b) => jsonEncode(a) == jsonEncode(b); + +bool _listEquals(Object? a, List b) => + a is List && _jsonEquals(a, b); diff --git a/packages/ack_generator/lib/src/model_generator.dart b/packages/ack_generator/lib/src/model_generator.dart index 1acc20cf..b756eb78 100644 --- a/packages/ack_generator/lib/src/model_generator.dart +++ b/packages/ack_generator/lib/src/model_generator.dart @@ -15,16 +15,6 @@ import 'models/schema_model_graph.dart'; /// Generates Ack models for `@Schemable` top-level schemas and classes. final class AckModelGenerator extends Generator { - static const _ackInferChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckInfer, - inPackage: 'ack', - ); - static const _ackModelChecker = TypeChecker.typeNamed( - // ignore: deprecated_member_use - AckModel, - inPackage: 'ack', - ); static const _schemableChecker = TypeChecker.typeNamed( Schemable, inPackage: 'ack', @@ -53,38 +43,37 @@ final class AckModelGenerator extends Generator { final annotatedModels = []; for (final element in library.allElements) { - if (!_hasAckInfer(element)) continue; - if (element is ClassElement) { - throw InvalidGenerationSource( - '@AckInfer can only be applied to top-level schema variables or ' - 'getters, not classes. Use @Schemable() on the class.', - element: element, - ); - } + if (!_hasSchemableSchema(element)) continue; if (element is TopLevelVariableElement) { annotated.add(element); - } else if (element is GetterElement && element.isOriginDeclaration) { + } else if (element is GetterElement) { + if (!element.isOriginDeclaration) continue; if (element.enclosingElement is! LibraryElement) { throw InvalidGenerationSource( - '${_annotationLabel(element)} can only be applied to top-level ' - 'schema variables or getters.', + '@Schemable can only be applied to top-level schema variables or ' + 'getters.', element: element, ); } annotated.add(element); + } else { + throw InvalidGenerationSource( + '@Schemable can only be applied to classes or top-level schema ' + 'variables or getters.', + element: element, + ); } } for (final classElement in library.classes) { - if (_ackModelChecker.hasAnnotationOfExact(classElement) || - _schemableChecker.hasAnnotationOfExact(classElement)) { + if (_schemableChecker.hasAnnotationOfExact(classElement)) { annotatedModels.add(classElement); } for (final getter in classElement.getters) { - if (_hasAckInfer(getter)) { + if (_hasSchemableSchema(getter)) { throw InvalidGenerationSource( - '${_annotationLabel(getter)} can only be applied to top-level ' - 'schema variables or getters.', + '@Schemable can only be applied to top-level schema variables or ' + 'getters.', element: getter, ); } @@ -97,14 +86,12 @@ final class AckModelGenerator extends Generator { annotated.isNotEmpty ? annotated.first : annotatedModels.first, ); - ({AckModelGraph graph, String? ackPrefix, String? schemablePrefix})? - schemaFirst; + ({AckModelGraph graph, String? ackPrefix})? schemaFirst; if (annotated.isNotEmpty) { final graph = await SchemaModelGraphBuilder(library).build(annotated); schemaFirst = ( graph: graph, ackPrefix: _ackRuntimeQualifier(library, annotated.first), - schemablePrefix: _schemableQualifier(library, annotated.first), ); } ({AckModelGraph graph, String? ackPrefix})? classFirst; @@ -130,7 +117,6 @@ final class AckModelGenerator extends Generator { if (schemaFirst != null) { final specs = AckModelEmitter( ackPrefix: schemaFirst.ackPrefix, - schemablePrefix: schemaFirst.schemablePrefix, schemaPrefixInScope: library.element.firstFragment.libraryImports.any( (import) => import.prefix?.element.name == 'schema', ), @@ -154,7 +140,11 @@ final class AckModelGenerator extends Generator { ).emit(classFirst.graph), ); } - return output.where((chunk) => chunk.trim().isNotEmpty).join('\n\n'); + final body = output.where((chunk) => chunk.trim().isNotEmpty).join('\n\n'); + if (body.isEmpty) return ''; + return '// ignore_for_file: type=lint\n' + '// coverage:ignore-file\n\n' + '$body'; } void _validateModernGeneratedNames( @@ -205,18 +195,12 @@ final class AckModelGenerator extends Generator { todo: 'Import package:ack/ack.dart, directly or through a barrel.', ); - String _annotationLabel(Element element) => - _ackInferChecker.hasAnnotationOfExact(element) - ? '@AckInfer' - : '@Schemable'; + bool _hasSchemableSchema(Element element) => + element is! ClassElement && + _schemableChecker.hasAnnotationOfExact(element); - bool _hasAckInfer(Element element) => - _ackInferChecker.hasAnnotationOfExact(element) || - (element is! ClassElement && - _schemableChecker.hasAnnotationOfExact(element)); - - /// Strips `./` segments so `part './user.ack.dart'` matches the file next to - /// the input, without treating `part 'sub/user.ack.dart'` as the same path. + /// Strips `./` segments so `part './user.g.dart'` matches the file next to + /// the input, without treating `part 'sub/user.g.dart'` as the same path. String _normalizedPartUri(String uri) { final segments = [ for (final segment in Uri.parse(uri).pathSegments) @@ -231,23 +215,19 @@ final class AckModelGenerator extends Generator { ) async { final inputName = buildStep.inputId.pathSegments.last; final baseName = inputName.substring(0, inputName.length - '.dart'.length); - final expectedAckPart = '$baseName.ack.dart'; - final expectedJsonPart = '$baseName.ack.g.dart'; + final expectedPart = '$baseName.g.dart'; final unit = await buildStep.resolver.compilationUnitFor(buildStep.inputId); final parts = { for (final directive in unit.directives.whereType()) if (directive.uri.stringValue case final uri?) _normalizedPartUri(uri), }; - if (parts.contains(expectedAckPart) && parts.contains(expectedJsonPart)) { + if (parts.contains(expectedPart)) { return; } throw InvalidGenerationSource( - "Ack model generation requires `part '$expectedAckPart';` and " - "`part '$expectedJsonPart';` in this library.", + "Ack model generation requires `part '$expectedPart';` in this library.", element: annotatedElement, - todo: - "Add `part '$expectedAckPart';` and `part '$expectedJsonPart';` " - "next to the library's directives.", + todo: "Add `part '$expectedPart';` next to the library's directives.", ); } @@ -278,25 +258,6 @@ final class AckModelGenerator extends Generator { 'are exposed.', ); - /// Resolves the visible `Schemable` qualifier for generated JSON markers. - /// - /// Uses import namespaces so barrel re-exports and `show` combinators work. - /// Prefixed imports win over unprefixed ones, in import order. - String? _schemableQualifier( - LibraryReader library, - Element annotatedElement, - ) => _visibleQualifier( - library, - annotatedElement, - requiredTypes: const {'Schemable': _schemableChecker}, - message: - 'Generated @Schemable.generatedJson requires a visible exact Schemable ' - 'import in this library.', - todo: - 'Import Schemable from package:ack, using the same prefix as the ' - 'schema annotation when one is present.', - ); - String? _visibleQualifier( LibraryReader library, Element annotatedElement, { diff --git a/packages/ack_generator/lib/src/models/constraint_info.dart b/packages/ack_generator/lib/src/models/constraint_info.dart deleted file mode 100644 index 02098a6c..00000000 --- a/packages/ack_generator/lib/src/models/constraint_info.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Information about a validation constraint -class ConstraintInfo { - final String name; - final List arguments; - - const ConstraintInfo({required this.name, required this.arguments}); -} diff --git a/packages/ack_generator/lib/src/models/field_info.dart b/packages/ack_generator/lib/src/models/field_info.dart deleted file mode 100644 index 1b111909..00000000 --- a/packages/ack_generator/lib/src/models/field_info.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:logging/logging.dart'; - -import 'constraint_info.dart'; - -/// Logger for field info extraction warnings and diagnostics. -final _log = Logger('FieldInfo'); - -/// Information about a field in the model -class FieldInfo { - final String name; - final String jsonKey; - final DartType type; - final bool isRequired; - final bool isNullable; - final List constraints; - final String? description; - - /// For list/set fields containing schema variable references (e.g., `Ack.list(addressSchema)`), - /// this stores the schema variable name so the type builder can generate - /// properly typed getters like `List`. - final String? listElementSchemaRef; - - /// For nested object fields that reference another schema variable (e.g., `'address': addressSchema`), - /// this stores the schema variable name so the type builder can generate - /// properly typed getters like `AddressType get address`. - final String? nestedSchemaRef; - - /// Optional display type override used when source qualification matters - /// (e.g., `alias.UserRole` from a prefixed import). - final String? displayTypeOverride; - - /// Optional collection element display type override for list/set fields. - final String? collectionElementDisplayTypeOverride; - - /// Optional cast type override for list/set element wrappers - /// (for example, `Map` for object schema references). - final String? collectionElementCastTypeOverride; - - /// Whether list/set elements should be wrapped as generated extension types. - final bool collectionElementIsCustomType; - - /// Optional cast type override for nested schema references. - final String? nestedSchemaCastTypeOverride; - - const FieldInfo({ - required this.name, - required this.jsonKey, - required this.type, - required this.isRequired, - required this.isNullable, - required this.constraints, - this.description, - this.listElementSchemaRef, - this.nestedSchemaRef, - this.displayTypeOverride, - this.collectionElementDisplayTypeOverride, - this.collectionElementCastTypeOverride, - this.collectionElementIsCustomType = false, - this.nestedSchemaCastTypeOverride, - }); - - /// Whether this field references another schema model - bool get isNestedSchema => - !isPrimitive && !isList && !isMap && !isSet && !isEnum && !isGeneric; - - /// Whether this field is a generic type parameter - bool get isGeneric { - // Check if this is a type parameter (like T, U, etc.) - return type is TypeParameterType; - } - - /// Whether this is a Set type - bool get isSet => type.isDartCoreSet; - - /// Whether this is a primitive type - bool get isPrimitive { - // Check if it's a built-in Dart type - return type.isDartCoreString || - type.isDartCoreInt || - type.isDartCoreDouble || - type.isDartCoreBool || - type.isDartCoreNum; - } - - /// Whether this field is an enum type - bool get isEnum { - final element = type.element; - if (element == null) return false; - - // Check if this is an enum by looking at the element type - return element is EnumElement; - } - - /// Get enum values if this is an enum type - List get enumValues { - if (!isEnum) return []; - final element = type.element; - if (element == null) return []; - - // For enums, get the enum constants using the analyzer API - if (element is EnumElement) { - try { - final enumConstants = element.constants - .map((field) => field.name!) - .toList(); - - return enumConstants; - } catch (e) { - // If the analyzer can't resolve the enum constants, fall back to empty - // values so generation still works for manual string-enum schemas. - _log.warning('Could not extract enum values for ${element.name}: $e'); - return []; - } - } - - return []; - } - - /// Whether this is a List type - bool get isList => type.isDartCoreList; - - /// Whether this is a Map type - bool get isMap => type.isDartCoreMap; -} diff --git a/packages/ack_generator/lib/src/models/model_info.dart b/packages/ack_generator/lib/src/models/model_info.dart deleted file mode 100644 index 146b1555..00000000 --- a/packages/ack_generator/lib/src/models/model_info.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'field_info.dart'; - -/// Default representation type for object schemas -const String kMapType = 'Map'; - -/// Information about an annotated model class -class ModelInfo { - final String className; - final String schemaClassName; - final String? description; - final List fields; - final bool additionalProperties; - - /// Computed property: returns list of required field JSON keys - List get requiredFields => - fields.where((f) => f.isRequired).map((f) => f.jsonKey).toList(); - - /// Field name for discrimination. - /// - /// This is set on declared discriminated bases and may also be propagated - /// to linked schema-variable subtypes. - final String? discriminatorKey; - - /// This class's discriminator value (only for subtypes) - final String? discriminatorValue; - - /// Map of discriminator values to subtype schema declarations. - final Map? subtypeNames; - - /// Canonical schema declaration identity. - final String? schemaIdentity; - - /// Parent discriminated base class name for subtypes. - final String? discriminatedBaseClassName; - - /// Computed property: Whether this model has a discriminator key. - /// - /// This may be true for linked schema-variable subtypes. - bool get isDiscriminatedBase => discriminatorKey != null; - - /// Computed property: Whether this model is a declared discriminated base. - bool get isDiscriminatedBaseDefinition => - discriminatorKey != null && subtypeNames != null; - - /// Computed property: Whether this class is a discriminated subtype (has discriminatedValue) - bool get isDiscriminatedSubtype => discriminatorValue != null; - - /// Representation type for extension type (e.g., `String`, `int`, `Map`) - final String representationType; - - /// Whether the schema declaration is nullable via `.nullable()`. - final bool isNullableSchema; - - const ModelInfo({ - required this.className, - required this.schemaClassName, - this.description, - required this.fields, - this.additionalProperties = false, - this.discriminatorKey, - this.discriminatorValue, - this.subtypeNames, - this.schemaIdentity, - this.discriminatedBaseClassName, - this.representationType = kMapType, - this.isNullableSchema = false, - }); -} diff --git a/packages/ack_generator/lib/src/validation/code_validator.dart b/packages/ack_generator/lib/src/validation/code_validator.dart deleted file mode 100644 index a3c3ce4a..00000000 --- a/packages/ack_generator/lib/src/validation/code_validator.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:analyzer/dart/analysis/utilities.dart'; - -/// Validates that generated Dart code is syntactically correct. -/// -/// Semantic errors are expected to resolve only after generated code is -/// combined with its source library, so this intentionally checks parsing only. -ValidationResult validateGeneratedDartCode(String dartCode) { - try { - final result = parseString(content: dartCode, throwIfDiagnostics: false); - - if (result.errors.isEmpty) { - return ValidationResult.success(); - } - - final errorMessages = result.errors.map((error) { - final location = result.lineInfo.getLocation(error.offset); - return 'Line ${location.lineNumber}: ${error.message}'; - }).toList(); - - return ValidationResult.failure( - 'Generated code contains syntax errors', - errorMessages, - ); - } catch (error) { - return ValidationResult.failure('Failed to parse generated code', [ - 'Parsing exception: $error', - ]); - } -} - -class ValidationResult { - final bool isSuccess; - final String? errorSummary; - final List errorDetails; - - const ValidationResult.success() - : isSuccess = true, - errorSummary = null, - errorDetails = const []; - - ValidationResult.failure(String summary, List details) - : isSuccess = false, - errorSummary = summary, - errorDetails = details; - - bool get isFailure => !isSuccess; - - String get errorMessage { - if (isSuccess) return ''; - - final buffer = StringBuffer(errorSummary ?? 'Validation failed'); - if (errorDetails.isNotEmpty) { - buffer.writeln(':'); - for (final detail in errorDetails) { - buffer.writeln(' • $detail'); - } - } - return buffer.toString(); - } -} diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 92d74cbb..a243419a 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -22,10 +22,10 @@ dependencies: code_builder: ^4.10.0 collection: ^1.18.0 dart_style: ^3.1.0 - json_annotation: ^4.12.0 - json_serializable: ^6.14.1 logging: ^1.3.0 meta: ^1.15.0 + package_config: ^2.1.0 + pub_semver: ^2.1.4 # Ack packages (versions are overridden locally by Melos) ack: ^1.7.0-beta.3 @@ -33,6 +33,8 @@ dependencies: dev_dependencies: build_runner: ^2.1.7 build_test: ^3.1.0 + json_annotation: ^4.12.0 + json_serializable: ^6.14.1 test: ^1.25.15 path: ^1.9.0 # Code quality diff --git a/packages/ack_generator/test/additional_properties_args_test.dart b/packages/ack_generator/test/additional_properties_args_test.dart deleted file mode 100644 index ff611086..00000000 --- a/packages/ack_generator/test/additional_properties_args_test.dart +++ /dev/null @@ -1,197 +0,0 @@ -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('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': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}).passthrough(); -''', - }, - 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': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - '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); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - '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); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/empty.dart': ''' -import 'package:ack/ack.dart'; -import 'package: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')), - ]), - ), - }, - ); - }, - ); - - test('generates correct filter for single field', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/single.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final singleFieldSchema = Ack.object({ - 'name': Ack.string(), -}).passthrough(); -''', - }, - outputs: { - 'test_pkg|lib/single.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name'"), - // Should not have && when there's only one field - isNot(contains(' && ')), - ]), - ), - }, - ); - }); - - test('generates correct filter for three fields', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/three.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final threeFieldsSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), - 'email': Ack.string(), -}).passthrough(); -''', - }, - outputs: { - 'test_pkg|lib/three.g.dart': decodedMatches( - allOf([ - contains('Map get args =>'), - contains("e.key != 'name'"), - contains("e.key != 'age'"), - contains("e.key != 'email'"), - contains(' && '), - ]), - ), - }, - ); - }); - }); -} diff --git a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart b/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart deleted file mode 100644 index 563c74fe..00000000 --- a/packages/ack_generator/test/bugs/schema_variable_bugs_test.dart +++ /dev/null @@ -1,1347 +0,0 @@ -/// Regression tests for schema variable type extraction. -/// -/// These tests verify correct behavior for previously reported issues: -/// - Issue #1: List type extraction (simple primitives) -/// - Issue #2: Nested schema references -/// - Issue #3: Method chain walker safety -/// - Issue #4: List elements with method chain modifiers -/// - Issue #5: Nested object lists with method chain modifiers -/// - Issue #6: Schema variable references with method chain modifiers -library; - -import 'package:ack_generator/src/analyzer/schema_ast_analyzer.dart'; -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:source_gen/source_gen.dart'; -import 'package:test/test.dart'; - -import '../test_utils/test_assets.dart'; - -void main() { - group('List type extraction', () { - test('extracts String from Ack.list(Ack.string())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final listSchema = Ack.object({ - 'tags': Ack.list(Ack.string()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'listSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final tagsField = modelInfo!.fields.firstWhere( - (f) => f.name == 'tags', - orElse: () => throw StateError('tags field not found'), - ); - - expect(tagsField.type.isDartCoreList, isTrue); - - final listType = tagsField.type as InterfaceType; - expect(listType.typeArguments.length, 1); - - final elementType = listType.typeArguments.first; - expect( - elementType.isDartCoreString, - isTrue, - reason: - 'Expected String, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - - test('extracts int from Ack.list(Ack.integer())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final listSchema = Ack.object({ - 'numbers': Ack.list(Ack.integer()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name == 'listSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final numbersField = modelInfo!.fields.firstWhere( - (f) => f.name == 'numbers', - ); - - final listType = numbersField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreInt, - isTrue, - reason: - 'Expected int, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - - test('handles nested lists (List>)', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final nestedListSchema = Ack.object({ - 'matrix': Ack.list(Ack.list(Ack.integer())), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name == 'nestedListSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final matrixField = modelInfo!.fields.firstWhere( - (f) => f.name == 'matrix', - ); - - final outerListType = matrixField.type as InterfaceType; - expect(outerListType.isDartCoreList, isTrue); - - final innerType = outerListType.typeArguments.first; - expect( - innerType.isDartCoreList, - isTrue, - reason: - 'Expected List, got ' - '${innerType.getDisplayString()}', - ); - - if (innerType is InterfaceType && innerType.isDartCoreList) { - final innerElementType = innerType.typeArguments.first; - expect( - innerElementType.isDartCoreInt, - isTrue, - reason: - 'Expected int, got ' - '${innerElementType.getDisplayString()}', - ); - } - }); - }); - }); - - group('Top-level list schema variables', () { - test('resolves element type from schema variable reference', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final statusSchema = Ack.string().minLength(1); - -@AckType() -final statusesSchema = Ack.list(statusSchema); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'statusesSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.representationType, equals('List')); - }); - }); - - test('throws on circular list schema variable references', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final schemaASchema = Ack.list(schemaBSchema); - -@AckType() -final schemaBSchema = Ack.list(schemaASchema); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'schemaASchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - - test('throws on top-level Ack.list(Ack.object(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final usersSchema = Ack.list(Ack.object({ - 'id': Ack.string(), -})); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'usersSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - - test( - 'throws on top-level Ack.list(schemaFactory()) when element is not statically resolvable', - () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -schemaFactory() => Ack.string(); - -@AckType() -final usersSchema = Ack.list(schemaFactory()); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'usersSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }, - ); - - test('supports prefixed Ack invocations in list schemas', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart' as ack; -import 'package:ack/annotations.dart'; - -@AckType() -final statusSchema = ack.Ack.string(); - -@AckType() -final statusesSchema = ack.Ack.list(ack.Ack.string().minLength(2)); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'statusesSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.representationType, equals('List')); - }); - }); - }); - - group('Schema alias cycles', () { - test('throws a clear circular-reference error for alias cycles', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final schemaASchema = schemaBSchema; - -@AckType() -final schemaBSchema = schemaASchema; -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'schemaASchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA( - predicate( - (error) => - error is InvalidGenerationSource && - error.toString().contains('Circular schema reference'), - ), - ), - ); - }); - }); - }); - - group('Nested schema references', () { - test('resolves schema variable reference', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final addressSchema = Ack.object({ - 'street': Ack.string(), - 'city': Ack.string(), -}); - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'address': addressSchema, -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name == 'userSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final addressField = modelInfo!.fields.firstWhere( - (f) => f.name == 'address', - orElse: () => throw StateError('address field not found'), - ); - - expect( - addressField.type.isDartCoreMap, - isTrue, - reason: 'Expected Map type for nested schema reference', - ); - }); - }); - - test('handles multiple schema references', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final addressSchema = Ack.object({'street': Ack.string()}); - -@AckType() -final phoneSchema = Ack.object({'number': Ack.string()}); - -@AckType() -final contactSchema = Ack.object({ - 'name': Ack.string(), - 'address': addressSchema, - 'phone': phoneSchema, -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name == 'contactSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect( - modelInfo!.fields.length, - 3, - reason: - 'Expected 3 fields (name, address, phone), ' - 'got ${modelInfo.fields.map((f) => f.name).join(", ")}', - ); - }); - }); - }); - - group('Method chain walker', () { - test('handles normal method chains correctly', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final chainedSchema = Ack.object({ - 'optionalNullable': Ack.string().optional().nullable(), - 'nullableOptional': Ack.string().nullable().optional(), - 'basicField': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name == 'chainedSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - // Verify optional().nullable() - final optNullField = modelInfo!.fields.firstWhere( - (f) => f.name == 'optionalNullable', - ); - expect(optNullField.isRequired, isFalse); - expect(optNullField.isNullable, isTrue); - - // Verify nullable().optional() (different order, same result) - final nullOptField = modelInfo.fields.firstWhere( - (f) => f.name == 'nullableOptional', - ); - expect(nullOptField.isRequired, isFalse); - expect(nullOptField.isNullable, isTrue); - }); - }); - - test( - 'throws a clear error when field chains exceed analyzer depth', - () async { - // Create a chain with 25 .optional() calls to test depth limits - final deepChain = List.generate(25, (_) => 'optional()').join('.'); - - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final deepSchema = Ack.object({ - 'field': Ack.string().$deepChain, -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables.firstWhere( - (e) => e.name == 'deepSchema', - ); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA( - predicate( - (error) => - error is InvalidGenerationSource && - error.toString().contains('exceeded max depth of 20'), - ), - ), - ); - }); - }, - ); - }); - - group('List elements with method chain modifiers', () { - test('extracts String from Ack.list(Ack.string().describe(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'colors': Ack.list(Ack.string().describe('A hex color value')), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final colorsField = modelInfo!.fields.firstWhere( - (f) => f.name == 'colors', - ); - final listType = colorsField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreString, - isTrue, - reason: - 'Expected String, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - - test('extracts String from Ack.list(Ack.enumString(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'styles': Ack.list(Ack.enumString(['bold', 'italic', 'underline'])), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final stylesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'styles', - ); - final listType = stylesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreString, - isTrue, - reason: - 'Expected String, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - - test('extracts int from Ack.list(Ack.integer().min(0).max(100))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'scores': Ack.list(Ack.integer().min(0).max(100)), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final scoresField = modelInfo!.fields.firstWhere( - (f) => f.name == 'scores', - ); - final listType = scoresField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreInt, - isTrue, - reason: - 'Expected int, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - - test( - 'throws on Ack.list(schemaFactory()) when element is not statically resolvable', - () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -schemaFactory() => Ack.string(); - -@AckType() -final testSchema = Ack.object({ - 'items': Ack.list(schemaFactory()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }, - ); - - test( - 'throws when list element method chain exceeds analyzer depth', - () async { - final deepChain = - 'Ack.string()${List.filled(24, ".describe('x')").join()}'; - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'items': Ack.list($deepChain), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }, - ); - }); - - group('Nested object lists with method chain modifiers', () { - test('throws on Ack.list(Ack.object({...}).describe(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'items': Ack.list(Ack.object({ - 'name': Ack.string(), - }).describe('An item')), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - - test('throws on Ack.list(Ack.object({...}).optional())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final testSchema = Ack.object({ - 'records': Ack.list(Ack.object({ - 'id': Ack.integer(), - }).optional()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'testSchema'); - - final analyzer = SchemaAstAnalyzer(); - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - ); - }); - }); - }); - - group('Schema variable references with method chain modifiers', () { - test('extracts Map from Ack.list(schemaRef.optional())', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final itemSchema = Ack.object({ - 'name': Ack.string(), -}); - -@AckType() -final containerSchema = Ack.object({ - 'items': Ack.list(itemSchema.optional()), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'containerSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final itemsField = modelInfo!.fields.firstWhere( - (f) => f.name == 'items', - ); - final listType = itemsField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreMap, - isTrue, - reason: - 'Expected Map, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - - test('extracts Map from Ack.list(schemaRef.describe(...))', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final addressSchema = Ack.object({ - 'street': Ack.string(), -}); - -@AckType() -final userSchema = Ack.object({ - 'addresses': Ack.list(addressSchema.describe('User address')), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'userSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - final addressesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'addresses', - ); - final listType = addressesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - - expect( - elementType.isDartCoreMap, - isTrue, - reason: - 'Expected Map, got ' - '${elementType.getDisplayString()}', - ); - }); - }); - }); - - group('Field name keyword validation', () { - const allowedKeywords = [ - 'of', - 'augment', - 'abstract', - 'covariant', - 'show', - 'hide', - 'on', - ]; - const reservedKeywords = ['class', 'if', 'return', 'void']; - - test('allows built-in and pseudo keywords as object field names', () async { - final properties = allowedKeywords - .map((keyword) => " '$keyword': Ack.string(),") - .join('\n'); - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final keywordSchema = Ack.object({ -$properties -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'keywordSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect( - modelInfo!.fields.map((f) => f.name), - containsAll(allowedKeywords), - ); - }); - }); - - test('rejects reserved keywords as object field names', () async { - for (final keyword in reservedKeywords) { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final keywordSchema = Ack.object({ - '$keyword': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'keywordSchema'); - - final analyzer = SchemaAstAnalyzer(); - - expect( - () => analyzer.analyzeSchemaVariable(schemaVar), - throwsA(isA()), - reason: 'Expected "$keyword" to be rejected as reserved keyword.', - ); - }); - } - }); - }); - - group('Enum, literal, and enumValues as fields inside Ack.object()', () { - test('handles Ack.enumString() as field in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final reviewSchema = Ack.object({ - 'file': Ack.string(), - 'severity': Ack.enumString(['error', 'warning', 'info']), - 'message': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'reviewSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.fields.length, 3); - - final severityField = modelInfo.fields.firstWhere( - (f) => f.name == 'severity', - ); - - expect(severityField.type.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.enumString() with optional/nullable modifiers', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final formSchema = Ack.object({ - 'priority': Ack.enumString(['low', 'medium', 'high']).optional().nullable(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'formSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final priorityField = modelInfo!.fields.firstWhere( - (f) => f.name == 'priority', - ); - - expect(priorityField.type.isDartCoreString, isTrue); - expect(priorityField.isRequired, isFalse); - expect(priorityField.isNullable, isTrue); - }); - }); - - test('handles Ack.literal() as field in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final eventSchema = Ack.object({ - 'type': Ack.literal('click'), - 'target': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'eventSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final typeField = modelInfo!.fields.firstWhere((f) => f.name == 'type'); - - expect(typeField.type.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.enumValues() as field in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'role': Ack.enumValues(UserRole.values), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'userSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - expect(modelInfo!.fields.length, 2); - - final roleField = modelInfo.fields.firstWhere((f) => f.name == 'role'); - - expect(roleField.type.element, isNotNull); - expect(roleField.type.getDisplayString(), equals('UserRole')); - }); - }); - - test( - 'handles Ack.enumValues() with optional modifier in Ack.object()', - () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum Priority { low, medium, high, critical } - -@AckType() -final taskSchema = Ack.object({ - 'title': Ack.string(), - 'priority': Ack.enumValues(Priority.values).optional(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'taskSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final priorityField = modelInfo!.fields.firstWhere( - (f) => f.name == 'priority', - ); - - expect(priorityField.isRequired, isFalse); - expect(priorityField.type.getDisplayString(), equals('Priority')); - }); - }, - ); - - test('handles Ack.list(Ack.enumString()) in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final configSchema = Ack.object({ - 'tags': Ack.list(Ack.enumString(['a', 'b', 'c'])), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'configSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final tagsField = modelInfo!.fields.firstWhere((f) => f.name == 'tags'); - - expect(tagsField.type.isDartCoreList, isTrue); - - final listType = tagsField.type as InterfaceType; - final elementType = listType.typeArguments.first; - expect(elementType.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.list(Ack.enumValues()) in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final teamSchema = Ack.object({ - 'name': Ack.string(), - 'roles': Ack.list(Ack.enumValues(UserRole.values)), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'teamSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final rolesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'roles', - ); - - expect(rolesField.type.isDartCoreList, isTrue); - - final listType = rolesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - expect(elementType.getDisplayString(), equals('UserRole')); - }); - }); - - test('handles Ack.list(Ack.literal()) in Ack.object()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final actionSchema = Ack.object({ - 'types': Ack.list(Ack.literal('click')), - 'name': Ack.string(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'actionSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final typesField = modelInfo!.fields.firstWhere( - (f) => f.name == 'types', - ); - - expect(typesField.type.isDartCoreList, isTrue); - - final listType = typesField.type as InterfaceType; - final elementType = listType.typeArguments.first; - expect(elementType.isDartCoreString, isTrue); - }); - }); - - test('handles Ack.enumValues().nullable() without optional()', () async { - final assets = { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final profileSchema = Ack.object({ - 'name': Ack.string(), - 'role': Ack.enumValues(UserRole.values).nullable(), -}); -''', - }; - - await resolveSources(assets, (resolver) async { - final library = await resolver.libraryFor( - AssetId('test_pkg', 'lib/schema.dart'), - ); - final schemaVar = library.topLevelVariables - .whereType() - .firstWhere((e) => e.name == 'profileSchema'); - - final analyzer = SchemaAstAnalyzer(); - final modelInfo = analyzer.analyzeSchemaVariable(schemaVar); - - expect(modelInfo, isNotNull); - - final roleField = modelInfo!.fields.firstWhere((f) => f.name == 'role'); - - expect(roleField.type.getDisplayString(), equals('UserRole')); - expect(roleField.isNullable, isTrue); - expect( - roleField.isRequired, - isTrue, - reason: 'nullable() alone should not make the field optional', - ); - }); - }); - }); -} diff --git a/packages/ack_generator/test/code_validation_test.dart b/packages/ack_generator/test/code_validation_test.dart deleted file mode 100644 index b85750da..00000000 --- a/packages/ack_generator/test/code_validation_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:ack_generator/src/validation/code_validator.dart'; -import 'package:test/test.dart'; - -void main() { - group('validateGeneratedDartCode', () { - test('should catch syntax errors', () { - const invalidSyntax = ''' - class Test { - void method() { - // Missing closing brace - } - '''; - - final result = validateGeneratedDartCode(invalidSyntax); - expect(result.isFailure, isTrue); - expect(result.errorMessage, contains('syntax')); - }); - - test('should pass valid syntax even with undefined identifiers', () { - const validSyntaxInvalidSemantic = ''' - class Test extends UndefinedClass { - UndefinedType method() { - return undefinedFunction(); - } - } - '''; - - final result = validateGeneratedDartCode(validSyntaxInvalidSemantic); - expect(result.isSuccess, isTrue); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_infer_contract_test.dart b/packages/ack_generator/test/integration/ack_infer_contract_test.dart index 6a040a39..0f5bdab6 100644 --- a/packages/ack_generator/test/integration/ack_infer_contract_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_contract_test.dart @@ -26,8 +26,7 @@ const _imports = ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; +part 'schema.g.dart'; '''; const _schemaShorthand = r'static final schema = $ack.modelSchema;'; @@ -61,10 +60,10 @@ final userSchema = Ack.object({ }); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ contains('final class User'), - contains('@Schemable.generatedJson'), + isNot(contains('generatedJson')), contains('factory User.parse(Object? input)'), contains('factory User.fromJson(Map json)'), contains(r'static final $ack = AckModelAdapter'), @@ -80,14 +79,18 @@ final userSchema = Ack.object({ contains('this.nickname'), contains('required this.middleName'), contains('required this.role'), - contains(r'_$UserFromJson'), - contains(r'_$UserToJson'), - contains('_ackFromRuntimeName'), - contains('_ackToRuntimeName'), - contains('if (middleName == null)'), - contains("result['middleName'] = null"), - isNot(contains("if (nickname != null) 'nickname': nickname")), - isNot(contains("value['name']")), + contains('_fromAckRuntime'), + contains('_toAckRuntime'), + contains("value['name'] as String"), + contains("value['nickname'] as String?"), + contains("value['middleName'] as String?"), + contains("value['role'] as String"), + contains("'nickname': ?nickname"), + contains("'middleName': middleName"), + isNot(contains(r'_$UserFromJson')), + isNot(contains(r'_$UserToJson')), + isNot(contains('_ackFromRuntimeName')), + isNot(contains('_ackToRuntimeName')), ]), ), }, @@ -103,17 +106,19 @@ $_imports final occurredAtSchema = Ack.datetime(); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ contains('final DateTime value;'), - contains('@Schemable.generatedJson'), + isNot(contains('generatedJson')), contains('factory OccurredAt.fromJson(String json)'), contains('String toJson()'), contains('SchemaResult safeToJson()'), contains( - r"_$OccurredAtFromJson({'value': value})", + 'static OccurredAt _fromAckRuntime(DateTime value) => OccurredAt(value);', ), - contains(r"_$OccurredAtToJson(this)['value'] as DateTime"), + contains('DateTime _toAckRuntime() => value;'), + isNot(contains(r'_$OccurredAtFromJson')), + isNot(contains(r'_$OccurredAtToJson')), ]), ), }, @@ -131,7 +136,7 @@ final memberTypeSchema = Ack.string(); final customSchema = Ack.string(); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ contains('final class MemberType'), contains('final class IntentionalType'), @@ -162,7 +167,7 @@ final petSchema = Ack.discriminated( ); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ _schemaShorthandOn('User', present: true), _schemaShorthandOn('OccurredAt', present: true), @@ -207,7 +212,7 @@ final shapeSchema = Ack.discriminated( ); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ // A field named `schema`. _schemaShorthandOn('Document', present: false), @@ -232,14 +237,13 @@ final shapeSchema = Ack.discriminated( import 'package:ack/ack.dart' as schema; import 'package:ack/annotations.dart'; -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; +part 'schema.g.dart'; @Schemable() final userSchema = schema.Ack.object({'name': schema.Ack.string()}); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ contains('schema.AckModelAdapter'), _schemaShorthandOn('User', present: false), diff --git a/packages/ack_generator/test/integration/ack_infer_data_class_test.dart b/packages/ack_generator/test/integration/ack_infer_data_class_test.dart index 7a38693f..3566e9a5 100644 --- a/packages/ack_generator/test/integration/ack_infer_data_class_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_data_class_test.dart @@ -43,8 +43,7 @@ const _imports = ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; '''; void main() { @@ -63,7 +62,7 @@ final class Point with _\$PointAck { } ''', outputs: { - 'test_pkg|lib/model.ack.dart': decodedMatches( + 'test_pkg|lib/model.g.dart': decodedMatches( allOf([ contains(r'mixin _$PointAck'), contains(r'$PointCopyWith get copyWith'), @@ -106,7 +105,7 @@ final class Bag with _\$BagAck { } ''', outputs: { - 'test_pkg|lib/model.ack.dart': decodedMatches( + 'test_pkg|lib/model.g.dart': decodedMatches( allOf([ contains('final _strictObject = Ack.object({'), isNot( @@ -115,11 +114,10 @@ final class Bag with _\$BagAck { ), ), contains('additionalProperties: true'), - contains("'args': Map.fromEntries"), - contains('result.remove(\'args\')'), + contains('args: deepUnmodifiableJsonMap('), + contains('Map.fromEntries('), contains('model.args.entries'), contains('self.args'), - contains('deepUnmodifiableJsonMap(value as Map)'), isNot(contains('_ackClassImmutableCopyValue')), isNot(contains('_ackClassImmutableCopyMap')), ]), @@ -154,7 +152,7 @@ $_imports @Schemable() final class User with _\$UserAck { const User({this.nickname}); - @AckField(presence: AckFieldPresence.required) + @Required() final String? nickname; } @@ -170,7 +168,7 @@ final class Cat extends Pet with _\$CatAck { } ''', outputs: { - 'test_pkg|lib/model.ack.dart': decodedMatches( + 'test_pkg|lib/model.g.dart': decodedMatches( allOf([ contains("'nickname': Ack.string().nullable()"), isNot(contains("'nickname': Ack.string().nullable().optional()")), @@ -194,12 +192,12 @@ $_imports final class Normalized with _\$NormalizedAck { const Normalized(String? value) : value = value ?? ''; - @AckField(presence: AckFieldPresence.optional) + @Optional() final String value; } ''', outputs: { - 'test_pkg|lib/model.ack.dart': decodedMatches( + 'test_pkg|lib/model.g.dart': decodedMatches( allOf([ contains("'value': Ack.string().optional()"), contains('Normalized call({Object? value = _ackCopyWithUnset})'), @@ -226,7 +224,7 @@ final userSchema = Ack.object({ }); ''', outputs: { - 'test_pkg|lib/model.ack.dart': decodedMatches( + 'test_pkg|lib/model.g.dart': decodedMatches( allOf([ contains('// ignore_for_file: type=lint'), contains('// coverage:ignore-file'), diff --git a/packages/ack_generator/test/integration/ack_infer_graph_test.dart b/packages/ack_generator/test/integration/ack_infer_graph_test.dart index 5e62973d..be090a2a 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -13,7 +13,7 @@ Future _expectOutput(String source, Matcher matcher) async { {'test_pkg|lib/schema.dart': source}, generateFor: const {'test_pkg|lib/schema.dart'}, readerWriter: readerWriter, - outputs: {'test_pkg|lib/schema.ack.dart': decodedMatches(matcher)}, + outputs: {'test_pkg|lib/schema.g.dart': decodedMatches(matcher)}, ); } @@ -41,8 +41,7 @@ const _head = ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; +part 'schema.g.dart'; '''; void main() { @@ -57,8 +56,7 @@ void main() { import 'package:ack/ack.dart'; import 'money_schema.dart'; -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; +part 'schema.g.dart'; @Schemable() final orderSchema = Ack.object({'price': moneySchema}); diff --git a/packages/ack_generator/test/integration/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index c59a53df..179c460c 100644 --- a/packages/ack_generator/test/integration/ack_infer_models_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_models_test.dart @@ -39,20 +39,19 @@ void main() { 'empty.dart': ''' $_imports -part 'empty.ack.dart'; -part 'empty.ack.g.dart'; +part 'empty.g.dart'; @Schemable() final emptySchema = Ack.object({}); ''', }, outputs: { - 'test_pkg|lib/empty.ack.dart': decodedMatches( + 'test_pkg|lib/empty.g.dart': decodedMatches( allOf([ contains('Empty()'), - contains('@Schemable.generatedJson'), - contains(r'_$EmptyFromJson'), - contains(r'_$EmptyToJson'), + isNot(contains('generatedJson')), + contains('static Empty _fromAckRuntime'), + contains('Map _toAckRuntime()'), isNot(contains('\n ,')), ]), ), @@ -67,8 +66,7 @@ final emptySchema = Ack.object({}); 'user.dart': ''' $_imports -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; const allowExtras = true; @@ -80,7 +78,7 @@ final userSchema = Ack.object( ''', }, outputs: { - 'test_pkg|lib/user.ack.dart': decodedMatches( + 'test_pkg|lib/user.g.dart': decodedMatches( allOf([ contains('final Map additionalProperties'), contains('additionalProperties.entries'), @@ -102,8 +100,7 @@ final userSchema = Ack.object( 'user.dart': ''' $_imports -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -113,7 +110,7 @@ final userSchema = Ack.object({ ''', }, outputs: { - 'test_pkg|lib/user.ack.dart': decodedMatches( + 'test_pkg|lib/user.g.dart': decodedMatches( allOf([ contains('final class _UserCopyWithUnset'), contains('const _UserCopyWithUnset()'), @@ -136,8 +133,7 @@ final userSchema = Ack.object({ 'values.dart': ''' $_imports -part 'values.ack.dart'; -part 'values.ack.g.dart'; +part 'values.g.dart'; enum Role { admin, member } @@ -151,7 +147,7 @@ final metricsSchema = Ack.object({ ''', }, outputs: { - 'test_pkg|lib/values.ack.dart': decodedMatches( + 'test_pkg|lib/values.g.dart': decodedMatches( allOf([ contains('required this.amount'), contains('required this.state'), @@ -171,8 +167,7 @@ final metricsSchema = Ack.object({ 'address.dart': ''' $_imports -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); @@ -183,8 +178,7 @@ final addressSchema = Ack.object({'city': Ack.string()}); $_imports import 'address.dart' as direct; import 'exports.dart' as exported; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({ @@ -194,15 +188,14 @@ final personSchema = Ack.object({ ''', }, outputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches( + 'test_pkg|lib/address.g.dart': decodedMatches( contains('final class Address'), ), - 'test_pkg|lib/person.ack.dart': decodedMatches( + 'test_pkg|lib/person.g.dart': decodedMatches( allOf([ contains('required this.home'), contains('required List history'), - contains(r'_$PersonFromJson'), - contains('_ackFromRuntimeHome'), + contains('static Person _fromAckRuntime'), contains(r'direct.Address.$ack.fromRuntime'), contains(r'exported.Address.$ack.toRuntime'), ]), @@ -220,8 +213,7 @@ final personSchema = Ack.object({ ''' $_imports import 'types.dart' as types; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -230,7 +222,7 @@ final userSchema = Ack.object({ ''', }, outputs: { - 'test_pkg|lib/user.ack.dart': decodedMatches( + 'test_pkg|lib/user.g.dart': decodedMatches( contains('final types.Role role;'), ), }, @@ -243,8 +235,7 @@ final userSchema = Ack.object({ 'address.dart': ''' $_imports -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); @@ -254,18 +245,17 @@ final addressSchema = Ack.object({'city': Ack.string()}); $_imports import 'address.dart' show addressSchema; import 'address.dart' show Address; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({'address': addressSchema}); ''', }, outputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches( + 'test_pkg|lib/address.g.dart': decodedMatches( contains('final class Address'), ), - 'test_pkg|lib/person.ack.dart': decodedMatches( + 'test_pkg|lib/person.g.dart': decodedMatches( allOf([ contains('final Address address'), contains(r'Address.$ack.fromRuntime'), @@ -283,8 +273,7 @@ final personSchema = Ack.object({'address': addressSchema}); 'address.dart': ''' $_imports -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -298,18 +287,17 @@ final class Address with _\$AddressAck { $_imports import 'address.dart' show Address; import 'address.dart' show AddressSchema; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({'address': AddressSchema.schema}); ''', }, outputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches( + 'test_pkg|lib/address.g.dart': decodedMatches( contains('abstract final class AddressSchema'), ), - 'test_pkg|lib/person.ack.dart': decodedMatches( + 'test_pkg|lib/person.g.dart': decodedMatches( contains('final Address address'), ), }, @@ -324,8 +312,7 @@ final personSchema = Ack.object({'address': AddressSchema.schema}); 'address.dart': ''' $_imports -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -339,14 +326,13 @@ final class Address with _\$AddressAck { ''' $_imports import 'exports.dart'; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({'address': AddressSchema.schema}); ''', }, - outputs: {'test_pkg|lib/address.ack.dart': anything}, + outputs: {'test_pkg|lib/address.g.dart': anything}, onLog: (log) { if (log.level.name != 'SEVERE') return; for (final message in ['AddressSchema', 'export combinator']) { @@ -366,8 +352,7 @@ final personSchema = Ack.object({'address': AddressSchema.schema}); 'address.dart': ''' $_imports -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); @@ -376,14 +361,13 @@ final addressSchema = Ack.object({'city': Ack.string()}); ''' $_imports import 'address.dart' show addressSchema; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({'address': addressSchema}); ''', }, - outputs: {'test_pkg|lib/address.ack.dart': anything}, + outputs: {'test_pkg|lib/address.g.dart': anything}, onLog: (log) { if (log.level.name != 'SEVERE') return; for (final message in ['Address', 'import combinator']) { @@ -402,8 +386,7 @@ final personSchema = Ack.object({'address': addressSchema}); 'address.dart': ''' $_imports -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); @@ -413,14 +396,13 @@ final addressSchema = Ack.object({'city': Ack.string()}); ''' $_imports import 'exports.dart'; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({'address': addressSchema}); ''', }, - outputs: {'test_pkg|lib/address.ack.dart': anything}, + outputs: {'test_pkg|lib/address.g.dart': anything}, onLog: (log) { if (log.level.name != 'SEVERE') return; for (final message in ['Address', 'export combinator']) { @@ -437,8 +419,7 @@ final personSchema = Ack.object({'address': addressSchema}); 'pet.dart': ''' $_imports -part 'pet.ack.dart'; -part 'pet.ack.g.dart'; +part 'pet.g.dart'; @Schemable() final catSchema = Ack.object({'kind': Ack.literal('cat'), 'lives': Ack.integer()}); @@ -454,18 +435,17 @@ final petSchema = Ack.discriminated( ''', }, outputs: { - 'test_pkg|lib/pet.ack.dart': decodedMatches( + 'test_pkg|lib/pet.g.dart': decodedMatches( allOf([ contains('sealed class Pet'), contains('final class Cat extends Pet'), contains('final class Dog extends Pet'), - isNot(contains('@Schemable.generatedJson\nsealed class Pet')), - contains('@Schemable.generatedJson\nfinal class Cat extends Pet'), + isNot(contains('generatedJson')), contains("String get kind => 'cat';"), contains("'kind': 'dog'"), contains('additionalProperties.entries'), - contains(r'_$CatFromJson'), - contains(r'_$DogToJson'), + contains('static Cat _fromAckRuntime'), + contains('static Dog _fromAckRuntime'), ]), ), }, @@ -479,8 +459,7 @@ final petSchema = Ack.discriminated( 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; @Schemable() final badSchema = Ack.object({'toJson': Ack.string()}); @@ -503,8 +482,7 @@ final badSchema = Ack.object({'toJson': Ack.string()}); 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; @Schemable() final userSchema = Ack.object({'name': Ack.string()}); @@ -531,8 +509,7 @@ final class \$UserCopyWith {} 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; @Schemable() final badSchema = Ack.object({'class': Ack.string()}); @@ -555,8 +532,7 @@ final badSchema = Ack.object({'class': Ack.string()}); 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; @Schemable() final catSchema = Ack.object({'lives': Ack.integer()}); @@ -584,8 +560,7 @@ final petSchema = Ack.discriminated( 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; @Schemable() final catSchema = Ack.object({ @@ -614,8 +589,7 @@ final petSchema = Ack.discriminated( 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; Object? _ackImmutableCopyValue(Object? value) => value; @@ -624,7 +598,7 @@ final bagSchema = Ack.object({}).passthrough(); ''', }, outputs: { - 'test_pkg|lib/bad.ack.dart': decodedMatches( + 'test_pkg|lib/bad.g.dart': decodedMatches( allOf([ contains('deepUnmodifiableJsonMap(additionalProperties)'), isNot(contains('Object? _ackImmutableCopyValue')), @@ -634,127 +608,6 @@ final bagSchema = Ack.object({}).passthrough(); ); }); - test('preserves a prefixed Schemable qualifier on the JSON marker', () async { - await _build( - { - 'schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart' as annotations; - -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; - -@annotations.Schemable() -final userSchema = Ack.object({'name': Ack.string()}); -''', - }, - outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( - contains('@annotations.Schemable.generatedJson'), - ), - }, - ); - }); - - test('preserves a direct Schemable qualifier on the JSON marker', () async { - await _build( - { - 'schema.dart': - ''' -$_imports -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; - -@Schemable() -final userSchema = Ack.object({'name': Ack.string()}); -''', - }, - outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( - contains('@Schemable.generatedJson'), - ), - }, - ); - }); - - test( - 'preserves an unprefixed barrel Schemable qualifier on the JSON marker', - () async { - await _build( - { - 'annotations.dart': "export 'package:ack/annotations.dart';", - 'schema.dart': ''' -import 'package:ack/ack.dart'; -import 'annotations.dart'; - -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; - -@Schemable() -final userSchema = Ack.object({'name': Ack.string()}); -''', - }, - outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( - contains('@Schemable.generatedJson'), - ), - }, - ); - }, - ); - - test( - 'prefers a prefixed Schemable qualifier when both imports are visible', - () async { - await _build( - { - 'schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'package:ack/annotations.dart' as annotations; - -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; - -@Schemable() -final userSchema = Ack.object({'name': Ack.string()}); -''', - }, - outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( - contains('@annotations.Schemable.generatedJson'), - ), - }, - ); - }, - ); - - test( - 'preserves a prefixed barrel Schemable qualifier on the JSON marker', - () async { - await _build( - { - 'annotations.dart': "export 'package:ack/annotations.dart';", - 'schema.dart': ''' -import 'package:ack/ack.dart'; -import 'annotations.dart' as annotations show Schemable; - -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; - -@annotations.Schemable() -final userSchema = Ack.object({'name': Ack.string()}); -''', - }, - outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( - contains('@annotations.Schemable.generatedJson'), - ), - }, - ); - }, - ); - test('preserves Ack runtime qualifiers through prefixed barrels', () async { await _build( { @@ -765,15 +618,14 @@ export 'package:ack/annotations.dart'; 'schema.dart': ''' import 'support.dart' as support; -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; +part 'schema.g.dart'; @support.Schemable() final userSchema = support.Ack.object({'name': support.Ack.string()}); ''', }, outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ contains('support.AckModelAdapter'), contains('support.SchemaResult'), @@ -790,8 +642,7 @@ final userSchema = support.Ack.object({'name': support.Ack.string()}); 'bad.dart': ''' $_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; +part 'bad.g.dart'; @Schemable() final valuesSchema = Ack.string().codec>( @@ -808,56 +659,6 @@ final valuesSchema = Ack.string().codec>( expect(messages.single, contains('Map')); }); - test('rejects field names that collide after bridge derivation', () async { - final messages = {}; - await _build( - { - 'bad.dart': - ''' -$_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; - -@Schemable() -final badSchema = Ack.object({ - 'name': Ack.string(), - 'Name': Ack.string(), -}); -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') messages.add(log.message); - }, - ); - expect(messages.single, contains('badSchema.Name')); - expect(messages.single, contains('_ackFromRuntimeName')); - }); - - test('rejects top-level JSON helper name collisions', () async { - final messages = {}; - await _build( - { - 'bad.dart': - ''' -$_imports -part 'bad.ack.dart'; -part 'bad.ack.g.dart'; - -void _\$UserFromJson() {} - -@Schemable() -final userSchema = Ack.object({'name': Ack.string()}); -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') messages.add(log.message); - }, - ); - expect(messages.single, contains(r'_$UserFromJson')); - }); - test( 'rejects an unsupported cross-library unannotated schema variable', () async { @@ -873,8 +674,7 @@ final payloadUnion = Ack.anyOf([Ack.string(), Ack.integer()]); ''' $_imports import 'other.dart'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -907,8 +707,7 @@ final payloadAny = Ack.any(); ''' $_imports import 'other.dart'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -917,7 +716,7 @@ final userSchema = Ack.object({ ''', }, outputs: { - 'test_pkg|lib/user.ack.dart': decodedMatches( + 'test_pkg|lib/user.g.dart': decodedMatches( contains('final Object? payload;'), ), }, @@ -932,8 +731,7 @@ final userSchema = Ack.object({ 'user.dart': ''' $_imports -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({'name': Ack.string()}); @@ -942,15 +740,14 @@ final userSchema = Ack.object({'name': Ack.string()}); ''' $_imports import 'user.dart' as other; -part 'admin.ack.dart'; -part 'admin.ack.g.dart'; +part 'admin.g.dart'; @Schemable() final adminSchema = other.userSchema; ''', }, outputs: { - 'test_pkg|lib/user.ack.dart': decodedMatches( + 'test_pkg|lib/user.g.dart': decodedMatches( contains('final class User'), ), }, @@ -970,8 +767,7 @@ final adminSchema = other.userSchema; 'cat.dart': ''' $_imports -part 'cat.ack.dart'; -part 'cat.ack.g.dart'; +part 'cat.g.dart'; @Schemable() final catSchema = Ack.object({ @@ -983,8 +779,7 @@ final catSchema = Ack.object({ ''' $_imports import 'cat.dart'; -part 'pet.ack.dart'; -part 'pet.ack.g.dart'; +part 'pet.g.dart'; @Schemable() final petSchema = Ack.discriminated( @@ -994,7 +789,7 @@ final petSchema = Ack.discriminated( ''', }, outputs: { - 'test_pkg|lib/cat.ack.dart': decodedMatches( + 'test_pkg|lib/cat.g.dart': decodedMatches( contains('final class Cat'), ), }, @@ -1006,4 +801,29 @@ final petSchema = Ack.discriminated( expect(messages.join('\n'), contains('petSchema.cat')); expect(messages.join('\n'), contains('cross-library')); }); + + test('emits multi-line descriptions as one doc line each', () async { + await _build( + { + 'doc.dart': + ''' +$_imports +part 'doc.g.dart'; + +@Schemable() +final docSchema = Ack.object({ + 'note': Ack.string().describe('First line.\\nSecond line.'), +}).describe('Model line one.\\r\\nModel line two.'); +''', + }, + outputs: { + 'test_pkg|lib/doc.g.dart': decodedMatches( + allOf( + contains('/// Model line one.\n/// Model line two.\nfinal class Doc'), + contains('/// First line.\n /// Second line.\n final String note;'), + ), + ), + }, + ); + }); } diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index afc06914..7003976e 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -107,8 +107,7 @@ linter: r''' import 'package:ack/ack.dart'; -part 'models.ack.dart'; -part 'models.ack.g.dart'; +part 'models.g.dart'; final class Box { const Box(this.values); @@ -265,8 +264,7 @@ final countedSchema = Ack.object({ r''' import 'package:ack/ack.dart'; -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); @@ -282,8 +280,7 @@ import 'package:ack/ack.dart'; import 'address.dart' as direct; import 'exports.dart' as exported; -part 'person.ack.dart'; -part 'person.ack.g.dart'; +part 'person.g.dart'; @Schemable() final personSchema = Ack.object({ @@ -600,27 +597,19 @@ void main() { in temporary .listSync(recursive: true) .whereType() - .where( - (file) => - file.path.endsWith('.ack.dart') || - file.path.endsWith('.ack.g.dart'), - )) + .where((file) => file.path.endsWith('.g.dart'))) p.relative(file.path, from: temporary.path): file .readAsStringSync(), }; + expect(generated.keys, contains('lib/models.g.dart')); expect( - generated.keys, - containsAll(['lib/models.ack.dart', 'lib/models.ack.g.dart']), + generated['lib/models.g.dart'], + contains('static Extras _fromAckRuntime'), ); expect( - generated['lib/models.ack.g.dart'], - contains(r'_$ExtrasFromJson'), + generated['lib/models.g.dart'], + contains('Map _toAckRuntime()'), ); - expect( - generated['lib/models.ack.g.dart'], - contains('Extras._ackFromRuntimeName'), - ); - expect(generated['lib/models.ack.dart'], contains(r'_$ExtrasFromJson')); _expectSuccess( await _run(temporary, ['analyze', '--fatal-infos']), @@ -642,11 +631,7 @@ Extras misuse(Extras extras) => extras.copyWith(nickname: const Object()); in temporary .listSync(recursive: true) .whereType() - .where( - (file) => - file.path.endsWith('.ack.dart') || - file.path.endsWith('.ack.g.dart'), - )) + .where((file) => file.path.endsWith('.g.dart'))) p.relative(file.path, from: temporary.path): file .readAsStringSync(), }; 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 deleted file mode 100644 index 97a14225..00000000 --- a/packages/ack_generator/test/integration/ack_type_cross_file_resolution_test.dart +++ /dev/null @@ -1,930 +0,0 @@ -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/generation_test_utils.dart'; -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); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), - 'title': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema, - '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)'), - ]), - ), - }, - ); - }, - ); - - test('resolves typed nested getters for prefixed schema imports', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart' as deck; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': deck.slideSchema, - 'slides': Ack.list(deck.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('deck.SlideType get currentSlide'), - contains('List get slides'), - ]), - ), - }, - ); - }); - - test('resolves typed nested getters through re-exported schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_schema_exports.dart': ''' -export 'deck_schemas.dart'; -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schema_exports.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema, - '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('SlideType get currentSlide'), - contains('List get slides'), - ]), - ), - }, - ); - }); - - test('resolves transformed schema refs across files', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('extension type ThemeType(Map _data)'), - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - ]), - ), - }, - ); - }); - - test( - 'resolves transformed schema refs through re-exported schemas', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/palette_schema_exports.dart': ''' -export 'palette_schemas.dart'; -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schema_exports.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains( - 'extension type ThemeType(Map _data)', - ), - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - ]), - ), - }, - ); - }, - ); - - test('resolves prefixed transformed schema refs across files', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.colorSchema, - 'colors': Ack.list(palette.colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.ColorType get accent'), - contains("palette.ColorType(_data['accent'] as palette.Color)"), - contains('List get colors'), - contains('palette.ColorType(e as palette.Color)'), - ]), - ), - }, - ); - }); - - test( - 'resolves prefixed transformed refs without @AckType using visible representation types', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; - -class Color { - final String value; - const Color(this.value); -} - -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.colorSchema, - 'colors': Ack.list(palette.colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.Color get accent'), - contains("_data['accent'] as palette.Color"), - contains('List get colors'), - contains("_\$ackListCast(_data['colors'])"), - ]), - ), - }, - ); - }, - ); - - test( - 'fails for direct-import transformed refs when representation types are not visible', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is not visible from this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/hidden_types.dart': ''' -class HiddenColor { - final String value; - const HiddenColor(this.value); -} -''', - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -import 'hidden_types.dart'; - -@AckType() -final hiddenColorSchema = Ack.string() - .transform((value) => HiddenColor(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': hiddenColorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'resolves prefixed transformed generic refs when representation types are exported', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -class Box { - final T value; - const Box(this.value); -} - -@AckType() -final boxedColorSchema = - Ack.string().transform>((value) => Box(Color(value))); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart' as palette; - -class Color { - final String localValue; - const Color(this.localValue); -} - -class Box { - final T localValue; - const Box(this.localValue); -} - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.boxedColorSchema, - 'colors': Ack.list(palette.boxedColorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type BoxedColorType(Box _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.BoxedColorType get accent'), - contains( - "palette.BoxedColorType(_data['accent'] as palette.Box)", - ), - contains('List get colors'), - contains( - 'palette.BoxedColorType(e as palette.Box)', - ), - ]), - ), - }, - ); - }, - ); - - test( - 'fails for direct-import transformed refs when representation types collide locally', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is ambiguous in this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart'; - -class Color { - final String localValue; - const Color(this.localValue); -} - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'fails for direct-import transformed refs when multiple imports expose the representation type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is ambiguous in this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/alt_color_types.dart': ''' -class Color { - final String localValue; - const Color(this.localValue); -} -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'alt_color_types.dart'; -import 'palette_schemas.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'fails for prefixed transformed refs when representation types are not visible', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'is not visible from this library', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/hidden_types.dart': ''' -class HiddenColor { - final String value; - const HiddenColor(this.value); -} -''', - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -import 'hidden_types.dart'; - -@AckType() -final hiddenColorSchema = Ack.string() - .transform((value) => HiddenColor(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.hiddenColorSchema, -}); -''', - }, - ); - }, - ); - - test( - 'fails for cross-file transformed refs that use qualified representation types', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: - 'uses a qualified type that cannot be referenced across library boundaries', - expectedOutputs: {'test_pkg|lib/palette_schemas.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/hidden_types.dart': ''' -class HiddenColor { - final String value; - const HiddenColor(this.value); -} -''', - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -import 'hidden_types.dart' as dep; - -@AckType() -final hiddenColorSchema = Ack.string() - .transform((value) => dep.HiddenColor(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schemas.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'accent': palette.hiddenColorSchema, -}); -''', - }, - ); - }, - ); - - test('supports @AckType alias schema declarations', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final sharedSlideSchema = slideSchema; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': sharedSlideSchema, -}); -''', - }, - 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 SharedSlideType'), - contains('SharedSlideType get currentSlide'), - ]), - ), - }, - ); - }); - - test( - 'resolves typed nested getters for schema references with optional/nullable modifiers', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema.optional(), - 'selectedSlide': slideSchema.nullable(), -}); -''', - }, - 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('SlideType? get currentSlide'), - contains( - "SlideType? get currentSlide => _data['currentSlide'] != null", - ), - contains( - "? SlideType(_data['currentSlide'] as Map)", - ), - contains('SlideType? get selectedSlide'), - contains( - "SlideType? get selectedSlide => _data['selectedSlide'] != null", - ), - contains( - "? SlideType(_data['selectedSlide'] as Map)", - ), - ]), - ), - }, - ); - }, - ); - - test('fails when nested object schema reference is unresolved', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; - -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart' as deck; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': deck.missingSlideSchema, -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'Could not resolve schema reference "missingSlideSchema"' - throwsA(isA()), - ); - }); - - test( - 'fails when prefixed schema reference does not exist in that prefix namespace', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/a_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final aOnlySchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/b_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'a_schemas.dart' as a; -import 'b_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': a.slideSchema, - 'known': slideSchema, -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'Could not resolve schema reference "slideSchema"' - throwsA(isA()), - ); - }, - ); - - test('fails when nested object schema reference lacks @AckType', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; - -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'currentSlide': slideSchema, - 'slides': Ack.list(slideSchema), -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'references object schema "slideSchema" without @AckType' - throwsA(isA()), - ); - }); - - test( - 'resolves direct-import transformed refs without @AckType using visible representation types', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette.dart': ''' -import 'package:ack/ack.dart'; - -class Color { - final String value; - const Color(this.value); -} - -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette.dart' as palette; - -@AckType() -final themeSchema = Ack.object({ - 'primary': palette.colorSchema, - 'accents': Ack.list(palette.colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('palette.Color get primary'), - contains("_data['primary'] as palette.Color"), - contains('List get accents'), - contains("_\$ackListCast(_data['accents'])"), - ]), - ), - }, - ); - }, - ); - - test( - 'fails when Ack.list(schemaRef) object reference lacks @AckType', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/deck_schemas.dart': ''' -import 'package:ack/ack.dart'; - -final slideSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - 'test_pkg|lib/deck_tools_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'deck_schemas.dart'; - -@AckType() -final deckToolArgsSchema = Ack.object({ - 'slides': Ack.list(slideSchema), -}); -''', - }, - outputs: {'test_pkg|lib/deck_tools_schemas.g.dart': anything}, - ), - // Generator emits: 'Ack.list(slideSchema) references object schema without @AckType' - throwsA(isA()), - ); - }, - ); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_custom_name_test.dart b/packages/ack_generator/test/integration/ack_type_custom_name_test.dart deleted file mode 100644 index dd22b072..00000000 --- a/packages/ack_generator/test/integration/ack_type_custom_name_test.dart +++ /dev/null @@ -1,317 +0,0 @@ -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 custom names', () { - test('generates extension types for non-nullable schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -// Primitive schemas - extension types ARE generated -@AckType() -final passwordSchema = Ack.string(); - -@AckType(name: 'CustomPassword') -final customPasswordSchema = Ack.string().nullable(); - -@AckType(name: 'Order2') -final orderSchema = Ack.integer(); - -// Object schema - extension type IS generated -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), -}); - -@AckType(name: 'CustomUser') -final customUserSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - // Non-nullable primitives SHOULD have extension types - contains('extension type PasswordType(String _value)'), - contains('extension type Order2Type(int _value)'), - // Nullable schema should NOT generate extension type - isNot( - contains('extension type CustomPasswordType(String _value)'), - ), - // Object types SHOULD have extension types - contains('extension type UserType(Map _data)'), - contains( - 'extension type CustomUserType(Map _data)', - ), - ]), - ), - }, - ); - }); - - test('normalizes lowercase custom names', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType(name: 'customUser') -final customUserSchema = Ack.object({ - 'id': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type CustomUserType(Map _data)', - ), - ]), - ), - }, - ); - }); - - test('throws when custom name contains invalid characters', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType(name: 'bad-name') -final invalidSchema = Ack.string(); -''', - }, - outputs: {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - expect( - log.message, - contains('Invalid custom @AckType name "bad-name"'), - ); - } - }, - ); - }); - - test('throws when custom name is empty', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType(name: '') -final invalidSchema = Ack.string(); -''', - }, - outputs: {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - expect( - log.message, - contains('Custom @AckType name cannot be empty'), - ); - } - }, - ); - }); - - test('rejects @AckType on classes', () async { - final builder = ackGenerator(BuilderOptions.empty); - var sawError = false; - - try { - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/annotations.dart'; - -@AckType() -class User { - final String name; - User(this.name); -} -''', - }, - outputs: {}, - onLog: (log) { - if (log.level.name == 'SEVERE') { - sawError = true; - expect(log.message, contains('AckType')); - } - }, - ); - } catch (error) { - sawError = true; - expect(error.toString(), contains('AckType')); - } - - expect(sawError, isTrue); - }); - - test('rejects @AckType on instance and static getters', () async { - for (final modifier in ['', 'static ']) { - var sawError = false; - await testBuilder( - ackGenerator(BuilderOptions.empty), - { - ...allAssets, - 'test_pkg|lib/schema.dart': - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Schemas { - @AckType() - ${modifier}AckSchema get nameSchema => Ack.string(); -} -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('top-level schema variables or getters')) { - sawError = true; - } - }, - ); - expect(sawError, isTrue, reason: 'modifier: "$modifier"'); - } - }); - - test('ignores a consumer annotation class named AckType', () async { - final builder = ackGenerator(BuilderOptions.empty); - var sawConsumerAnnotationDiagnostic = false; - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -class AckType { - const AckType(); -} - -@AckType() -final ordinaryValue = 1; -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('ordinaryValue')) { - sawConsumerAnnotationDiagnostic = true; - } - }, - ); - - expect(sawConsumerAnnotationDiagnostic, isFalse); - }); - - test( - 'does not treat a consumer AckType annotation as a typed schema edge', - () async { - final builder = ackGenerator(BuilderOptions.empty); - var sawUntypedObjectDiagnostic = false; - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart' as annotations; - -class AckType { - const AckType(); -} - -@AckType() -final impostorSchema = Ack.object({'id': Ack.string()}); - -@annotations.AckType() -final userSchema = Ack.object({'nested': impostorSchema}); -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('impostorSchema') && - log.message.contains('without @AckType')) { - sawUntypedObjectDiagnostic = true; - } - }, - ); - - expect(sawUntypedObjectDiagnostic, isTrue); - }, - ); - - test('nullable object schemas do not generate extension types', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -// Non-nullable object schema - extension type IS generated -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), -}); - -// Nullable object schema - extension type should NOT be generated -@AckType() -final nullableUserSchema = Ack.object({ - 'name': Ack.string(), -}).nullable(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - // Non-nullable object SHOULD have extension type - contains('extension type UserType(Map _data)'), - // Nullable object should NOT have extension type - isNot(contains('extension type NullableUserType')), - ]), - ), - }, - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart b/packages/ack_generator/test/integration/ack_type_discriminated_test.dart deleted file mode 100644 index 25f5d80f..00000000 --- a/packages/ack_generator/test/integration/ack_type_discriminated_test.dart +++ /dev/null @@ -1,678 +0,0 @@ -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/generation_test_utils.dart'; -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType discriminated schemas', () { - test( - 'generates discriminated subtypes when branches omit discriminator property', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'lives': Ack.integer(), -}); - -@AckType() -ObjectSchema get dogSchema => Ack.object({ - 'bark': Ack.boolean(), -}).passthrough(); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - 'dog': dogSchema, - }, -); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type PetType(Map _data)'), - contains('implements Map'), - contains("switch (map['kind'])"), - contains("'cat' => CatType(map)"), - contains("'dog' => DogType(map)"), - contains('extension type CatType(Map _data)'), - contains('extension type DogType(Map _data)'), - contains('implements PetType, Map'), - contains('return petSchema.parseAs('), - contains('return petSchema.safeParseAs('), - contains(".effectiveBranch('cat')"), - contains(".effectiveBranch('dog')"), - isNot(contains('return catSchema.parseAs(')), - isNot(contains('return catSchema.safeParseAs(')), - isNot(contains('return dogSchema.parseAs(')), - isNot(contains('return dogSchema.safeParseAs(')), - isNot(contains("map['kind'] !=")), - isNot(contains('Expected kind')), - contains('Map get args =>'), - contains("e.key != 'kind' && e.key != 'bark'"), - predicate((content) { - final source = content as String; - final count = RegExp( - r"String get kind => _data\['kind'\] as String;", - ).allMatches(source).length; - return count == 3; - }, 'contains one kind getter per generated type'), - ]), - ), - }, - ); - }, - ); - - test('allows existing matching discriminator literal', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type PetType(Map _data)'), - contains('extension type CatType(Map _data)'), - contains("String get kind => _data['kind'] as String;"), - ]), - ), - }, - ); - }); - - test('allows existing matching discriminator enum', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.enumString(['cat', 'kitty']), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type PetType(Map _data)'), - contains('extension type CatType(Map _data)'), - contains("String get kind => _data['kind'] as String;"), - ]), - ), - }, - ); - }); - - test('fails when branch discriminator property is broad string', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'could not be proven to accept "cat"', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.string(), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test( - 'fails when matching discriminator literal has restrictive chain', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'could not be proven to accept "cat"', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat').minLength(4), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }, - ); - - test( - 'fails when matching discriminator enum has restrictive chain', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'could not be proven to accept "cat"', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.enumString(['cat']).minLength(4), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }, - ); - - test('fails when a branch is an inline expression', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must reference a top-level schema variable/getter', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), - }), - }, -); -''', - }, - ); - }); - - test('fails when a branch lacks @AckType', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must be annotated with @AckType', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when a branch schema is not object-shaped', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must be an object schema', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.string(); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when a branch comes from another library', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must be declared in the same library', - expectedOutputs: {'test_pkg|lib/branches.g.dart': anything}, - assets: { - ...allAssets, - 'test_pkg|lib/branches.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'branches.dart'; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when discriminated base is nullable', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'cannot be nullable when used with @AckType', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -).nullable(); -''', - }, - ); - }); - - test('fails when schemas map is empty', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'must contain at least one branch', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: {}, -); -''', - }, - ); - }); - - test('fails when a branch schema is nullable', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'cannot be nullable', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}).nullable(); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when discriminator values are duplicated', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'duplicate discriminator value', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package: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, - 'cat': dogSchema, - }, -); -''', - }, - ); - }); - - test( - 'fails when branch map key mismatches discriminator literal', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'but is mapped as', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - 'kitty': catSchema, - }, -); -''', - }, - ); - }, - ); - - test( - 'fails when schemas key does not match branch discriminator literal', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'but is mapped as', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final dogSchema = Ack.object({ - 'kind': Ack.literal('dog'), - 'bark': Ack.boolean(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': dogSchema, - }, -); -''', - }, - ); - }, - ); - - test('fails when a branch is reused across multiple bases', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'mapped to multiple discriminated bases', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); - -@AckType() -final anotherPetSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); -''', - }, - ); - }); - - test('fails when aliased branch is reused across multiple bases', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'mapped to multiple discriminated bases', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final catAliasOne = catSchema; - -@AckType() -final catAliasTwo = catSchema; - -@AckType() -final petSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catAliasOne, - }, -); - -@AckType() -final anotherPetSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catAliasTwo, - }, -); -''', - }, - ); - }); - - test('fails when a branch is itself a discriminated base', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - expectedMessage: 'Nested discriminated unions are not supported', - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final catSchema = Ack.object({ - 'kind': Ack.literal('cat'), - 'lives': Ack.integer(), -}); - -@AckType() -final innerSchema = Ack.discriminated( - discriminatorKey: 'kind', - schemas: { - 'cat': catSchema, - }, -); - -@AckType() -final outerSchema = Ack.discriminated( - discriminatorKey: 'type', - schemas: { - 'inner': innerSchema, - }, -); -''', - }, - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart b/packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart deleted file mode 100644 index 79ed32f1..00000000 --- a/packages/ack_generator/test/integration/ack_type_enum_literal_fields_test.dart +++ /dev/null @@ -1,438 +0,0 @@ -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 with enumString, literal, and enumValues fields', () { - test('enumString field generates String getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final reviewSchema = Ack.object({ - 'file': Ack.string(), - 'severity': Ack.enumString(['error', 'warning', 'info']), - 'message': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ReviewType(Map _data)'), - contains('String get file'), - contains("_data['file'] as String"), - contains('String get severity'), - contains("_data['severity'] as String"), - contains('String get message'), - contains("_data['message'] as String"), - ]), - ), - }, - ); - }); - - test('literal field generates String getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final eventSchema = Ack.object({ - 'type': Ack.literal('click'), - 'target': Ack.string(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type EventType(Map _data)'), - contains('String get type'), - contains("_data['type'] as String"), - contains('String get target'), - contains("_data['target'] as String"), - ]), - ), - }, - ); - }); - - test('enumValues field generates enum type getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'role': Ack.enumValues(UserRole.values), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains('String get name'), - contains('UserRole get role'), - contains("_data['role'] as UserRole"), - ]), - ), - }, - ); - }); - - test( - 'enumString with optional().nullable() generates String? getter', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final formSchema = Ack.object({ - 'title': Ack.string(), - 'priority': Ack.enumString(['low', 'medium', 'high']).optional().nullable(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type FormType(Map _data)'), - contains('String get title'), - contains('String? get priority'), - contains("_data['priority'] as String?"), - ]), - ), - }, - ); - }, - ); - - test('enumValues with optional() generates T? getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum Priority { low, medium, high, critical } - -@AckType() -final taskSchema = Ack.object({ - 'title': Ack.string(), - 'priority': Ack.enumValues(Priority.values).optional(), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type TaskType(Map _data)'), - contains('String get title'), - contains('Priority? get priority'), - contains("_data['priority'] as Priority?"), - ]), - ), - }, - ); - }); - - test('Ack.list(Ack.enumString()) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final configSchema = Ack.object({ - 'name': Ack.string(), - 'tags': Ack.list(Ack.enumString(['a', 'b', 'c'])), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ConfigType(Map _data)'), - contains('String get name'), - contains('List get tags'), - contains("_\$ackListCast(_data['tags'])"), - ]), - ), - }, - ); - }); - - test('Ack.list(Ack.enumValues()) generates List getter', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum UserRole { admin, editor, viewer } - -@AckType() -final teamSchema = Ack.object({ - 'name': Ack.string(), - 'roles': Ack.list(Ack.enumValues(UserRole.values)), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type TeamType(Map _data)'), - contains('String get name'), - contains('List get roles'), - contains("_\$ackListCast(_data['roles'])"), - ]), - ), - }, - ); - }); - - test( - 'enumValues with imported prefixed enum keeps prefixed field type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/enums.dart': ''' -enum UserRole { admin, editor, viewer } -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'enums.dart' as models; - -@AckType() -final userSchema = Ack.object({ - 'role': Ack.enumValues(models.UserRole.values), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains('models.UserRole get role'), - contains("_data['role'] as models.UserRole"), - ]), - ), - }, - ); - }, - ); - - test( - 'top-level list enumValues preserves imported prefixed enum type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/enums.dart': ''' -enum UserRole { admin, editor, viewer } -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'enums.dart' as models; - -@AckType() -final roleListSchema = Ack.list(Ack.enumValues(models.UserRole.values)); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type RoleListType(List _value)', - ), - contains('implements List'), - contains('RoleListType(validated as List)'), - ]), - ), - }, - ); - }, - ); - - test( - 'list enumValues with imported prefixed enum keeps prefixed getter type', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/enums.dart': ''' -enum UserRole { admin, editor, viewer } -''', - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'enums.dart' as models; - -@AckType() -final teamSchema = Ack.object({ - 'roles': Ack.list(Ack.enumValues(models.UserRole.values)), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('List get roles'), - contains("_\$ackListCast(_data['roles'])"), - ]), - ), - }, - ); - }, - ); - - test( - 'mixed schema with literal, enumString, enumValues, and string fields', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum Color { red, green, blue } - -@AckType() -final widgetSchema = Ack.object({ - 'type': Ack.literal('button'), - 'label': Ack.string(), - 'color': Ack.enumValues(Color.values), - 'style': Ack.enumString(['solid', 'outline', 'ghost']), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type WidgetType(Map _data)', - ), - contains('String get type'), - contains("_data['type'] as String"), - contains('String get label'), - contains("_data['label'] as String"), - contains('Color get color'), - contains("_data['color'] as Color"), - contains('String get style'), - contains("_data['style'] as String"), - ]), - ), - }, - ); - }, - ); - - test( - 'enumValues infers enum type from variable and property inputs', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -enum UserRole { admin, editor, viewer } - -final roleValues = UserRole.values; - -class RoleHolder { - const RoleHolder(this.values); - final List values; -} - -const holder = RoleHolder(UserRole.values); - -@AckType() -final userSchema = Ack.object({ - 'roleFromVar': Ack.enumValues(roleValues), - 'roleFromProp': Ack.enumValues(holder.values), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('UserRole get roleFromVar'), - contains("_data['roleFromVar'] as UserRole"), - contains('UserRole get roleFromProp'), - contains("_data['roleFromProp'] as UserRole"), - ]), - ), - }, - ); - }, - ); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_getter_test.dart b/packages/ack_generator/test/integration/ack_type_getter_test.dart deleted file mode 100644 index 17394ef0..00000000 --- a/packages/ack_generator/test/integration/ack_type_getter_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -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/generation_test_utils.dart'; -import '../test_utils/test_assets.dart'; - -void main() { - group('@AckType getter support', () { - test('generates extension types from expression-body getters', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -StringSchema get statusSchema => Ack.string(); - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'status': statusSchema, - 'aliases': Ack.list(statusSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type StatusType(String _value)'), - contains('extension type UserType(Map _data)'), - contains('StatusType get status'), - contains("StatusType(_data['status'] as String)"), - contains('List get aliases'), - contains('StatusType(e as String)'), - contains('return statusSchema.parseAs('), - contains('return userSchema.safeParseAs('), - isNot(contains('_\$ackParse<')), - isNot(contains('_\$ackSafeParse<')), - ]), - ), - }, - ); - }); - - test('supports block-body getters and custom names', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType(name: 'CustomAddress') -ObjectSchema get addressSchema { - return Ack.object({ - 'city': Ack.string(), - }); -} - -@AckType() -ObjectSchema get userSchema { - return Ack.object({ - 'address': addressSchema, - }); -} -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains( - 'extension type CustomAddressType(Map _data)', - ), - contains('extension type UserType(Map _data)'), - contains('CustomAddressType get address'), - contains( - "CustomAddressType(_data['address'] as Map)", - ), - ]), - ), - }, - ); - }); - - test('uses prefixed SchemaResult 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.dart'; - -@AckType() -ack.StringSchema get statusSchema => ack.Ack.string(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type StatusType(String _value)'), - contains('ack.SchemaResult safeParse'), - contains('return statusSchema.parseAs('), - contains('return statusSchema.safeParseAs('), - isNot(contains('_\$ackParse<')), - isNot(contains('_\$ackSafeParse<')), - ]), - ), - }, - ); - }); - - test('uses shared collection cast helper for primitive lists', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'tags': Ack.list(Ack.string()), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UserType(Map _data)'), - contains( - "List get tags => _\$ackListCast(_data['tags'])", - ), - contains( - 'List _\$ackListCast(Object? value) => (value as List).cast();', - ), - ]), - ), - }, - ); - }); - - test('rejects nullable list element schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'tags': Ack.list(Ack.string().nullable()), -}); -''', - }, - expectedMessage: - 'Ack.list(...) does not support nullable element schemas', - ); - }); - - test('rejects nullable list element schema references', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectGenerationFailure( - builder: builder, - assets: { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -final tagSchema = Ack.string().nullable(); - -@AckType() -ObjectSchema get userSchema => Ack.object({ - 'tags': Ack.list(tagSchema), -}); -''', - }, - expectedMessage: - 'Ack.list(...) does not support nullable element schemas', - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_golden_test.dart b/packages/ack_generator/test/integration/ack_type_golden_test.dart deleted file mode 100644 index f9161dc2..00000000 --- a/packages/ack_generator/test/integration/ack_type_golden_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -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'; - -/// Full-output snapshot for one canonical `@AckType` object schema. -/// -/// The scattered `decodedMatches(contains(...))` assertions in the sibling -/// integration tests catch missing pieces, but not formatting drift, member -/// reordering, or unexpected additions. This single exact-match golden guards -/// the overall shape of generated code for a representative schema; update the -/// expected string deliberately when the emitter output is meant to change. -void main() { - test('emits stable extension-type output for an object schema', () async { - final builder = ackGenerator(BuilderOptions.empty); - - const expected = ''' -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'schema.dart'; - -/// Extension type for User -extension type UserType(Map _data) - implements Map { - static UserType parse(Object? data) { - return userSchema.parseAs( - data, - (validated) => UserType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return userSchema.safeParseAs( - data, - (validated) => UserType(validated as Map), - ); - } - - String get name => _data['name'] as String; - - int get age => _data['age'] as int; -} -'''; - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'age': Ack.integer(), -}); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': decodedMatches(expected)}, - ); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_map_schema_test.dart b/packages/ack_generator/test/integration/ack_type_map_schema_test.dart deleted file mode 100644 index 452ef92d..00000000 --- a/packages/ack_generator/test/integration/ack_type_map_schema_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:ack_generator/builder.dart'; -import 'package:build/build.dart'; -import 'package:test/test.dart'; - -import '../test_utils/generation_test_utils.dart'; -import '../test_utils/test_assets.dart'; - -void main() { - // @AckType is frozen until Ack 2.0; Ack.map inference is Schemable - // only. Pin that the legacy generator fails loudly instead of guessing. - test('frozen @AckType rejects Ack.map fields', () async { - await expectGenerationFailure( - builder: ackGenerator(BuilderOptions.empty), - expectedMessage: 'Ack.map', - assets: { - ...allAssets, - 'test_pkg|lib/config_schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final configSchema = Ack.object({'scores': Ack.map(Ack.integer())}); -''', - }, - ); - }); -} 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 deleted file mode 100644 index b4a92cab..00000000 --- a/packages/ack_generator/test/integration/ack_type_nested_schema_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -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 nested schema references', () { - test( - 'generates typed getters for primitive and object schema refs', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final statusSchema = Ack.string(); - -@AckType() -final addressSchema = Ack.object({ - 'street': Ack.string(), - 'city': Ack.string(), -}); - -@AckType() -final userSchema = Ack.object({ - 'name': Ack.string(), - 'status': statusSchema, - 'address': addressSchema, - 'aliases': Ack.list(statusSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type StatusType(String _value)'), - contains( - 'extension type AddressType(Map _data)', - ), - contains('extension type UserType(Map _data)'), - contains('StatusType get status'), - contains("StatusType(_data['status'] as String)"), - contains('AddressType get address'), - contains( - "AddressType(_data['address'] as Map)", - ), - contains('List get aliases'), - contains('StatusType(e as String)'), - ]), - ), - }, - ); - }, - ); - - test('resolves custom @AckType names for nested refs', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType(name: 'CustomStatus') -final statusSchema = Ack.string(); - -@AckType() -final orderSchema = Ack.object({ - 'status': statusSchema, -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type CustomStatusType(String _value)'), - contains('CustomStatusType get status'), - contains("CustomStatusType(_data['status'] as String)"), - ]), - ), - }, - ); - }); - - test('fails on anonymous inline object fields in strict mode', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'profile': Ack.object({ - 'name': Ack.string(), - }), -}); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - // Generator emits: 'anonymous inline Ack.object(...). Strict typed generation requires a named schema reference.' - throwsA(isA()), - ); - }); - - test('fails on Ack.list(Ack.object(...)) in strict mode', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final userSchema = Ack.object({ - 'profiles': Ack.list(Ack.object({ - 'name': Ack.string(), - })), -}); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - // Generator emits: 'anonymous inline Ack.object(...). Strict typed generation requires a named schema reference.' - throwsA(isA()), - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/ack_type_transform_test.dart b/packages/ack_generator/test/integration/ack_type_transform_test.dart deleted file mode 100644 index 89bf11ca..00000000 --- a/packages/ack_generator/test/integration/ack_type_transform_test.dart +++ /dev/null @@ -1,326 +0,0 @@ -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 transform support', () { - test( - 'supports top-level transformed schemas and direct transformed factories', - () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -class TagList { - final List value; - const TagList(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); - -@AckType() -final aliasColorSchema = colorSchema.transform((value) => value); - -@AckType() -final uriSchema = Ack.uri(); - -@AckType() -final dateSchema = Ack.date(); - -@AckType() -final datetimeSchema = Ack.datetime(); - -@AckType() -final durationSchema = Ack.duration(); - -@AckType() -final validatedStringSchema = Ack.string().uri(); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ColorType(Color _value)'), - contains('extension type AliasColorType(Color _value)'), - contains('extension type UriType(Uri _value)'), - contains('extension type DateType(DateTime _value)'), - contains('extension type DatetimeType(DateTime _value)'), - contains('extension type DurationType(Duration _value)'), - contains('extension type ValidatedStringType(String _value)'), - ]), - ), - }, - ); - }, - ); - - test('supports nested transformed fields, refs, and list elements', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); - -final baseColorSchema = Ack.string(); - -@AckType() -final profileSchema = Ack.object({ - 'homepage': Ack.uri(), - 'birthday': Ack.date(), - 'lastLogin': Ack.datetime().optional().nullable(), - 'timeout': Ack.duration(), - 'links': Ack.list(Ack.uri()), - 'nestedLinks': Ack.list(Ack.list(Ack.uri())), - 'favoriteColor': Ack.string().transform((value) => Color(value)), - 'slug': Ack.string().transform((value) => value + '#'), - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), - 'customColors': Ack.list( - baseColorSchema.transform((value) => Color(value)), - ), - 'tagList': Ack.list(Ack.string()).transform((value) => TagList(value)), -}); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ColorType(Color _value)'), - contains( - 'extension type ProfileType(Map _data)', - ), - contains('Uri get homepage => _data[\'homepage\'] as Uri'), - contains( - 'DateTime get birthday => _data[\'birthday\'] as DateTime', - ), - contains( - 'DateTime? get lastLogin => _data[\'lastLogin\'] as DateTime?', - ), - contains( - 'Duration get timeout => _data[\'timeout\'] as Duration', - ), - contains('List get links'), - contains('_\$ackListCast(_data[\'links\'])'), - contains('List> get nestedLinks'), - contains('_\$ackListCast>(_data[\'nestedLinks\'])'), - contains( - 'Color get favoriteColor => _data[\'favoriteColor\'] as Color', - ), - contains('String get slug => _data[\'slug\'] as String'), - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - contains('List get customColors'), - contains('_\$ackListCast(_data[\'customColors\'])'), - contains('TagList get tagList => _data[\'tagList\'] as TagList'), - isNot(contains('TagList get tagList => _\$ackListCast')), - isNot(contains('Uri.parse(')), - isNot(contains('DateTime.parse(')), - isNot(contains('Duration(milliseconds:')), - ]), - ), - }, - ); - }); - - test('supports transformed getter schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -AckSchema get baseColorSchema => Ack.string(); - -@AckType() -AckSchema get colorSchema => - baseColorSchema.transform((value) => Color(value)); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type ColorType(Color _value)'), - contains('return colorSchema.parseAs('), - ]), - ), - }, - ); - }); - - test('supports top-level list schema with chained modifiers', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final uniqueTagsSchema = Ack.list(Ack.string()).unique(); - -@AckType() -final describedTagsSchema = Ack.list(Ack.string()).describe('A list of tags'); -''', - }, - outputs: { - 'test_pkg|lib/schema.g.dart': decodedMatches( - allOf([ - contains('extension type UniqueTagsType(List _value)'), - contains('extension type DescribedTagsType(List _value)'), - ]), - ), - }, - ); - }); - - test('supports transformed refs through re-exported schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/palette_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - 'test_pkg|lib/palette_schema_exports.dart': ''' -export 'palette_schemas.dart'; -''', - 'test_pkg|lib/theme_schemas.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'palette_schema_exports.dart'; - -@AckType() -final themeSchema = Ack.object({ - 'accent': colorSchema, - 'colors': Ack.list(colorSchema), -}); -''', - }, - outputs: { - 'test_pkg|lib/palette_schemas.g.dart': decodedMatches( - contains('extension type ColorType(Color _value)'), - ), - 'test_pkg|lib/theme_schemas.g.dart': decodedMatches( - allOf([ - contains('ColorType get accent'), - contains("ColorType(_data['accent'] as Color)"), - contains('List get colors'), - contains('ColorType(e as Color)'), - ]), - ), - }, - ); - }); - - test('rejects transform without an explicit output type', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -class Color { - final String value; - const Color(this.value); -} - -@AckType() -final colorSchema = Ack.string().transform((value) => Color(value)); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - throwsA(isA()), - ); - }); - - test('rejects transformed object and discriminated schemas', () async { - final builder = ackGenerator(BuilderOptions.empty); - - await expectLater( - () => testBuilder( - builder, - { - ...allAssets, - 'test_pkg|lib/schema.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -@AckType() -final objectSchema = Ack.object({ - 'name': Ack.string(), -}).transform((value) => 'name'); - -@AckType() -final discriminatedSchema = Ack.discriminated( - discriminatorKey: 'type', - schemas: { - 'user': Ack.string(), - }, -).transform((value) => 'user'); -''', - }, - outputs: {'test_pkg|lib/schema.g.dart': anything}, - ), - throwsA(isA()), - ); - }); - }); -} diff --git a/packages/ack_generator/test/integration/class_first_cycle_build_test.dart b/packages/ack_generator/test/integration/class_first_cycle_build_test.dart index 797bb2ca..26e4a13b 100644 --- a/packages/ack_generator/test/integration/class_first_cycle_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_cycle_build_test.dart @@ -46,8 +46,7 @@ import 'package:ack/ack.dart'; import 'child.dart'; -part 'parent.ack.dart'; -part 'parent.ack.g.dart'; +part 'parent.g.dart'; @Schemable() final class Parent with _$ParentAck { @@ -61,8 +60,7 @@ import 'package:ack/ack.dart'; import 'parent.dart'; -part 'child.ack.dart'; -part 'child.ack.g.dart'; +part 'child.g.dart'; @Schemable() final class Child with _$ChildAck { @@ -90,11 +88,11 @@ final class Child with _$ChildAck { expect(output, contains('recursive class-first schema graph')); expect(output, anyOf(contains('Child.parent'), contains('Parent.child'))); expect( - File(p.join(temporary.path, 'lib', 'parent.ack.dart')).existsSync(), + File(p.join(temporary.path, 'lib', 'parent.g.dart')).existsSync(), isFalse, ); expect( - File(p.join(temporary.path, 'lib', 'child.ack.dart')).existsSync(), + File(p.join(temporary.path, 'lib', 'child.g.dart')).existsSync(), isFalse, ); }, diff --git a/packages/ack_generator/test/integration/class_first_json_test.dart b/packages/ack_generator/test/integration/class_first_json_test.dart index d66fdde8..200f7dbb 100644 --- a/packages/ack_generator/test/integration/class_first_json_test.dart +++ b/packages/ack_generator/test/integration/class_first_json_test.dart @@ -4,124 +4,107 @@ import 'package:build/build.dart'; import 'package:build_test/build_test.dart'; import 'package:test/test.dart'; -Future _expectJsonOutput(String source, Matcher matcher) async { +Future _expectGeneratedPart(String source, Matcher matcher) async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); await readerWriter.testing.loadIsolateSources(); await testBuilder( - ackModelJsonBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/model.dart': source, - 'test_pkg|lib/model.ack.dart': "part of 'model.dart';", - }, + ackModelBuilder(BuilderOptions.empty), + {'test_pkg|lib/model.dart': source}, generateFor: const {'test_pkg|lib/model.dart'}, readerWriter: readerWriter, - outputs: {'test_pkg|lib/model.ack.g.dart': decodedMatches(matcher)}, + outputs: {'test_pkg|lib/model.g.dart': decodedMatches(matcher)}, ); } const _head = ''' +import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; '''; void main() { test( - 'class-first JSON uses top-level bridges and pinned case style', + 'class-first runtime mapping uses direct constructor and map literals with pinned case style', () async { - await _expectJsonOutput( + await _expectGeneratedPart( ''' $_head @Schemable(caseStyle: AckCaseStyle.snake) -final class User { +final class User with _\$UserAck { const User({required this.firstName, this.nickname}); final String firstName; final String? nickname; } - -String _ackUserFromRuntimeFirstName(Object? value) => value as String; -Object? _ackUserToRuntimeFirstName(String value) => value; -String? _ackUserFromRuntimeNickname(Object? value) => value as String?; -Object? _ackUserToRuntimeNickname(String? value) => value; ''', allOf([ - contains( - "firstName: _ackUserFromRuntimeFirstName(json['first_name'])", - ), - contains("nickname: _ackUserFromRuntimeNickname(json['nickname'])"), - contains( - "'first_name': _ackUserToRuntimeFirstName(instance.firstName)", - ), - contains("'nickname': ?_ackUserToRuntimeNickname(instance.nickname)"), - isNot(contains('User._ackFromRuntime')), + contains(r'User _$UserFromRuntime(Map value)'), + contains("firstName: value['first_name'] as String"), + contains("nickname: value['nickname'] as String?"), + contains(r'Map _$UserToRuntime(User model)'), + contains("'first_name': model.firstName"), + contains("'nickname': ?model.nickname"), + isNot(contains(r'_$UserFromJson')), + isNot(contains(r'_$UserToJson')), ]), ); }, ); test( - 'JSON phase auto-includes unannotated concrete sealed branches', + 'runtime emission auto-includes unannotated concrete sealed branches', () async { - await _expectJsonOutput( + await _expectGeneratedPart( ''' $_head @Schemable(discriminatorKey: 'type') -sealed class Pet { +sealed class Pet with _\$PetAck { const Pet({required this.id}); final String id; } -final class Cat extends Pet { +final class Cat extends Pet with _\$CatAck { const Cat({required super.id, required this.lives}); final int lives; } - -String _ackCatFromRuntimeId(Object? value) => value as String; -Object? _ackCatToRuntimeId(String value) => value; -int _ackCatFromRuntimeLives(Object? value) => value as int; -Object? _ackCatToRuntimeLives(int value) => value; ''', allOf([ - contains(r'Cat _$CatFromJson'), - contains("id: _ackCatFromRuntimeId(json['id'])"), - contains("lives: _ackCatFromRuntimeLives(json['lives'])"), - contains("'id': _ackCatToRuntimeId(instance.id)"), - contains("'lives': _ackCatToRuntimeLives(instance.lives)"), - isNot(contains(r'Pet _$PetFromJson')), + contains(r'Cat _$CatFromRuntime'), + contains("id: value['id'] as String"), + contains("lives: value['lives'] as int"), + contains("'id': model.id"), + contains("'lives': model.lives"), + isNot(contains(r'Pet _$PetFromRuntime')), ]), ); }, ); - test('JSON phase applies an annotated branch case style', () async { - await _expectJsonOutput( + test('runtime emission applies an annotated branch case style', () async { + await _expectGeneratedPart( ''' $_head @Schemable(discriminatorKey: 'type') -sealed class Pet { +sealed class Pet with _\$PetAck { const Pet(); } @Schemable(caseStyle: AckCaseStyle.snake) -final class Cat extends Pet { +final class Cat extends Pet with _\$CatAck { const Cat({required this.livesLeft}); final int livesLeft; } - -int _ackCatFromRuntimeLivesLeft(Object? value) => value as int; -Object? _ackCatToRuntimeLivesLeft(int value) => value; ''', allOf([ - contains("livesLeft: _ackCatFromRuntimeLivesLeft(json['lives_left'])"), - contains("'lives_left': _ackCatToRuntimeLivesLeft(instance.livesLeft)"), + contains("livesLeft: value['lives_left'] as int"), + contains("'lives_left': model.livesLeft"), ]), ); }); for (final style in AckCaseStyle.values) { - test('JSON phase renames keys for caseStyle ${style.name}', () async { + test('runtime emission renames keys for caseStyle ${style.name}', () async { final key = switch (style) { AckCaseStyle.none => 'firstName', AckCaseStyle.snake => 'first_name', @@ -129,22 +112,19 @@ Object? _ackCatToRuntimeLivesLeft(int value) => value; AckCaseStyle.pascal => 'FirstName', AckCaseStyle.screamingSnake => 'FIRST_NAME', }; - await _expectJsonOutput( + await _expectGeneratedPart( ''' $_head @Schemable(caseStyle: AckCaseStyle.${style.name}) -final class User { +final class User with _\$UserAck { const User({required this.firstName}); final String firstName; } - -String _ackUserFromRuntimeFirstName(Object? value) => value as String; -Object? _ackUserToRuntimeFirstName(String value) => value; ''', allOf([ - contains("firstName: _ackUserFromRuntimeFirstName(json['$key'])"), - contains("'$key': _ackUserToRuntimeFirstName(instance.firstName)"), + contains("firstName: value['$key'] as String"), + contains("'$key': model.firstName"), ]), ); }); diff --git a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart index bf60c42b..01b0a7dc 100644 --- a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart @@ -23,11 +23,7 @@ Map _generatedFiles(Directory directory) => { in directory .listSync(recursive: true) .whereType() - .where( - (file) => - file.path.endsWith('.ack.dart') || - file.path.endsWith('.ack.g.dart'), - )) + .where((file) => file.path.endsWith('.g.dart'))) p.relative(file.path, from: directory.path): file.readAsStringSync(), }; @@ -68,8 +64,7 @@ dependency_overrides: r''' import 'package:ack/ack.dart'; -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable(schemaName: 'PostalAddressSchema') final class Address with _$AddressAck { @@ -103,8 +98,7 @@ import 'package:ack/ack.dart'; import 'address.dart' show Address, PostalAddressSchema; -part 'customer.ack.dart'; -part 'customer.ack.g.dart'; +part 'customer.g.dart'; @Schemable() final class Customer with _$CustomerAck { @@ -127,8 +121,7 @@ import 'package:ack/ack.dart'; import 'models.dart' as models; -part 'parcel.ack.dart'; -part 'parcel.ack.g.dart'; +part 'parcel.g.dart'; @Schemable() final class Parcel with _$ParcelAck { @@ -141,8 +134,7 @@ final class Parcel with _$ParcelAck { File(p.join(temporary.path, 'lib', 'north.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; -part 'north.ack.dart'; -part 'north.ack.g.dart'; +part 'north.g.dart'; @Schemable() final class Place with _$PlaceAck { @@ -154,8 +146,7 @@ final class Place with _$PlaceAck { File(p.join(temporary.path, 'lib', 'south.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; -part 'south.ack.dart'; -part 'south.ack.g.dart'; +part 'south.g.dart'; @Schemable() final class Place with _$PlaceAck { @@ -171,8 +162,7 @@ import 'package:ack/ack.dart'; import 'north.dart' as north; import 'south.dart' as south; -part 'itinerary.ack.dart'; -part 'itinerary.ack.g.dart'; +part 'itinerary.g.dart'; @Schemable() final class Itinerary with _$ItineraryAck { @@ -186,8 +176,7 @@ final class Itinerary with _$ItineraryAck { File(p.join(temporary.path, 'lib', 'pet.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; -part 'pet.ack.dart'; -part 'pet.ack.g.dart'; +part 'pet.g.dart'; @Schemable(discriminatorKey: 'type') sealed class Pet with _$PetAck { @@ -208,8 +197,7 @@ import 'package:ack/ack.dart'; import 'address.dart' as address; import 'pet.dart' as pets; -part 'order.ack.dart'; -part 'order.ack.g.dart'; +part 'order.g.dart'; @Schemable() final class Order with _$OrderAck { @@ -232,8 +220,7 @@ final class Order with _$OrderAck { r''' import 'package:ack/ack.dart'; -part 'legacy.ack.dart'; -part 'legacy.ack.g.dart'; +part 'legacy.g.dart'; @Schemable(name: 'LegacyAddress') final legacyAddressContract = Ack.object({'city': Ack.string()}); @@ -245,8 +232,7 @@ import 'package:ack/ack.dart'; import 'legacy.dart' as legacy; -part 'holder.ack.dart'; -part 'holder.ack.g.dart'; +part 'holder.g.dart'; @Schemable() final class Holder with _$HolderAck { @@ -273,8 +259,7 @@ import 'package:ack/ack.dart'; import 'address.dart' as address; -part 'address_envelope.ack.dart'; -part 'address_envelope.ack.g.dart'; +part 'address_envelope.g.dart'; @Schemable() final addressEnvelopeSchema = Ack.object({ @@ -336,8 +321,8 @@ void main() { 'cat': {'type': 'Cat', 'id': 'c1', 'lives': 8}, }); expect(PostalAddressSchema.schema, isNotNull); - expect(PetSchema.toJsonSchema()['x-transformed'], isTrue); - expect(CatSchema.toJsonSchema()['x-transformed'], isTrue); + expect(PetSchema.toJsonSchema().containsKey('x-transformed'), isFalse); + expect(CatSchema.toJsonSchema().containsKey('x-transformed'), isFalse); final book = AddressBookSchema.parse({ 'primary': {'city': 'Delft'}, @@ -463,36 +448,36 @@ void main() { 'clean build_runner build', ); final generated = _generatedFiles(temporary); - final orderOutput = generated['lib/order.ack.dart']; + final orderOutput = generated['lib/order.g.dart']; expect(orderOutput, contains('address.PostalAddressSchema.schema')); expect(orderOutput, contains('pets.PetSchema.schema')); expect(orderOutput, contains('pets.CatSchema.schema')); expect(orderOutput, isNot(contains('address.addressSchema'))); expect( - generated['lib/address.ack.dart'], + generated['lib/address.g.dart'], contains("'primary': PostalAddressSchema.schema"), ); expect( - generated['lib/customer.ack.dart'], + generated['lib/customer.g.dart'], contains("'primary': PostalAddressSchema.schema.withDefault"), ); expect( - generated['lib/parcel.ack.dart'], + generated['lib/parcel.g.dart'], contains('models.PostalAddressSchema.schema'), ); expect( - generated['lib/itinerary.ack.dart'], + generated['lib/itinerary.g.dart'], allOf( contains('north.PlaceSchema.schema'), contains('south.PlaceSchema.schema'), ), ); expect( - generated['lib/holder.ack.dart'], + generated['lib/holder.g.dart'], contains(r'legacy.LegacyAddress.$ack.schema'), ); expect( - generated['lib/address_envelope.ack.dart'], + generated['lib/address_envelope.g.dart'], contains('address.Address'), ); _expectSuccess( diff --git a/packages/ack_generator/test/integration/class_first_prerequisites_test.dart b/packages/ack_generator/test/integration/class_first_prerequisites_test.dart deleted file mode 100644 index 303ae8ab..00000000 --- a/packages/ack_generator/test/integration/class_first_prerequisites_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:ack_generator/src/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:test/test.dart'; - -void main() { - test( - 'pinned JSON phase generates inherited fields through super parameters', - () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - - await testBuilder( - ackModelJsonBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/pet.dart': r''' -import 'package:ack/ack_generator_support.dart'; - -part 'pet.ack.dart'; -part 'pet.ack.g.dart'; - -sealed class Pet { - const Pet({required this.id}); - - final String id; -} - -@AckGeneratedJson() -final class Cat extends Pet { - const Cat({required super.id, required this.lives, this.nickname}); - - final int lives; - final String? nickname; - - static String _ackFromRuntimeId(Object? value) => value as String; - static Object? _ackToRuntimeId(String value) => value; - static int _ackFromRuntimeLives(Object? value) => value as int; - static Object? _ackToRuntimeLives(int value) => value; - static String? _ackFromRuntimeNickname(Object? value) => value as String?; - static Object? _ackToRuntimeNickname(String? value) => value; -} -''', - 'test_pkg|lib/pet.ack.dart': "part of 'pet.dart';", - }, - generateFor: const {'test_pkg|lib/pet.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/pet.ack.g.dart': decodedMatches( - allOf([ - contains('Cat('), - contains("id: Cat._ackFromRuntimeId(json['id'])"), - contains("lives: Cat._ackFromRuntimeLives(json['lives'])"), - contains( - "nickname: Cat._ackFromRuntimeNickname(json['nickname'])", - ), - contains("'id': Cat._ackToRuntimeId(instance.id)"), - contains("'lives': Cat._ackToRuntimeLives(instance.lives)"), - contains( - "'nickname': ?Cat._ackToRuntimeNickname(instance.nickname)", - ), - ]), - ), - }, - ); - }, - ); -} diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index 6beec51f..2a2747e9 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -41,8 +41,7 @@ Future _expectConsumerSafeParts( const lintSuppression = '// ignore_for_file: type=lint\n'; final modern = { for (final MapEntry(key: path, value: source) in generated.entries) - if (path.endsWith('.ack.dart') || path.endsWith('.ack.g.dart')) - path: source, + if (path.endsWith('.g.dart')) path: source, }; expect(modern, isNotEmpty); for (final MapEntry(key: path, value: source) in modern.entries) { @@ -50,7 +49,7 @@ Future _expectConsumerSafeParts( expect(source, contains('// coverage:ignore-file\n'), reason: path); File( p.join(project.path, path), - ).writeAsStringSync(source.replaceFirst(lintSuppression, '')); + ).writeAsStringSync(source.replaceAll(lintSuppression, '')); } try { _expectSuccess( @@ -123,17 +122,6 @@ linter: - prefer_null_aware_operators ''', ); - File(p.join(temporary.path, 'build.yaml')).writeAsStringSync(''' -targets: - \$default: - builders: - ack_generator:ack_generator: - generate_for: - - lib/coexist.dart - source_gen:combining_builder: - generate_for: - - lib/models.dart -'''); File(p.join(temporary.path, 'lib', 'alpha.dart')).writeAsStringSync( 'final class Item { const Item(this.value); final String value; }\n', ); @@ -143,15 +131,8 @@ targets: File(p.join(temporary.path, 'lib', 'coexist.dart')).writeAsStringSync( r''' import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; part 'coexist.g.dart'; -part 'coexist.ack.dart'; -part 'coexist.ack.g.dart'; - -// ignore: deprecated_member_use -@AckType() -final frozenSchema = Ack.object({'id': Ack.string()}); @Schemable() final modernSchema = Ack.object({'name': Ack.string()}); @@ -174,8 +155,6 @@ import 'package:json_annotation/json_annotation.dart' import 'alpha.dart' as alpha; import 'beta.dart' as beta; -part 'models.ack.dart'; -part 'models.ack.g.dart'; part 'models.g.dart'; final class Color { @@ -643,7 +622,7 @@ void main() { throwsA(isA()), ); expect(ProfileSchema.safeParse({'name': 'Ada'}).isFail, isTrue); - expect(ProfileSchema.toJsonSchema()['x-transformed'], isTrue); + expect(ProfileSchema.toJsonSchema().containsKey('x-transformed'), isFalse); expect( ProfileSchema.toSchemaModel().toJsonSchema(), ProfileSchema.toJsonSchema(), @@ -1154,8 +1133,7 @@ void main() { expect(plain.toJson(), {'value': 'plain'}); }); - test('all three Ack model generators coexist in one build-runner library', () { - expect(FrozenType.parse({'id': 'frozen'}).id, 'frozen'); + test('schema-first and class-first Ack models coexist in one build-runner library', () { expect(Modern.parse({'name': 'modern'}).name, 'modern'); expect(HandwrittenSchema.parse({'enabled': true}).enabled, isTrue); }); @@ -1172,61 +1150,53 @@ void main() { generated.keys, containsAll([ 'lib/coexist.g.dart', - 'lib/coexist.ack.dart', - 'lib/coexist.ack.g.dart', - 'lib/models.ack.dart', - 'lib/models.ack.g.dart', 'lib/models.g.dart', ]), ); expect( - generated['lib/models.ack.g.dart'], - contains('_ackProfileFromRuntimeName'), + generated['lib/models.g.dart'], + contains(r'decode: _$ProfileFromRuntime'), ); expect( generated['lib/models.g.dart'], contains(r'_$PlainJsonFromJson'), ); - expect(generated['lib/models.ack.dart'], contains('class Legacy')); + expect(generated['lib/models.g.dart'], contains('class Legacy')); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], contains(r'mixin _$ProfileAck'), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], contains(r'implements $ParentCopyWith<$Result>'), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], contains(r'implements $RabbitCopyWith<$Result>'), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], contains('abstract final class ProfileSchema'), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], contains('final _profileSchema'), ); + expect(generated['lib/coexist.g.dart'], contains('class Modern')); expect( generated['lib/coexist.g.dart'], - contains('extension type FrozenType'), - ); - expect(generated['lib/coexist.ack.dart'], contains('class Modern')); - expect( - generated['lib/coexist.ack.dart'], contains(r'mixin _$HandwrittenAck'), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], isNot(contains('final profileSchema =')), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], contains('parameters: parameters ?? _source.parameters'), ); expect( - generated['lib/models.ack.dart'], + generated['lib/models.g.dart'], isNot(contains('parameters as Map?')), ); diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index fd0272af..54593cb4 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -49,8 +49,7 @@ void main() { import 'package:ack/ack.dart'; import 'package:ack/annotations.dart' as ack; -part 'contact.ack.dart'; -part 'contact.ack.g.dart'; +part 'contact.g.dart'; final class Email { const Email(this.address); @@ -70,7 +69,7 @@ final class Contact with _\$ContactAck { ''', }, outputs: { - 'test_pkg|lib/contact.ack.dart': decodedMatches( + 'test_pkg|lib/contact.g.dart': decodedMatches( _containsCode( "Ack.object({'address': Ack.string().email(), " "'id': Ack.string().uuid()})", @@ -87,8 +86,7 @@ final class Contact with _\$ContactAck { 'scores.dart': ''' $_imports -part 'scores.ack.dart'; -part 'scores.ack.g.dart'; +part 'scores.g.dart'; MapSchema scoresSchema() => Ack.map(Ack.integer().min(0)); @@ -102,7 +100,7 @@ final class Board with _\$BoardAck { ''', }, outputs: { - 'test_pkg|lib/scores.ack.dart': decodedMatches( + 'test_pkg|lib/scores.g.dart': decodedMatches( _containsCode("Ack.object({'scores': scoresSchema()})"), ), }, @@ -117,8 +115,7 @@ final class Board with _\$BoardAck { 'grid.dart': ''' $_imports -part 'grid.ack.dart'; -part 'grid.ack.g.dart'; +part 'grid.g.dart'; @Schemable() final class Grid with _\$GridAck { @@ -130,7 +127,7 @@ final class Grid with _\$GridAck { ''', }, outputs: { - 'test_pkg|lib/grid.ack.dart': decodedMatches( + 'test_pkg|lib/grid.g.dart': decodedMatches( allOf( _containsCode( 'Ack.list(Ack.list(Ack.integer()).codec>(' @@ -152,8 +149,7 @@ final class Grid with _\$GridAck { 'documented.dart': ''' $_imports -part 'documented.ack.dart'; -part 'documented.ack.g.dart'; +part 'documented.g.dart'; @Schemable() final class Documented with _\$DocumentedAck { @@ -166,7 +162,7 @@ final class Documented with _\$DocumentedAck { ''', }, outputs: { - 'test_pkg|lib/documented.ack.dart': decodedMatches( + 'test_pkg|lib/documented.g.dart': decodedMatches( contains("'title': Ack.string().describe('The item title.')"), ), }, @@ -180,8 +176,7 @@ final class Documented with _\$DocumentedAck { 'documented.dart': ''' $_imports -part 'documented.ack.dart'; -part 'documented.ack.g.dart'; +part 'documented.g.dart'; /// A task the person's \$team can complete. /// @description A task the person's \$team can complete. @@ -208,7 +203,7 @@ final class Note with _\$NoteAck { ''', }, outputs: { - 'test_pkg|lib/documented.ack.dart': decodedMatches( + 'test_pkg|lib/documented.g.dart': decodedMatches( allOf( _containsCode( 'final _taskObject = Ack.object({' @@ -240,8 +235,7 @@ final class Note with _\$NoteAck { 'plain.dart': ''' $_imports -part 'plain.ack.dart'; -part 'plain.ack.g.dart'; +part 'plain.g.dart'; /// Ordinary class prose is not schema data. @Schemable() @@ -257,7 +251,7 @@ final class Plain with _\$PlainAck { ''', }, outputs: { - 'test_pkg|lib/plain.ack.dart': decodedMatches( + 'test_pkg|lib/plain.g.dart': decodedMatches( isNot(contains('.describe(')), ), }, @@ -270,8 +264,7 @@ final class Plain with _\$PlainAck { 'escaped.dart': ''' $_imports -part 'escaped.ack.dart'; -part 'escaped.ack.g.dart'; +part 'escaped.g.dart'; @Schemable(description: 'First\\nsecond') final class Escaped with _\$EscapedAck { @@ -283,7 +276,7 @@ final class Escaped with _\$EscapedAck { ''', }, outputs: { - 'test_pkg|lib/escaped.ack.dart': decodedMatches( + 'test_pkg|lib/escaped.g.dart': decodedMatches( allOf([ contains(r".describe('First\u000asecond')"), contains(r".describe('A\u0009B')"), @@ -299,8 +292,7 @@ final class Escaped with _\$EscapedAck { 'shapes.dart': ''' $_imports -part 'shapes.ack.dart'; -part 'shapes.ack.g.dart'; +part 'shapes.g.dart'; /// A shape to draw. /// @description A shape to draw. @@ -326,7 +318,7 @@ final class Square extends Shape with _\$SquareAck { ''', }, outputs: { - 'test_pkg|lib/shapes.ack.dart': decodedMatches( + 'test_pkg|lib/shapes.g.dart': decodedMatches( allOf( _containsCode("'radius': Ack.double(),}).describe('A circle.');"), _containsCode( @@ -356,8 +348,7 @@ final class Square extends Shape with _\$SquareAck { ''' $_imports import 'package:ack/format_annotations.dart' as formats; -part 'formats.ack.dart'; -part 'formats.ack.g.dart'; +part 'formats.g.dart'; @Schemable() final class Formats with _\$FormatsAck { @@ -383,7 +374,7 @@ final class Formats with _\$FormatsAck { ''', }, outputs: { - 'test_pkg|lib/formats.ack.dart': decodedMatches( + 'test_pkg|lib/formats.g.dart': decodedMatches( allOf([ contains("'url': Ack.string().url()"), contains("'uri': Ack.string().uri()"), @@ -402,8 +393,7 @@ final class Formats with _\$FormatsAck { 'defaults.dart': ''' $_imports -part 'defaults.ack.dart'; -part 'defaults.ack.g.dart'; +part 'defaults.g.dart'; @Schemable() final class Defaults with _\$DefaultsAck { @@ -415,14 +405,14 @@ final class Defaults with _\$DefaultsAck { ''', }, outputs: { - 'test_pkg|lib/defaults.ack.dart': decodedMatches( + 'test_pkg|lib/defaults.g.dart': decodedMatches( allOf([ contains( "'fallback': Ack.string().nullable().withDefault('fallback')", ), contains("'empty': Ack.string().optional().nullable()"), - contains("result['fallback'] = null"), - contains("result['empty'] = null"), + contains("'fallback': model.fallback"), + contains("'empty': model.empty"), isNot(contains('withDefault(null)')), ]), ), @@ -436,8 +426,7 @@ final class Defaults with _\$DefaultsAck { 'envelope.dart': ''' $_imports -part 'envelope.ack.dart'; -part 'envelope.ack.g.dart'; +part 'envelope.g.dart'; @Schemable() final class Envelope with _\$EnvelopeAck { @@ -465,7 +454,7 @@ final class Envelope with _\$EnvelopeAck { ''', }, outputs: { - 'test_pkg|lib/envelope.ack.dart': decodedMatches( + 'test_pkg|lib/envelope.g.dart': decodedMatches( allOf([ contains("'kind': Ack.any()"), contains("'payload': Ack.any().optional().nullable()"), @@ -482,14 +471,13 @@ final class Envelope with _\$EnvelopeAck { ); }); - test('emits a codec schema, presence semantics, mixin, and bridges', () async { + test('emits a codec schema, presence semantics, mixin, and direct decode/encode', () async { await _build( { 'profile.dart': ''' $_imports -part 'profile.ack.dart'; -part 'profile.ack.g.dart'; +part 'profile.g.dart'; @Schemable() final class Profile with _\$ProfileAck { @@ -513,7 +501,7 @@ final class Profile with _\$ProfileAck { ''', }, outputs: { - 'test_pkg|lib/profile.ack.dart': decodedMatches( + 'test_pkg|lib/profile.g.dart': decodedMatches( allOf([ contains('final _profileObject = Ack.object'), contains( @@ -553,9 +541,8 @@ final class Profile with _\$ProfileAck { contains('_profileSchema.parse(value, debugName: debugName)!'), contains('_profileSchema.encode(value, debugName: debugName)!'), contains(r'Profile _$ProfileFromRuntime'), - contains(r'_$ProfileFromJson'), contains(r'Map _$ProfileToRuntime'), - contains("result['nickname'] = null"), + contains("'nickname': model.nickname"), contains(r'mixin _$ProfileAck'), contains(r'$ProfileCopyWith get copyWith'), contains('final class _ProfileCopyWithUnset'), @@ -571,8 +558,6 @@ final class Profile with _\$ProfileAck { contains('SchemaResult> safeToJson()'), contains('ProfileSchema.encode(this as Profile)'), contains('ProfileSchema.safeEncode(this as Profile)'), - contains('_ackProfileFromRuntimeBio'), - contains('_ackProfileToRuntimeTags'), ]), ), }, @@ -587,8 +572,7 @@ final class Profile with _\$ProfileAck { 'types.dart': ''' $_imports -part 'types.ack.dart'; -part 'types.ack.g.dart'; +part 'types.g.dart'; final class Color { const Color(this.value); @@ -634,7 +618,7 @@ final class Record with _\$RecordAck { ''', }, outputs: { - 'test_pkg|lib/types.ack.dart': decodedMatches( + 'test_pkg|lib/types.g.dart': decodedMatches( allOf([ contains("'color': colorSchema()"), isNot(contains("'color': colorSchema().describe(")), @@ -644,8 +628,6 @@ final class Record with _\$RecordAck { contains("'website': Ack.uri()"), contains("'timeout': Ack.duration()"), contains("'names': Ack.list(Ack.list(Ack.string()))"), - contains('Map _ackRecordFromRuntimeScores'), - contains('List> _ackRecordFromRuntimeNames'), contains('Map.unmodifiable('), contains('List>.unmodifiable('), contains('List.unmodifiable('), @@ -656,33 +638,40 @@ final class Record with _\$RecordAck { }, ); - test('computes case-style and JsonKey schema keys once', () async { + test('computes case-style, AckField(name:), and JsonKey schema keys once', () async { await _build( { 'account.dart': ''' $_imports import 'package:json_annotation/json_annotation.dart' show JsonKey; -part 'account.ack.dart'; -part 'account.ack.g.dart'; +part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) final class Account with _\$AccountAck { - const Account({required this.firstName, required this.imageUrl}); + const Account({ + required this.firstName, + required this.imageUrl, + required this.handleName, + }); final String firstName; @JsonKey(name: 'avatar') final String imageUrl; + @AckField(name: 'handle') + final String handleName; } ''', }, outputs: { - 'test_pkg|lib/account.ack.dart': decodedMatches( + 'test_pkg|lib/account.g.dart': decodedMatches( allOf([ contains("'first_name': Ack.string()"), contains("'avatar': Ack.string()"), + contains("'handle': Ack.string()"), isNot(contains("'firstName':")), isNot(contains("'image_url':")), + isNot(contains("'handle_name':")), ]), ), }, @@ -697,8 +686,7 @@ final class Account with _\$AccountAck { 'a.dart': ''' $_imports -part 'a.ack.dart'; -part 'a.ack.g.dart'; +part 'a.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -716,8 +704,7 @@ final class Address { $_imports import 'a.dart' as a; import 'b.dart' as b; -part 'order.ack.dart'; -part 'order.ack.g.dart'; +part 'order.g.dart'; @Schemable() final class Order with _\$OrderAck { @@ -730,16 +717,16 @@ const Type otherAddressType = b.Address; ''', }, outputs: { - 'test_pkg|lib/a.ack.dart': decodedMatches( + 'test_pkg|lib/a.g.dart': decodedMatches( allOf([ contains('final _addressSchema'), contains('abstract final class AddressSchema'), ]), ), - 'test_pkg|lib/order.ack.dart': decodedMatches( + 'test_pkg|lib/order.g.dart': decodedMatches( allOf([ contains("'shipping': a.AddressSchema.schema"), - contains('a.Address _ackOrderFromRuntimeShipping'), + contains("shipping: value['shipping'] as a.Address"), isNot(contains('b.AddressSchema')), ]), ), @@ -756,8 +743,7 @@ const Type otherAddressType = b.Address; 'pet.dart': ''' $_imports -part 'pet.ack.dart'; -part 'pet.ack.g.dart'; +part 'pet.g.dart'; @Schemable(discriminatorKey: 'type') sealed class Pet with _\$PetAck { @@ -780,7 +766,7 @@ final class Dog extends Pet with _\$DogAck { ''', }, outputs: { - 'test_pkg|lib/pet.ack.dart': decodedMatches( + 'test_pkg|lib/pet.g.dart': decodedMatches( allOf([ contains('final _catObject = Ack.object'), contains("'id': Ack.string()"), @@ -799,8 +785,8 @@ final class Dog extends Pet with _\$DogAck { contains("discriminatorKey: 'type'"), contains("schemas: {'cat': _catObject, 'Dog': _dogObject}"), contains('.codec('), - contains("'cat' => _\$CatFromRuntime(value)"), - contains("'Dog' => _\$DogFromRuntime(value)"), + contains(r"'cat' => _$CatFromRuntime(value)"), + contains(r"'Dog' => _$DogFromRuntime(value)"), contains('encode: (model) => switch (model)'), contains(r'Cat() => _$CatToRuntime(model)'), contains(r'Dog() => _$DogToRuntime(model)'), @@ -826,8 +812,7 @@ final class Dog extends Pet with _\$DogAck { 'account.dart': ''' $_imports -part 'account.ack.dart'; -part 'account.ack.g.dart'; +part 'account.g.dart'; @Schemable(schemaName: 'WireAccountSchema') final class Account with _\$AccountAck { @@ -837,7 +822,7 @@ final class Account with _\$AccountAck { ''', }, outputs: { - 'test_pkg|lib/account.ack.dart': decodedMatches( + 'test_pkg|lib/account.g.dart': decodedMatches( allOf([ contains('final _accountObject = Ack.object'), contains('final _accountSchema = _accountObject.codec'), @@ -859,8 +844,7 @@ final class Account with _\$AccountAck { 'fields.dart': ''' $_imports -part 'fields.ack.dart'; -part 'fields.ack.g.dart'; +part 'fields.g.dart'; @Schemable() final class Example with _\$ExampleAck { @@ -915,14 +899,6 @@ final class Forced with _\$ForcedAck { final String? summary; } -@Schemable() -final class LegacyOptional with _\$LegacyOptionalAck { - const LegacyOptional({this.note}); - - @AckField(presence: AckFieldPresence.optional) - final String? note; -} - final class ParameterEntry { const ParameterEntry(this.value); final String value; @@ -957,7 +933,7 @@ final class CapabilityBinding with _\$CapabilityBindingAck { ''', }, outputs: { - 'test_pkg|lib/fields.ack.dart': decodedMatches( + 'test_pkg|lib/fields.g.dart': decodedMatches( allOf([ contains( "'label': Ack.string().optional().nullable(value: false)", @@ -977,7 +953,6 @@ final class CapabilityBinding with _\$CapabilityBindingAck { contains("'value': Ack.string().nullable(value: false)"), isNot(contains("'value': Ack.string().optional()")), contains("'summary': Ack.string().nullable()"), - contains("'note': Ack.string().optional().nullable()"), contains("'parameters': parameterMapSchema().optional()"), isNot(contains('parameterMapSchema().optional().nullable(')), contains('Map? parameters'), @@ -1002,8 +977,7 @@ import 'package:ack/ack.dart' as ack import 'package:ack/annotations.dart' as annotations show Schemable; -part 'account.ack.dart'; -part 'account.ack.g.dart'; +part 'account.g.dart'; @annotations.Schemable() final class Account with _\$AccountAck { @@ -1012,7 +986,7 @@ final class Account with _\$AccountAck { ''', }, outputs: { - 'test_pkg|lib/account.ack.dart': decodedMatches( + 'test_pkg|lib/account.g.dart': decodedMatches( allOf([ contains('ack.Ack.object'), contains('ack.AckSchema, Account>'), diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 93f65eaa..53318812 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -34,41 +34,19 @@ Future _expectFailure( expect(seen, containsAll(messages)); } -Future _expectWarning(String body, List messages) async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - final seen = {}; - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - {'test_pkg|lib/model.dart': '$_head\n$body'}, - generateFor: const {'test_pkg|lib/model.dart'}, - readerWriter: readerWriter, - outputs: {'test_pkg|lib/model.ack.dart': decodedMatches(contains('mixin'))}, - onLog: (LogRecord log) { - if (log.level.name != 'WARNING') return; - for (final message in messages) { - if (log.message.contains(message)) seen.add(message); - } - }, - ); - expect(seen, containsAll(messages)); -} - const _head = ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; import 'package:json_annotation/json_annotation.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; '''; const _aliasHead = ''' import 'package:ack/ack.dart'; import 'aliases.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; '''; void main() { @@ -161,7 +139,7 @@ final class Person with _\$PersonAck { ], head: _aliasHead, allowedOutputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches(anything), + 'test_pkg|lib/address.g.dart': decodedMatches(anything), }, extraSources: { 'aliases.dart': ''' @@ -174,8 +152,7 @@ typedef Shade = Color; 'address.dart': ''' import 'package:ack/ack.dart'; -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -907,8 +884,7 @@ import 'package:ack/ack.dart' show Ack, AckSchema, AckSchemaModel, SchemaResult; import 'package:ack/annotations.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; ''', ); }); @@ -989,39 +965,14 @@ abstract final class CatSchema {} }); for (final collision in <({String name, String declaration})>[ - ( - name: r'_$UserFromRuntime', - declaration: - r'User _$UserFromRuntime(Map value) => throw 0;', - ), - ( - name: r'_$UserToRuntime', - declaration: - r'Map _$UserToRuntime(User value) => throw 0;', - ), - ( - name: '_ackUserFromRuntimeName', - declaration: 'String _ackUserFromRuntimeName(Object? value) => "";', - ), - ( - name: '_ackUserToRuntimeName', - declaration: 'Object? _ackUserToRuntimeName(String value) => value;', - ), - ( - name: r'_$UserFromJson', - declaration: - r'User _$UserFromJson(Map value) => throw 0;', - ), - ( - name: r'_$UserToJson', - declaration: r'Map _$UserToJson(User value) => throw 0;', - ), (name: r'_$UserAck', declaration: r'mixin _$UserAck {}'), (name: r'_userObject', declaration: r'final _userObject = Ack.object({});'), ( name: r'_userWireSchema', declaration: r'final _userWireSchema = Ack.string();', ), + (name: r'_$UserFromRuntime', declaration: r'void _$UserFromRuntime() {}'), + (name: r'_$UserToRuntime', declaration: r'void _$UserToRuntime() {}'), ]) { test('rejects local ${collision.name} helper collisions', () async { await _expectFailure( @@ -1131,16 +1082,14 @@ import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; import 'child.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; ''', extraSources: { 'child.dart': ''' import 'package:ack/annotations.dart'; import 'model.dart'; -part 'child.ack.dart'; -part 'child.ack.g.dart'; +part 'child.g.dart'; @Schemable() final class Child with _\$ChildAck { @@ -1171,16 +1120,14 @@ import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; import 'address.dart' show Address; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; ''', extraSources: { 'address.dart': ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -1191,7 +1138,7 @@ final class Address with _\$AddressAck { ''', }, allowedOutputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches(anything), + 'test_pkg|lib/address.g.dart': decodedMatches(anything), }, ); }, @@ -1209,8 +1156,7 @@ import 'package:ack/annotations.dart'; import 'address.dart' show Address; import 'address.dart' show AddressSchema; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; @Schemable() final class Order with _\$OrderAck { @@ -1223,8 +1169,7 @@ final class Order with _\$OrderAck { import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -1240,8 +1185,8 @@ final class Address with _\$AddressAck { }, readerWriter: readerWriter, outputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches(anything), - 'test_pkg|lib/model.ack.dart': decodedMatches( + 'test_pkg|lib/address.g.dart': decodedMatches(anything), + 'test_pkg|lib/model.g.dart': decodedMatches( contains('AddressSchema.schema'), ), }, @@ -1264,8 +1209,7 @@ import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; import 'exports.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; ''', extraSources: { 'exports.dart': "export 'address.dart' show Address;", @@ -1273,8 +1217,7 @@ part 'model.ack.g.dart'; import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'address.ack.dart'; -part 'address.ack.g.dart'; +part 'address.g.dart'; @Schemable() final class Address with _\$AddressAck { @@ -1285,7 +1228,7 @@ final class Address with _\$AddressAck { ''', }, allowedOutputs: { - 'test_pkg|lib/address.ack.dart': decodedMatches(anything), + 'test_pkg|lib/address.g.dart': decodedMatches(anything), }, ); }); @@ -1334,105 +1277,68 @@ final class User with _\$UserAck { ); }); - test( - 'rejects optional presence on a required constructor parameter', - () async { - await _expectFailure( - ''' -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(presence: AckFieldPresence.optional) - final String name; -} -''', - ['User.name', 'optional', 'constructor'], - ); - }, - ); - - test('rejects @Optional() on a required constructor parameter', () async { + test('rejects blank @AckField(name: ...)', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { const User({required this.name}); - @Optional() + @AckField(name: ' ') final String name; } ''', - ['User.name', 'optional', 'constructor'], + ['User.name', '@AckField.name must not be blank.'], ); }); - test('rejects combining @Optional() and @Required()', () async { + test('rejects combining @AckField(name: ...) and @JsonKey(name: ...)', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { - const User({this.name}); + const User({required this.name}); - @Optional() - @Required() - final String? name; + @AckField(name: 'a') + @JsonKey(name: 'b') + final String name; } ''', - ['User.name', '@Optional()', '@Required()'], + ['User.name', 'sets both @AckField(name: ...) and @JsonKey(name: ...)'], ); }); - test('rejects conflicting legacy and new presence declarations', () async { + test('rejects @Optional() on a required constructor parameter', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { - const User({this.name}); + const User({required this.name}); @Optional() - @AckField(presence: AckFieldPresence.required) - final String? name; + final String name; } ''', - ['User.name', 'conflicting', 'presence'], + ['User.name', 'optional', 'constructor'], ); }); - test('warns when legacy AckField presence is used', () async { - await _expectWarning( + test('rejects combining @Optional() and @Required()', () async { + await _expectFailure( ''' @Schemable() final class User with _\$UserAck { const User({this.name}); - @AckField(presence: AckFieldPresence.optional) + @Optional() + @Required() final String? name; } ''', - ['User.name', '@AckField(presence:', '@Optional()', '2.0.0'], + ['User.name', '@Optional()', '@Required()'], ); }); - test( - 'warns when matching legacy and new presence declarations coexist', - () async { - await _expectWarning( - ''' -@Schemable() -final class User with _\$UserAck { - const User({this.name}); - - @Optional() - @AckField(presence: AckFieldPresence.optional) - final String? name; -} -''', - ['User.name', '@AckField(presence:', '2.0.0'], - ); - }, - ); - test('rejects an unmapped constructor parameter', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/integration/deprecated_annotations_test.dart b/packages/ack_generator/test/integration/deprecated_annotations_test.dart deleted file mode 100644 index ae25e77b..00000000 --- a/packages/ack_generator/test/integration/deprecated_annotations_test.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:ack_generator/src/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:logging/logging.dart'; -import 'package:test/test.dart'; - -const _head = ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -part 'model.ack.dart'; -part 'model.ack.g.dart'; -'''; - -Future _generate(String body) async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - late String output; - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - {'test_pkg|lib/model.dart': '$_head\n$body'}, - generateFor: const {'test_pkg|lib/model.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/model.ack.dart': decodedMatches( - predicate((generated) { - output = generated; - return true; - }), - ), - }, - ); - return output; -} - -Future> _errors(String body) async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - final errors = []; - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - {'test_pkg|lib/model.dart': '$_head\n$body'}, - generateFor: const {'test_pkg|lib/model.dart'}, - readerWriter: readerWriter, - outputs: const {}, - onLog: (LogRecord log) { - if (log.level == Level.SEVERE) errors.add(log.message); - }, - ); - return errors; -} - -const _classModel = r''' -@ANNOTATION(caseStyle: AckCaseStyle.snake, description: 'An account.') -final class Account with _$AccountAck { - const Account({required this.displayName, this.tags = const []}); - - final String displayName; - final List tags; -} -'''; - -const _schemaModel = ''' -@ANNOTATION(name: 'Profile') -final profileSchema = Ack.object({ - 'handle': Ack.string().minLength(2), - 'bio': Ack.string().optional(), -}); -'''; - -void main() { - test( - '@AckModel generates the same class-first output as @Schemable', - () async { - final schemable = await _generate( - _classModel.replaceFirst('ANNOTATION', 'Schemable'), - ); - final deprecated = await _generate( - _classModel.replaceFirst('ANNOTATION', 'AckModel'), - ); - - expect(schemable, contains('abstract final class AccountSchema')); - expect(deprecated, schemable); - }, - ); - - test( - '@AckInfer generates the same schema-first output as @Schemable', - () async { - final schemable = await _generate( - _schemaModel.replaceFirst('ANNOTATION', 'Schemable'), - ); - final deprecated = await _generate( - _schemaModel.replaceFirst('ANNOTATION', 'AckInfer'), - ); - - expect(schemable, contains('@Schemable.generatedJson')); - expect(deprecated, schemable); - }, - ); - - test( - 'class-first diagnostics name the annotation that was written', - () async { - const body = r''' -@ANNOTATION() -final class _Hidden with _$_HiddenAck { - const _Hidden(); -} -'''; - - expect( - await _errors(body.replaceFirst('ANNOTATION', 'Schemable')), - contains(contains('@Schemable requires a public class')), - ); - expect( - await _errors(body.replaceFirst('ANNOTATION', 'AckModel')), - contains(contains('@AckModel requires a public class')), - ); - }, - ); - - test( - 'schema-first diagnostics name the annotation that was written', - () async { - const body = ''' -@ANNOTATION(name: 'lower') -final userSchema = Ack.object({'id': Ack.string()}); -'''; - - expect( - await _errors(body.replaceFirst('ANNOTATION', 'Schemable')), - contains(contains('Invalid @Schemable name "lower"')), - ); - expect( - await _errors(body.replaceFirst('ANNOTATION', 'AckInfer')), - contains(contains('Invalid @AckInfer name "lower"')), - ); - }, - ); - - test('@AckInfer on a class points to @Schemable', () async { - final errors = await _errors(''' -@AckInfer() -final class Account {} -'''); - - expect( - errors, - contains( - contains( - '@AckInfer can only be applied to top-level schema variables or ' - 'getters, not classes. Use @Schemable() on the class.', - ), - ), - ); - }); -} diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 101f634e..91fc4fe9 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -112,52 +112,35 @@ dependency_overrides: final first = _generatedContents(temporaryExample); expect(first.keys, { - 'lib/additional_properties_example.ack.dart', - 'lib/additional_properties_example.ack.g.dart', - 'lib/class_first_models.ack.dart', - 'lib/class_first_models.ack.g.dart', - 'lib/pet.ack.dart', - 'lib/pet.ack.g.dart', - 'lib/schema_types_discriminated.ack.dart', - 'lib/schema_types_discriminated.ack.g.dart', - 'lib/schema_types_edge_cases.ack.dart', - 'lib/schema_types_edge_cases.ack.g.dart', - 'lib/schema_types_primitives.ack.dart', - 'lib/schema_types_primitives.ack.g.dart', - 'lib/schema_types_simple.ack.dart', - 'lib/schema_types_simple.ack.g.dart', - 'lib/schema_types_transforms.ack.dart', - 'lib/schema_types_transforms.ack.g.dart', - 'lib/user_with_color.ack.dart', - 'lib/user_with_color.ack.g.dart', + 'lib/additional_properties_example.g.dart', + 'lib/class_first_models.g.dart', + 'lib/pet.g.dart', + 'lib/schema_types_discriminated.g.dart', + 'lib/schema_types_edge_cases.g.dart', + 'lib/schema_types_primitives.g.dart', + 'lib/schema_types_simple.g.dart', + 'lib/schema_types_transforms.g.dart', + 'lib/user_with_color.g.dart', }); for (final entry in first.entries) { final isClassFirst = entry.key.contains('class_first_models'); - if (entry.key.endsWith('.ack.dart')) { - if (isClassFirst) { - expect(entry.value, contains(r'mixin _$AccountAck')); - expect(entry.value, contains('final _accountSchema')); - expect( - entry.value, - contains('abstract final class AccountSchema'), - ); - expect(entry.value, isNot(contains('final accountSchema'))); - } else { - expect(entry.value, contains('class ')); - expect(entry.value, contains('generatedJson')); - } - expect(entry.value, isNot(contains('extension type'))); - expect(entry.value, isNot(contains('fromMap'))); - expect(entry.value, isNot(contains('toMap'))); - } else { - expect(entry.value, contains('JsonSerializableGenerator')); + if (isClassFirst) { + expect(entry.value, contains(r'mixin _$AccountAck')); + expect(entry.value, contains('final _accountSchema')); expect( entry.value, - contains( - isClassFirst ? '_ackAccountFromRuntime' : '_ackFromRuntime', - ), + contains('abstract final class AccountSchema'), ); + expect(entry.value, contains(r'decode: _$AccountFromRuntime')); + expect(entry.value, isNot(contains('final accountSchema'))); + } else { + expect(entry.value, contains('class ')); } + expect(entry.value, isNot(contains('generatedJson'))); + expect(entry.value, isNot(contains('JsonSerializableGenerator'))); + expect(entry.value, isNot(contains('extension type'))); + expect(entry.value, isNot(contains('fromMap'))); + expect(entry.value, isNot(contains('toMap'))); } _expectSuccess( @@ -238,7 +221,7 @@ dependency_overrides: ), ); final generatedFile = File( - p.join(temporaryExample.path, 'lib', 'schema_types_simple.ack.dart'), + p.join(temporaryExample.path, 'lib', 'schema_types_simple.g.dart'), ); final before = generatedFile.readAsStringSync(); diff --git a/packages/ack_generator/test/integration/inference_literal_test.dart b/packages/ack_generator/test/integration/inference_literal_test.dart index 7460d69b..8625ab3b 100644 --- a/packages/ack_generator/test/integration/inference_literal_test.dart +++ b/packages/ack_generator/test/integration/inference_literal_test.dart @@ -100,26 +100,4 @@ void widget({@Matches(${example.literal}) required String value}) {} ); }); } - - test('the deprecated @Pattern spelling still adds matches', () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - const _PatternProbe(), - { - 'test_pkg|lib/widget.dart': ''' -import 'package:ack/annotations.dart'; - -void widget({@Pattern('^a') required String value}) {} -''', - }, - generateFor: const {'test_pkg|lib/widget.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/widget.probe': decodedMatches( - "Ack.string().matches('^a')", - ), - }, - ); - }); } diff --git a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart new file mode 100644 index 00000000..3585c70b --- /dev/null +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -0,0 +1,234 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +Future _run(Directory project, List args) => + Process.run('dart', args, workingDirectory: project.path); + +void _succeeds(ProcessResult result, String command) { + expect( + result.exitCode, + 0, + reason: '$command failed\n${result.stdout}\n${result.stderr}', + ); +} + +void main() { + test( + 'JSON Schema builds into usable models in one deterministic build', + () async { + var repository = Directory.current; + while (!Directory( + p.join(repository.path, 'packages', 'ack_generator'), + ).existsSync()) { + repository = repository.parent; + } + final project = await Directory.systemTemp.createTemp('ack_json_schema_'); + try { + Directory(p.join(project.path, 'lib')).createSync(); + Directory(p.join(project.path, 'test')).createSync(); + File(p.join(project.path, 'pubspec.yaml')).writeAsStringSync(''' +name: ack_json_schema_runtime +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' +dependencies: + ack: + path: ${p.join(repository.path, 'packages', 'ack')} +dev_dependencies: + ack_generator: + path: ${p.join(repository.path, 'packages', 'ack_generator')} + build_runner: ^2.15.0 + test: ^1.29.0 +dependency_overrides: + ack: + path: ${p.join(repository.path, 'packages', 'ack')} +'''); + File(p.join(project.path, 'build.yaml')).writeAsStringSync(''' +targets: + \$default: + builders: + ack_generator:ack_json_schema: + enabled: true + options: + unknown_properties: preserve + assert_formats: true + documents: + "https://example.test/flag.json": "lib/flag.json" +'''); + File(p.join(project.path, 'lib', 'flag.json')).writeAsStringSync( + '{"type":"object","properties":{"flag":{"type":"boolean"}},' + '"required":["flag"],"additionalProperties":false}', + ); + final schema = File( + p.join(project.path, 'lib', 'contract.schema.json'), + ); + schema.writeAsStringSync(r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Photo": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + "additionalProperties": false + }, + "Listing": { + "type": "object", + "properties": { + "key": {"type": "string"}, + "photo": {"$ref": "#/$defs/Photo"} + }, + "required": ["key"], + "additionalProperties": false + } + } +} +'''); + File( + p.join(project.path, 'test', 'models_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_schema_runtime/contract.schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('parse, preserve extras, round-trip, and typed copyWith', () { + final listing = Listing.parse({ + 'key': 'abc', + 'photo': {'url': 'https://example.test/1', 'newField': 1}, + 'serverField': true, + }); + expect(listing.key, 'abc'); + expect(listing.photo?.url, 'https://example.test/1'); + expect(listing.photo?.additionalProperties, {'newField': 1}); + expect(listing.additionalProperties, {'serverField': true}); + expect(listing.toJson(), { + 'key': 'abc', + 'photo': {'url': 'https://example.test/1', 'newField': 1}, + 'serverField': true, + }); + expect(listing.copyWith(key: 'def').key, 'def'); + expect(listing.copyWith(), listing); + }); +} +'''); + + File( + p.join(project.path, 'lib', 'advanced.schema.json'), + ).writeAsStringSync(r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Node": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "value": {"type": "integer"}, + "next": {"$dynamicRef": "#node"} + }, + "required": ["value"], + "unevaluatedProperties": false + }, + "Choice": { + "oneOf": [ + {"type": "string", "format": "email"}, + {"type": "number", "multipleOf": 0.5} + ] + }, + "External": { + "$ref": "https://example.test/flag.json" + } + }, + "anyOf": [ + {"$ref": "#/$defs/Node"}, + {"$ref": "#/$defs/Choice"}, + {"$ref": "#/$defs/External"} + ] +} +'''); + File( + p.join(project.path, 'test', 'advanced_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_schema_runtime/advanced.schema.dart'; +import 'package:test/test.dart'; + +void main() { + test('validated value models preserve 2020-12 parse and encode', () { + final tree = {'value': 1, 'next': {'value': 2}}; + final node = Node.parse(tree); + expect(node.value, tree); + expect(Node.encode(node), tree); + expect(node.toJson(), tree); + expect(Node.safeParse({'value': 1, 'next': null}).isFail, isTrue); + expect(Node.safeParse({'value': 1, 'extra': true}).isFail, isTrue); + expect(Choice.safeParse('a@example.test').isOk, isTrue); + expect(Choice.safeParse('not-an-email').isFail, isTrue); + expect(Choice.safeParse(1.5).isOk, isTrue); + expect(Choice.safeParse(1.3).isFail, isTrue); + expect(Root.safeParse(tree).isOk, isTrue); + expect(Root.safeParse(1.3).isFail, isTrue); + expect(External.safeParse({'flag': true, 'new': 1}).isOk, isTrue); + expect(External.safeParse(1).isFail, isTrue); + expect(Root.encode(Root.parse('a@example.test')), 'a@example.test'); + }); +} +'''); + + _succeeds(await _run(project, ['pub', 'get']), 'dart pub get'); + _succeeds( + await _run(project, ['run', 'build_runner', 'build']), + 'first build_runner build', + ); + final outputs = { + for (final suffix in ['.dart', '.g.dart']) + suffix: File( + p.join(project.path, 'lib', 'contract.schema$suffix'), + ).readAsStringSync(), + }; + expect(outputs['.dart'], contains('unknown_properties: preserve')); + expect(outputs['.g.dart'], contains('class Listing')); + final advancedOutput = File( + p.join(project.path, 'lib', 'advanced.schema.dart'), + ).readAsStringSync(); + expect(advancedOutput, contains('final class Node')); + expect(advancedOutput, contains('final class Choice')); + expect(advancedOutput, contains('final class Root')); + _succeeds( + await _run(project, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + _succeeds(await _run(project, ['test']), 'dart test'); + _succeeds( + await _run(project, ['run', 'build_runner', 'build']), + 'second build_runner build', + ); + expect({ + for (final suffix in outputs.keys) + suffix: File( + p.join(project.path, 'lib', 'contract.schema$suffix'), + ).readAsStringSync(), + }, outputs); + expect( + File( + p.join(project.path, 'lib', 'advanced.schema.dart'), + ).readAsStringSync(), + advancedOutput, + ); + + schema.writeAsStringSync(r''' +{"$defs":{"Broken":{"$ref":"#/$defs/Missing"}}} +'''); + final rejected = await _run(project, ['run', 'build_runner', 'build']); + expect(rejected.exitCode, isNot(0)); + expect( + '${rejected.stdout}\n${rejected.stderr}', + contains(r'#/$defs/Broken/$ref'), + ); + } finally { + project.deleteSync(recursive: true); + } + }, + timeout: const Timeout(Duration(minutes: 5)), + ); +} diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart index dcc7ff40..81099198 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -38,7 +38,7 @@ int _helperDefinitionCount(String source, String className, String suffix) { void main() { test( - 'ack_generator alone generates json_serializable Ack helpers', + 'ack_generator alone generates direct runtime helpers in .g.dart without json_serializable', () async { final projectRoot = _projectRoot(); final temporary = await Directory.systemTemp.createTemp( @@ -65,8 +65,7 @@ dependency_overrides: File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -81,26 +80,15 @@ final userSchema = Ack.object({ 'clean build_runner build', ); - final ackPart = File( - p.join(temporary.path, 'lib', 'user.ack.dart'), - ).readAsStringSync(); - final jsonPart = File( - p.join(temporary.path, 'lib', 'user.ack.g.dart'), + final generatedPart = File( + p.join(temporary.path, 'lib', 'user.g.dart'), ).readAsStringSync(); - expect(ackPart, contains('@Schemable.generatedJson')); - expect(ackPart, contains(r'_$UserFromJson')); - expect(ackPart, contains('_ackFromRuntimeCreatedAt')); - expect(jsonPart, contains('JsonSerializableGenerator')); - expect(jsonPart, contains('User._ackFromRuntimeName(json[\'name\'])')); - expect( - jsonPart, - contains('User._ackFromRuntimeCreatedAt(json[\'createdAt\'])'), - ); - expect(jsonPart, contains('User._ackToRuntimeName(instance.name)')); - expect(_helperDefinitionCount(jsonPart, 'User', 'FromJson'), 1); - expect(_helperDefinitionCount(jsonPart, 'User', 'ToJson'), 1); - expect(jsonPart, isNot(contains("value['name']"))); + expect(generatedPart, contains('AckModelGenerator')); + expect(generatedPart, contains('User._fromAckRuntime')); + expect(generatedPart, contains('model._toAckRuntime()')); + expect(generatedPart, isNot(contains('generatedJson'))); + expect(generatedPart, isNot(contains('JsonSerializableGenerator'))); } finally { temporary.deleteSync(recursive: true); } @@ -109,7 +97,7 @@ final userSchema = Ack.object({ ); test( - 'ordinary json_serializable coexists without duplicate Ack helpers', + 'ordinary json_serializable coexists in the same .g.dart part via combining_builder', () async { final projectRoot = _projectRoot(); final temporary = await Directory.systemTemp.createTemp( @@ -141,11 +129,6 @@ dependency_overrides: targets: \$default: builders: - # Both legacy AckType and source_gen own `.g.dart` by contract. This - # modern-only library disables the unused legacy builder so the ordinary - # combining builder remains the sole `.g.dart` owner. - ack_generator:ack_generator: - enabled: false json_serializable: options: include_if_null: true @@ -154,8 +137,6 @@ targets: import 'package:ack/ack.dart'; import 'package:json_annotation/json_annotation.dart'; -part 'same.ack.dart'; -part 'same.ack.g.dart'; part 'same.g.dart'; @Schemable() @@ -201,26 +182,21 @@ void main() { 'dart analyze --fatal-infos', ); - final ackJson = File( - p.join(temporary.path, 'lib', 'same.ack.g.dart'), - ).readAsStringSync(); - final ordinaryJson = File( + final combinedJson = File( p.join(temporary.path, 'lib', 'same.g.dart'), ).readAsStringSync(); - expect(_helperDefinitionCount(ackJson, 'User', 'FromJson'), 1); - expect(_helperDefinitionCount(ackJson, 'User', 'ToJson'), 1); - expect(_helperDefinitionCount(ordinaryJson, 'User', 'FromJson'), 0); - expect(_helperDefinitionCount(ordinaryJson, 'User', 'ToJson'), 0); + expect(_helperDefinitionCount(combinedJson, 'User', 'FromJson'), 0); + expect(_helperDefinitionCount(combinedJson, 'User', 'ToJson'), 0); expect( - _helperDefinitionCount(ordinaryJson, 'SameEnvelope', 'FromJson'), + _helperDefinitionCount(combinedJson, 'SameEnvelope', 'FromJson'), 1, ); expect( - _helperDefinitionCount(ordinaryJson, 'SameEnvelope', 'ToJson'), + _helperDefinitionCount(combinedJson, 'SameEnvelope', 'ToJson'), 1, ); - expect(ackJson, contains('User._ackFromRuntimeName(json[\'name\'])')); - expect(ackJson, contains('JsonSerializableGenerator')); + expect(combinedJson, contains('User._fromAckRuntime')); + expect(combinedJson, contains('JsonSerializableGenerator')); _expectSuccess(await _run(temporary, ['test']), 'dart test'); } finally { temporary.deleteSync(recursive: true); @@ -230,7 +206,7 @@ void main() { ); test( - 'prefixed barrel Schemable imports compile through the JSON phase', + 'prefixed barrel Schemable imports compile cleanly into .g.dart', () async { final projectRoot = _projectRoot(); final temporary = await Directory.systemTemp.createTemp( @@ -267,8 +243,7 @@ dependency_overrides: File(p.join(temporary.path, 'lib', 'user.dart')).writeAsStringSync(r''' import 'support.dart' as support; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @support.Schemable() final userSchema = support.Ack.object({ @@ -302,12 +277,11 @@ void main() { 'dart analyze --fatal-infos', ); - final ackPart = File( - p.join(temporary.path, 'lib', 'user.ack.dart'), + final generatedPart = File( + p.join(temporary.path, 'lib', 'user.g.dart'), ).readAsStringSync(); - expect(ackPart, contains('@support.Schemable.generatedJson')); - expect(ackPart, contains('support.AckModelAdapter')); - expect(ackPart, contains('final support.Role role;')); + expect(generatedPart, contains('support.AckModelAdapter')); + expect(generatedPart, contains('final support.Role role;')); _expectSuccess(await _run(temporary, ['test']), 'dart test'); } finally { temporary.deleteSync(recursive: true); diff --git a/packages/ack_generator/test/integration/legacy_runtime_build_test.dart b/packages/ack_generator/test/integration/legacy_runtime_build_test.dart deleted file mode 100644 index 5bdb193e..00000000 --- a/packages/ack_generator/test/integration/legacy_runtime_build_test.dart +++ /dev/null @@ -1,120 +0,0 @@ -@Tags(['integration']) -library; - -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:test/test.dart'; - -Future _run(Directory directory, List arguments) => - Process.run('dart', arguments, workingDirectory: directory.path); - -void _expectSuccess(ProcessResult result, String command) { - expect( - result.exitCode, - 0, - reason: - '$command failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}', - ); -} - -void main() { - test( - 'an unchanged Ack 1.1 source builds and reports the migration deprecation', - () async { - var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { - projectRoot = projectRoot.parent; - } - final temporary = await Directory.systemTemp.createTemp( - 'ack_legacy_compat_', - ); - try { - Directory(p.join(temporary.path, 'lib')).createSync(); - Directory(p.join(temporary.path, 'test')).createSync(); - File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' -name: ack_legacy_compat -publish_to: none -environment: - sdk: '>=3.9.0 <4.0.0' -dependencies: - ack: - path: ${p.join(projectRoot.path, 'packages', 'ack')} -dev_dependencies: - ack_generator: - path: ${p.join(projectRoot.path, 'packages', 'ack_generator')} - build_runner: ^2.15.0 - test: ^1.29.0 -dependency_overrides: - ack: - path: ${p.join(projectRoot.path, 'packages', 'ack')} -'''); - File(p.join(temporary.path, 'lib', 'legacy.dart')).writeAsStringSync( - r''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -part 'legacy.g.dart'; - -@AckType(name: 'LegacyUser') -final userSchema = Ack.object({ - 'name': Ack.string(), -}).passthrough(); -''', - ); - File( - p.join(temporary.path, 'test', 'legacy_test.dart'), - ).writeAsStringSync(r''' -import 'package:ack_legacy_compat/legacy.dart'; -import 'package:test/test.dart'; - -List sortedKeys(Map value) => - value.keys.toList()..sort(); - -void main() { - test('legacy extension type keeps its Ack 1.1 API', () { - final user = LegacyUserType.parse({'name': 'Ada', 'role': 'admin'}); - final Map map = user; - expect(user.name, 'Ada'); - expect(user['name'], 'Ada'); - expect(user.args, {'role': 'admin'}); - expect(map.length, 2); - expect(sortedKeys(user), ['name', 'role']); - expect(map.values, containsAll(['Ada', 'admin'])); - expect( - map.entries.map((entry) => '${entry.key}=${entry.value}'), - containsAll(['name=Ada', 'role=admin']), - ); - expect(map.containsKey('name'), isTrue); - expect(map.containsValue('admin'), isTrue); - expect(LegacyUserType.safeParse({'name': 'Ada'}).isOk, isTrue); - }); -} -'''); - - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'build_runner build', - ); - _expectSuccess(await _run(temporary, ['test']), 'dart test'); - - final analysis = await _run(temporary, ['analyze']); - _expectSuccess(analysis, 'dart analyze'); - final diagnostics = '${analysis.stdout}\n${analysis.stderr}'; - expect( - diagnostics, - contains( - 'Use @Schemable() for schema-first or class-first models. AckType ' - 'will be removed in 2.0.0.', - ), - ); - } finally { - temporary.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); -} diff --git a/packages/ack_generator/test/integration/mixed_graph_test.dart b/packages/ack_generator/test/integration/mixed_graph_test.dart deleted file mode 100644 index 312d8cf9..00000000 --- a/packages/ack_generator/test/integration/mixed_graph_test.dart +++ /dev/null @@ -1,704 +0,0 @@ -import 'package:ack_generator/src/builder.dart'; -import 'package:build/build.dart'; -import 'package:build_test/build_test.dart'; -import 'package:logging/logging.dart'; -import 'package:test/test.dart'; - -Future _build( - Builder builder, - String source, { - required Map outputs, - void Function(LogRecord log)? onLog, - Map supportingSources = const {}, -}) async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - builder, - { - 'test_pkg|lib/models.dart': source, - for (final entry in supportingSources.entries) - 'test_pkg|lib/${entry.key}': entry.value, - }, - generateFor: const {'test_pkg|lib/models.dart'}, - readerWriter: readerWriter, - outputs: outputs, - onLog: onLog, - ); -} - -const _parts = """ -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -part 'models.g.dart'; -part 'models.ack.dart'; -part 'models.ack.g.dart'; -"""; - -void main() { - test( - 'unrelated legacy and modern declarations coexist in one library', - () async { - const source = - """ -$_parts -@AckType() -final legacySchema = Ack.object({'id': Ack.string()}); - -@Schemable() -final modernSchema = Ack.object({'name': Ack.string()}); -"""; - - await _build( - ackGenerator(BuilderOptions.empty), - source, - outputs: { - 'test_pkg|lib/models.g.dart': decodedMatches( - contains('extension type LegacyType('), - ), - }, - ); - await _build( - ackModelBuilder(BuilderOptions.empty), - source, - outputs: { - 'test_pkg|lib/models.ack.dart': decodedMatches( - contains('final class Modern'), - ), - }, - ); - }, - ); - - test('modern generators reject a class and facade name collision', () async { - var sawDiagnostic = false; - await _build( - ackModelBuilder(BuilderOptions.empty), - ''' -$_parts -@Schemable(name: 'AddressSchema') -final valueSchema = Ack.string(); - -@Schemable() -final class Address with _\$AddressAck { - const Address({required this.city}); - - final String city; -} -''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('AddressSchema') && - log.message.contains('schema-first class') && - log.message.contains('class-first facade')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - for (final entry in { - 'List': 'List', - 'Set': 'Set', - 'nested List': 'List>', - }.entries) { - test('class-first models reject nullable future-generated elements in ' - '${entry.key}', () async { - var sawLocatedDiagnostic = false; - await _build( - ackModelBuilder(BuilderOptions.empty), - ''' -$_parts -@Schemable() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({required this.addresses}); - - final ${entry.value} addresses; -} -''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('User.addresses') && - log.message.contains('nullable collection elements') && - log.message.contains('Ack.list')) { - sawLocatedDiagnostic = true; - } - }, - ); - expect(sawLocatedDiagnostic, isTrue); - }); - } - - test( - 'class-first models explain an unsupported future-generated Map value', - () async { - final errors = []; - await _build( - ackModelBuilder(BuilderOptions.empty), - ''' -$_parts -@Schemable() -final tagSchema = Ack.object({'label': Ack.string()}); - -@Schemable() -final class Board with _\$BoardAck { - const Board({required this.tags, required this.list}); - - final Map tags; - final List list; -} -''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') errors.add(log.message); - }, - ); - expect( - errors, - contains( - contains( - 'Board.tags uses Map. A model generated from a ' - '@Schemable schema in this build can be a field type directly or a ' - 'List or Set item, but not a Map value.', - ), - ), - ); - expect(errors, everyElement(isNot(contains('InvalidType')))); - }, - ); - - test('class-first models report an unresolvable field type', () async { - final errors = []; - await _build( - ackModelBuilder(BuilderOptions.empty), - ''' -$_parts -@Schemable() -final class Board with _\$BoardAck { - const Board({required this.owner}); - - final Missing owner; -} -''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE') errors.add(log.message); - }, - ); - expect( - errors, - contains( - contains( - 'Board.owner uses Missing, which does not resolve. Check that it is ' - 'imported.', - ), - ), - ); - }); - - test('class-first models allow a direct nullable future-generated model', () { - return _build( - ackModelBuilder(BuilderOptions.empty), - ''' -$_parts -@Schemable() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({this.address}); - - final Address? address; -} -''', - outputs: { - 'test_pkg|lib/models.ack.dart': decodedMatches( - allOf([ - contains(r'Address.$ack.schema.optional().nullable()'), - contains('address as Address?'), - ]), - ), - }, - ); - }); - - test( - 'AckField schema overrides nullable future-generated collection inference', - () { - return _build( - ackModelBuilder(BuilderOptions.empty), - ''' -$_parts -AckSchema> addressListSchema() => - Ack.any().nullable().codec>( - decode: (value) => value as List, - encode: (value) => value, - ); - -@Schemable() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({required this.addresses}); - - @AckField(schema: addressListSchema) - final List addresses; -} -''', - outputs: { - 'test_pkg|lib/models.ack.dart': decodedMatches( - contains('addressListSchema()'), - ), - }, - ); - }, - ); - - test( - 'class-first models resolve a future schema-first model through a barrel', - () { - return _build( - ackModelBuilder(BuilderOptions.empty), - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'exports.dart'; - -part 'models.ack.dart'; -part 'models.ack.g.dart'; - -@Schemable() -final class User with _\$UserAck { - const User({required this.address}); - - final Address address; -} -''', - supportingSources: { - 'address.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -part 'address.ack.dart'; -part 'address.ack.g.dart'; - -@Schemable() -final addressSchema = Ack.object({'city': Ack.string()}); -''', - 'exports.dart': "export 'address.dart' show addressSchema, Address;", - }, - outputs: { - 'test_pkg|lib/models.ack.dart': decodedMatches( - allOf([ - contains(r'Address.$ack.schema'), - contains(r'Address.$ack.fromRuntime'), - ]), - ), - 'test_pkg|lib/address.ack.dart': anything, - }, - ); - }, - ); - - test( - 'class-first models reject a future schema-first class hidden by a barrel', - () async { - var sawLocatedDiagnostic = false; - await _build( - ackModelBuilder(BuilderOptions.empty), - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'exports.dart'; - -part 'models.ack.dart'; -part 'models.ack.g.dart'; - -@Schemable() -final class User with _\$UserAck { - const User({required this.address}); - - final Address address; -} -''', - supportingSources: { - 'address.dart': ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -part 'address.ack.dart'; -part 'address.ack.g.dart'; - -@Schemable() -final addressSchema = Ack.object({'city': Ack.string()}); -''', - 'exports.dart': "export 'address.dart' show addressSchema;", - }, - outputs: {'test_pkg|lib/address.ack.dart': anything}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('User.address') && - log.message.contains('Address') && - log.message.contains('export combinator')) { - sawLocatedDiagnostic = true; - } - }, - ); - expect(sawLocatedDiagnostic, isTrue); - }, - ); - - test('modern schemas reject a nested legacy reference graph', () async { - var sawDiagnostic = false; - await _build( - ackModelBuilder(BuilderOptions.empty), - """ -$_parts -@AckType() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final userSchema = Ack.object({'address': addressSchema}); -""", - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('userSchema.address') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test('legacy schemas reject a nested modern reference graph', () async { - var sawDiagnostic = false; - await _build( - ackGenerator(BuilderOptions.empty), - """ -$_parts -@Schemable() -final addressSchema = Ack.object({'city': Ack.string()}); - -@AckType() -final userSchema = Ack.object({'address': addressSchema}); -""", - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('userSchema.address') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test('class-first models reject a nested legacy generated type', () async { - var sawDiagnostic = false; - await _build( - ackModelBuilder(BuilderOptions.empty), - """ -$_parts -@AckType() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({required this.addresses}); - - final List addresses; -} -""", - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('User.addresses') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test('class-first models reject a scalar legacy generated type', () async { - var sawDiagnostic = false; - await _build( - ackModelBuilder(BuilderOptions.empty), - """ -$_parts -@AckType() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({required this.address}); - - final AddressType address; -} -""", - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('User.address') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test('class-first models reject a resolved legacy generated type', () async { - const source = - """ -$_parts -@AckType() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({required this.addresses}); - - final List addresses; -} -"""; - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - ackGenerator(BuilderOptions.empty), - {'test_pkg|lib/models.dart': source}, - generateFor: const {'test_pkg|lib/models.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/models.g.dart': decodedMatches( - contains('extension type AddressType('), - ), - }, - ); - final generatedAsset = readerWriter.testing.assets.singleWhere( - (asset) => asset.path.endsWith('/models.g.dart'), - ); - final generatedSource = readerWriter.testing.readString(generatedAsset); - final resolvedReaderWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await resolvedReaderWriter.testing.loadIsolateSources(); - - var sawDiagnostic = false; - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/models.dart': source, - 'test_pkg|lib/models.g.dart': generatedSource, - }, - generateFor: const {'test_pkg|lib/models.dart'}, - readerWriter: resolvedReaderWriter, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('User.addresses') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test('class-first models reject a resolved scalar legacy type', () async { - const source = - """ -$_parts -@AckType() -final addressSchema = Ack.object({'city': Ack.string()}); - -@Schemable() -final class User with _\$UserAck { - const User({required this.address}); - - final AddressType address; -} -"""; - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - ackGenerator(BuilderOptions.empty), - {'test_pkg|lib/models.dart': source}, - generateFor: const {'test_pkg|lib/models.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/models.g.dart': decodedMatches( - contains('extension type AddressType('), - ), - }, - ); - final generatedAsset = readerWriter.testing.assets.singleWhere( - (asset) => asset.path.endsWith('/models.g.dart'), - ); - final generatedSource = readerWriter.testing.readString(generatedAsset); - final resolvedReaderWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await resolvedReaderWriter.testing.loadIsolateSources(); - - var sawDiagnostic = false; - await testBuilder( - ackModelBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/models.dart': source, - 'test_pkg|lib/models.g.dart': generatedSource, - }, - generateFor: const {'test_pkg|lib/models.dart'}, - readerWriter: resolvedReaderWriter, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('User.address') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test('legacy schemas reject a nested class-first facade', () async { - var sawDiagnostic = false; - await _build( - ackGenerator(BuilderOptions.empty), - """ -$_parts -@Schemable() -final class Address with _\$AddressAck { - const Address({required this.city}); - - final String city; -} - -@AckType() -final userSchema = Ack.object({ - 'addresses': Ack.list(AddressSchema.schema), -}); -""", - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('userSchema.addresses') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - for (final entry in { - 'scalar': 'AddressSchema.schema', - 'parenthesized': '(AddressSchema.schema)', - 'postfix non-null assertion': 'AddressSchema.schema!', - }.entries) { - test( - 'legacy schemas reject ${entry.key} class-first facade references', - () async { - var sawDiagnostic = false; - await _build( - ackGenerator(BuilderOptions.empty), - ''' -$_parts -@Schemable() -final class Address with _\$AddressAck { - const Address({required this.city}); - - final String city; -} - -@AckType() -final userSchema = Ack.object({'address': ${entry.value}}); -''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('userSchema.address') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }, - ); - } - - test('legacy schemas reject class-first facades through barrels', () async { - var sawDiagnostic = false; - await _build( - ackGenerator(BuilderOptions.empty), - ''' -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -import 'address_models.dart'; - -part 'models.g.dart'; -part 'models.ack.dart'; -part 'models.ack.g.dart'; - -@AckType() -final userSchema = Ack.object({'address': AddressSchema.schema}); -''', - supportingSources: const { - 'address_models.dart': "export 'address.dart';\n", - 'address.dart': ''' -import 'package:ack/annotations.dart'; - -@Schemable() -final class Address { - const Address({required this.city}); - - final String city; -} -''', - }, - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('userSchema.address') && - log.message.contains('migrate this connected graph together')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }); - - test( - 'legacy schemas reject an unparsed field instead of dropping it', - () async { - var sawDiagnostic = false; - await _build( - ackGenerator(BuilderOptions.empty), - ''' -$_parts -@AckType() -final userSchema = Ack.object({'name': (Ack.string())}); -''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains('userSchema.name') && - log.message.contains('unsupported schema expression')) { - sawDiagnostic = true; - } - }, - ); - expect(sawDiagnostic, isTrue); - }, - ); -} diff --git a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart index 97c28d90..60f9f7eb 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart @@ -120,8 +120,7 @@ import 'package:ack/ack.dart'; import 'types.dart'; -part 'models.ack.dart'; -part 'models.ack.g.dart'; +part 'models.g.dart'; @Schemable() final class Habit with _$HabitAck { @@ -292,7 +291,7 @@ void main() { 'build_runner build', ); final generated = File( - p.join(temporary.path, 'lib', 'models.ack.dart'), + p.join(temporary.path, 'lib', 'models.g.dart'), ).readAsStringSync(); expect(generated, contains('Command.schema()')); expect(generated, contains('Trigger.schema()')); diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index 98939a03..835bc6f7 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -8,8 +8,7 @@ const _head = ''' import 'package:ack/ack.dart'; import 'types.dart'; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; '''; const _types = r''' @@ -120,7 +119,7 @@ Future _expectOutput( }, generateFor: const {'test_pkg|lib/model.dart'}, readerWriter: readerWriter, - outputs: {'test_pkg|lib/model.ack.dart': decodedMatches(matcher)}, + outputs: {'test_pkg|lib/model.g.dart': decodedMatches(matcher)}, ); } @@ -268,8 +267,7 @@ final class Section with _$SectionAck { import 'package:ack/ack.dart'; import 'types.dart' as types; -part 'model.ack.dart'; -part 'model.ack.g.dart'; +part 'model.g.dart'; ''', ); }); diff --git a/packages/ack_generator/test/json_schema/fixture_library.dart b/packages/ack_generator/test/json_schema/fixture_library.dart new file mode 100644 index 00000000..b8235168 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixture_library.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; + +/// Directory of the JSON Schema fixtures and their golden schema libraries. +const fixtureDirectory = 'test/json_schema/fixtures'; + +/// The fixture `.schema.json` as a decoded document. +Map fixtureDocument(String name) => + jsonDecode(File('$fixtureDirectory/$name.schema.json').readAsStringSync()) + as Map; + +/// The URI the fixture `.schema.json` is generated under. +Uri fixtureUri(String name) => + Uri.parse('package:ack_generator_fixtures/$name.schema.json'); + +/// Generates the parts-free schema library for the fixture [name]. +AckJsonSchemaLibrary generateFixture( + String name, { + AckUnknownPropertiesMode unknownProperties = AckUnknownPropertiesMode.schema, +}) => generateAckSchemaLibrary( + document: fixtureDocument(name), + documentUri: fixtureUri(name), + partStem: '$name.schema', + includeParts: false, + options: AckJsonSchemaLibraryOptions(unknownProperties: unknownProperties), +); diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart new file mode 100644 index 00000000..63fbbbc4 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// ignore_for_file: type=lint +// coverage:ignore-file +// +// Generated from mapping.schema.json by ack_generator:ack_json_schema. + +import 'package:ack/ack.dart' as ack; + +@ack.Schemable(name: 'ListingAddress') +final listingAddressSchema = ack.Ack.object({'city': ack.Ack.string()}); + +@ack.Schemable(name: 'Listing') +final listingSchema = ack.Ack.object({ + 'key': ack.Ack.string() + .minLength(2) + .maxLength(8) + .describe('The key, with \'quotes\', \$dollars, and \\backslashes\\.'), + 'code': ack.Ack.string().matches('^[A-Z]{2}-\\d+\$').optional(), + 'rooms': ack.Ack.integer().min(1).max(9), + 'floors': ack.Ack.integer().greaterThan(0).max(4).optional(), + 'price': ack.Ack.number().min(0).lessThan(1000000.5).optional(), + 'active': ack.Ack.boolean(), + 'status': ack.Ack.enumString(['Active', 'Closed']).nullable().optional(), + 'kind': ack.Ack.literal('residential').optional(), + 'tags': ack.Ack.list( + ack.Ack.string(), + ).minItems(1).maxItems(3).unique().optional(), + 'notes': ack.Ack.string().nullable().optional(), + 'nickname': ack.Ack.string().nullable().optional(), + 'streetNumber': ack.Ack.any() + .refine( + (value) => value is String || value is num, + message: 'Expected a string or a number.', + ) + .nullable() + .optional(), + 'extra': ack.Ack.any().nullable().optional(), + 'labels': ack.Ack.map(ack.Ack.integer()).optional(), + 'meta': ack.Ack.map(ack.Ack.any().nullable()).optional(), + 'address': listingAddressSchema.optional(), + 'photo': photoSchema.optional(), + 'cover': photoSchema.nullable().optional(), + 'photos': ack.Ack.list(photoSchema).optional(), +}).describe('A listing.\nWith a second line.'); + +@ack.Schemable(name: 'Photo') +final photoSchema = ack.Ack.object({ + 'url': ack.Ack.string(), + 'width': ack.Ack.integer().optional(), +}, additionalProperties: true); diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.json b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.json new file mode 100644 index 00000000..b9ecd704 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Listing": { + "type": "object", + "description": "A listing.\nWith a second line.", + "properties": { + "key": { + "type": "string", + "minLength": 2, + "maxLength": 8, + "description": "The key, with 'quotes', $dollars, and \\backslashes\\." + }, + "code": { + "type": "string", + "pattern": "^[A-Z]{2}-\\d+$", + "examples": ["AB-12"] + }, + "rooms": { "type": "integer", "minimum": 1, "maximum": 9 }, + "floors": { + "type": "integer", + "exclusiveMinimum": 0, + "exclusiveMaximum": 4.5 + }, + "price": { + "type": "number", + "minimum": 0, + "exclusiveMaximum": 1000000.5 + }, + "active": { "type": "boolean" }, + "status": { "enum": ["Active", "Closed", "Active", null] }, + "kind": { "const": "residential" }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true + }, + "notes": { "anyOf": [{ "type": "string" }, { "type": "null" }] }, + "nickname": { "type": ["string", "null"] }, + "streetNumber": { + "anyOf": [ + { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + { "type": "null" } + ] + }, + "extra": {}, + "labels": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": { "type": "integer" } + }, + "meta": { "type": "object", "additionalProperties": true }, + "address": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + }, + "photo": { "$ref": "#/$defs/Photo" }, + "cover": { "anyOf": [{ "$ref": "#/$defs/Photo" }, { "type": "null" }] }, + "photos": { "type": "array", "items": { "$ref": "#/$defs/Photo" } } + }, + "required": ["key", "rooms", "active"], + "additionalProperties": false + }, + "Photo": { + "type": "object", + "properties": { + "url": { "type": "string" }, + "width": { "type": "integer" } + }, + "required": ["url"] + } + } +} diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart new file mode 100644 index 00000000..3c1ea588 --- /dev/null +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 +// ignore_for_file: type=lint +// coverage:ignore-file +// +// Generated from mapping.schema.json by ack_generator:ack_json_schema. +// unknown_properties: preserve widened these objects: +// - #/$defs/Listing +// - #/$defs/Listing/properties/address + +import 'package:ack/ack.dart' as ack; + +@ack.Schemable(name: 'ListingAddress') +final listingAddressSchema = ack.Ack.object({ + 'city': ack.Ack.string(), +}, additionalProperties: true); + +@ack.Schemable(name: 'Listing') +final listingSchema = ack.Ack.object({ + 'key': ack.Ack.string() + .minLength(2) + .maxLength(8) + .describe('The key, with \'quotes\', \$dollars, and \\backslashes\\.'), + 'code': ack.Ack.string().matches('^[A-Z]{2}-\\d+\$').optional(), + 'rooms': ack.Ack.integer().min(1).max(9), + 'floors': ack.Ack.integer().greaterThan(0).max(4).optional(), + 'price': ack.Ack.number().min(0).lessThan(1000000.5).optional(), + 'active': ack.Ack.boolean(), + 'status': ack.Ack.enumString(['Active', 'Closed']).nullable().optional(), + 'kind': ack.Ack.literal('residential').optional(), + 'tags': ack.Ack.list( + ack.Ack.string(), + ).minItems(1).maxItems(3).unique().optional(), + 'notes': ack.Ack.string().nullable().optional(), + 'nickname': ack.Ack.string().nullable().optional(), + 'streetNumber': ack.Ack.any() + .refine( + (value) => value is String || value is num, + message: 'Expected a string or a number.', + ) + .nullable() + .optional(), + 'extra': ack.Ack.any().nullable().optional(), + 'labels': ack.Ack.map(ack.Ack.integer()).optional(), + 'meta': ack.Ack.map(ack.Ack.any().nullable()).optional(), + 'address': listingAddressSchema.optional(), + 'photo': photoSchema.optional(), + 'cover': photoSchema.nullable().optional(), + 'photos': ack.Ack.list(photoSchema).optional(), +}, additionalProperties: true).describe('A listing.\nWith a second line.'); + +@ack.Schemable(name: 'Photo') +final photoSchema = ack.Ack.object({ + 'url': ack.Ack.string(), + 'width': ack.Ack.integer().optional(), +}, additionalProperties: true); diff --git a/packages/ack_generator/test/json_schema/instance_corpus.dart b/packages/ack_generator/test/json_schema/instance_corpus.dart new file mode 100644 index 00000000..5a5b10a8 --- /dev/null +++ b/packages/ack_generator/test/json_schema/instance_corpus.dart @@ -0,0 +1,220 @@ +/// Instances that probe whether two validators agree on a JSON Schema model. +/// +/// Starts from one valid instance and applies one mutation at a time: remove a +/// property, set it to `null`, replace it with a value of each JSON type, step +/// over a bound, and add unknown keys, including inside nested objects. +final class InstanceCorpus { + /// Creates a corpus over the `$defs` of an effective document. + InstanceCorpus(Map document) + : _defs = document[r'$defs']! as Map; + + final Map _defs; + + static const _probes = [ + 's', + 0, + 1, + 1.0, + 1.5, + true, + [], + {}, + ]; + + /// Every probe instance for the model named [name]. + List instancesFor(String name) { + final schema = _defs[name]; + final full = valid(schema)! as Map; + return [ + full, + _requiredOnly(schema, full), + {}, + null, + 'not an object', + ..._objectMutations(schema, full, depth: 0), + ]; + } + + /// A canonical valid value for [schema]. + Object? valid(Object? schema) { + final node = _resolve(schema); + if (node is! Map) return 'any'; + if (node['anyOf'] case final List branches) { + return valid(branches.firstWhere((b) => !_isNull(b))); + } + if (node['enum'] case final List values) { + return values.firstWhere((v) => v != null); + } + if (node.containsKey('const')) return node['const']; + return switch (_type(node)) { + 'string' => _validString(node), + 'integer' => _validInteger(node), + 'number' => _validNumber(node), + 'boolean' => true, + 'array' => _validArray(node), + 'object' => _validObject(node), + _ => 'any', + }; + } + + Iterable _objectMutations( + Object? schema, + Map instance, { + required int depth, + }) sync* { + final node = _resolve(schema); + if (node is! Map) return; + final properties = node['properties']; + if (properties is Map) { + for (final MapEntry(key: key, value: property) in properties.entries) { + final without = {...instance}..remove(key); + yield without; + for (final value in [null, ..._probes, ..._boundaryValues(property)]) { + yield {...instance, key: value}; + } + final nested = _resolve(_nonNull(property)); + final nestedValue = instance[key]; + if (depth == 0 && + nested is Map && + _type(nested) == 'object' && + nestedValue is Map) { + for (final mutation in _objectMutations( + nested, + nestedValue, + depth: depth + 1, + )) { + yield {...instance, key: mutation}; + } + } + } + } + yield {...instance, '__x': 1}; + yield {...instance, '__x': null}; + } + + List _boundaryValues(Object? schema) { + final node = _resolve(_nonNull(schema)); + if (node is! Map) return const []; + return [ + if (node['minLength'] case final int n when n > 0) 'a' * (n - 1), + if (node['maxLength'] case final int n) 'a' * (n + 1), + if (node['minLength'] case final int n) 'a' * n, + if (node['maxLength'] case final int n) 'a' * n, + for (final keyword in const [ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + ]) + if (node[keyword] case final num n) ...[ + n, + n - 1, + n + 1, + n - 0.5, + n + 0.5, + ], + if (node['enum'] != null || node['const'] != null) 'not-a-member', + // Every member, plus near misses, so a dropped or case-folded member + // shows up as a disagreement. + for (final member in [ + ...?(node['enum'] as List?), + if (node['const'] case final String value) value, + ]) + if (member is String) ...[member, '${member}x', member.toLowerCase()], + if (_type(node) == 'array') ...[ + [null], + [valid(node['items']), valid(node['items'])], + [for (var i = 0; i < ((node['maxItems'] as int?) ?? 3) + 1; i++) 'x$i'], + for (final probe in _probes) [probe], + ], + if (_type(node) == 'object' && node['properties'] == null) ...[ + {'k': 's'}, + {'k': 1}, + {'k': null}, + ], + ]; + } + + Map _requiredOnly( + Object? schema, + Map full, + ) { + final node = _resolve(schema)! as Map; + final required = {...?(node['required'] as List?)}; + return { + for (final MapEntry(:key, :value) in full.entries) + if (required.contains(key)) key: value, + }; + } + + String _validString(Map node) { + if (node['examples'] case [final String example, ...]) return example; + final minimum = (node['minLength'] as int?) ?? 1; + return 'a' * (minimum < 1 ? 1 : minimum); + } + + int _validInteger(Map node) { + final exclusive = node['exclusiveMinimum'] as num?; + final minimum = node['minimum'] as num?; + return switch ((minimum, exclusive)) { + (final num m, _) => m.ceil(), + (_, final num e) => e.floor() + 1, + _ => 1, + }; + } + + num _validNumber(Map node) { + final minimum = node['minimum'] as num?; + final exclusive = node['exclusiveMinimum'] as num?; + return minimum ?? (exclusive == null ? 1.5 : exclusive + 0.5); + } + + List _validArray(Map node) { + final count = (node['minItems'] as int?) ?? 1; + final item = valid(node['items']); + return [ + for (var i = 0; i < count; i++) + node['uniqueItems'] == true && item is String ? '$item$i' : item, + ]; + } + + Map _validObject(Map node) { + final properties = node['properties']; + if (properties is Map) { + return { + for (final MapEntry(:key, :value) in properties.entries) + key: valid(value), + }; + } + final additional = node['additionalProperties']; + return { + 'k': additional is Map && additional.isNotEmpty ? valid(additional) : 'v', + }; + } + + Object? _resolve(Object? schema) { + if (schema is Map && schema[r'$ref'] is String) { + final ref = schema[r'$ref']! as String; + return _defs[ref.substring(r'#/$defs/'.length)]; + } + return schema; + } + + Object? _nonNull(Object? schema) { + if (schema is Map && schema['anyOf'] is List) { + final branches = (schema['anyOf']! as List).where((b) => !_isNull(b)); + return branches.length == 1 ? _nonNull(branches.single) : schema; + } + return schema; + } + + static bool _isNull(Object? schema) => + schema is Map && (schema['type'] == 'null'); + + static String? _type(Map node) => switch (node['type']) { + final String type => type, + final List types => + types.firstWhere((t) => t != 'null') as String?, + _ => null, + }; +} diff --git a/packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart b/packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart new file mode 100644 index 00000000..f9fb1d8b --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_equivalence_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:ack/ack.dart'; +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:test/test.dart'; + +import 'fixture_library.dart'; +import 'fixtures/mapping.schema.dart' as schema_mode; +import 'fixtures/mapping_preserve.schema.dart' as preserve_mode; +import 'instance_corpus.dart'; + +/// Checks acceptance criterion 1: over a generated instance corpus, each +/// generated schema accepts exactly what `Ack.fromJsonSchema` accepts for the +/// effective document. +void main() { + final cases = { + AckUnknownPropertiesMode.schema: { + 'Listing': schema_mode.listingSchema, + 'Photo': schema_mode.photoSchema, + }, + AckUnknownPropertiesMode.preserve: { + 'Listing': preserve_mode.listingSchema, + 'Photo': preserve_mode.photoSchema, + }, + }; + + for (final MapEntry(key: mode, value: generated) in cases.entries) { + group('unknown_properties: ${mode.name}', () { + final effective = generateFixture( + 'mapping', + unknownProperties: mode, + ).effectiveDocument; + final corpus = InstanceCorpus(effective); + final uri = fixtureUri('mapping'); + + for (final MapEntry(key: name, value: schema) in generated.entries) { + test('$name agrees with Ack.fromJsonSchema', () { + final oracle = Ack.fromJsonSchema( + {r'$ref': '$uri#/\$defs/$name'}, + baseUri: uri.resolve('__oracle__.json'), + documents: {uri: effective}, + ); + final instances = corpus.instancesFor(name); + final mismatches = [ + for (final instance in instances) + if (oracle.safeParse(instance).isOk != + schema.safeParse(instance).isOk) + 'importer ${oracle.safeParse(instance).isOk ? 'accepts' : 'rejects'}, ' + 'generated ${schema.safeParse(instance).isOk ? 'accepts' : 'rejects'}: ' + '${jsonEncode(instance)}', + ]; + final properties = + ((effective[r'$defs']! as Map)[name]! as Map)['properties']! + as Map; + final accepted = instances.where((i) => oracle.safeParse(i).isOk); + // A broken corpus would pass trivially; require real coverage of + // both outcomes and at least ten probes per declared property. + expect( + instances.length, + greaterThanOrEqualTo(10 * properties.length), + ); + expect(accepted, isNotEmpty); + expect(accepted.length, lessThan(instances.length)); + expect(mismatches, isEmpty); + printOnFailure( + '$name: ${instances.length} instances, ${accepted.length} valid', + ); + }); + } + }); + } +} diff --git a/packages/ack_generator/test/json_schema/json_schema_golden_test.dart b/packages/ack_generator/test/json_schema/json_schema_golden_test.dart new file mode 100644 index 00000000..86e6133d --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_golden_test.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:test/test.dart'; + +import 'fixture_library.dart'; + +/// The committed schema libraries the equivalence test imports. +/// +/// Regenerate them with `ACK_UPDATE_GOLDENS=1 dart test +/// test/json_schema/json_schema_golden_test.dart`. +const _goldens = { + 'mapping.schema.dart': ('mapping', AckUnknownPropertiesMode.schema), + 'mapping_preserve.schema.dart': ( + 'mapping', + AckUnknownPropertiesMode.preserve, + ), +}; + +void main() { + final update = Platform.environment['ACK_UPDATE_GOLDENS'] == '1'; + + for (final MapEntry(key: golden, value: (fixture, mode)) + in _goldens.entries) { + test('$golden matches the generated library', () { + final source = generateFixture(fixture, unknownProperties: mode).source; + final file = File('$fixtureDirectory/$golden'); + if (update) file.writeAsStringSync(source); + expect( + file.readAsStringSync(), + source, + reason: 'Regenerate with ACK_UPDATE_GOLDENS=1.', + ); + }); + } +} diff --git a/packages/ack_generator/test/json_schema/json_schema_library_test.dart b/packages/ack_generator/test/json_schema/json_schema_library_test.dart new file mode 100644 index 00000000..edffd4a1 --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_library_test.dart @@ -0,0 +1,543 @@ +import 'package:ack_generator/src/json_schema/json_schema_generation_exception.dart'; +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:test/test.dart'; + +final _uri = Uri.parse('package:test_pkg/model.schema.json'); + +AckJsonSchemaLibrary _generate( + Object? document, { + AckUnknownPropertiesMode unknownProperties = AckUnknownPropertiesMode.schema, + AckJsonSchemaModelMode modelMode = AckJsonSchemaModelMode.auto, +}) => generateAckSchemaLibrary( + document: document, + documentUri: _uri, + partStem: 'model.schema', + includeParts: false, + options: AckJsonSchemaLibraryOptions( + unknownProperties: unknownProperties, + modelMode: modelMode, + ), +); + +Map _model( + Map properties, { + List required = const [], +}) => { + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': properties, + if (required.isNotEmpty) 'required': required, + }, + }, +}; + +/// The generated source without whitespace or formatter trailing commas, so +/// expectations don't depend on line breaks. +String _squash(String source) => source + .replaceAll(RegExp(r'\s'), '') + .replaceAll(',)', ')') + .replaceAll(',}', '}') + .replaceAll(',]', ']'); + +String _field(Object? schema, {bool required = false}) => _squash( + _generate(_model({'f': schema}, required: [if (required) 'f'])).source, +); + +List _diagnostics(Object? document) { + try { + _generate(document); + } on AckJsonSchemaGenerationException catch (error) { + return error.diagnostics; + } + fail('Expected generation to fail.'); +} + +Matcher _diagnostic(String pointer, String message) => + isA() + .having((d) => d.pointer, 'pointer', pointer) + .having((d) => d.message, 'message', contains(message)); + +void main() { + group('maps', () { + final rows = { + 'a string': ({'type': 'string'}, "'f':ack.Ack.string().optional()"), + 'string length bounds': ( + {'type': 'string', 'minLength': 2, 'maxLength': 8}, + 'ack.Ack.string().minLength(2).maxLength(8).optional()', + ), + 'an escaped pattern': ( + {'type': 'string', 'pattern': r'^[A-Z]{2}-\d+$'}, + r"ack.Ack.string().matches('^[A-Z]{2}-\\d+\$')", + ), + 'integer bounds': ( + {'type': 'integer', 'minimum': 1, 'maximum': 9}, + 'ack.Ack.integer().min(1).max(9)', + ), + 'fractional integer bounds inward': ( + {'type': 'integer', 'minimum': 1.5, 'exclusiveMaximum': 4.5}, + 'ack.Ack.integer().min(2).max(4)', + ), + 'integral exclusive integer bounds': ( + {'type': 'integer', 'exclusiveMinimum': 0, 'exclusiveMaximum': 5}, + 'ack.Ack.integer().greaterThan(0).lessThan(5)', + ), + 'a number as num': ( + {'type': 'number', 'minimum': 0, 'exclusiveMaximum': 1.5}, + 'ack.Ack.number().min(0).lessThan(1.5)', + ), + 'a boolean': ({'type': 'boolean'}, 'ack.Ack.boolean()'), + 'a deduped enum with null': ( + { + 'enum': ['A', 'B', 'A', null], + }, + "ack.Ack.enumString(['A','B']).nullable()", + ), + 'a string-typed enum ignoring a null member': ( + { + 'type': 'string', + 'enum': ['A', null], + }, + "ack.Ack.enumString(['A']).optional()", + ), + 'a string const': ({'const': 'x'}, "ack.Ack.literal('x')"), + 'array bounds and uniqueness': ( + { + 'type': 'array', + 'items': {'type': 'string'}, + 'minItems': 1, + 'maxItems': 3, + 'uniqueItems': true, + }, + 'ack.Ack.list(ack.Ack.string()).minItems(1).maxItems(3).unique()', + ), + 'anyOf with null': ( + { + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + }, + 'ack.Ack.string().nullable()', + ), + 'a nullable type list': ( + { + 'type': ['integer', 'null'], + }, + 'ack.Ack.integer().nullable()', + ), + 'a nested scalar union': ( + { + 'anyOf': [ + { + 'anyOf': [ + {'type': 'string'}, + {'type': 'number'}, + ], + }, + {'type': 'null'}, + ], + }, + 'ack.Ack.any().refine((value)=>valueisString||valueisnum,' + "message:'Expectedastringoranumber.').nullable()", + ), + 'the empty schema': ({}, 'ack.Ack.any().nullable()'), + 'a map with typed values': ( + { + 'type': 'object', + 'propertyNames': {'type': 'string'}, + 'additionalProperties': {'type': 'integer'}, + }, + 'ack.Ack.map(ack.Ack.integer())', + ), + 'an open map': ( + {'type': 'object'}, + 'ack.Ack.map(ack.Ack.any().nullable())', + ), + 'a description with special characters': ( + {'type': 'string', 'description': "It's \$5\n\\ok"}, + r"ack.Ack.string().describe('It\'s\$5\n\\ok')", + ), + }; + for (final MapEntry(key: name, value: (schema, expected)) in rows.entries) { + test(name, () => expect(_field(schema), contains(expected))); + } + + test('a required property without optional()', () { + expect( + _field({'type': 'string'}, required: true), + contains("'f':ack.Ack.string()}"), + ); + }); + + test('an inline object as a hoisted named model', () { + final source = _field({ + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + }, + 'additionalProperties': false, + }); + expect(source, contains("'f':modelFSchema.optional()")); + expect( + source, + contains( + "@ack.Schemable(name:'ModelF')finalmodelFSchema=" + "ack.Ack.object({'city':ack.Ack.string().optional()})", + ), + ); + }); + + test(r'a $ref as the referenced model', () { + final source = _squash( + _generate({ + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': { + 'photo': {r'$ref': r'#/$defs/Photo'}, + }, + }, + 'Photo': {'type': 'object', 'properties': {}}, + }, + }).source, + ); + expect(source, contains("'photo':photoSchema.optional()")); + }); + + test('models in document order with explicit names', () { + final source = _generate({ + r'$defs': { + 'Beta': {'type': 'object'}, + 'Alpha': {'type': 'object'}, + }, + }).source; + expect( + source.indexOf("Schemable(name: 'Beta')"), + lessThan(source.indexOf("Schemable(name: 'Alpha')")), + ); + expect(source, contains('final betaSchema = ack.Ack.object(')); + }); + }); + + group('unknown_properties: preserve', () { + test('widens strict objects and records them', () { + final library = _generate({ + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': { + 'address': { + 'type': 'object', + 'properties': { + 'street': {'type': 'string'}, + }, + 'additionalProperties': false, + }, + }, + 'additionalProperties': false, + }, + }, + }, unknownProperties: AckUnknownPropertiesMode.preserve); + + expect(library.widenedPointers, [ + r'#/$defs/Model', + r'#/$defs/Model/properties/address', + ]); + expect( + library.source, + contains(r'// - #/$defs/Model/properties/address'), + ); + final model = + (library.effectiveDocument[r'$defs']! as Map)['Model']! as Map; + expect(model['additionalProperties'], isTrue); + }); + + test('leaves the input document unchanged', () { + final document = + _model({ + 'f': {'type': 'string'}, + }) + ..[r'$defs'] = { + 'Model': { + 'type': 'object', + 'properties': { + 'f': {'type': 'string'}, + }, + 'additionalProperties': false, + }, + }; + _generate(document, unknownProperties: AckUnknownPropertiesMode.preserve); + expect( + ((document[r'$defs']! as Map)['Model']! as Map)['additionalProperties'], + isFalse, + ); + }); + + test('records fallback widenings in supplied resources', () { + final externalUri = Uri.parse('https://example.test/external.json'); + final external = { + 'type': 'object', + 'additionalProperties': false, + }; + final library = generateAckSchemaLibrary( + document: {r'$ref': externalUri.toString()}, + documentUri: _uri, + partStem: 'model.schema', + includeParts: false, + options: const AckJsonSchemaLibraryOptions( + unknownProperties: AckUnknownPropertiesMode.preserve, + ), + documents: {externalUri: external}, + ); + expect(external['additionalProperties'], isFalse); + expect(library.widenedPointers, ['$externalUri#']); + expect(library.source, contains('// - $externalUri#')); + }); + + test('escapes schema keys in generated widening comments', () { + final library = _generate( + _model({ + 'x\nfinal injected = true;': { + 'type': 'object', + 'additionalProperties': false, + }, + }), + unknownProperties: AckUnknownPropertiesMode.preserve, + ); + expect(library.source, contains(r'x\nfinal injected = true;')); + expect(library.source, isNot(contains('\nfinal injected = true;'))); + }); + }); + + group('Draft 2020-12 validated fallback', () { + final examples = { + 'composition': { + 'allOf': [ + {'type': 'string'}, + {'minLength': 2}, + ], + }, + 'format annotation': {'type': 'string', 'format': 'email'}, + 'multipleOf': {'type': 'number', 'multipleOf': 0.1}, + 'nullable array items': { + 'type': 'array', + 'items': { + 'type': ['string', 'null'], + }, + }, + 'unconstrained array': {'type': 'array'}, + 'mixed enum': { + 'enum': ['a', 1, null], + }, + 'conditional': { + 'if': { + 'properties': { + 'kind': {'const': 'a'}, + }, + }, + 'then': { + 'required': ['name'], + }, + }, + 'unevaluated properties': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + 'unevaluatedProperties': false, + }, + }; + for (final MapEntry(:key, :value) in examples.entries) { + test(key, () { + final library = _generate(_model({'f': value})); + expect(library.source, contains('final class Model')); + expect(library.source, contains('Ack.fromJsonSchema')); + expect(library.source, isNot(contains('@ack.Schemable'))); + expect(library.source, isNot(contains("part 'model.schema.g.dart'"))); + }); + } + + test(r'generates a root model without $defs', () { + final library = _generate({ + 'type': 'array', + 'prefixItems': [ + {'type': 'string'}, + {'type': 'integer'}, + ], + 'items': false, + }); + expect(library.source, contains('final class Root')); + expect(library.source, contains('rootSchema')); + }); + + test('generates boolean root schemas', () { + final library = _generate(false); + expect(library.source, contains('final class Root')); + expect(library.effectiveDocument, { + 'allOf': [false], + }); + }); + + test( + 'validated mode uses the exact importer for a typed-compatible model', + () { + final library = _generate( + _model({ + 'count': {'type': 'integer'}, + }), + modelMode: AckJsonSchemaModelMode.validated, + ); + expect(library.source, contains('final class Model')); + expect(library.source, isNot(contains('@ack.Schemable'))); + }, + ); + + test('assigns unique stable names to arbitrary definition keys', () { + final library = _generate({ + r'$defs': { + 'my-model': {'type': 'string'}, + 'my model': {'type': 'integer'}, + 'String': {'type': 'null'}, + }, + }); + expect(library.source, contains('final class MyModel ')); + expect(library.source, contains('final class MyModel2 ')); + expect(library.source, contains('final class StringModel ')); + expect(_generate(library.effectiveDocument).source, library.source); + }); + + test( + 'keeps a named Root definition when the document has a root model', + () { + final library = _generate({ + r'$defs': { + 'Root': {'type': 'string', 'format': 'email'}, + }, + r'$ref': r'#/$defs/Root', + }); + expect(library.source, contains('final class Root ')); + expect(library.source, contains('final class DocumentRoot ')); + }, + ); + + test('generates recursive and nullable definition wrappers', () { + final library = _generate({ + r'$defs': { + 'Node': { + 'anyOf': [ + { + 'type': 'object', + 'properties': { + 'next': {r'$ref': r'#/$defs/Node'}, + }, + }, + {'type': 'null'}, + ], + }, + }, + }); + expect(library.source, contains('final class Node')); + expect(library.source, contains('nodeSchema')); + }); + + test('reports invalid references at their JSON pointer', () { + expect( + _diagnostics( + _model({ + 'f': {r'$ref': r'#/$defs/Missing'}, + }), + ), + [ + _diagnostic( + r'#/$defs/Model/properties/f/$ref', + 'Unresolved schema reference', + ), + ], + ); + }); + + test('still rejects malformed and unsupported source semantics', () { + for (final (schema, keyword) in <(Object?, String)>[ + ({'type': 'not-a-type'}, 'type'), + ({'pattern': '['}, 'pattern'), + ( + { + r'$vocabulary': {'https://example.test/required': true}, + }, + r'$vocabulary', + ), + ]) { + expect(_diagnostics(_model({'f': schema})), [ + isA().having( + (diagnostic) => diagnostic.pointer, + 'pointer', + r'#/$defs/Model/properties/f/' + keyword, + ), + ]); + } + expect(_diagnostics(null), [_diagnostic('#', 'Expected')]); + }); + }); + + group('options', () { + test('parse unknown_properties', () { + expect( + AckJsonSchemaLibraryOptions.fromConfig(const { + 'unknown_properties': 'preserve', + }).unknownProperties, + AckUnknownPropertiesMode.preserve, + ); + expect( + AckJsonSchemaLibraryOptions.fromConfig(const {}).unknownProperties, + AckUnknownPropertiesMode.schema, + ); + final configured = AckJsonSchemaLibraryOptions.fromConfig(const { + 'assert_formats': true, + 'documents': {'https://example.test/schema.json': 'lib/schema.json'}, + }); + expect(configured.assertFormats, isTrue); + expect(configured.modelMode, AckJsonSchemaModelMode.auto); + expect(configured.documentPaths, { + Uri.parse('https://example.test/schema.json'): 'lib/schema.json', + }); + expect( + AckJsonSchemaLibraryOptions.fromConfig(const { + 'model_mode': 'validated', + }).modelMode, + AckJsonSchemaModelMode.validated, + ); + }); + + test('reject unknown keys and values', () { + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const {'typo': 1}), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'unknown_properties': 'loose', + }), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'assert_formats': 'yes', + }), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'documents': ['lib/schema.json'], + }), + throwsArgumentError, + ); + expect( + () => AckJsonSchemaLibraryOptions.fromConfig(const { + 'model_mode': 'loose', + }), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart b/packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart new file mode 100644 index 00000000..fe5e3036 --- /dev/null +++ b/packages/ack_generator/test/json_schema/json_schema_required_corpus_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ack_generator/src/json_schema/json_schema_library.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + const fixtureRoot = '../ack/test/fixtures/json_schema_2020_12_full'; + final remoteRoot = Directory('$fixtureRoot/remotes/draft2020-12'); + final documents = { + for (final file in remoteRoot.listSync(recursive: true).whereType()) + if (file.path.endsWith('.json')) + Uri.parse( + 'http://localhost:1234/draft2020-12/' + '${p.relative(file.path, from: remoteRoot.path).replaceAll('\\', '/')}', + ): jsonDecode( + file.readAsStringSync(), + )!, + }; + final corpus = Directory('$fixtureRoot/draft2020-12'); + + group('complete required 2020-12 generation corpus', () { + for (final file in corpus.listSync().whereType()) { + if (!file.path.endsWith('.json')) continue; + final filename = p.basename(file.path); + final groups = (jsonDecode(file.readAsStringSync()) as List) + .cast>(); + for (final group in groups) { + test('$filename: ${group['description']}', () { + final generated = generateAckSchemaLibrary( + document: group['schema'], + documentUri: Uri.parse( + 'http://localhost:1234/draft2020-12/__case__.json', + ), + partStem: 'case.schema', + documents: documents, + includeParts: false, + ); + expect(generated.source, contains('final class Root')); + expect(generated.effectiveDocument, isNotEmpty); + }); + } + } + }); +} diff --git a/packages/ack_generator/test/src/ack_exports_test.dart b/packages/ack_generator/test/src/ack_exports_test.dart index 23c1dba0..a1879848 100644 --- a/packages/ack_generator/test/src/ack_exports_test.dart +++ b/packages/ack_generator/test/src/ack_exports_test.dart @@ -40,7 +40,7 @@ void main() { readerWriter: readerWriter, outputs: { 'test_pkg|lib/probe.exports': decodedMatches( - 'AckCaseStyle,AckField,AckFieldPresence,AckUnknownPropertyPolicy,' + 'AckCaseStyle,AckField,AckUnknownPropertyPolicy,' 'NotNull,Optional,Required,Schemable', ), }, diff --git a/packages/ack_generator/test/src/generator_test.dart b/packages/ack_generator/test/src/generator_test.dart index 73ce77d3..3763326d 100644 --- a/packages/ack_generator/test/src/generator_test.dart +++ b/packages/ack_generator/test/src/generator_test.dart @@ -30,14 +30,13 @@ void main() { import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'schema.ack.dart'; -part 'schema.ack.g.dart'; +part 'schema.g.dart'; @Schemable() final userSchema = Ack.object({'name': Ack.string()}); ''', outputs: { - 'test_pkg|lib/schema.ack.dart': decodedMatches( + 'test_pkg|lib/schema.g.dart': decodedMatches( allOf([ contains('// GENERATED CODE - DO NOT MODIFY BY HAND'), contains("part of 'schema.dart';"), @@ -53,7 +52,7 @@ final userSchema = Ack.object({'name': Ack.string()}); await _build('final value = 1;', outputs: const {}); }); - test('reports the exact required part directives', () async { + test('reports the exact required part directive', () async { var sawError = false; await _build( ''' @@ -66,8 +65,7 @@ final userSchema = Ack.string(); outputs: const {}, onLog: (log) { if (log.level.name == 'SEVERE' && - log.message.contains("part 'schema.ack.dart';") && - log.message.contains("part 'schema.ack.g.dart';")) { + log.message.contains("part 'schema.g.dart';")) { sawError = true; } }, @@ -75,7 +73,7 @@ final userSchema = Ack.string(); expect(sawError, isTrue); }); - test('reports the required part directives for Schemable', () async { + test('reports the required part directive for Schemable classes', () async { var sawError = false; await _build( ''' @@ -91,8 +89,7 @@ final class User with _\$UserAck { outputs: const {}, onLog: (log) { if (log.level.name == 'SEVERE' && - log.message.contains("part 'schema.ack.dart';") && - log.message.contains("part 'schema.ack.g.dart';")) { + log.message.contains("part 'schema.g.dart';")) { sawError = true; } }, @@ -100,12 +97,10 @@ final class User with _\$UserAck { expect(sawError, isTrue); }); - test( - 'rejects a missing JSON part even when the Ack part is present', - () async { - var sawError = false; - await _build( - ''' + test('rejects legacy .ack.dart part directive without .g.dart', () async { + var sawError = false; + await _build( + ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; @@ -114,17 +109,16 @@ part 'schema.ack.dart'; @Schemable() final userSchema = Ack.string(); ''', - outputs: const {}, - onLog: (log) { - if (log.level.name == 'SEVERE' && - log.message.contains("part 'schema.ack.g.dart';")) { - sawError = true; - } - }, - ); - expect(sawError, isTrue); - }, - ); + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE' && + log.message.contains("part 'schema.g.dart';")) { + sawError = true; + } + }, + ); + expect(sawError, isTrue); + }); test('does not reject part directives with a leading ./', () async { var sawOurPartError = false; @@ -133,8 +127,7 @@ final userSchema = Ack.string(); import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part './schema.ack.dart'; -part './schema.ack.g.dart'; +part './schema.g.dart'; @Schemable() final userSchema = Ack.object({'name': Ack.string()}); @@ -159,8 +152,7 @@ final userSchema = Ack.object({'name': Ack.string()}); import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'sub/schema.ack.dart'; -part 'sub/schema.ack.g.dart'; +part 'sub/schema.g.dart'; @Schemable() final userSchema = Ack.string(); @@ -168,8 +160,7 @@ final userSchema = Ack.string(); outputs: const {}, onLog: (log) { if (log.level.name == 'SEVERE' && - log.message.contains("part 'schema.ack.dart';") && - log.message.contains("part 'schema.ack.g.dart';")) { + log.message.contains("part 'schema.g.dart';")) { sawError = true; } }, @@ -178,15 +169,14 @@ final userSchema = Ack.string(); }, ); - test('rejects a JSON part that does not match the basename', () async { + test('rejects a part that does not match the basename', () async { var sawError = false; await _build( ''' import 'package:ack/ack.dart'; import 'package:ack/annotations.dart'; -part 'schema.ack.dart'; -part 'other.ack.g.dart'; +part 'other.g.dart'; @Schemable() final userSchema = Ack.string(); @@ -194,7 +184,7 @@ final userSchema = Ack.string(); outputs: const {}, onLog: (log) { if (log.level.name == 'SEVERE' && - log.message.contains("part 'schema.ack.g.dart';")) { + log.message.contains("part 'schema.g.dart';")) { sawError = true; } }, @@ -202,19 +192,21 @@ final userSchema = Ack.string(); expect(sawError, isTrue); }); - test('rejects AckInfer on classes', () async { + test('rejects Schemable on enums', () async { var sawError = false; await _build( ''' import 'package:ack/annotations.dart'; -@AckInfer() -class InvalidSchema {} +@Schemable() +enum InvalidTarget { a, b } ''', outputs: const {}, onLog: (log) { if (log.level.name == 'SEVERE' && - log.message.contains('top-level schema variables or getters')) { + log.message.contains( + '@Schemable can only be applied to classes or top-level schema', + )) { sawError = true; } }, diff --git a/packages/ack_generator/test/src/json_builder_test.dart b/packages/ack_generator/test/src/json_builder_test.dart index 81f57507..f80f2d72 100644 --- a/packages/ack_generator/test/src/json_builder_test.dart +++ b/packages/ack_generator/test/src/json_builder_test.dart @@ -8,7 +8,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { - test('build.yaml encodes the two-phase Ack JSON contract', () { + test('build.yaml encodes the single-builder Ack 2.0 contract', () { var directory = Directory.current; if (!File(p.join(directory.path, 'build.yaml')).existsSync()) { directory = Directory( @@ -18,60 +18,30 @@ void main() { final yaml = File(p.join(directory.path, 'build.yaml')).readAsStringSync(); expect(yaml, contains('ack_generator:')); expect(yaml, contains('builder_factories: ["ackGenerator"]')); - expect(yaml, contains('build_extensions: {".dart": [".g.dart"]}')); - expect(yaml, contains('ack_models:')); - expect(yaml, contains('builder_factories: ["ackModelBuilder"]')); - expect(yaml, contains('build_extensions: {".dart": [".ack.dart"]}')); - expect(yaml, contains('ack_model_json:')); - expect(yaml, contains('builder_factories: ["ackModelJsonBuilder"]')); - expect(yaml, contains('build_extensions: {".dart": [".ack.g.dart"]}')); - expect(yaml, contains('required_inputs: [".ack.dart"]')); - expect(yaml, contains('ack_generator|ack_model_json')); - expect(yaml, isNot(contains('source_gen|combining_builder'))); + expect(yaml, contains('build_extensions: {".dart": [".ack.g.part"]}')); + expect(yaml, contains('build_to: cache')); + expect(yaml, contains('applies_builders: ["source_gen|combining_builder"]')); + expect(yaml, isNot(contains('ack_model_json'))); + expect(yaml, isNot(contains('.ack.g.dart'))); }); test('derived helper names stay deterministic', () { - expect(ackFromRuntimeBridgeName('createdAt'), '_ackFromRuntimeCreatedAt'); - expect(ackToRuntimeBridgeName('createdAt'), '_ackToRuntimeCreatedAt'); - expect(jsonFromHelperName('User'), r'_$UserFromJson'); - expect(jsonToHelperName('User'), r'_$UserToJson'); - expect(ackBridgePascal('name'), ackBridgePascal('Name')); + expect(ackClassFromRuntimeName('User'), r'_$UserFromRuntime'); + expect(ackClassToRuntimeName('User'), r'_$UserToRuntime'); + expect(ackCopyWithInterfaceName('User'), r'$UserCopyWith'); + expect(ackCopyWithImplementationName('User'), r'_$UserCopyWith'); + expect(ackCopyWithUnsetTypeName('User'), '_UserCopyWithUnset'); }); - test('JSON builder exits immediately for ordinary libraries', () async { + test('Ack model builder exits immediately for ordinary libraries', () async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); await readerWriter.testing.loadIsolateSources(); await testBuilder( - ackModelJsonBuilder(BuilderOptions.empty), + ackModelBuilder(BuilderOptions.empty), {'test_pkg|lib/plain.dart': 'final value = 1;'}, generateFor: const {'test_pkg|lib/plain.dart'}, readerWriter: readerWriter, outputs: const {}, ); }); - - test('JSON builder writes nothing when the Ack part is missing', () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - ackModelJsonBuilder(BuilderOptions.empty), - { - 'test_pkg|lib/model.dart': r''' -import 'package:ack/ack.dart'; - -part 'model.ack.dart'; -part 'model.ack.g.dart'; - -@Schemable() -final class User with _$UserAck { - const User({required this.name}); - final String name; -} -''', - }, - generateFor: const {'test_pkg|lib/model.dart'}, - readerWriter: readerWriter, - outputs: const {}, - ); - }); } diff --git a/packages/ack_generator/test/test_utils/test_assets.dart b/packages/ack_generator/test/test_utils/test_assets.dart deleted file mode 100644 index 4430ece2..00000000 --- a/packages/ack_generator/test/test_utils/test_assets.dart +++ /dev/null @@ -1,354 +0,0 @@ -/// Test assets for ack_generator tests -/// Provides reusable mock file contents -library; - -const metaAssets = { - 'meta|lib/meta_meta.dart': ''' -enum TargetKind { - classType, - field, - function, - getter, - library, - method, - setter, - topLevelVariable, - type, - parameter, -} - -class Target { - final Set kinds; - const Target(this.kinds); -} -''', -}; - -const ackPackageAnnotationsAsset = { - 'ack|lib/annotations.dart': ''' -library; - -import 'package:meta/meta_meta.dart'; - -@Target({TargetKind.topLevelVariable, TargetKind.getter}) -class AckType { - final String? name; - const AckType({this.name}); -} -''', -}; - -const ackPackageAsset = { - 'ack|lib/ack.dart': ''' -library ack; - -export 'annotations.dart'; -export 'src/ack.dart'; -export 'src/schemas/schema_model.dart'; -export 'src/schemas/object_schema.dart'; -export 'src/validation/ack_exception.dart'; -export 'src/validation/schema_result.dart'; -''', - 'ack|lib/src/ack.dart': ''' -class Ack { - static StringSchema string() => const StringSchema(); - static IntegerSchema integer() => const IntegerSchema(); - static DoubleSchema double() => const DoubleSchema(); - static NumberSchema number() => const NumberSchema(); - static BooleanSchema boolean() => const BooleanSchema(); - static AnySchema any() => const AnySchema(); - static TransformedSchema uri() => TransformedSchema(const StringSchema()); - static TransformedSchema date() => TransformedSchema(const StringSchema()); - static TransformedSchema datetime() => TransformedSchema(const StringSchema()); - static TransformedSchema duration() => TransformedSchema(const IntegerSchema()); - - static ListSchema list(AckSchema itemSchema) => ListSchema(itemSchema); - static MapSchema map(AckSchema valueSchema) => MapSchema(valueSchema); - static ObjectSchema object( - Map properties, { - List? required, - bool additionalProperties = false, - }) => - ObjectSchema( - properties, - required: required, - additionalProperties: additionalProperties, - ); - - static DiscriminatedSchema discriminated({ - required String discriminatorKey, - required Map schemas, - }) => - DiscriminatedSchema( - discriminatorKey: discriminatorKey, - schemas: schemas, - ); - - static StringSchema literal(String value) => const StringSchema(); - static StringSchema enumString(List values) => const StringSchema(); - static EnumSchema enumValues(List values) => EnumSchema(); -} - -abstract class AckSchema { - TransformedSchema transform(R Function(T value) transformer) => - TransformedSchema(this); - Map toJsonSchema(); -} -class TransformedSchema extends AckSchema { - final AckSchema schema; - TransformedSchema(this.schema); - TransformedSchema nullable() => this; - TransformedSchema optional() => this; - TransformedSchema describe(String description) => this; - - @override - Map toJsonSchema() => schema.toJsonSchema(); -} -class StringSchema extends AckSchema { - const StringSchema(); - StringSchema email() => this; - StringSchema notEmpty() => this; - StringSchema minLength(int length) => this; - StringSchema maxLength(int length) => this; - StringSchema enumString(List values) => this; - StringSchema uri() => this; - StringSchema date() => this; - StringSchema datetime() => this; - StringSchema nullable() => this; - StringSchema optional() => this; - StringSchema describe(String description) => this; - StringSchema withDefault(String defaultValue) => this; - - @override - Map toJsonSchema() => {'type': 'string'}; -} -class IntegerSchema extends AckSchema { - const IntegerSchema(); - IntegerSchema min(int value) => this; - IntegerSchema max(int value) => this; - IntegerSchema positive() => this; - IntegerSchema nullable() => this; - IntegerSchema optional() => this; - IntegerSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'integer'}; -} -class DoubleSchema extends AckSchema { - const DoubleSchema(); - DoubleSchema nullable() => this; - DoubleSchema optional() => this; - DoubleSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'number'}; -} -class NumberSchema extends AckSchema { - const NumberSchema(); - NumberSchema nullable() => this; - NumberSchema optional() => this; - NumberSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'number'}; -} -class BooleanSchema extends AckSchema { - const BooleanSchema(); - BooleanSchema nullable() => this; - BooleanSchema optional() => this; - BooleanSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'boolean'}; -} -class AnySchema extends AckSchema { - const AnySchema(); - AnySchema nullable() => this; - AnySchema optional() => this; - - @override - Map toJsonSchema() => {}; -} -class ListSchema extends AckSchema> { - final AckSchema itemSchema; - const ListSchema(this.itemSchema); - ListSchema nullable() => this; - ListSchema optional() => this; - ListSchema unique() => this; - ListSchema describe(String description) => this; - - @override - Map toJsonSchema() => { - 'type': 'array', - 'items': itemSchema.toJsonSchema(), - }; -} -class MapSchema extends AckSchema> { - final AckSchema valueSchema; - const MapSchema(this.valueSchema); - MapSchema nullable() => this; - MapSchema optional() => this; - MapSchema describe(String description) => this; - - @override - Map toJsonSchema() => { - 'type': 'object', - 'additionalProperties': valueSchema.toJsonSchema(), - }; -} -class ObjectSchema extends AckSchema> { - final Map properties; - final List? required; - final bool additionalProperties; - const ObjectSchema(this.properties, {this.required, this.additionalProperties = false}); - - ObjectSchema copyWith({ - Map? properties, - List? required, - bool? additionalProperties, - }) { - return ObjectSchema( - properties ?? this.properties, - required: required ?? this.required, - additionalProperties: additionalProperties ?? this.additionalProperties, - ); - } - - Map toJsonSchema() { - return { - 'type': 'object', - 'properties': properties.map((k, v) => MapEntry(k, v.toJsonSchema())), - if (required != null && required!.isNotEmpty) 'required': required, - 'additionalProperties': additionalProperties, - }; - } -} - -extension ObjectSchemaExtensions on ObjectSchema { - ObjectSchema passthrough() => copyWith(additionalProperties: true); -} -class EnumSchema extends AckSchema { - EnumSchema(); - EnumSchema nullable() => this; - EnumSchema optional() => this; - EnumSchema describe(String description) => this; - - @override - Map toJsonSchema() => {'type': 'string', 'enum': []}; -} -class DiscriminatedSchema extends AckSchema> { - final String discriminatorKey; - final Map schemas; - const DiscriminatedSchema({required this.discriminatorKey, required this.schemas}); - - // Test stub no-op: generator nullability checks inspect the AST chain. - DiscriminatedSchema nullable() => this; - - @override - Map toJsonSchema() => { - 'oneOf': schemas.values.map((schema) => schema.toJsonSchema()).toList(), - 'discriminator': {'propertyName': discriminatorKey}, - }; -} -''', - 'ack|lib/src/schemas/schema_model.dart': ''' -import 'package:meta/meta.dart'; - -abstract class SchemaModel { - final Map? _data; - - const SchemaModel() : _data = null; - - @protected - const SchemaModel.validated(Map data) : _data = data; - - @protected - ObjectSchema get schema; - - bool get hasData => _data != null; - - SchemaModel parse(Object? input) { - // Parse test input into the schema model contract used by test fixtures. - return createValidated(input as Map); - } - - SchemaModel? tryParse(Object? input) { - try { - return parse(input); - } catch (_) { - return null; - } - } - - @protected - SchemaModel createValidated(Map data); - - T createFromMap(Map map); - - @protected - TValue getValue(String key) { - if (_data == null) { - throw StateError('No data available - use parse() first'); - } - return _data![key] as TValue; - } - - @protected - TValue? getValueOrNull(String key) { - if (_data == null) return null; - return _data![key] as TValue?; - } - - Map toMap() { - if (_data == null) return const {}; - return Map.unmodifiable(_data!); - } - - Map toJsonSchema() { - return schema.toJsonSchema(); - } -} -''', - 'ack|lib/src/validation/schema_result.dart': ''' -sealed class SchemaResult { - const SchemaResult(); - - factory SchemaResult.ok(T value) = SchemaSuccess; - factory SchemaResult.fail(String error) = SchemaFailure; - - R match({ - required R Function(T value) onOk, - required R Function(String error) onFail, - }); -} - -class SchemaSuccess extends SchemaResult { - final T value; - const SchemaSuccess(this.value); - - @override - R match({ - required R Function(T value) onOk, - required R Function(String error) onFail, - }) => onOk(value); -} - -class SchemaFailure extends SchemaResult { - final String error; - const SchemaFailure(this.error); - - @override - R match({ - required R Function(T value) onOk, - required R Function(String error) onFail, - }) => onFail(error); -} -''', -}; - -/// Combine all assets for easy use in tests -Map get allAssets => { - ...metaAssets, - ...ackPackageAnnotationsAsset, - ...ackPackageAsset, -}; 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 dcd542d3..ff965b44 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 @@ -1,7 +1,8 @@ /// Bidirectional JSON Schema Builder bridge for ACK validation library. /// /// Converts ACK validation schemas to json_schema_builder Schema format -/// from ACK's generic Draft-7 JSON Schema renderer. +/// from ACK's stable Draft-7 JSON Schema renderer. Use the explicit preserving +/// export on an imported schema when its draft 2020-12 dialect is required. /// Imports draft 2020-12 builder models through [AckSchemaImportExtension]. /// /// ## Usage @@ -25,15 +26,19 @@ import 'package:json_schema_builder/json_schema_builder.dart' as jsb; /// Imports json_schema_builder models using ACK's JSON Schema importer. extension AckSchemaImportExtension on jsb.Schema { - /// Strictly converts this draft 2020-12 model to an ACK validator. + /// Converts this draft 2020-12 model to an ACK validator. + /// Formats are annotations unless [assertFormats] is true or the model's + /// dialect requires format assertions. /// Unsupported semantics throw [JsonSchemaImportException]. AckSchema toAckSchema({ Uri? baseUri, Map documents = const {}, + bool assertFormats = false, }) => Ack.fromJsonSchema( value, baseUri: baseUri, documents: documents.map((uri, schema) => MapEntry(uri, schema.value)), + assertFormats: assertFormats, ); } @@ -41,11 +46,17 @@ extension AckSchemaImportExtension on jsb.Schema { extension JsonSchemaBuilderExtension on AckSchema { /// Converts this ACK schema to json_schema_builder Schema format. /// - /// Returns a json_schema_builder [Schema] instance from ACK's generic - /// Draft-7 JSON Schema map. + /// Returns a json_schema_builder [Schema] instance from ACK's JSON Schema + /// map, preserving the 1.x Draft-7 export representation. jsb.Schema toJsonSchemaBuilder() { return jsb.Schema.fromMap(toJsonSchema()); } + + /// Converts imports without lowering their draft 2020-12 resources. + /// Native schemas retain Draft-7 output. + jsb.Schema toJsonSchemaBuilderPreservingImportedDialect() { + return jsb.Schema.fromMap(toJsonSchemaPreservingImportedDialect()); + } } /// Converts a [AckSchemaModel] model directly to json_schema_builder [Schema] format. diff --git a/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart index b8153d03..5f33c9d4 100644 --- a/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart @@ -19,24 +19,27 @@ void main() { expect(strict.safeParse({}).isFail, isTrue); }); - test('builder bridge retains strictness and diagnostics', () { - final model = jsb.Schema.fromMap({'format': 'email'}); - expect( - () => model.toAckSchema(), - throwsA( - isA().having( - (error) => error.diagnostics.single.keyword, - 'keyword', - 'format', - ), - ), - ); + test('builder bridge forwards the format assertion policy', () { + final model = jsb.Schema.fromMap({'type': 'string', 'format': 'email'}); + expect(model.toAckSchema().safeParse('not an email').isOk, isTrue); + + final asserted = model.toAckSchema(assertFormats: true); + expect(asserted.safeParse('ada@example.com').isOk, isTrue); + expect(asserted.safeParse('not an email').isFail, isTrue); }); - test('builder bridge asserts supported date-time on import', () { - final model = jsb.Schema.fromMap({'type': 'string', 'format': 'date-time'}); - final schema = model.toAckSchema(); - expect(schema.safeParse('2024-02-29T01:02:03Z').isOk, isTrue); - expect(schema.safeParse('2024-02-30T01:02:03Z').isFail, isTrue); + test('builder exports keep the legacy default and expose 2020-12 opt-in', () { + final imported = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + final legacy = imported.toJsonSchemaBuilder().value as Map; + final preserving = + imported.toJsonSchemaBuilderPreservingImportedDialect().value as Map; + + expect(legacy['definitions'], isA>()); + expect(legacy[r'$schema'], isNull); + expect(preserving['type'], 'string'); + expect( + preserving[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); }); } diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index cc1cf900..5d7d2c17 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -112,7 +112,7 @@ It does not throw `McpError`. A parsed null is also rejected by the wrapper. | --- | --- | --- | | Optional default | `default` annotation; field may be omitted | ACK fills the value during parse | | Refinement | Predicate is not exported | ACK runs the predicate | -| Codec / transform | Boundary shape and `x-transformed` metadata | ACK produces typed values, e.g. `DateTime` | +| Codec / transform | Boundary shape | ACK produces typed values, e.g. `DateTime` | | Integer | JSON Schema integer semantics | `2.0` becomes `int 2`; `2.5` fails | | Structural failure | Rejected by `mcp_dart` | ACK and custom invalid handler are not reached | From 4d70eea5d8fc01e1b13b686b2d8f60e8a09d6c10 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 08:49:05 -0400 Subject: [PATCH 02/29] refactor(ack): unify native and imported multipleOf on exact decimal division --- .../constraints/comparison_constraint.dart | 76 +++++++++++-------- .../lib/src/schemas/imported_json_schema.dart | 34 +-------- packages/ack/lib/src/schemas/schema.dart | 1 + 3 files changed, 46 insertions(+), 65 deletions(-) diff --git a/packages/ack/lib/src/constraints/comparison_constraint.dart b/packages/ack/lib/src/constraints/comparison_constraint.dart index 0aa1041e..4d3fbc59 100644 --- a/packages/ack/lib/src/constraints/comparison_constraint.dart +++ b/packages/ack/lib/src/constraints/comparison_constraint.dart @@ -53,15 +53,6 @@ class ComparisonConstraint extends Constraint /// Optional custom message builder. If provided, overrides default messages. final String Function(T value, num extractedValue)? customMessageBuilder; - /// Machine epsilon used for floating-point multipleOf comparisons. - /// - /// The tolerance is scaled to the reconstructed value below so it follows - /// floating-point precision instead of accepting a fixed quotient error. - static const _doubleMachineEpsilon = 2.220446049250313e-16; - - /// Allows for rounding in division, integer rounding, and multiplication. - static const _multipleOfPrecisionFactor = 4; - const ComparisonConstraint({ required super.constraintKey, required super.description, @@ -283,29 +274,7 @@ class ComparisonConstraint extends Constraint ComparisonType.lte => extracted <= threshold, ComparisonType.eq => () { if (multipleValue != null && constraintKey == 'number_multiple_of') { - if (!extracted.isFinite) return false; - if (extracted is int && multipleValue is int) { - return extracted.remainder(multipleValue!) == 0; - } - - // Deliberate: scale the tolerance to the reconstructed value rather - // than using a fixed absolute epsilon. A constant epsilon (the former - // 1e-10) accepts every value once the divisor drops below it and is - // meaningless at large magnitudes; a relative tolerance stays correct - // across scales. Do not revert to a fixed epsilon. - final quotient = extracted / multipleValue!; - if (!quotient.isFinite) return false; - - final nearestMultiple = quotient.roundToDouble(); - final reconstructed = nearestMultiple * multipleValue!; - if (!reconstructed.isFinite) return false; - - final magnitude = extracted.abs() > reconstructed.abs() - ? extracted.abs() - : reconstructed.abs(); - final tolerance = - magnitude * _doubleMachineEpsilon * _multipleOfPrecisionFactor; - return (extracted - reconstructed).abs() <= tolerance; + return isExactDecimalMultipleOf(extracted, multipleValue!); } return extracted == threshold; @@ -431,3 +400,46 @@ class ComparisonConstraint extends Constraint multipleValue, ); } + +/// Checks whether [value] is an exact multiple of [divisor] using their +/// decimal representations. +/// +/// Dividing binary floating-point values directly would reject JSON decimals +/// such as `0.3` with `multipleOf: 0.1` or overflow on extreme finite doubles +/// such as `1e308` with `multipleOf: 0.5`. +bool isExactDecimalMultipleOf(num value, num divisor) { + if (!value.isFinite || !divisor.isFinite || divisor <= 0) return false; + if (value is int && divisor is int) { + return value.remainder(divisor) == 0; + } + final (valueCoefficient, valueScale) = decimalParts(value); + final (divisorCoefficient, divisorScale) = decimalParts(divisor); + final scale = valueScale > divisorScale ? valueScale : divisorScale; + final ten = BigInt.from(10); + final numerator = valueCoefficient * ten.pow(scale - valueScale); + final denominator = divisorCoefficient * ten.pow(scale - divisorScale); + return numerator % denominator == BigInt.zero; +} + +/// Parses the decimal representation of a finite [num] into `(coefficient, scale)` +/// such that `value == coefficient * 10^(-scale)` with `scale >= 0`. +(BigInt, int) decimalParts(num value) { + final text = value.toString().toLowerCase(); + final exponentIndex = text.indexOf('e'); + final mantissa = exponentIndex < 0 ? text : text.substring(0, exponentIndex); + final exponent = exponentIndex < 0 + ? 0 + : int.parse(text.substring(exponentIndex + 1)); + final pointIndex = mantissa.indexOf('.'); + final fractionalDigits = pointIndex < 0 + ? 0 + : mantissa.length - pointIndex - 1; + var coefficient = BigInt.parse(mantissa.replaceAll('.', '')); + final scale = fractionalDigits - exponent; + if (scale < 0) { + coefficient *= BigInt.from(10).pow(-scale); + return (coefficient, 0); + } + return (coefficient, scale); +} + diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index 3a858508..5c5e811e 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -445,7 +445,7 @@ _ImportEvaluation _evaluateImportedNode( case num(): if (node.validationVocabulary) { if (node.multipleOf case final divisor? - when !_isJsonMultipleOf(value, divisor)) { + when !isExactDecimalMultipleOf(value, divisor)) { return fail('multipleOf'); } if (node.minimum case final limit? when value < limit) { @@ -699,35 +699,3 @@ bool _isImportType(Object? type, Object? value) => switch (type) { _ => false, }; -/// Compares the decimal values represented by finite Dart numbers. Dividing -/// binary floating-point values directly would reject JSON decimals such as -/// `0.3` with `multipleOf: 0.1`. -bool _isJsonMultipleOf(num value, num divisor) { - final (valueCoefficient, valueScale) = _decimalParts(value); - final (divisorCoefficient, divisorScale) = _decimalParts(divisor); - final scale = valueScale > divisorScale ? valueScale : divisorScale; - final ten = BigInt.from(10); - final numerator = valueCoefficient * ten.pow(scale - valueScale); - final denominator = divisorCoefficient * ten.pow(scale - divisorScale); - return numerator % denominator == BigInt.zero; -} - -(BigInt, int) _decimalParts(num value) { - final text = value.toString().toLowerCase(); - final exponentIndex = text.indexOf('e'); - final mantissa = exponentIndex < 0 ? text : text.substring(0, exponentIndex); - final exponent = exponentIndex < 0 - ? 0 - : int.parse(text.substring(exponentIndex + 1)); - final pointIndex = mantissa.indexOf('.'); - final fractionalDigits = pointIndex < 0 - ? 0 - : mantissa.length - pointIndex - 1; - var coefficient = BigInt.parse(mantissa.replaceAll('.', '')); - final scale = fractionalDigits - exponent; - if (scale < 0) { - coefficient *= BigInt.from(10).pow(-scale); - return (coefficient, 0); - } - return (coefficient, scale); -} diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index f9b6891e..d4318183 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -7,6 +7,7 @@ import 'package:punycoder/punycoder.dart' as punycoder; import 'package:unorm_dart/unorm_dart.dart' as unorm; import '../common_types.dart'; +import '../constraints/comparison_constraint.dart'; import '../constraints/constraint.dart'; import '../constraints/number_finite_constraint.dart'; import '../constraints/pattern_constraint.dart'; From 05ab5b6f214a8e8e5ce42b2bb160e58a7f2de872 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 09:11:14 -0400 Subject: [PATCH 03/29] style: format workspace with dart format --- .../constraints/comparison_constraint.dart | 1 - .../lib/src/schemas/imported_json_schema.dart | 1 - .../ack/test/reference_semantics_test.dart | 5 +- .../ack/test/runtime_dependencies_test.dart | 35 ++-- .../ack_schema_model_builder_test.dart | 10 +- .../lib/src/builders/model_emitter.dart | 5 +- .../lib/src/json/helper_names.dart | 3 +- .../integration/ack_infer_models_test.dart | 12 +- .../class_first_runtime_build_test.dart | 10 +- .../integration/class_model_emitter_test.dart | 184 +++++++++--------- .../integration/class_model_graph_test.dart | 23 ++- .../example_folder_build_test.dart | 5 +- .../test/src/json_builder_test.dart | 5 +- 13 files changed, 146 insertions(+), 153 deletions(-) diff --git a/packages/ack/lib/src/constraints/comparison_constraint.dart b/packages/ack/lib/src/constraints/comparison_constraint.dart index 4d3fbc59..01be1689 100644 --- a/packages/ack/lib/src/constraints/comparison_constraint.dart +++ b/packages/ack/lib/src/constraints/comparison_constraint.dart @@ -442,4 +442,3 @@ bool isExactDecimalMultipleOf(num value, num divisor) { } return (coefficient, scale); } - diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index 5c5e811e..7925711b 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -698,4 +698,3 @@ bool _isImportType(Object? type, Object? value) => switch (type) { 'object' => value is Map, _ => false, }; - diff --git a/packages/ack/test/reference_semantics_test.dart b/packages/ack/test/reference_semantics_test.dart index 607774fd..8bc58d2b 100644 --- a/packages/ack/test/reference_semantics_test.dart +++ b/packages/ack/test/reference_semantics_test.dart @@ -96,10 +96,7 @@ void main() { // not claim its mapping preserves keywords, so it is enforced locally // and reported as omitted rather than published. final model = schema.toSchemaModel(); - expect(model.toJsonSchema(), { - 'type': 'string', - 'minLength': 10, - }); + expect(model.toJsonSchema(), {'type': 'string', 'minLength': 10}); expect(model.warnings.map((warning) => warning.code), [ 'codec_runtime_constraint_not_exported', ]); diff --git a/packages/ack/test/runtime_dependencies_test.dart b/packages/ack/test/runtime_dependencies_test.dart index 13455090..8afd947e 100644 --- a/packages/ack/test/runtime_dependencies_test.dart +++ b/packages/ack/test/runtime_dependencies_test.dart @@ -6,21 +6,24 @@ import 'dart:io'; import 'package:test/test.dart'; void main() { - test('ack depends only on lightweight runtime packages (no json_annotation)', () { - final lines = File('pubspec.yaml').readAsLinesSync(); - final start = lines.indexOf('dependencies:'); - final dependencies = [ - for (final line - in lines - .skip(start + 1) - .takeWhile((line) => line.isEmpty || line.startsWith(' '))) - if (RegExp(r'^ (\w+):').firstMatch(line) case final match?) - match.group(1), - ]; + test( + 'ack depends only on lightweight runtime packages (no json_annotation)', + () { + final lines = File('pubspec.yaml').readAsLinesSync(); + final start = lines.indexOf('dependencies:'); + final dependencies = [ + for (final line + in lines + .skip(start + 1) + .takeWhile((line) => line.isEmpty || line.startsWith(' '))) + if (RegExp(r'^ (\w+):').firstMatch(line) case final match?) + match.group(1), + ]; - expect( - dependencies, - unorderedEquals(['meta', 'collection', 'punycoder', 'unorm_dart']), - ); - }); + expect( + dependencies, + unorderedEquals(['meta', 'collection', 'punycoder', 'unorm_dart']), + ); + }, + ); } diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index 4a6337cd..441481d8 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -349,10 +349,7 @@ void main() { .max(DateTime(2026, 12, 31)); final model = schema.toSchemaModel(); - expect(model.toJsonSchema(), { - 'type': 'string', - 'format': 'date', - }); + expect(model.toJsonSchema(), {'type': 'string', 'format': 'date'}); expect( model.warnings.map((warning) => warning.code), everyElement('datetime_constraint_not_draft7'), @@ -405,10 +402,7 @@ void main() { .transform((value) => value.trim()); final model = schema.toSchemaModel(); - expect(model.toJsonSchema(), { - 'type': 'string', - 'minLength': 1, - }); + expect(model.toJsonSchema(), {'type': 'string', 'minLength': 1}); expect(model.warnings, isEmpty); }); diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 1a747f0c..dc8e0773 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -754,10 +754,7 @@ return ${node.className}( } final declaredLiteral = _declaredKeysLiteral( - _declaredJsonKeys( - effectiveFields, - additionalKeys: leadingEntries.keys, - ), + _declaredJsonKeys(effectiveFields, additionalKeys: leadingEntries.keys), ); final allEntries = [ 'for (final entry in additionalProperties.entries)\n' diff --git a/packages/ack_generator/lib/src/json/helper_names.dart b/packages/ack_generator/lib/src/json/helper_names.dart index cc63db1c..a614a6bf 100644 --- a/packages/ack_generator/lib/src/json/helper_names.dart +++ b/packages/ack_generator/lib/src/json/helper_names.dart @@ -14,7 +14,8 @@ String ackClassWireSchemaName(String className) => '_${className[0].toLowerCase()}${className.substring(1)}WireSchema'; /// Private runtime decoder generated for a class-first object model. -String ackClassFromRuntimeName(String className) => '_\$${className}FromRuntime'; +String ackClassFromRuntimeName(String className) => + '_\$${className}FromRuntime'; /// Private runtime encoder generated for a class-first object model. String ackClassToRuntimeName(String className) => '_\$${className}ToRuntime'; diff --git a/packages/ack_generator/test/integration/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index 179c460c..17be2ce9 100644 --- a/packages/ack_generator/test/integration/ack_infer_models_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_models_test.dart @@ -789,9 +789,7 @@ final petSchema = Ack.discriminated( ''', }, outputs: { - 'test_pkg|lib/cat.g.dart': decodedMatches( - contains('final class Cat'), - ), + 'test_pkg|lib/cat.g.dart': decodedMatches(contains('final class Cat')), }, onLog: (log) { if (log.level.name == 'SEVERE') messages.add(log.message); @@ -819,8 +817,12 @@ final docSchema = Ack.object({ outputs: { 'test_pkg|lib/doc.g.dart': decodedMatches( allOf( - contains('/// Model line one.\n/// Model line two.\nfinal class Doc'), - contains('/// First line.\n /// Second line.\n final String note;'), + contains( + '/// Model line one.\n/// Model line two.\nfinal class Doc', + ), + contains( + '/// First line.\n /// Second line.\n final String note;', + ), ), ), }, diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index 2a2747e9..c521bfac 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -1148,10 +1148,7 @@ void main() { final generated = _generatedFiles(temporary); expect( generated.keys, - containsAll([ - 'lib/coexist.g.dart', - 'lib/models.g.dart', - ]), + containsAll(['lib/coexist.g.dart', 'lib/models.g.dart']), ); expect( generated['lib/models.g.dart'], @@ -1162,10 +1159,7 @@ void main() { contains(r'_$PlainJsonFromJson'), ); expect(generated['lib/models.g.dart'], contains('class Legacy')); - expect( - generated['lib/models.g.dart'], - contains(r'mixin _$ProfileAck'), - ); + expect(generated['lib/models.g.dart'], contains(r'mixin _$ProfileAck')); expect( generated['lib/models.g.dart'], contains(r'implements $ParentCopyWith<$Result>'), diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 54593cb4..99f9ba2a 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -471,11 +471,13 @@ final class Envelope with _\$EnvelopeAck { ); }); - test('emits a codec schema, presence semantics, mixin, and direct decode/encode', () async { - await _build( - { - 'profile.dart': - ''' + test( + 'emits a codec schema, presence semantics, mixin, and direct decode/encode', + () async { + await _build( + { + 'profile.dart': + ''' $_imports part 'profile.g.dart'; @@ -499,70 +501,73 @@ final class Profile with _\$ProfileAck { final Set tags; } ''', - }, - outputs: { - 'test_pkg|lib/profile.g.dart': decodedMatches( - allOf([ - contains('final _profileObject = Ack.object'), - contains( - 'final _profileWireSchema = ' - 'Ack.preserveBoundary(_profileObject)', - ), - contains('final _profileSchema = _profileObject.codec'), - contains('get wireSchema'), - contains('_profileWireSchema;'), - isNot(contains('final profileSchema =')), - contains("'bio': Ack.string().minLength(1).maxLength(500)"), - contains("'website': Ack.uri().optional().nullable()"), - contains("'nickname': Ack.string().nullable()"), - contains("'role': Ack.string().withDefault('member')"), - contains('Ack.list(Ack.string())'), - contains( - RegExp( - r'\.minItems\(1\)[\s\S]*\.unique\(\)[\s\S]*\.codec>', + }, + outputs: { + 'test_pkg|lib/profile.g.dart': decodedMatches( + allOf([ + contains('final _profileObject = Ack.object'), + contains( + 'final _profileWireSchema = ' + 'Ack.preserveBoundary(_profileObject)', ), - ), - contains('.codec('), - contains(r'decode: _$ProfileFromRuntime'), - contains(r'encode: _$ProfileToRuntime'), - contains('abstract final class ProfileSchema'), - contains( - 'static AckSchema, Profile> get schema', - ), - contains('static Profile parse('), - contains('static SchemaResult safeParse('), - contains( - 'static Profile fromJson(Map json) => parse(json)', - ), - contains('static Map encode('), - contains('static SchemaResult> safeEncode('), - contains('static Map toJsonSchema()'), - contains('static AckSchemaModel toSchemaModel()'), - contains('_profileSchema.parse(value, debugName: debugName)!'), - contains('_profileSchema.encode(value, debugName: debugName)!'), - contains(r'Profile _$ProfileFromRuntime'), - contains(r'Map _$ProfileToRuntime'), - contains("'nickname': model.nickname"), - contains(r'mixin _$ProfileAck'), - contains(r'$ProfileCopyWith get copyWith'), - contains('final class _ProfileCopyWithUnset'), - contains('const _ProfileCopyWithUnset()'), - contains('static const _ProfileCopyWithUnset _ackCopyWithUnset ='), - contains('Object? website = _ackCopyWithUnset'), - contains('website: identical(website, _ackCopyWithUnset)'), - contains(': website as Uri?'), - isNot(contains('_ackCopyWithOmitted')), - contains('deepEquals('), - contains('deepHashCode('), - contains('Map toJson()'), - contains('SchemaResult> safeToJson()'), - contains('ProfileSchema.encode(this as Profile)'), - contains('ProfileSchema.safeEncode(this as Profile)'), - ]), - ), - }, - ); - }); + contains('final _profileSchema = _profileObject.codec'), + contains('get wireSchema'), + contains('_profileWireSchema;'), + isNot(contains('final profileSchema =')), + contains("'bio': Ack.string().minLength(1).maxLength(500)"), + contains("'website': Ack.uri().optional().nullable()"), + contains("'nickname': Ack.string().nullable()"), + contains("'role': Ack.string().withDefault('member')"), + contains('Ack.list(Ack.string())'), + contains( + RegExp( + r'\.minItems\(1\)[\s\S]*\.unique\(\)[\s\S]*\.codec>', + ), + ), + contains('.codec('), + contains(r'decode: _$ProfileFromRuntime'), + contains(r'encode: _$ProfileToRuntime'), + contains('abstract final class ProfileSchema'), + contains( + 'static AckSchema, Profile> get schema', + ), + contains('static Profile parse('), + contains('static SchemaResult safeParse('), + contains( + 'static Profile fromJson(Map json) => parse(json)', + ), + contains('static Map encode('), + contains('static SchemaResult> safeEncode('), + contains('static Map toJsonSchema()'), + contains('static AckSchemaModel toSchemaModel()'), + contains('_profileSchema.parse(value, debugName: debugName)!'), + contains('_profileSchema.encode(value, debugName: debugName)!'), + contains(r'Profile _$ProfileFromRuntime'), + contains(r'Map _$ProfileToRuntime'), + contains("'nickname': model.nickname"), + contains(r'mixin _$ProfileAck'), + contains(r'$ProfileCopyWith get copyWith'), + contains('final class _ProfileCopyWithUnset'), + contains('const _ProfileCopyWithUnset()'), + contains( + 'static const _ProfileCopyWithUnset _ackCopyWithUnset =', + ), + contains('Object? website = _ackCopyWithUnset'), + contains('website: identical(website, _ackCopyWithUnset)'), + contains(': website as Uri?'), + isNot(contains('_ackCopyWithOmitted')), + contains('deepEquals('), + contains('deepHashCode('), + contains('Map toJson()'), + contains('SchemaResult> safeToJson()'), + contains('ProfileSchema.encode(this as Profile)'), + contains('ProfileSchema.safeEncode(this as Profile)'), + ]), + ), + }, + ); + }, + ); test( 'emits escape-hatch schemas and built-in recursive type coverage', @@ -638,11 +643,13 @@ final class Record with _\$RecordAck { }, ); - test('computes case-style, AckField(name:), and JsonKey schema keys once', () async { - await _build( - { - 'account.dart': - ''' + test( + 'computes case-style, AckField(name:), and JsonKey schema keys once', + () async { + await _build( + { + 'account.dart': + ''' $_imports import 'package:json_annotation/json_annotation.dart' show JsonKey; part 'account.g.dart'; @@ -662,21 +669,22 @@ final class Account with _\$AccountAck { final String handleName; } ''', - }, - outputs: { - 'test_pkg|lib/account.g.dart': decodedMatches( - allOf([ - contains("'first_name': Ack.string()"), - contains("'avatar': Ack.string()"), - contains("'handle': Ack.string()"), - isNot(contains("'firstName':")), - isNot(contains("'image_url':")), - isNot(contains("'handle_name':")), - ]), - ), - }, - ); - }); + }, + outputs: { + 'test_pkg|lib/account.g.dart': decodedMatches( + allOf([ + contains("'first_name': Ack.string()"), + contains("'avatar': Ack.string()"), + contains("'handle': Ack.string()"), + isNot(contains("'firstName':")), + isNot(contains("'image_url':")), + isNot(contains("'handle_name':")), + ]), + ), + }, + ); + }, + ); test( 'preserves prefixed field types and same-named import identity', diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 53318812..9976419f 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -138,9 +138,7 @@ final class Person with _\$PersonAck { 'Import package:test_pkg/address.dart.', ], head: _aliasHead, - allowedOutputs: { - 'test_pkg|lib/address.g.dart': decodedMatches(anything), - }, + allowedOutputs: {'test_pkg|lib/address.g.dart': decodedMatches(anything)}, extraSources: { 'aliases.dart': ''' import 'address.dart'; @@ -1227,9 +1225,7 @@ final class Address with _\$AddressAck { } ''', }, - allowedOutputs: { - 'test_pkg|lib/address.g.dart': decodedMatches(anything), - }, + allowedOutputs: {'test_pkg|lib/address.g.dart': decodedMatches(anything)}, ); }); @@ -1292,9 +1288,11 @@ final class User with _\$UserAck { ); }); - test('rejects combining @AckField(name: ...) and @JsonKey(name: ...)', () async { - await _expectFailure( - ''' + test( + 'rejects combining @AckField(name: ...) and @JsonKey(name: ...)', + () async { + await _expectFailure( + ''' @Schemable() final class User with _\$UserAck { const User({required this.name}); @@ -1304,9 +1302,10 @@ final class User with _\$UserAck { final String name; } ''', - ['User.name', 'sets both @AckField(name: ...) and @JsonKey(name: ...)'], - ); - }); + ['User.name', 'sets both @AckField(name: ...) and @JsonKey(name: ...)'], + ); + }, + ); test('rejects @Optional() on a required constructor parameter', () async { await _expectFailure( diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 91fc4fe9..30907574 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -127,10 +127,7 @@ dependency_overrides: if (isClassFirst) { expect(entry.value, contains(r'mixin _$AccountAck')); expect(entry.value, contains('final _accountSchema')); - expect( - entry.value, - contains('abstract final class AccountSchema'), - ); + expect(entry.value, contains('abstract final class AccountSchema')); expect(entry.value, contains(r'decode: _$AccountFromRuntime')); expect(entry.value, isNot(contains('final accountSchema'))); } else { diff --git a/packages/ack_generator/test/src/json_builder_test.dart b/packages/ack_generator/test/src/json_builder_test.dart index f80f2d72..86f8cf0e 100644 --- a/packages/ack_generator/test/src/json_builder_test.dart +++ b/packages/ack_generator/test/src/json_builder_test.dart @@ -20,7 +20,10 @@ void main() { expect(yaml, contains('builder_factories: ["ackGenerator"]')); expect(yaml, contains('build_extensions: {".dart": [".ack.g.part"]}')); expect(yaml, contains('build_to: cache')); - expect(yaml, contains('applies_builders: ["source_gen|combining_builder"]')); + expect( + yaml, + contains('applies_builders: ["source_gen|combining_builder"]'), + ); expect(yaml, isNot(contains('ack_model_json'))); expect(yaml, isNot(contains('.ack.g.dart'))); }); From 0dfbf93d7db18953ac861569987ed928dc43c8ff Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 13:19:14 -0400 Subject: [PATCH 04/29] feat(ack)!: preserve Draft 2020-12 by default on toJsonSchema and toSchemaModel --- .../src/json_schema/json_schema_compiler.dart | 6 +- .../ack_schema_model_builder.dart | 18 +-- ..._schema_2020_12_full_conformance_test.dart | 4 +- ...n_schema_dynamic_ref_conformance_test.dart | 4 +- .../json_schema_format_conformance_test.dart | 8 +- .../ack/test/json_schema_import_api_test.dart | 28 ++--- .../json_schema_import_conformance_test.dart | 4 +- .../ack/test/json_schema_import_test.dart | 60 +++------- .../ack_schema_model_builder_test.dart | 111 ++++++++++-------- .../src/json_schema/json_schema_library.dart | 2 +- packages/ack_json_schema_builder/README.md | 9 +- .../lib/ack_json_schema_builder.dart | 16 +-- .../test/from_json_schema_builder_test.dart | 28 +++-- 13 files changed, 140 insertions(+), 158 deletions(-) diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index 337d2fae..8a19d5af 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -104,15 +104,15 @@ final class _ImportedNode { ]; /// The 1.x Draft-7 representation of an imported node. Newer 2020-12 - /// assertions that cannot be lowered must use the preserving export. + /// assertions that cannot be lowered fail when `toJsonSchemaDraft7()` is used. Map renderLegacy(String Function(_ImportedNode) refOf) { if (dynamicReference != null || dynamicAnchorName != null || (source is Map && (source as Map).containsKey('format'))) { throw UnsupportedError( - 'This imported schema needs 2020-12 semantics. Use ' - 'toJsonSchemaPreservingImportedDialect().', + 'This imported schema needs 2020-12 semantics and cannot be lowered ' + 'with toJsonSchemaDraft7().', ); } if (isFalse) return {'not': {}}; diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index d8e8ac07..88468eef 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -17,20 +17,24 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - AckSchemaModel toSchemaModel() => _SchemaModelBuilder().build(this); - /// Preserves imported Draft 2020-12 resources, including dynamic references. /// Native Ack schemas continue to use their Draft-7 representation. - AckSchemaModel toSchemaModelPreservingImportedDialect() => + AckSchemaModel toSchemaModel() => _SchemaModelBuilder(preserveImportedDialect: true).build(this); - /// Exports imported schemas with their Draft 2020-12 dialect and resources. - Map toJsonSchemaPreservingImportedDialect() => - toSchemaModelPreservingImportedDialect().toJsonSchema(); + /// Lowers imported schemas into the legacy Draft-7 `definitions` / `$ref` + /// representation when possible. + AckSchemaModel toSchemaModelDraft7() => + _SchemaModelBuilder(preserveImportedDialect: false).build(this); + + /// Exports imported schemas using the legacy Draft-7 `definitions` / `$ref` + /// representation when possible. + Map toJsonSchemaDraft7() => + toSchemaModelDraft7().toJsonSchema(); } final class _SchemaModelBuilder { - _SchemaModelBuilder({this.preserveImportedDialect = false}); + _SchemaModelBuilder({this.preserveImportedDialect = true}); final bool preserveImportedDialect; // Every emitted definition name is reserved here. A null value marks a lazy diff --git a/packages/ack/test/json_schema_2020_12_full_conformance_test.dart b/packages/ack/test/json_schema_2020_12_full_conformance_test.dart index 0deefadb..2c1e1d16 100644 --- a/packages/ack/test/json_schema_2020_12_full_conformance_test.dart +++ b/packages/ack/test/json_schema_2020_12_full_conformance_test.dart @@ -35,9 +35,7 @@ void main() { ), documents: documents, ); - final roundTrip = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); for (final example in group['tests']! as List) { final testCase = example as Map; final expected = testCase['valid'] as bool; diff --git a/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart index 28e923bd..72e4c742 100644 --- a/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart +++ b/packages/ack/test/json_schema_dynamic_ref_conformance_test.dart @@ -47,9 +47,7 @@ void main() { ), documents: documents, ); - final exported = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final exported = Ack.fromJsonSchema(schema.toJsonSchema()); for (final example in group['tests']! as List) { final value = example['data']; final expected = example['valid'] as bool; diff --git a/packages/ack/test/json_schema_format_conformance_test.dart b/packages/ack/test/json_schema_format_conformance_test.dart index ca7e5504..bebdfbf4 100644 --- a/packages/ack/test/json_schema_format_conformance_test.dart +++ b/packages/ack/test/json_schema_format_conformance_test.dart @@ -28,9 +28,7 @@ void main() { group['schema']!, assertFormats: true, ); - final roundTrip = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); final mismatches = []; for (final example in group['tests']! as List) { final testCase = example as Map; @@ -78,9 +76,7 @@ void main() { group['schema']!, documents: documents, ); - final roundTrip = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); for (final example in group['tests']! as List) { final testCase = example as Map; expect( diff --git a/packages/ack/test/json_schema_import_api_test.dart b/packages/ack/test/json_schema_import_api_test.dart index 1c9c28fc..762f9b10 100644 --- a/packages/ack/test/json_schema_import_api_test.dart +++ b/packages/ack/test/json_schema_import_api_test.dart @@ -10,20 +10,23 @@ void main() { final Map modelExport = schema .toSchemaModel() .toJsonSchema(); + final Map draft7Export = schema.toJsonSchemaDraft7(); - expect(exported['type'], isNull); - expect(exported['allOf'], [ + expect( + exported[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); + expect(exported['type'], 'string'); + expect(exported['minLength'], 2); + expect(modelExport, exported); + expect(draft7Export['type'], isNull); + expect(draft7Export['allOf'], [ {r'$ref': '#/definitions/_ack_import_0_0'}, ]); - expect((exported['definitions'] as Map)['_ack_import_0_0'], { + expect((draft7Export['definitions'] as Map)['_ack_import_0_0'], { 'type': 'string', 'minLength': 2, }); - expect( - schema.toJsonSchemaPreservingImportedDialect()[r'$schema'], - 'https://json-schema.org/draft/2020-12/schema', - ); - expect(modelExport, exported); expect(schema.safeParse('Ada').isOk, isTrue); expect(schema.safeParse('A').isFail, isTrue); }); @@ -135,7 +138,7 @@ void main() { expect(never.safeParse('anything').isFail, isTrue); }); - test('dynamic references require the explicit preserving export', () { + test('dynamic references export by default and reject Draft-7 lowering', () { final schema = Ack.fromJsonSchema({ r'$dynamicAnchor': 'node', 'type': 'object', @@ -143,10 +146,7 @@ void main() { 'next': {r'$dynamicRef': '#node'}, }, }); - expect(schema.toJsonSchema, throwsUnsupportedError); - expect( - schema.toJsonSchemaPreservingImportedDialect()[r'$dynamicAnchor'], - 'node', - ); + expect(schema.toJsonSchema()[r'$dynamicAnchor'], 'node'); + expect(schema.toJsonSchemaDraft7, throwsUnsupportedError); }); } diff --git a/packages/ack/test/json_schema_import_conformance_test.dart b/packages/ack/test/json_schema_import_conformance_test.dart index ad796f84..698fabfc 100644 --- a/packages/ack/test/json_schema_import_conformance_test.dart +++ b/packages/ack/test/json_schema_import_conformance_test.dart @@ -23,9 +23,7 @@ void main() { (uri, value) => MapEntry(Uri.parse(uri), value!), ), ); - final roundTrip = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); for (final example in group['tests']! as List) { final value = example['data']; final expected = example['valid'] as bool; diff --git a/packages/ack/test/json_schema_import_test.dart b/packages/ack/test/json_schema_import_test.dart index 76dd6603..94e58511 100644 --- a/packages/ack/test/json_schema_import_test.dart +++ b/packages/ack/test/json_schema_import_test.dart @@ -96,7 +96,7 @@ void main() { expect(schema.safeParse('ok').isOk, isTrue); expect(schema.safeParse(1).isFail, isTrue); expect(schema.safeParse(null).isOk, isTrue); - final exported = schema.toJsonSchemaPreservingImportedDialect(); + final exported = schema.toJsonSchema(); expect(exported.containsKey(r'$ref'), isFalse); expect( exported[r'$schema'], @@ -109,10 +109,7 @@ void main() { expect(constrained, isNot(contains(r'$ref'))); expect(Ack.fromJsonSchema(exported).safeParse(1).isFail, isTrue); expect(Ack.fromJsonSchema(exported).safeParse(null).isOk, isTrue); - expect( - schema.toSchemaModelPreservingImportedDialect().toJsonSchema(), - exported, - ); + expect(schema.toSchemaModel().toJsonSchema(), exported); }, ); @@ -133,9 +130,7 @@ void main() { 'alias': {r'$ref': '#/properties/name'}, }, }).nullable(); - final restored = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); for (final value in [ null, {'name': 'Ada', 'alias': 'Ada'}, @@ -152,9 +147,7 @@ void main() { 'child': {r'$ref': '#'}, }, }).nullable(); - final restored = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); for (final value in [ null, {}, @@ -177,9 +170,7 @@ void main() { 'child': {reference: '#node'}, }, }).nullable(); - final restored = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); for (final value in [ null, {}, @@ -224,7 +215,7 @@ void main() { expect(schema.safeParse({'text': 1}).isFail, isTrue); expect( Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), + schema.toJsonSchema(), ).safeParse({'text': 1}).isFail, isTrue, ); @@ -251,9 +242,7 @@ void main() { for (final value in [null, 1, 'x', [], {}]) { expect(never.safeParse(value).isFail, isTrue); expect( - Ack.fromJsonSchema( - never.toJsonSchemaPreservingImportedDialect(), - ).safeParse(value).isFail, + Ack.fromJsonSchema(never.toJsonSchema()).safeParse(value).isFail, isTrue, ); } @@ -261,13 +250,8 @@ void main() { 'enum': [1, 1.0, 'x', 'x'], }); expect(repeated.safeParse(1).isOk, isTrue); - expect(repeated.toJsonSchemaPreservingImportedDialect()['enum'], [ - 1, - 1.0, - 'x', - 'x', - ]); - expect(never.toJsonSchemaPreservingImportedDialect()['enum'], isEmpty); + expect(repeated.toJsonSchema()['enum'], [1, 1.0, 'x', 'x']); + expect(never.toJsonSchema()['enum'], isEmpty); }); test('pattern and propertyNames validate and export verbatim', () { @@ -286,7 +270,7 @@ void main() { // "pattern" is skipped rather than failed for non-strings. expect(schema.safeParse({'name': 42}).isOk, isTrue); - final export = schema.toJsonSchemaPreservingImportedDialect(); + final export = schema.toJsonSchema(); expect((export['properties'] as Map)['name'], {'pattern': r'^a+$'}); expect(export['propertyNames'], {'maxLength': 4}); final roundTrip = Ack.fromJsonSchema(export); @@ -304,9 +288,7 @@ void main() { expect(schema.safeParse('東京').isOk, isTrue); expect(schema.safeParse('123').isFail, isTrue); expect( - Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ).safeParse('東京').isOk, + Ack.fromJsonSchema(schema.toJsonSchema()).safeParse('東京').isOk, isTrue, ); }); @@ -319,9 +301,7 @@ void main() { for (final value in [0.31, 1.2000000000000002, 1e-7]) { expect(schema.safeParse(value).isFail, isTrue, reason: '$value'); } - final restored = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); expect(restored.safeParse(0.3).isOk, isTrue); expect(restored.safeParse(0.31).isFail, isTrue); for (final invalid in [0, -0.1, '0.1']) { @@ -556,9 +536,7 @@ void main() { Ack.fromJsonSchema({'type': 'string'}).nullable(), Ack.fromJsonSchema(true).nullable(value: false), ]) { - final roundTrip = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); expect(schema.safeParse(null).isOk, schema.isNullable); expect(schema.safeEncode(null).isOk, schema.isNullable); expect(roundTrip.safeParse(null).isOk, schema.isNullable); @@ -597,9 +575,7 @@ void main() { }; expect(schema.parse(value), value); expect(schema.safeParse({'value': 1, 'next': {}}).isFail, isTrue); - final roundTrip = Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ); + final roundTrip = Ack.fromJsonSchema(schema.toJsonSchema()); expect(roundTrip.safeParse(value).isOk, isTrue); expect(roundTrip.safeParse({'value': 1, 'next': {}}).isFail, isTrue); }, @@ -691,7 +667,7 @@ void main() { final schema = Ack.fromJsonSchema(document); expect(schema.safeParse('not an email or base64').isOk, isTrue); expect(schema.safeParse(1).isFail, isTrue); - final imported = schema.toJsonSchemaPreservingImportedDialect(); + final imported = schema.toJsonSchema(); for (final key in [ 'format', 'contentEncoding', @@ -702,9 +678,7 @@ void main() { expect(imported[key], document[key], reason: key); } expect( - Ack.fromJsonSchema( - schema.toJsonSchemaPreservingImportedDialect(), - ).safeParse('invalid').isOk, + Ack.fromJsonSchema(schema.toJsonSchema()).safeParse('invalid').isOk, isTrue, ); }, @@ -726,7 +700,7 @@ void main() { }); expect(schema.safeParse('accepted').isOk, isTrue); expect(schema.safeParse(1).isFail, isTrue); - expect(schema.toJsonSchemaPreservingImportedDialect()[r'$vocabulary'], { + expect(schema.toJsonSchema()[r'$vocabulary'], { 'https://example.test/optional': false, }); }); diff --git a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart index 441481d8..d5514d39 100644 --- a/packages/ack/test/schema_model/ack_schema_model_builder_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_builder_test.dart @@ -162,68 +162,77 @@ void main() { ); }); - test('uses existing composition models for imported JSON Schema', () { - final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + test( + 'uses a resource-preserving model for imported JSON Schema by default', + () { + final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); - final model = schema.toSchemaModel(); + final model = schema.toSchemaModel(); - expect(model, isA()); - final allOf = model as AckAllOfSchemaModel; - expect(allOf.schemas, hasLength(1)); - expect(allOf.schemas.single, isA()); - expect(model.toJsonSchema(), schema.toJsonSchema()); - }); + expect(model, isA()); + expect(model.toJsonSchema()['type'], 'string'); + expect(model.toJsonSchema(), schema.toJsonSchema()); + }, + ); - test('uses a resource-preserving model for imported JSON Schema', () { - final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + test( + 'uses existing composition models for Draft-7 lowered imported JSON Schema', + () { + final schema = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); - final model = schema.toSchemaModelPreservingImportedDialect(); + final model = schema.toSchemaModelDraft7(); - expect(model, isA()); - expect(model.toJsonSchema()['type'], 'string'); - expect( - model.toJsonSchema(), - schema.toJsonSchemaPreservingImportedDialect(), - ); - }); + expect(model, isA()); + final allOf = model as AckAllOfSchemaModel; + expect(allOf.schemas, hasLength(1)); + expect(allOf.schemas.single, isA()); + expect(model.toJsonSchema(), schema.toJsonSchemaDraft7()); + }, + ); - test('rejects an imported definition reused as a lazy target', () { - final imported = Ack.fromJsonSchema(true).nullable(value: false); - final schema = Ack.object({ - 'a': imported, - 'b': Ack.lazy('_ack_import_0_0', () => imported), - }); + test( + 'rejects an imported definition reused as a lazy target in Draft-7 lowering', + () { + final imported = Ack.fromJsonSchema(true).nullable(value: false); + final schema = Ack.object({ + 'a': imported, + 'b': Ack.lazy('_ack_import_0_0', () => imported), + }); - expect( - schema.toJsonSchema, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('collides with an imported definition'), + expect( + schema.toJsonSchemaDraft7, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('collides with an imported definition'), + ), ), - ), - ); - }); + ); + }, + ); - test('rejects an import generated name occupied by an earlier lazy', () { - final imported = Ack.fromJsonSchema(true); - final schema = Ack.object({ - 'a': Ack.lazy('_ack_import_0_0', Ack.string), - 'b': imported, - }); + test( + 'rejects an import generated name occupied by an earlier lazy in Draft-7 lowering', + () { + final imported = Ack.fromJsonSchema(true); + final schema = Ack.object({ + 'a': Ack.lazy('_ack_import_0_0', Ack.string), + 'b': imported, + }); - expect( - schema.toJsonSchema, - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Imported definition collides with'), + expect( + schema.toJsonSchemaDraft7, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Imported definition collides with'), + ), ), - ), - ); - }); + ); + }, + ); test('rejects nullable list item schemas at construction', () { expect( diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart index fdce5389..c603f027 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -1208,7 +1208,7 @@ String _emitFallback( ..writeln(' Object? toJson() => encode(this);') ..writeln() ..writeln(' static Map toJsonSchema() =>') - ..writeln(' schema.toJsonSchemaPreservingImportedDialect();') + ..writeln(' schema.toJsonSchema();') ..writeln('}') ..writeln(); } diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index 7b2bc10c..cbff4c37 100644 --- a/packages/ack_json_schema_builder/README.md +++ b/packages/ack_json_schema_builder/README.md @@ -95,9 +95,12 @@ other logic that JSON Schema cannot represent. ### Target Schema Support -The converter emits ACK's generic Draft-7 JSON Schema map before constructing -the `json_schema_builder` schema. If a downstream validator or consumer ignores -a JSON Schema keyword, validate with ACK after parsing. +The converter uses `AckSchema.toJsonSchema()` before constructing the +`json_schema_builder` schema, preserving imported Draft 2020-12 resources by +default and emitting Draft-7 for native ACK schemas. To lower an imported +schema into legacy Draft-7 `definitions`, call +`schema.toJsonSchemaBuilderDraft7()`. If a downstream validator or consumer +ignores a JSON Schema keyword, validate with ACK after parsing. ```dart final schema = Ack.date().min(DateTime(2026)); 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 ff965b44..1ae3e10e 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 @@ -1,8 +1,9 @@ /// Bidirectional JSON Schema Builder bridge for ACK validation library. /// -/// Converts ACK validation schemas to json_schema_builder Schema format -/// from ACK's stable Draft-7 JSON Schema renderer. Use the explicit preserving -/// export on an imported schema when its draft 2020-12 dialect is required. +/// Converts ACK validation schemas to json_schema_builder Schema format, +/// preserving imported Draft 2020-12 resources by default and emitting ACK's +/// Draft-7 representation for native schemas. Use [toJsonSchemaBuilderDraft7] +/// when an imported schema must be lowered to Draft-7 definitions. /// Imports draft 2020-12 builder models through [AckSchemaImportExtension]. /// /// ## Usage @@ -47,15 +48,14 @@ extension JsonSchemaBuilderExtension on AckSchema { /// Converts this ACK schema to json_schema_builder Schema format. /// /// Returns a json_schema_builder [Schema] instance from ACK's JSON Schema - /// map, preserving the 1.x Draft-7 export representation. + /// map, preserving imported Draft 2020-12 resources by default. jsb.Schema toJsonSchemaBuilder() { return jsb.Schema.fromMap(toJsonSchema()); } - /// Converts imports without lowering their draft 2020-12 resources. - /// Native schemas retain Draft-7 output. - jsb.Schema toJsonSchemaBuilderPreservingImportedDialect() { - return jsb.Schema.fromMap(toJsonSchemaPreservingImportedDialect()); + /// Converts imports using the legacy Draft-7 `definitions` / `$ref` lowering. + jsb.Schema toJsonSchemaBuilderDraft7() { + return jsb.Schema.fromMap(toJsonSchemaDraft7()); } } diff --git a/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart b/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart index 5f33c9d4..2c511420 100644 --- a/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart +++ b/packages/ack_json_schema_builder/test/from_json_schema_builder_test.dart @@ -28,18 +28,20 @@ void main() { expect(asserted.safeParse('not an email').isFail, isTrue); }); - test('builder exports keep the legacy default and expose 2020-12 opt-in', () { - final imported = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); - final legacy = imported.toJsonSchemaBuilder().value as Map; - final preserving = - imported.toJsonSchemaBuilderPreservingImportedDialect().value as Map; + test( + 'builder exports preserve 2020-12 by default and expose Draft-7 lowering', + () { + final imported = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + final exported = imported.toJsonSchemaBuilder().value as Map; + final legacy = imported.toJsonSchemaBuilderDraft7().value as Map; - expect(legacy['definitions'], isA>()); - expect(legacy[r'$schema'], isNull); - expect(preserving['type'], 'string'); - expect( - preserving[r'$schema'], - 'https://json-schema.org/draft/2020-12/schema', - ); - }); + expect(exported['type'], 'string'); + expect( + exported[r'$schema'], + 'https://json-schema.org/draft/2020-12/schema', + ); + expect(legacy['definitions'], isA>()); + expect(legacy[r'$schema'], isNull); + }, + ); } From 092ed37cbbbaa7fd4decde09d0d44cc3c12da3cb Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 14:07:59 -0400 Subject: [PATCH 05/29] feat(ack,ack_generator): unify constraint annotations under @Check.* namespace (#191) --- packages/ack/lib/ack.dart | 3 +- .../ack/lib/src/annotations/constraints.dart | 111 ++++++++ .../src/annotations/format_constraints.dart | 2 + .../ack/test/annotations/ack_model_test.dart | 63 ++++- .../ack/test/annotations/exports_test.dart | 25 +- packages/ack_generator/example/example.dart | 5 +- .../analyzer/class_model_graph_builder.dart | 93 +++++-- .../lib/src/inference/schema_inference.dart | 257 ++++++++++++++++-- .../class_first_runtime_build_test.dart | 6 +- .../integration/class_model_emitter_test.dart | 120 ++++++++ .../integration/class_model_graph_test.dart | 41 +++ 11 files changed, 675 insertions(+), 51 deletions(-) diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index ae9ab384..d1318695 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -45,6 +45,7 @@ export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; // Validation results export 'src/validation/schema_result.dart'; -// Model annotations. Constraint annotations live in annotations.dart. +// Model annotations. Legacy top-level constraint annotations live in annotations.dart. export 'src/annotations/ack_field.dart'; +export 'src/annotations/constraints.dart' show Check; export 'src/annotations/schemable.dart'; diff --git a/packages/ack/lib/src/annotations/constraints.dart b/packages/ack/lib/src/annotations/constraints.dart index 72a32129..156b4256 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -1,6 +1,102 @@ import 'package:meta/meta_meta.dart'; +/// Unified namespace for Ack field and parameter constraint annotations. +/// +/// Mirrors the fluent schema constraint methods so model files only need +/// `import 'package:ack/ack.dart';`: +/// +/// ```dart +/// @Schemable() +/// final class User with _$UserAck { +/// const User({required this.email, required this.name}); +/// +/// @Check.email() +/// final String email; +/// +/// @Check.minLength(2) +/// final String name; +/// } +/// ``` +@Target({TargetKind.field, TargetKind.parameter}) +final class Check { + /// The constraint identifier inspected by `ack_generator`. + final String kind; + + /// Optional constraint argument (`num`, `int`, or `String`). + final Object? value; + + /// Adds `.min(value)` to an inferred numeric schema. + const Check.min(num this.value) : kind = 'min'; + + /// Adds `.max(value)` to an inferred numeric schema. + const Check.max(num this.value) : kind = 'max'; + + /// Adds `.multipleOf(value)` to an inferred numeric schema. + const Check.multipleOf(num this.value) : kind = 'multipleOf'; + + /// Adds `.positive()` to an inferred numeric schema. + const Check.positive() : kind = 'positive', value = null; + + /// Adds `.negative()` to an inferred numeric schema. + const Check.negative() : kind = 'negative', value = null; + + /// Adds `.minLength(length)` to an inferred string schema. + const Check.minLength(int length) : kind = 'minLength', value = length; + + /// Adds `.maxLength(length)` to an inferred string schema. + const Check.maxLength(int length) : kind = 'maxLength', value = length; + + /// Adds `.matches(pattern)` to an inferred string schema. + const Check.matches(String pattern) : kind = 'matches', value = pattern; + + /// Adds `.notEmpty()` to an inferred string schema. + const Check.notEmpty() : kind = 'notEmpty', value = null; + + /// Adds `.email()` to an inferred string schema. + const Check.email() : kind = 'email', value = null; + + /// Adds `.url()` to an inferred string schema. + const Check.url() : kind = 'url', value = null; + + /// Adds `.uri()` to an inferred string schema. + const Check.uri() : kind = 'uri', value = null; + + /// Adds `.uuid()` to an inferred string schema. + const Check.uuid() : kind = 'uuid', value = null; + + /// Adds `.date()` to an inferred string schema. + const Check.date() : kind = 'date', value = null; + + /// Adds `.datetime()` to an inferred string schema. + const Check.dateTime() : kind = 'dateTime', value = null; + + /// Adds `.datetime()` to an inferred string schema. + const Check.datetime() : kind = 'datetime', value = null; + + /// Adds `.ip()` to an inferred string schema. + const Check.ip({int? version}) : kind = 'ip', value = version; + + /// Adds `.ipv4()` to an inferred string schema. + const Check.ipv4() : kind = 'ipv4', value = null; + + /// Adds `.ipv6()` to an inferred string schema. + const Check.ipv6() : kind = 'ipv6', value = null; + + /// Adds `.minItems(count)` to an inferred collection schema. + const Check.minItems(int count) : kind = 'minItems', value = count; + + /// Adds `.maxItems(count)` to an inferred collection schema. + const Check.maxItems(int count) : kind = 'maxItems', value = count; + + /// Adds `.unique()` to an inferred collection schema. + const Check.uniqueItems() : kind = 'uniqueItems', value = null; + + /// Adds `.unique()` to an inferred collection schema. + const Check.unique() : kind = 'unique', value = null; +} + /// Adds `.min(value)` to an inferred numeric schema. +@Deprecated('Use @Check.min(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Min { const Min(this.value); @@ -9,6 +105,7 @@ final class Min { } /// Adds `.max(value)` to an inferred numeric schema. +@Deprecated('Use @Check.max(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Max { const Max(this.value); @@ -17,6 +114,7 @@ final class Max { } /// Adds `.multipleOf(value)` to an inferred numeric schema. +@Deprecated('Use @Check.multipleOf(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MultipleOf { const MultipleOf(this.value); @@ -25,18 +123,21 @@ final class MultipleOf { } /// Adds `.positive()` to an inferred numeric schema. +@Deprecated('Use @Check.positive() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Positive { const Positive(); } /// Adds `.negative()` to an inferred numeric schema. +@Deprecated('Use @Check.negative() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Negative { const Negative(); } /// Adds `.minLength(length)` to an inferred string schema. +@Deprecated('Use @Check.minLength(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MinLength { const MinLength(this.length); @@ -45,6 +146,7 @@ final class MinLength { } /// Adds `.maxLength(length)` to an inferred string schema. +@Deprecated('Use @Check.maxLength(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MaxLength { const MaxLength(this.length); @@ -53,6 +155,7 @@ final class MaxLength { } /// Adds `.matches(pattern)` to an inferred string schema. +@Deprecated('Use @Check.matches(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Matches { const Matches(this.pattern); @@ -61,36 +164,42 @@ final class Matches { } /// Adds `.email()` to an inferred string schema. +@Deprecated('Use @Check.email() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Email { const Email(); } /// Adds `.url()` to an inferred string schema. +@Deprecated('Use @Check.url() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Url { const Url(); } /// Adds `.uuid()` to an inferred string schema. +@Deprecated('Use @Check.uuid() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Uuid { const Uuid(); } /// Adds `.date()` to an inferred string schema. +@Deprecated('Use @Check.date() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Date { const Date(); } /// Adds `.notEmpty()` to an inferred string schema. +@Deprecated('Use @Check.notEmpty() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class NotEmpty { const NotEmpty(); } /// Adds `.minItems(count)` to an inferred collection schema. +@Deprecated('Use @Check.minItems(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MinItems { const MinItems(this.count); @@ -99,6 +208,7 @@ final class MinItems { } /// Adds `.maxItems(count)` to an inferred collection schema. +@Deprecated('Use @Check.maxItems(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MaxItems { const MaxItems(this.count); @@ -107,6 +217,7 @@ final class MaxItems { } /// Adds `.unique()` to an inferred collection schema. +@Deprecated('Use @Check.uniqueItems() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class UniqueItems { const UniqueItems(); diff --git a/packages/ack/lib/src/annotations/format_constraints.dart b/packages/ack/lib/src/annotations/format_constraints.dart index b098c76f..56d51167 100644 --- a/packages/ack/lib/src/annotations/format_constraints.dart +++ b/packages/ack/lib/src/annotations/format_constraints.dart @@ -1,12 +1,14 @@ import 'package:meta/meta_meta.dart'; /// Adds `.uri()` to an inferred string schema. +@Deprecated('Use @Check.uri() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Uri { const Uri(); } /// Adds `.datetime()` to an inferred string schema. +@Deprecated('Use @Check.dateTime() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class DateTime { const DateTime(); diff --git a/packages/ack/test/annotations/ack_model_test.dart b/packages/ack/test/annotations/ack_model_test.dart index 584317fe..c90240d4 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'package:ack/annotations.dart'; import 'package:test/test.dart'; @@ -70,7 +72,66 @@ void main() { expect(notNull, isA()); }); - test('constraint sugar annotations are const data', () { + test('Check.* constraint annotations are const and expose kind + value', () { + const checks = [ + Check.min(1), + Check.max(9.5), + Check.multipleOf(2), + Check.positive(), + Check.negative(), + Check.minLength(1), + Check.maxLength(100), + Check.matches(r'^[a-z]+$'), + Check.notEmpty(), + Check.email(), + Check.url(), + Check.uri(), + Check.uuid(), + Check.date(), + Check.dateTime(), + Check.datetime(), + Check.ip(), + Check.ip(version: 4), + Check.ipv4(), + Check.ipv6(), + Check.minItems(1), + Check.maxItems(10), + Check.uniqueItems(), + Check.unique(), + ]; + + expect( + checks.map((c) => (c.kind, c.value)).toList(), + const <(String, Object?)>[ + ('min', 1), + ('max', 9.5), + ('multipleOf', 2), + ('positive', null), + ('negative', null), + ('minLength', 1), + ('maxLength', 100), + ('matches', r'^[a-z]+$'), + ('notEmpty', null), + ('email', null), + ('url', null), + ('uri', null), + ('uuid', null), + ('date', null), + ('dateTime', null), + ('datetime', null), + ('ip', null), + ('ip', 4), + ('ipv4', null), + ('ipv6', null), + ('minItems', 1), + ('maxItems', 10), + ('uniqueItems', null), + ('unique', null), + ], + ); + }); + + test('legacy constraint sugar annotations remain const data', () { const annotations = [ Min(1), Max(9), diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index 4ddf4b4d..4d056473 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'package:ack/ack.dart'; import 'package:ack/annotations.dart' as ack; import 'package:test/test.dart'; @@ -11,17 +13,31 @@ final class Contact { final Email email; } +final class SingleImportContact { + const SingleImportContact(this.email, this.id); + + @Check.email() + final Email email; + + @Check.uuid() + final Uuid id; +} + void main() { - test('package:ack/ack.dart exports the model annotations', () { + test('package:ack/ack.dart exports the model and Check annotations', () { const values = [ Schemable(caseStyle: AckCaseStyle.snake), AckField(description: 'id'), Required(), Optional(), NotNull(), + Check.email(), + Check.minLength(2), + Check.matches(r'^\d+$'), + ack.Check.uuid(), ]; - expect(values, hasLength(5)); + expect(values, hasLength(9)); expect(AckUnknownPropertyPolicy.values, hasLength(3)); }); @@ -34,6 +50,7 @@ void main() { const date = Date('2026-10-01'); const min = Min(1); const pattern = Pattern('^a'); + const contact = SingleImportContact(email, uuid); expect( [ @@ -43,6 +60,8 @@ void main() { date.iso, min.value, pattern.source, + contact.email.address, + contact.id.value, ], [ '7c9e6679', @@ -51,6 +70,8 @@ void main() { '2026-10-01', 1, '^a', + 'ada@example.com', + '7c9e6679', ], ); }, diff --git a/packages/ack_generator/example/example.dart b/packages/ack_generator/example/example.dart index 735e68e4..e8fc2c51 100644 --- a/packages/ack_generator/example/example.dart +++ b/packages/ack_generator/example/example.dart @@ -17,7 +17,6 @@ // Add `ack` to `dependencies`, and `ack_generator` plus `build_runner` to // `dev_dependencies`. import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; /// Schema-first. `@Schemable()` derives an immutable `User` class from this /// schema: final `name`, `email`, and `age` fields, `User.parse`, @@ -44,10 +43,10 @@ final settingsSchema = Ack.object({ final class Product { const Product({required this.displayName, required this.priceCents}); - @NotEmpty() + @Check.notEmpty() final String displayName; - @Min(0) + @Check.min(0) final int priceCents; } diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 7eaa1e73..962fc4e2 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -2,6 +2,7 @@ import 'package:ack/ack.dart' show AckModelAdapter, AckSchema; import 'package:ack/annotations.dart' as annotations; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; @@ -621,7 +622,8 @@ final class ClassModelGraphBuilder { ); } captureJsonKey = - _jsonKey(extras) ?? _rename(captureFieldName, options.caseStyle); + _jsonKey(extras, parameters[captureFieldName]) ?? + _rename(captureFieldName, options.caseStyle); } final nodes = []; @@ -653,7 +655,8 @@ final class ClassModelGraphBuilder { _validateMapKey(field, field.type); final futureType = futureTypes[name]; _recordClassFirstDependencies(element, field.type, field); - final jsonKey = _jsonKey(field) ?? _rename(name, options.caseStyle); + final jsonKey = + _jsonKey(field, parameter) ?? _rename(name, options.caseStyle); final prior = ownerByJsonKey[jsonKey]; if (prior != null) { throw InvalidGenerationSource( @@ -674,7 +677,7 @@ final class ClassModelGraphBuilder { final dartNullable = futureType?.runtimeRef is AckNullableTypeRef || _isNullable(field.type); - final rejectNull = _notNullChecker.hasAnnotationOfExact(field); + final rejectNull = _hasAnnotation(_notNullChecker, field, parameter); final acceptsNull = dartNullable && !rejectNull; final presence = _effectivePresence( field, @@ -683,8 +686,12 @@ final class ClassModelGraphBuilder { ); var schema = isDiscriminator && discriminatorValue != null ? '${_ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' - : await _fieldSchema(field, futureType: futureType); - schema = _inference.applyDescription(schema, field); + : await _fieldSchema( + field, + parameter: parameter, + futureType: futureType, + ); + schema = _applyDescription(schema, field, parameter); schema = _applyPresence( schema, presence: presence, @@ -795,14 +802,15 @@ final class ClassModelGraphBuilder { Future _fieldSchema( FieldElement field, { + FormalParameterElement? parameter, _FutureGeneratedType? futureType, }) async { - final override = _ackFieldChecker.firstAnnotationOfExact(field); + final override = _firstAnnotation(_ackFieldChecker, field, parameter); if (override != null) { final reader = ConstantReader(override); if (!reader.read('schema').isNull) { final base = await _escapeHatchExpression(field, reader); - return _applySugar(base, field); + return _applySugar(base, field, parameter); } } if (futureType != null) { @@ -810,11 +818,11 @@ final class ClassModelGraphBuilder { final setListSchema = futureType.setListSchema; if (setListSchema != null) { return _setCodec( - _applySugar(setListSchema, field), + _applySugar(setListSchema, field, parameter), futureType.runtimeRef, ); } - return _applySugar(futureType.schemaExpression, field); + return _applySugar(futureType.schemaExpression, field, parameter); } final type = field.type; if (type is InterfaceType && @@ -823,10 +831,10 @@ final class ClassModelGraphBuilder { final itemType = type.typeArguments.single; _rejectNullableCollectionElement(field, itemType); final item = await _schemaForType(itemType, field); - final list = _applySugar('${_ack('Ack')}.list($item)', field); + final list = _applySugar('${_ack('Ack')}.list($item)', field, parameter); return _setCodec(list, _typeRef(type, field)); } - return _applySugar(await _schemaForType(type, field), field); + return _applySugar(await _schemaForType(type, field), field, parameter); } String _setCodec(String listSchema, AckInferRef runtimeRef) { @@ -1387,8 +1395,57 @@ final class ClassModelGraphBuilder { } } - String _applySugar(String schema, FieldElement field) => - _inference.applyConstraints(schema, field, field.type); + String _applySugar( + String schema, + FieldElement field, + FormalParameterElement? parameter, + ) { + if (field.isOriginDeclaringFormalParameter) { + final target = field.metadata.annotations.isNotEmpty + ? field + : (parameter ?? field); + return _inference.applyConstraints(schema, target, field.type); + } + var output = _inference.applyConstraints(schema, field, field.type); + if (parameter != null && !identical(parameter, field)) { + output = _inference.applyConstraints(output, parameter, field.type); + } + return output; + } + + String _applyDescription( + String schema, + FieldElement field, + FormalParameterElement? parameter, + ) { + final target = _ackFieldChecker.hasAnnotationOfExact(field) + ? field + : (parameter != null && _ackFieldChecker.hasAnnotationOfExact(parameter) + ? parameter + : field); + return _inference.applyDescription( + schema, + target, + sourceComment: field.documentationComment, + ); + } + + DartObject? _firstAnnotation( + TypeChecker checker, + FieldElement field, [ + FormalParameterElement? parameter, + ]) => + checker.firstAnnotationOfExact(field) ?? + (parameter == null ? null : checker.firstAnnotationOfExact(parameter)); + + bool _hasAnnotation( + TypeChecker checker, + FieldElement field, [ + FormalParameterElement? parameter, + ]) => + checker.hasAnnotationOfExact(field) || + (parameter != null && checker.hasAnnotationOfExact(parameter)); + void _rejectUnsupportedStaticType(FieldElement field, DartType type) { if (type is DynamicType || type is TypeParameterType) { _unsupportedFieldType(field, type); @@ -1549,8 +1606,8 @@ final class ClassModelGraphBuilder { required bool isDiscriminator, }) { final inferred = _fieldPresence(parameter); - final hasOptional = _optionalChecker.hasAnnotationOfExact(field); - final hasRequired = _requiredChecker.hasAnnotationOfExact(field); + final hasOptional = _hasAnnotation(_optionalChecker, field, parameter); + final hasRequired = _hasAnnotation(_requiredChecker, field, parameter); if (hasOptional && hasRequired) { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} cannot combine ' @@ -1559,7 +1616,7 @@ final class ClassModelGraphBuilder { ); } - final annotation = _ackFieldChecker.firstAnnotationOfExact(field); + final annotation = _firstAnnotation(_ackFieldChecker, field, parameter); if (annotation != null) { final reader = ConstantReader(annotation); if (reader.read('name').isNull && @@ -1909,9 +1966,9 @@ final class ClassModelGraphBuilder { : null; } - String? _jsonKey(FieldElement field) { + String? _jsonKey(FieldElement field, [FormalParameterElement? parameter]) { String? ackFieldName; - final ackField = _ackFieldChecker.firstAnnotationOfExact(field); + final ackField = _firstAnnotation(_ackFieldChecker, field, parameter); if (ackField != null) { final nameReader = ConstantReader(ackField).read('name'); if (!nameReader.isNull) { diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 1f7b18aa..10d619a8 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -1,3 +1,5 @@ +// ignore_for_file: deprecated_member_use + import 'package:ack/annotations.dart' as annotations; import 'package:ack/format_annotations.dart' as formats; import 'package:analyzer/dart/analysis/utilities.dart'; @@ -20,6 +22,10 @@ final class AckSchemaInference { final String? ackPrefix; + static const _check = TypeChecker.typeNamed( + annotations.Check, + inPackage: 'ack', + ); static const _min = TypeChecker.typeNamed(annotations.Min, inPackage: 'ack'); static const _max = TypeChecker.typeNamed(annotations.Max, inPackage: 'ack'); static const _multipleOf = TypeChecker.typeNamed( @@ -154,11 +160,218 @@ final class AckSchemaInference { final isSet = type is InterfaceType && type.isDartCoreSet; final setConstraints = []; var hasUniqueConstraint = false; + + void addCollectionConstraint(String constraint, {bool isUnique = false}) { + if (isSet) { + if (isUnique) hasUniqueConstraint = true; + setConstraints.add(constraint); + } else { + output = '$output$constraint'; + } + } + for (final metadata in declaration.metadata.annotations) { final value = metadata.computeConstantValue(); final valueType = value?.type; if (value == null || valueType == null) continue; - if (_min.isExactlyType(valueType)) { + if (_check.isExactlyType(valueType)) { + final kind = value.getField('kind')!.toStringValue()!; + switch (kind) { + case 'min': + _require( + declaration, + type, + '@Check.min', + isNumeric, + '@Check.minLength', + ); + output = + '$output.min(${_number(declaration, '@Check.min', value)})'; + case 'max': + _require( + declaration, + type, + '@Check.max', + isNumeric, + '@Check.maxLength', + ); + output = + '$output.max(${_number(declaration, '@Check.max', value)})'; + case 'multipleOf': + _require( + declaration, + type, + '@Check.multipleOf', + isNumeric, + 'numeric field', + ); + output = + '$output.multipleOf(${_number(declaration, '@Check.multipleOf', value)})'; + case 'positive': + _require( + declaration, + type, + '@Check.positive', + isNumeric, + 'numeric field', + ); + output = '$output.positive()'; + case 'negative': + _require( + declaration, + type, + '@Check.negative', + isNumeric, + 'numeric field', + ); + output = '$output.negative()'; + case 'minLength': + _require( + declaration, + type, + '@Check.minLength', + isString, + '@Check.min', + ); + output = + '$output.minLength(${value.getField('value')!.toIntValue()})'; + case 'maxLength': + _require( + declaration, + type, + '@Check.maxLength', + isString, + '@Check.max', + ); + output = + '$output.maxLength(${value.getField('value')!.toIntValue()})'; + case 'matches': + _require( + declaration, + type, + '@Check.matches', + isString, + 'String field', + ); + output = + '$output.matches(${dartStringLiteral(value.getField('value')!.toStringValue()!)})'; + case 'notEmpty': + _require( + declaration, + type, + '@Check.notEmpty', + isString, + 'String field', + ); + output = '$output.notEmpty()'; + case 'email': + _require( + declaration, + type, + '@Check.email', + isString, + 'String field', + ); + output = '$output.email()'; + case 'url': + _require(declaration, type, '@Check.url', isString, 'String field'); + output = '$output.url()'; + case 'uri': + _require(declaration, type, '@Check.uri', isString, 'String field'); + output = '$output.uri()'; + case 'uuid': + _require( + declaration, + type, + '@Check.uuid', + isString, + 'String field', + ); + output = '$output.uuid()'; + case 'date': + _require( + declaration, + type, + '@Check.date', + isString, + 'String field', + ); + output = '$output.date()'; + case 'dateTime' || 'datetime': + _require( + declaration, + type, + '@Check.$kind', + isString, + 'String field', + ); + output = '$output.datetime()'; + case 'ip': + _require(declaration, type, '@Check.ip', isString, 'String field'); + final version = value.getField('value')?.toIntValue(); + if (version != null) { + if (version != 4 && version != 6) { + throw InvalidGenerationSource( + '${_qualifiedName(declaration)} has @Check.ip(version: ' + '$version); version must be 4 or 6.', + element: declaration, + ); + } + output = '$output.ip(version: $version)'; + } else { + output = '$output.ip()'; + } + case 'ipv4': + _require( + declaration, + type, + '@Check.ipv4', + isString, + 'String field', + ); + output = '$output.ipv4()'; + case 'ipv6': + _require( + declaration, + type, + '@Check.ipv6', + isString, + 'String field', + ); + output = '$output.ipv6()'; + case 'minItems': + _require( + declaration, + type, + '@Check.minItems', + isCollection, + 'List or Set field', + ); + addCollectionConstraint( + '.minItems(${value.getField('value')!.toIntValue()})', + ); + case 'maxItems': + _require( + declaration, + type, + '@Check.maxItems', + isCollection, + 'List or Set field', + ); + addCollectionConstraint( + '.maxItems(${value.getField('value')!.toIntValue()})', + ); + case 'uniqueItems' || 'unique': + _require( + declaration, + type, + '@Check.$kind', + isCollection, + 'List or Set field', + ); + addCollectionConstraint('.unique()', isUnique: true); + } + } else if (_min.isExactlyType(valueType)) { _require(declaration, type, '@Min', isNumeric, '@MinLength'); output = '$output.min(${_number(declaration, '@Min', value)})'; } else if (_max.isExactlyType(valueType)) { @@ -213,13 +426,9 @@ final class AckSchemaInference { isCollection, 'List or Set field', ); - final constraint = - '.minItems(${value.getField('count')!.toIntValue()})'; - if (isSet) { - setConstraints.add(constraint); - } else { - output = '$output$constraint'; - } + addCollectionConstraint( + '.minItems(${value.getField('count')!.toIntValue()})', + ); } else if (_maxItems.isExactlyType(valueType)) { _require( declaration, @@ -228,13 +437,9 @@ final class AckSchemaInference { isCollection, 'List or Set field', ); - final constraint = - '.maxItems(${value.getField('count')!.toIntValue()})'; - if (isSet) { - setConstraints.add(constraint); - } else { - output = '$output$constraint'; - } + addCollectionConstraint( + '.maxItems(${value.getField('count')!.toIntValue()})', + ); } else if (_uniqueItems.isExactlyType(valueType)) { _require( declaration, @@ -243,12 +448,7 @@ final class AckSchemaInference { isCollection, 'List or Set field', ); - if (isSet) { - hasUniqueConstraint = true; - setConstraints.add('.unique()'); - } else { - output = '$output.unique()'; - } + addCollectionConstraint('.unique()', isUnique: true); } } if (setConstraints.isNotEmpty) { @@ -352,7 +552,7 @@ final class AckSchemaInference { final number = (field.toIntValue() ?? field.toDoubleValue())!; if (!number.isFinite) { throw InvalidGenerationSource( - '${declaration.enclosingElement?.name}.${declaration.name} has ' + '${_qualifiedName(declaration)} has ' '$annotation($number); the value must be a finite number.', element: declaration, ); @@ -369,9 +569,20 @@ final class AckSchemaInference { ) { if (valid) return; throw InvalidGenerationSource( - '${declaration.enclosingElement?.name}.${declaration.name} has ' + '${_qualifiedName(declaration)} has ' '$annotation on ${type.getDisplayString()}; use $alternative instead.', element: declaration, ); } + + String _qualifiedName(Element declaration) { + final owner = switch (declaration.enclosingElement) { + ConstructorElement(:final enclosingElement) => enclosingElement.name, + final enclosing? => enclosing.name, + null => null, + }; + return owner == null || owner.isEmpty + ? '${declaration.name}' + : '$owner.${declaration.name}'; + } } diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index c521bfac..e9b9d3ed 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -203,7 +203,7 @@ final class Profile with _$ProfileAck { final Uri? website; final String? nickname; final String role; - @UniqueItems() + @Check.uniqueItems() final Set tags; @AckField(schema: colorSchema) final Color color; @@ -291,7 +291,7 @@ final class ImmutableCollections with _$ImmutableCollectionsAck { }); final List> matrix; - @UniqueItems() + @Check.uniqueItems() final Set labels; @AckField(schema: groupsSchema) final Map> groups; @@ -307,7 +307,7 @@ final class Example with _$ExampleAck { @Optional() @NotNull() - @NotEmpty() + @Check.notEmpty() final String? title; static final fromJson = ExampleSchema.fromJson; diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 99f9ba2a..a1daf594 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -1006,4 +1006,124 @@ final class Account with _\$AccountAck { }, ); }); + + test( + '@Check.* constraints work with single package:ack/ack.dart import alongside same-named types and constructor parameters', + () async { + await _build( + { + 'check_models.dart': ''' +import 'package:ack/ack.dart'; + +part 'check_models.g.dart'; + +final class Email { + const Email(this.address); + final String address; +} + +final class Uuid { + const Uuid(this.value); + final String value; +} + +@Schemable() +final class CheckedRecord with _\$CheckedRecordAck { + const CheckedRecord({ + required this.email, + required this.id, + required this.website, + required this.resourceUri, + required this.birthDate, + required this.createdAt, + required this.clientIp, + required this.v4, + required this.v6, + required this.code, + required this.score, + required this.debt, + required this.tags, + @Check.minLength(2) @Check.maxLength(20) required this.handle, + }); + + @Check.email() + final String email; + + @Check.uuid() + final String id; + + @Check.url() + final String website; + + @Check.uri() + final String resourceUri; + + @Check.date() + final String birthDate; + + @Check.dateTime() + final String createdAt; + + @Check.ip(version: 4) + final String clientIp; + + @Check.ipv4() + final String v4; + + @Check.ipv6() + final String v6; + + @Check.notEmpty() + @Check.matches(r'^[A-Z]+\$') + final String code; + + @Check.min(0) + @Check.max(100) + @Check.multipleOf(5) + @Check.positive() + final int score; + + @Check.negative() + final double debt; + + @Check.minItems(1) + @Check.maxItems(5) + @Check.uniqueItems() + final List tags; + + final String handle; +} +''', + }, + outputs: { + 'test_pkg|lib/check_models.g.dart': decodedMatches( + allOf([ + _containsCode("'email': Ack.string().email()"), + _containsCode("'id': Ack.string().uuid()"), + _containsCode("'website': Ack.string().url()"), + _containsCode("'resourceUri': Ack.string().uri()"), + _containsCode("'birthDate': Ack.string().date()"), + _containsCode("'createdAt': Ack.string().datetime()"), + _containsCode("'clientIp': Ack.string().ip(version: 4)"), + _containsCode("'v4': Ack.string().ipv4()"), + _containsCode("'v6': Ack.string().ipv6()"), + _containsCode( + r"'code': Ack.string().notEmpty().matches('^[A-Z]+\$')", + ), + _containsCode( + "'score': Ack.integer().min(0).max(100).multipleOf(5).positive()", + ), + _containsCode("'debt': Ack.double().negative()"), + _containsCode( + "'tags': Ack.list(Ack.string()).minItems(1).maxItems(5).unique()", + ), + _containsCode( + "'handle': Ack.string().minLength(2).maxLength(20)", + ), + ]), + ), + }, + ); + }, + ); } diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 9976419f..dbb25a38 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -1352,4 +1352,45 @@ final class User with _\$UserAck { ['User', 'extra', 'mapped'], ); }); + + test( + 'rejects invalid @Check.* constraints on mismatched field types or values', + () async { + await _expectFailure( + ''' +@Schemable() +final class User with _\$UserAck { + const User({required this.name}); + + @Check.min(1) + final String name; +} +''', + ['User.name', '@Check.min', 'String', '@Check.minLength'], + ); + await _expectFailure( + ''' +@Schemable() +final class User with _\$UserAck { + const User({required this.ip}); + + @Check.ip(version: 5) + final String ip; +} +''', + ['User.ip', '@Check.ip(version: 5)', 'version must be 4 or 6.'], + ); + await _expectFailure( + ''' +@Schemable() +final class User with _\$UserAck { + const User({@Check.minLength(1) required this.age}); + + final int age; +} +''', + ['User.age', '@Check.minLength', 'int', '@Check.min'], + ); + }, + ); } From 43d3698d0cfe45f4433ec05568d50bc7de19a40f Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 14:13:23 -0400 Subject: [PATCH 06/29] feat(ack): seal SchemaError and AckSchema hierarchies --- example/lib/class_first_models.dart | 7 +- packages/ack/lib/ack.dart | 2 + packages/ack/lib/src/schemas/schema.dart | 2 +- .../src/schemas/testing/testing_schemas.dart | 136 ++++++++++++++++++ .../ack/lib/src/validation/schema_error.dart | 10 +- .../encode_error_propagation_test.dart | 57 +------- .../typed_codecs_characterization_test.dart | 129 ++++++++--------- 7 files changed, 208 insertions(+), 135 deletions(-) diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index 856252a1..f133d77f 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -1,5 +1,4 @@ import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; part 'class_first_models.g.dart'; @@ -16,7 +15,7 @@ final class Account with _$AccountAck { }); /// The name shown to other users. - @MinLength(2) + @Check.minLength(2) @AckField(description: 'The name shown to other users.') final String displayName; final Uri? website; @@ -40,8 +39,8 @@ sealed class Pet with _$PetAck { final class Cat extends Pet with _$CatAck { const Cat({required super.id, required this.lives}); - @Min(1) - @Max(9) + @Check.min(1) + @Check.max(9) final int lives; } diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index d1318695..bcb27e14 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -39,6 +39,8 @@ export 'src/schemas/schema.dart' ImportedJsonSchema, Refinement, SchemaOperation, + TestOperationRecordingAckSchema, + TestThrowingLeafAckSchema, WrapperSchema, createCodecSchemaInternal; export 'src/validation/ack_exception.dart'; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index d4318183..0b650d2f 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -75,7 +75,7 @@ enum SchemaOperation { parse, encode } /// methods. Subclasses override the three methods; they should not override /// the public wrappers. @immutable -abstract class AckSchema { +sealed class AckSchema { final bool isNullable; final bool isOptional; final String? description; diff --git a/packages/ack/lib/src/schemas/testing/testing_schemas.dart b/packages/ack/lib/src/schemas/testing/testing_schemas.dart index 7505329d..207815fb 100644 --- a/packages/ack/lib/src/schemas/testing/testing_schemas.dart +++ b/packages/ack/lib/src/schemas/testing/testing_schemas.dart @@ -56,3 +56,139 @@ final class TestUnsupportedAckSchema extends AckSchema @override int get hashCode => baseFieldsHashCode; } + +/// Testing-only schema that records parse, validate, and encode operations. +@visibleForTesting +final class TestOperationRecordingAckSchema extends AckSchema + with FluentSchema { + const TestOperationRecordingAckSchema({ + required this.parseOperations, + required this.validateOperations, + required this.encodeOperations, + super.isNullable, + super.isOptional, + super.description, + super.constraints, + super.refinements, + }); + + final List parseOperations; + final List validateOperations; + final List encodeOperations; + + @override + SchemaType get schemaType => SchemaType.string; + + @override + SchemaResult parseWithContext(Object? value, SchemaContext context) { + parseOperations.add(context.operation); + return validateRuntimeWithContext(value, context); + } + + @override + SchemaResult validateRuntimeWithContext( + Object? value, + SchemaContext context, + ) { + validateOperations.add(context.operation); + return SchemaResult.ok(value as String); + } + + @override + SchemaResult encodeWithContext(String value, SchemaContext context) { + encodeOperations.add(context.operation); + final validated = validateRuntimeWithContext(value, context); + if (validated.isFail) return SchemaResult.fail(validated.getError()); + return SchemaResult.ok(value); + } + + @override + TestOperationRecordingAckSchema copyWith({ + bool? isNullable, + bool? isOptional, + String? description, + List>? constraints, + List>? refinements, + }) { + return TestOperationRecordingAckSchema( + parseOperations: parseOperations, + validateOperations: validateOperations, + encodeOperations: encodeOperations, + isNullable: isNullable ?? this.isNullable, + isOptional: isOptional ?? this.isOptional, + description: description ?? this.description, + constraints: constraints ?? this.constraints, + refinements: refinements ?? this.refinements, + ); + } + + @override + Map toJsonSchema() => const {'type': 'string'}; + + @override + bool operator ==(Object other) => + other is TestOperationRecordingAckSchema && + identical(parseOperations, other.parseOperations) && + identical(validateOperations, other.validateOperations) && + identical(encodeOperations, other.encodeOperations) && + baseFieldsEqual(other); + + @override + int get hashCode => Object.hash( + baseFieldsHashCode, + identityHashCode(parseOperations), + identityHashCode(validateOperations), + identityHashCode(encodeOperations), + ); +} + +/// Testing-only non-codec schema whose encode throws an ordinary [Exception]. +@visibleForTesting +final class TestThrowingLeafAckSchema extends AckSchema + with FluentSchema { + const TestThrowingLeafAckSchema({ + super.isNullable, + super.isOptional, + super.description, + super.constraints, + super.refinements, + }); + + @override + SchemaType get schemaType => SchemaType.string; + + @override + SchemaResult validateRuntimeWithContext( + Object? value, + SchemaContext context, + ) => SchemaResult.ok(value as String); + + @override + SchemaResult encodeWithContext(String value, SchemaContext context) { + throw const FormatException('leaf encode refused'); + } + + @override + TestThrowingLeafAckSchema copyWith({ + bool? isNullable, + bool? isOptional, + String? description, + List>? constraints, + List>? refinements, + }) { + return TestThrowingLeafAckSchema( + isNullable: isNullable ?? this.isNullable, + isOptional: isOptional ?? this.isOptional, + description: description ?? this.description, + constraints: constraints ?? this.constraints, + refinements: refinements ?? this.refinements, + ); + } + + @override + bool operator ==(Object other) => + other is TestThrowingLeafAckSchema && baseFieldsEqual(other); + + @override + int get hashCode => baseFieldsHashCode; +} diff --git a/packages/ack/lib/src/validation/schema_error.dart b/packages/ack/lib/src/validation/schema_error.dart index 168d251a..4a8621e1 100644 --- a/packages/ack/lib/src/validation/schema_error.dart +++ b/packages/ack/lib/src/validation/schema_error.dart @@ -5,7 +5,7 @@ import '../context.dart'; import '../schemas/schema.dart'; @immutable -abstract class SchemaError { +sealed class SchemaError { final String message; final SchemaContext context; final Object? cause; @@ -50,7 +50,7 @@ abstract class SchemaError { } @immutable -class TypeMismatchError extends SchemaError { +final class TypeMismatchError extends SchemaError { final SchemaType _expectedJsonType; final SchemaType _actualJsonType; @@ -83,7 +83,7 @@ class TypeMismatchError extends SchemaError { } } -class SchemaConstraintsError extends SchemaError { +final class SchemaConstraintsError extends SchemaError { final List constraints; SchemaConstraintsError({required this.constraints, required super.context}) @@ -111,7 +111,7 @@ class SchemaConstraintsError extends SchemaError { } @immutable -class SchemaNestedError extends SchemaError { +final class SchemaNestedError extends SchemaError { final List errors; const SchemaNestedError({required this.errors, required super.context}) @@ -127,7 +127,7 @@ class SchemaNestedError extends SchemaError { } @immutable -class SchemaValidationError extends SchemaError { +base class SchemaValidationError extends SchemaError { SchemaValidationError({ required String message, required super.context, diff --git a/packages/ack/test/schemas/encode_error_propagation_test.dart b/packages/ack/test/schemas/encode_error_propagation_test.dart index bda62fbe..4849a1d7 100644 --- a/packages/ack/test/schemas/encode_error_propagation_test.dart +++ b/packages/ack/test/schemas/encode_error_propagation_test.dart @@ -1,7 +1,5 @@ import 'package:ack/ack.dart'; -// `Refinement` is intentionally hidden from the public ack.dart export; the -// test-local schema below reaches into the source path to declare it. -import 'package:ack/src/schemas/schema.dart' show Refinement; +import 'package:ack/src/schemas/schema.dart' show TestThrowingLeafAckSchema; import 'package:test/test.dart'; CodecSchema _throwingCodec(Object error) { @@ -19,57 +17,6 @@ CodecSchema _countingCodec(List calls) { ); } -/// A non-codec schema whose encode throws an ordinary [Exception], used to pin -/// that composite catch blocks still attribute a child path. -final class _ThrowingLeafSchema extends AckSchema - with FluentSchema { - const _ThrowingLeafSchema({ - super.isNullable, - super.isOptional, - super.description, - super.constraints, - super.refinements, - }); - - @override - SchemaType get schemaType => SchemaType.string; - - @override - SchemaResult validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) => SchemaResult.ok(value as String); - - @override - SchemaResult encodeWithContext(String value, SchemaContext context) { - throw const FormatException('leaf encode refused'); - } - - @override - _ThrowingLeafSchema copyWith({ - bool? isNullable, - bool? isOptional, - String? description, - List>? constraints, - List>? refinements, - }) { - return _ThrowingLeafSchema( - isNullable: isNullable ?? this.isNullable, - isOptional: isOptional ?? this.isOptional, - description: description ?? this.description, - constraints: constraints ?? this.constraints, - refinements: refinements ?? this.refinements, - ); - } - - @override - bool operator ==(Object other) => - other is _ThrowingLeafSchema && baseFieldsEqual(other); - - @override - int get hashCode => baseFieldsHashCode; -} - Iterable _flatten(SchemaError error) sync* { yield error; if (error is SchemaNestedError) { @@ -245,7 +192,7 @@ void main() { }); test('a non-codec child Exception is still attributed to #/leaf', () { - final schema = Ack.object({'leaf': const _ThrowingLeafSchema()}); + final schema = Ack.object({'leaf': const TestThrowingLeafAckSchema()}); final result = schema.safeEncode({'leaf': 'v'}); diff --git a/packages/ack/test/typed_codecs_characterization_test.dart b/packages/ack/test/typed_codecs_characterization_test.dart index cf45c85f..316c934a 100644 --- a/packages/ack/test/typed_codecs_characterization_test.dart +++ b/packages/ack/test/typed_codecs_characterization_test.dart @@ -2,7 +2,11 @@ import 'package:ack/ack.dart'; // These symbols are intentionally hidden from the public ack.dart export; // the internal characterization tests below reach into the source path. import 'package:ack/src/schemas/schema.dart' - show AnyAckSchema, Refinement, SchemaOperation, WrapperSchema; + show + AnyAckSchema, + SchemaOperation, + TestOperationRecordingAckSchema, + WrapperSchema; import 'package:test/test.dart'; final class _Event { @@ -31,73 +35,6 @@ final class _StartsWithConstraint extends Constraint String buildMessage(String value) => 'Expected value to start with $prefix'; } -final class _OperationRecordingSchema extends AckSchema - with FluentSchema { - _OperationRecordingSchema({ - required this.parseOperations, - required this.validateOperations, - required this.encodeOperations, - super.isNullable, - super.isOptional, - super.description, - super.constraints, - super.refinements, - }); - - final List parseOperations; - final List validateOperations; - final List encodeOperations; - - @override - SchemaType get schemaType => SchemaType.string; - - @override - SchemaResult parseWithContext(Object? value, SchemaContext context) { - parseOperations.add(context.operation); - return validateRuntimeWithContext(value, context); - } - - @override - SchemaResult validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) { - validateOperations.add(context.operation); - return SchemaResult.ok(value as String); - } - - @override - SchemaResult encodeWithContext(String value, SchemaContext context) { - encodeOperations.add(context.operation); - final validated = validateRuntimeWithContext(value, context); - if (validated.isFail) return SchemaResult.fail(validated.getError()); - return SchemaResult.ok(value); - } - - @override - _OperationRecordingSchema copyWith({ - bool? isNullable, - bool? isOptional, - String? description, - List>? constraints, - List>? refinements, - }) { - return _OperationRecordingSchema( - parseOperations: parseOperations, - validateOperations: validateOperations, - encodeOperations: encodeOperations, - isNullable: isNullable ?? this.isNullable, - isOptional: isOptional ?? this.isOptional, - description: description ?? this.description, - constraints: constraints ?? this.constraints, - refinements: refinements ?? this.refinements, - ); - } - - @override - Map toJsonSchema() => const {'type': 'string'}; -} - void main() { group('AckSchema type model', () { test('Ack.string is AckSchema', () { @@ -557,7 +494,7 @@ void main() { test('parse path observes SchemaOperation.parse', () { final parseOperations = []; final validateOperations = []; - final schema = _OperationRecordingSchema( + final schema = TestOperationRecordingAckSchema( parseOperations: parseOperations, validateOperations: validateOperations, encodeOperations: [], @@ -572,7 +509,7 @@ void main() { test('encode path observes SchemaOperation.encode', () { final validateOperations = []; final encodeOperations = []; - final schema = _OperationRecordingSchema( + final schema = TestOperationRecordingAckSchema( parseOperations: [], validateOperations: validateOperations, encodeOperations: encodeOperations, @@ -584,4 +521,56 @@ void main() { expect(validateOperations, [SchemaOperation.encode]); }); }); + + group('Sealed SchemaError exhaustiveness', () { + String classifyError(SchemaError error) => switch (error) { + TypeMismatchError() => 'type_mismatch', + SchemaConstraintsError() => 'constraints', + SchemaNestedError() => 'nested', + SchemaValidationError() => 'validation', + SchemaTransformError() => 'transform', + SchemaEncodeError() => 'encode', + }; + + test( + 'exhaustive switch handles all SchemaError subtypes without default', + () { + expect( + classifyError(Ack.string().safeParse(42).getError()), + 'type_mismatch', + ); + expect( + classifyError(Ack.string().minLength(5).safeParse('hi').getError()), + 'constraints', + ); + expect( + classifyError( + Ack.object({'a': Ack.string()}).safeParse({'a': 1}).getError(), + ), + 'nested', + ); + expect( + classifyError( + Ack.string() + .refine((v) => v == 'ok', message: 'nope') + .safeParse('no') + .getError(), + ), + 'validation', + ); + expect( + classifyError( + Ack.string().transform(int.parse).safeParse('abc').getError(), + ), + 'transform', + ); + expect( + classifyError( + Ack.string().transform(int.parse).safeEncode(1).getError(), + ), + 'encode', + ); + }, + ); + }); } From 309d494356a0b487644710ab22709510e06884b4 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 14:20:24 -0400 Subject: [PATCH 07/29] feat(ack,ack_generator): make trim, toLowerCase, and toUpperCase two-way StringSchema normalizers --- packages/ack/lib/ack.dart | 5 +- .../extensions/string_schema_extensions.dart | 37 ++-- .../ack/lib/src/schemas/string_schema.dart | 195 +++++++++++++++++- .../extensions/string_transformers_test.dart | 56 +++++ .../test/schemas/schema_equality_test.dart | 14 +- .../analyzer/class_model_graph_builder.dart | 7 +- .../analyzer/schema_model_graph_builder.dart | 7 +- .../integration/ack_infer_graph_test.dart | 13 +- .../integration/class_model_graph_test.dart | 5 +- .../test/src/one_way_wrappers_test.dart | 32 +-- 10 files changed, 311 insertions(+), 60 deletions(-) diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index bcb27e14..ac909a6f 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -42,7 +42,10 @@ export 'src/schemas/schema.dart' TestOperationRecordingAckSchema, TestThrowingLeafAckSchema, WrapperSchema, - createCodecSchemaInternal; + createCodecSchemaInternal, + lowercaseStringSchemaInternal, + trimStringSchemaInternal, + uppercaseStringSchemaInternal; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; // Validation results diff --git a/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart index cf097596..fa159235 100644 --- a/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart +++ b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart @@ -2,7 +2,6 @@ import '../../constraints/comparison_constraint.dart'; import '../../constraints/pattern_constraint.dart'; import '../../constraints/string_ip_constraint.dart'; import '../schema.dart'; -import 'ack_schema_extensions.dart'; /// Adds fluent validation methods to [StringSchema]. extension StringSchemaExtensions on StringSchema { @@ -127,27 +126,27 @@ extension StringSchemaExtensions on StringSchema { /// Adds a constraint that the string must be a valid IPv6 address. StringSchema ipv6() => ip(version: 6); - /// Trims leading and trailing whitespace from the string before validation. - /// Returns a one-way codec that applies String.trim() to the input. - CodecSchema trim() { - return transform(_trimString); + /// Trims leading and trailing whitespace from the string. + /// + /// Runs in declaration order with any constraints and refinements chained + /// before or after this call, on both `parse` and `encode`. + StringSchema trim() { + return trimStringSchemaInternal(this); } - /// Converts the string to lowercase after validation. - /// Returns a one-way codec that applies String.toLowerCase() to the input. - CodecSchema toLowerCase() { - return transform(_lowercaseString); + /// Converts the string to lowercase. + /// + /// Runs in declaration order with any constraints and refinements chained + /// before or after this call, on both `parse` and `encode`. + StringSchema toLowerCase() { + return lowercaseStringSchemaInternal(this); } - /// Converts the string to uppercase after validation. - /// Returns a one-way codec that applies String.toUpperCase() to the input. - CodecSchema toUpperCase() { - return transform(_uppercaseString); + /// Converts the string to uppercase. + /// + /// Runs in declaration order with any constraints and refinements chained + /// before or after this call, on both `parse` and `encode`. + StringSchema toUpperCase() { + return uppercaseStringSchemaInternal(this); } } - -String _trimString(String value) => value.trim(); - -String _lowercaseString(String value) => value.toLowerCase(); - -String _uppercaseString(String value) => value.toUpperCase(); diff --git a/packages/ack/lib/src/schemas/string_schema.dart b/packages/ack/lib/src/schemas/string_schema.dart index 8342661e..b61800bc 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -1,16 +1,80 @@ part of 'schema.dart'; +enum _StringNormalizer { + trim, + toLowerCase, + toUpperCase; + + String apply(String value) => switch (this) { + _StringNormalizer.trim => value.trim(), + _StringNormalizer.toLowerCase => value.toLowerCase(), + _StringNormalizer.toUpperCase => value.toUpperCase(), + }; +} + +@immutable +final class _StringNormalizerStep { + const _StringNormalizerStep({ + required this.normalizer, + required this.constraintIndex, + required this.refinementIndex, + }); + + final _StringNormalizer normalizer; + final int constraintIndex; + final int refinementIndex; + + @override + bool operator ==(Object other) => + other is _StringNormalizerStep && + normalizer == other.normalizer && + constraintIndex == other.constraintIndex && + refinementIndex == other.refinementIndex; + + @override + int get hashCode => Object.hash(normalizer, constraintIndex, refinementIndex); +} + /// Schema for validating string values. @immutable final class StringSchema extends AckSchema with FluentSchema { + final List<_StringNormalizerStep> _normalizers; + const StringSchema({ super.isNullable, super.isOptional, super.description, super.constraints, super.refinements, - }); + }) : _normalizers = const []; + + const StringSchema._({ + super.isNullable, + super.isOptional, + super.description, + super.constraints, + super.refinements, + List<_StringNormalizerStep> normalizers = const [], + }) : _normalizers = normalizers; + + StringSchema _withNormalizer(_StringNormalizer normalizer) { + return StringSchema._( + isNullable: isNullable, + isOptional: isOptional, + description: description, + constraints: constraints, + refinements: refinements, + normalizers: List.unmodifiable([ + ..._normalizers, + _StringNormalizerStep( + normalizer: normalizer, + constraintIndex: _constraints.length, + refinementIndex: _refinements.length, + ), + ]), + ); + } @override @protected @@ -31,9 +95,110 @@ final class StringSchema extends AckSchema ); } - return applyConstraintsAndRefinements(value, context); + if (_normalizers.isEmpty) { + return applyConstraintsAndRefinements(value, context); + } + + var current = value; + var constraintStart = 0; + var refinementStart = 0; + + for (final step in _normalizers) { + final constraintEnd = step.constraintIndex.clamp( + constraintStart, + _constraints.length, + ); + final refinementEnd = step.refinementIndex.clamp( + refinementStart, + _refinements.length, + ); + final segmentResult = _applySegment( + current, + context, + constraintStart: constraintStart, + constraintEnd: constraintEnd, + refinementStart: refinementStart, + refinementEnd: refinementEnd, + ); + if (segmentResult.isFail) return segmentResult; + constraintStart = constraintEnd; + refinementStart = refinementEnd; + current = step.normalizer.apply(current); + } + + return _applySegment( + current, + context, + constraintStart: constraintStart, + constraintEnd: _constraints.length, + refinementStart: refinementStart, + refinementEnd: _refinements.length, + ); + } + + SchemaResult _applySegment( + String value, + SchemaContext context, { + required int constraintStart, + required int constraintEnd, + required int refinementStart, + required int refinementEnd, + }) { + if (constraintStart < constraintEnd) { + final constraintViolations = []; + for (var i = constraintStart; i < constraintEnd; i++) { + final constraint = _constraints[i]; + if (constraint is! Validator) continue; + try { + final violation = constraint.validate(value); + if (violation != null) constraintViolations.add(violation); + } catch (error, stackTrace) { + return _failFromThrown( + 'Constraint "${constraint.constraintKey}" threw: $error', + context, + error, + stackTrace, + ); + } + } + if (constraintViolations.isNotEmpty) { + return SchemaResult.fail( + SchemaConstraintsError( + constraints: constraintViolations, + context: context, + ), + ); + } + } + + for (var i = refinementStart; i < refinementEnd; i++) { + final refinement = _refinements[i]; + final bool isValid; + try { + isValid = refinement.validate(value); + } catch (error, stackTrace) { + return _failFromThrown( + 'Refinement threw: $error', + context, + error, + stackTrace, + ); + } + if (!isValid) { + return SchemaResult.fail( + SchemaValidationError(message: refinement.message, context: context), + ); + } + } + + return SchemaResult.ok(value); } + @override + @protected + SchemaResult encodeWithContext(String value, SchemaContext context) => + validateRuntimeWithContext(value, context); + @override StringSchema copyWith({ bool? isNullable, @@ -42,12 +207,13 @@ final class StringSchema extends AckSchema List>? constraints, List>? refinements, }) { - return StringSchema( + return StringSchema._( isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, constraints: constraints ?? this.constraints, refinements: refinements ?? this.refinements, + normalizers: _normalizers, ); } @@ -56,12 +222,31 @@ final class StringSchema extends AckSchema if (identical(this, other)) return true; if (other is! StringSchema) return false; - return baseFieldsEqual(other); + return baseFieldsEqual(other) && + const ListEquality<_StringNormalizerStep>().equals( + _normalizers, + other._normalizers, + ); } @override SchemaType get schemaType => SchemaType.string; @override - int get hashCode => baseFieldsHashCode; + int get hashCode => Object.hash( + baseFieldsHashCode, + const ListEquality<_StringNormalizerStep>().hash(_normalizers), + ); } + +@internal +StringSchema trimStringSchemaInternal(StringSchema schema) => + schema._withNormalizer(_StringNormalizer.trim); + +@internal +StringSchema lowercaseStringSchemaInternal(StringSchema schema) => + schema._withNormalizer(_StringNormalizer.toLowerCase); + +@internal +StringSchema uppercaseStringSchemaInternal(StringSchema schema) => + schema._withNormalizer(_StringNormalizer.toUpperCase); diff --git a/packages/ack/test/schemas/extensions/string_transformers_test.dart b/packages/ack/test/schemas/extensions/string_transformers_test.dart index b10d8417..b3c6c810 100644 --- a/packages/ack/test/schemas/extensions/string_transformers_test.dart +++ b/packages/ack/test/schemas/extensions/string_transformers_test.dart @@ -325,5 +325,61 @@ void main() { expect(data['username'], equals('johndoe')); }); }); + + group('Two-way StringSchema normalizers (C3)', () { + test( + 'trim, toLowerCase, and toUpperCase return StringSchema and chain directly', + () { + final StringSchema schema = Ack.string() + .trim() + .toLowerCase() + .minLength(3) + .email(); + + expect(schema.parse(' USER@EXAMPLE.COM '), 'user@example.com'); + expect(schema.safeParse(' a ').isFail, isTrue); + expect(schema.safeParse(' not-an-email ').isFail, isTrue); + }, + ); + + test( + 'declaration order is preserved between normalizers and constraints', + () { + final trimFirst = Ack.string().trim().minLength(3); + final minLengthFirst = Ack.string().minLength(3).trim(); + + // ' a ' has length 5 before trim, length 1 after trim. + expect(trimFirst.safeParse(' a ').isFail, isTrue); + expect(minLengthFirst.parse(' a '), 'a'); + }, + ); + + test('encode and safeEncode normalize and validate bidirectionally', () { + final trimmed = Ack.string().trim().notEmpty(); + expect(trimmed.encode(' hello '), 'hello'); + expect(trimmed.safeEncode(' hello ').getOrThrow(), 'hello'); + expect(trimmed.safeEncode(' ').isFail, isTrue); + + final lower = Ack.string().toLowerCase(); + expect(lower.encode('HeLLo'), 'hello'); + + final upper = Ack.string().toUpperCase(); + expect(upper.encode('HeLLo'), 'HELLO'); + + final nullableTrimmed = Ack.string().nullable().trim(); + expect(nullableTrimmed.encode(null), isNull); + expect(nullableTrimmed.encode(' hi '), 'hi'); + }); + + test('exports clean JSON Schema without x-transformed', () { + final schema = Ack.string().trim().toLowerCase().minLength(2).email(); + final jsonSchema = schema.toJsonSchema(); + + expect(jsonSchema['type'], 'string'); + expect(jsonSchema['minLength'], 2); + expect(jsonSchema['format'], 'email'); + expect(jsonSchema.containsKey('x-transformed'), isFalse); + }); + }); }); } diff --git a/packages/ack/test/schemas/schema_equality_test.dart b/packages/ack/test/schemas/schema_equality_test.dart index 73e29577..6a415099 100644 --- a/packages/ack/test/schemas/schema_equality_test.dart +++ b/packages/ack/test/schemas/schema_equality_test.dart @@ -198,10 +198,22 @@ void main() { expect(a.hashCode, equals(b.hashCode)); }); - test('equivalent built-in transformers are equal', () { + test('equivalent built-in normalizers are equal and order-sensitive', () { expect(Ack.string().trim(), equals(Ack.string().trim())); expect(Ack.string().toLowerCase(), equals(Ack.string().toLowerCase())); expect(Ack.string().toUpperCase(), equals(Ack.string().toUpperCase())); + expect( + Ack.string().trim().minLength(2), + equals(Ack.string().trim().minLength(2)), + ); + expect( + Ack.string().trim().minLength(2), + isNot(equals(Ack.string().minLength(2).trim())), + ); + expect( + Ack.string().trim().toLowerCase(), + isNot(equals(Ack.string().toLowerCase().trim())), + ); }); }); diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 962fc4e2..1ff5bfad 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -132,12 +132,7 @@ final class ClassModelGraphBuilder { 'yield', }; - static const _oneWayTransformMethods = { - 'transform', - 'trim', - 'toLowerCase', - 'toUpperCase', - }; + static const _oneWayTransformMethods = {'transform'}; static const _schemableChecker = TypeChecker.typeNamed( annotations.Schemable, diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 3ed5c7ab..a6c17e40 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -56,12 +56,7 @@ final class SchemaModelGraphBuilder { static const _reservedMembers = ackSchemaFirstReservedMembers; static const _dartKeywords = ackDartReservedWords; - static const _oneWayTransformMethods = { - 'transform', - 'trim', - 'toLowerCase', - 'toUpperCase', - }; + static const _oneWayTransformMethods = {'transform'}; static const _maxReferenceDepth = 16; diff --git a/packages/ack_generator/test/integration/ack_infer_graph_test.dart b/packages/ack_generator/test/integration/ack_infer_graph_test.dart index be090a2a..7523e496 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -269,13 +269,13 @@ final payloadSchema = ${unsupported.key}; }); } - test('rejects a .trim() field with the declaration path', () async { + test('rejects a .transform() field with the declaration path', () async { await _expectFailure( ''' $_head @Schemable() final userSchema = Ack.object({ - 'nick': Ack.string().trim(), + 'nick': Ack.string().transform((v) => v.trim()), }); ''', ['userSchema.nick', '.transform()'], @@ -317,7 +317,7 @@ final ageSchema = ageFromString; await _expectFailure( ''' $_head -final normalized = Ack.string().trim(); +final normalized = Ack.string().transform((v) => v.trim()); @Schemable() final valueSchema = normalized.codec( @@ -333,7 +333,8 @@ final valueSchema = normalized.codec( await _expectFailure( ''' $_head -AckSchema normalized() => Ack.string().trim(); +AckSchema normalized() => + Ack.string().transform((v) => v.trim()); @Schemable() final valueSchema = normalized().codec( @@ -349,7 +350,7 @@ final valueSchema = normalized().codec( await _expectFailure( ''' $_head -final normalized = Ack.string().trim(); +final normalized = Ack.string().transform((v) => v.trim()); @Schemable() final valueSchema = (normalized).codec( @@ -368,7 +369,7 @@ final valueSchema = (normalized).codec( ''' $_head @Schemable() -final valueSchema = (Ack.string().trim()).codec( +final valueSchema = (Ack.string().transform((v) => v.trim())).codec( decode: (value) => value, encode: (value) => value, ); diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index dbb25a38..7ef7c218 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -443,7 +443,8 @@ final class User with _\$UserAck { test('rejects a one-way transform returned by AckField', () async { await _expectFailure( ''' -AckSchema normalizedSchema() => Ack.string().trim(); +AckSchema normalizedSchema() => + Ack.string().transform((v) => v.trim()); @Schemable() final class User with _\$UserAck { @@ -460,7 +461,7 @@ final class User with _\$UserAck { test('rejects a referenced one-way transform returned by AckField', () async { await _expectFailure( ''' -final normalized = Ack.string().trim(); +final normalized = Ack.string().transform((v) => v.trim()); AckSchema normalizedSchema() => normalized; @Schemable() diff --git a/packages/ack_generator/test/src/one_way_wrappers_test.dart b/packages/ack_generator/test/src/one_way_wrappers_test.dart index d96b19a3..2ea77c84 100644 --- a/packages/ack_generator/test/src/one_way_wrappers_test.dart +++ b/packages/ack_generator/test/src/one_way_wrappers_test.dart @@ -2,22 +2,26 @@ import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { - test('string convenience wrappers are one-way transforms', () { - final wrappers = - Function(StringSchema)>{ - 'trim': (schema) => schema.trim(), - 'toLowerCase': (schema) => schema.toLowerCase(), - 'toUpperCase': (schema) => schema.toUpperCase(), - }; + test( + 'string normalizers are two-way StringSchema while transform is one-way', + () { + final normalizers = { + 'trim': (schema) => schema.trim(), + 'toLowerCase': (schema) => schema.toLowerCase(), + 'toUpperCase': (schema) => schema.toUpperCase(), + }; - for (final entry in wrappers.entries) { - final result = entry.value(Ack.string()).safeEncode('X'); - expect(result.isFail, isTrue, reason: entry.key); + for (final entry in normalizers.entries) { + final result = entry.value(Ack.string()).safeEncode(' TeSt '); + expect(result.isOk, isTrue, reason: entry.key); + } + + final oneWay = Ack.string().transform((v) => v.trim()).safeEncode('X'); + expect(oneWay.isFail, isTrue); expect( - (result.getError() as SchemaEncodeError).kind, + (oneWay.getError() as SchemaEncodeError).kind, SchemaEncodeFailureKind.oneWayTransform, - reason: entry.key, ); - } - }); + }, + ); } From 047b807e696d3aeeb21a9c2fb1b5bbaabe7085b9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 9 Oct 2026 14:33:40 -0400 Subject: [PATCH 08/29] test(ack_generator): align exports and guide integration tests with @Check.* --- docs/core-concepts/typesafe-schemas.mdx | 38 +++++++------------ .../class_first_runtime_build_test.dart | 1 - .../test/src/ack_exports_test.dart | 2 +- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 2407e82b..d0c32550 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -10,20 +10,11 @@ and generate a Dart model, or start with a Dart model and generate its schema. | A top-level Ack schema | `@Schemable()` | An immutable Dart model | | A Dart class | `@Schemable()` | An Ack codec schema and JSON helpers | -`@Schemable()` and the model annotations (`@AckField`, `@Optional`, -`@Required`, `@NotNull`) come from `package:ack/ack.dart`. -Constraint annotations such as `@MinLength` and `@Email` come from -`package:ack/annotations.dart`. Where a constraint name clashes with another -type, such as `package:uuid`'s `Uuid` or your own `Email`, import it with a -prefix and write `@ack.Email()`: - -```dart -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart' as ack; -``` - -`@Uri()` and `@DateTime()` come from `package:ack/format_annotations.dart`, -imported with a prefix because they share names with `dart:core` types. +`@Schemable()`, the model annotations (`@AckField`, `@Optional`, +`@Required`, `@NotNull`), and the `@Check.*` constraint namespace come from +`package:ack/ack.dart`. Legacy top-level constraint annotations in +`package:ack/annotations.dart` and `package:ack/format_annotations.dart` are +deprecated and will be removed in 2.0.0. There is no `@AckSchema()` annotation. `AckSchema` is the runtime type returned by factories such as `Ack.string()` and `Ack.object()`. `@AckInfer()` and `@AckModel()` are deprecated spellings of `@Schemable()` and @@ -55,7 +46,6 @@ the same builder and generated part. ```dart title="lib/models.dart" import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; part 'models.g.dart'; @@ -77,10 +67,10 @@ final class Account with _$AccountAck { this.role = 'member', }); - @MinLength(2) + @Check.minLength(2) final String displayName; - @Email() + @Check.email() final String email; final String? middleName; @@ -342,16 +332,14 @@ Map values may be `null` only when the value type is nullable. `List` stays rejected because `Ack.list` has no nullable-item contract. Parsed `Object` values are recursively unmodifiable, like other stored collections. -Constraint annotations follow the field type: +Constraint annotations use `@Check.*` and follow the field type: -- numeric: `@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`; -- strings: `@MinLength`, `@MaxLength`, `@Matches`, `@Email`, `@NotEmpty`; -- string formats: `@Url`, `@Uuid`, `@Date`; `@Uri()` and `@DateTime()` from - `package:ack/format_annotations.dart` imported with a prefix - (`@formats.Uri()`); -- lists and sets: `@MinItems`, `@MaxItems`, `@UniqueItems`. +- numeric: `@Check.min`, `@Check.max`, `@Check.multipleOf`, `@Check.positive`, `@Check.negative`; +- strings: `@Check.minLength`, `@Check.maxLength`, `@Check.matches`, `@Check.email`, `@Check.notEmpty`; +- string formats: `@Check.url`, `@Check.uri`, `@Check.uuid`, `@Check.date`, `@Check.dateTime` (`@Check.datetime`), `@Check.ip`, `@Check.ipv4`, `@Check.ipv6`; +- lists and sets: `@Check.minItems`, `@Check.maxItems`, `@Check.uniqueItems` (`@Check.unique`). -`@Matches` is the annotation form of `Ack.string().matches(...)`. Numeric +`@Check.matches` is the annotation form of `Ack.string().matches(...)`. Numeric constraint values must be finite. On a `Set>` field the collection constraints apply to the outer set. diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index e9b9d3ed..971259d2 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -148,7 +148,6 @@ final class Handwritten with _$HandwrittenAck { File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( r''' import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; import 'package:json_annotation/json_annotation.dart' show JsonKey, JsonSerializable; diff --git a/packages/ack_generator/test/src/ack_exports_test.dart b/packages/ack_generator/test/src/ack_exports_test.dart index a1879848..e13b169b 100644 --- a/packages/ack_generator/test/src/ack_exports_test.dart +++ b/packages/ack_generator/test/src/ack_exports_test.dart @@ -41,7 +41,7 @@ void main() { outputs: { 'test_pkg|lib/probe.exports': decodedMatches( 'AckCaseStyle,AckField,AckUnknownPropertyPolicy,' - 'NotNull,Optional,Required,Schemable', + 'Check,NotNull,Optional,Required,Schemable', ), }, ); From 9e77a962a02b9f5fbbf238d29758b2decd3af4db Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 04:40:58 -0400 Subject: [PATCH 09/29] feat(ack): add StandardSchemaV1 interoperability contract (#129) --- packages/ack/lib/src/schemas/schema.dart | 11 +- .../lib/src/validation/standard_schema.dart | 257 ++++++++++++++++++ .../test/validation/standard_schema_test.dart | 59 ++++ 3 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 packages/ack/lib/src/validation/standard_schema.dart create mode 100644 packages/ack/test/validation/standard_schema_test.dart diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 0b650d2f..63d6be6f 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -17,6 +17,7 @@ import '../helpers.dart'; import '../schema_model/ack_schema_model_builder.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; +import '../validation/standard_schema.dart'; part '../json_schema/json_schema_compiler.dart'; part '../json_schema/json_schema_formats.dart'; @@ -497,13 +498,17 @@ sealed class AckSchema { ); } - /// Converts this schema to a JSON Schema Draft-7 representation. + /// Converts this schema to a JSON Schema representation. /// /// Delegates to the sealed [AckSchemaModel] boundary so all renderers share - /// the same Draft-7 output. Subclasses should not override this directly; - /// instead they are dispatched in `ack_schema_model_builder.dart`. + /// the same output. Subclasses should not override this directly; instead + /// they are dispatched in `ack_schema_model_builder.dart`. Map toJsonSchema() => toSchemaModel().toJsonSchema(); + /// Returns a [StandardSchemaV1] view over this schema (`https://standardschema.dev/`). + StandardSchemaV1 get standard => + AckStandardSchema(this); + Map toMap() { return { 'type': schemaType.typeName, diff --git a/packages/ack/lib/src/validation/standard_schema.dart b/packages/ack/lib/src/validation/standard_schema.dart new file mode 100644 index 00000000..0776db69 --- /dev/null +++ b/packages/ack/lib/src/validation/standard_schema.dart @@ -0,0 +1,257 @@ +import 'package:meta/meta.dart'; + +import '../context.dart'; +import '../schemas/schema.dart'; +import '../utils/collection_utils.dart'; +import 'schema_error.dart'; +import 'schema_result.dart'; + +/// Dart contract for the Standard Schema v1 and Standard JSON Schema v1 +/// specifications (`https://standardschema.dev/`). +/// +/// Validation libraries implement or expose [StandardSchemaV1] (for example, +/// via `AckSchema.standard`) so framework, router, form, and AI/agent tooling +/// can consume schemas generically without vendor-specific adapters. +abstract interface class StandardSchemaV1 { + /// The Standard Schema specification version (`1`). + int get version; + + /// The schema library vendor identifier (for Ack, `'ack'`). + String get vendor; + + /// Validates [value] and returns either a [StandardSchemaSuccess] with the + /// parsed output or a [StandardSchemaFailure] with a list of [StandardSchemaIssue]s. + StandardSchemaResult validate(Object? value); + + /// Renders this schema as a JSON Schema map per the Standard JSON Schema + /// companion specification. + Map toJsonSchema(); +} + +/// The outcome of a [StandardSchemaV1.validate] invocation. +@immutable +sealed class StandardSchemaResult { + const StandardSchemaResult(); + + /// Creates a successful validation result. + const factory StandardSchemaResult.success(Output? value) = + StandardSchemaSuccess; + + /// Creates a failed validation result with [issues]. + factory StandardSchemaResult.failure(List issues) = + StandardSchemaFailure; + + /// Whether validation succeeded. + bool get isSuccess => this is StandardSchemaSuccess; + + /// Whether validation failed. + bool get isFailure => this is StandardSchemaFailure; + + /// The validated value when successful, or `null` when validation failed. + Output? get valueOrNull => switch (this) { + StandardSchemaSuccess(:final value) => value, + StandardSchemaFailure() => null, + }; + + /// The validation issues when failed, or `null` when validation succeeded. + List? get issues => switch (this) { + StandardSchemaSuccess() => null, + StandardSchemaFailure(:final issues) => issues, + }; +} + +/// A successful [StandardSchemaResult] wrapping the validated [value]. +@immutable +final class StandardSchemaSuccess extends StandardSchemaResult { + /// Creates a successful result. + const StandardSchemaSuccess(this.value); + + /// The validated output value (`null` only when the schema accepts `null`). + final Output? value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StandardSchemaSuccess && + deepEquals(value, other.value)); + + @override + int get hashCode => Object.hash(StandardSchemaSuccess, deepHashCode(value)); + + @override + String toString() => 'StandardSchemaSuccess($value)'; +} + +/// A failed [StandardSchemaResult] containing one or more [issues]. +@immutable +final class StandardSchemaFailure extends StandardSchemaResult { + /// Creates a failed result with an unmodifiable copy of [issues]. + StandardSchemaFailure(Iterable issues) + : issues = List.unmodifiable(issues); + + /// The non-empty list of validation issues reported by the schema. + @override + final List issues; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StandardSchemaFailure && + deepEquals(issues, other.issues)); + + @override + int get hashCode => Object.hash(StandardSchemaFailure, deepHashCode(issues)); + + @override + String toString() => 'StandardSchemaFailure($issues)'; +} + +/// A single validation issue reported by [StandardSchemaV1.validate]. +@immutable +final class StandardSchemaIssue { + /// Creates a validation issue with a human-readable [message] and an + /// optional [path] from the root input to the failing location. + StandardSchemaIssue({required this.message, Iterable? path}) + : path = List.unmodifiable(path ?? const []); + + /// Human-readable description of the validation failure. + final String message; + + /// Path segments (`String` property keys and `int` array indices) from the + /// root value to the failing location. Empty when the issue occurred at the + /// root value. + final List path; + + /// Converts this issue to a JSON-compatible map matching the Standard Schema + /// v1 issue shape. + Map toMap() => { + 'message': message, + if (path.isNotEmpty) 'path': path, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StandardSchemaIssue && + message == other.message && + deepEquals(path, other.path)); + + @override + int get hashCode => Object.hash(message, deepHashCode(path)); + + @override + String toString() => path.isEmpty + ? 'StandardSchemaIssue(message: $message)' + : 'StandardSchemaIssue(message: $message, path: $path)'; +} + +/// [StandardSchemaV1] adapter backed by an [AckSchema]. +@immutable +final class AckStandardSchema + implements StandardSchemaV1 { + /// Creates a Standard Schema v1 view over [schema]. + const AckStandardSchema(this.schema); + + /// The underlying Ack schema. + final AckSchema schema; + + @override + int get version => 1; + + @override + String get vendor => 'ack'; + + @override + StandardSchemaResult validate(Object? value) { + return switch (schema.safeParse(value)) { + Ok(value: final parsed) => StandardSchemaResult.success(parsed), + Fail(:final error) => StandardSchemaResult.failure(_collectIssues(error)), + }; + } + + @override + Map toJsonSchema() => schema.toJsonSchema(); + + static List _collectIssues(SchemaError error) { + final issues = []; + _appendIssues(error, issues); + if (issues.isEmpty) { + issues.add( + StandardSchemaIssue( + message: error.message, + path: _extractPathSegments(error), + ), + ); + } + return issues; + } + + static void _appendIssues( + SchemaError error, + List output, + ) { + switch (error) { + case SchemaNestedError(:final errors) when errors.isNotEmpty: + for (final child in errors) { + _appendIssues(child, output); + } + case SchemaConstraintsError(:final constraints) + when constraints.isNotEmpty: + final path = _extractPathSegments(error); + for (final violation in constraints) { + output.add( + StandardSchemaIssue(message: violation.message, path: path), + ); + } + case TypeMismatchError() || + SchemaConstraintsError() || + SchemaNestedError() || + SchemaValidationError() || + SchemaTransformError() || + SchemaEncodeError(): + output.add( + StandardSchemaIssue( + message: error.message, + path: _extractPathSegments(error), + ), + ); + } + } + + static List _extractPathSegments(SchemaError error) { + final contextSegments = _segmentsFromContext(error.context); + if (contextSegments.isNotEmpty) return contextSegments; + final pointer = error.path; + if (pointer.isEmpty || pointer == '#') return const []; + final stripped = pointer.startsWith('#/') + ? pointer.substring(2) + : pointer.startsWith('/') + ? pointer.substring(1) + : pointer; + if (stripped.isEmpty) return const []; + return [ + for (final raw in stripped.split('/')) _unescapeJsonPointerSegment(raw), + ]; + } + + static List _segmentsFromContext(SchemaContext context) { + final reversed = []; + SchemaContext? current = context; + while (current != null && current.parent != null) { + final parent = current.parent!; + final rawSegment = current.pathSegment; + if (rawSegment != '') { + final segment = rawSegment ?? current.name; + final isArrayParent = + parent.value is List || parent.schema is ListSchema; + final parsedIndex = isArrayParent ? int.tryParse(segment) : null; + reversed.add(parsedIndex ?? segment); + } + current = parent; + } + return reversed.reversed.toList(growable: false); + } + + static String _unescapeJsonPointerSegment(String segment) => + segment.replaceAll('~1', '/').replaceAll('~0', '~'); +} diff --git a/packages/ack/test/validation/standard_schema_test.dart b/packages/ack/test/validation/standard_schema_test.dart new file mode 100644 index 00000000..2640715c --- /dev/null +++ b/packages/ack/test/validation/standard_schema_test.dart @@ -0,0 +1,59 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + group('StandardSchemaV1', () { + test('exposes version 1 and vendor ack', () { + final schema = Ack.string().minLength(3); + final standard = schema.standard; + + expect(standard.version, equals(1)); + expect(standard.vendor, equals('ack')); + }); + + test('returns StandardSchemaSuccess on valid input', () { + final schema = Ack.object({ + 'name': Ack.string().trim(), + 'age': Ack.integer().min(0), + }); + + final result = schema.standard.validate({'name': ' Ada ', 'age': 36}); + expect(result, isA>>()); + expect(result.isSuccess, isTrue); + expect(result.isFailure, isFalse); + expect(result.issues, isNull); + + final success = result as StandardSchemaSuccess>; + expect(success.value, equals({'name': 'Ada', 'age': 36})); + }); + + test( + 'returns StandardSchemaFailure with path segments on nested object/list errors', + () { + final schema = Ack.object({ + 'user': Ack.object({'tags': Ack.list(Ack.string().minLength(3))}), + }); + + final result = schema.standard.validate({ + 'user': { + 'tags': ['valid', 'no'], + }, + }); + + expect(result, isA>>()); + expect(result.isFailure, isTrue); + expect(result.isSuccess, isFalse); + + final issues = result.issues!; + expect(issues, isNotEmpty); + expect(issues.first.path, equals(['user', 'tags', 1])); + expect(issues.first.message, isNotEmpty); + }, + ); + + test('delegates toJsonSchema() to underlying AckSchema', () { + final schema = Ack.string().email(); + expect(schema.standard.toJsonSchema(), equals(schema.toJsonSchema())); + }); + }); +} From 556e426c659dfb06fa53f81ba47d876123a3cdbf Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 04:40:58 -0400 Subject: [PATCH 10/29] feat(ack,ack_generator): add JsonMaybe for PATCH key presence (#148) --- packages/ack/lib/ack.dart | 2 + packages/ack/lib/annotations.dart | 1 + packages/ack/lib/src/models/json_maybe.dart | 104 ++++++++++++++++++ packages/ack/test/models/json_maybe_test.dart | 67 +++++++++++ .../lib/src/builders/class_model_emitter.dart | 49 ++++++++- .../lib/src/builders/data_class_emitter.dart | 2 + .../lib/src/builders/model_emitter.dart | 2 + .../lib/src/models/schema_model_graph.dart | 6 + .../integration/class_model_emitter_test.dart | 58 ++++++++++ 9 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 packages/ack/lib/src/models/json_maybe.dart create mode 100644 packages/ack/test/models/json_maybe_test.dart diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index ac909a6f..c78b61ac 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -10,6 +10,7 @@ export 'src/ack.dart'; export 'src/common_types.dart' show JsonMap; // Generated model support export 'src/models/ack_model_adapter.dart'; +export 'src/models/json_maybe.dart'; export 'src/utils/collection_utils.dart' show deepEquals, deepHashCode, deepUnmodifiableJsonMap; // Constraints @@ -50,6 +51,7 @@ export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; // Validation results export 'src/validation/schema_result.dart'; +export 'src/validation/standard_schema.dart'; // Model annotations. Legacy top-level constraint annotations live in annotations.dart. export 'src/annotations/ack_field.dart'; export 'src/annotations/constraints.dart' show Check; diff --git a/packages/ack/lib/annotations.dart b/packages/ack/lib/annotations.dart index d313de0b..33fdda2c 100644 --- a/packages/ack/lib/annotations.dart +++ b/packages/ack/lib/annotations.dart @@ -4,3 +4,4 @@ library; export 'src/annotations/ack_field.dart'; export 'src/annotations/constraints.dart'; export 'src/annotations/schemable.dart'; +export 'src/models/json_maybe.dart'; diff --git a/packages/ack/lib/src/models/json_maybe.dart b/packages/ack/lib/src/models/json_maybe.dart new file mode 100644 index 00000000..1567a3cf --- /dev/null +++ b/packages/ack/lib/src/models/json_maybe.dart @@ -0,0 +1,104 @@ +import 'package:meta/meta.dart'; + +import '../utils/collection_utils.dart'; + +/// Represents an optional, nullable JSON object property with three distinct +/// states: +/// +/// * [JsonAbsent] (`const JsonMaybe.absent()`): the key is omitted from the +/// JSON map. +/// * [JsonValue] (`const JsonMaybe.value(null)`): the key is present in the +/// JSON map with a `null` value. +/// * [JsonValue] (`const JsonMaybe.value(value)`): the key is present in the +/// JSON map with [value]. +/// +/// Use `JsonMaybe` on a `@Schemable()` class field when you need to +/// distinguish an omitted JSON property from an explicit `null` on both parse +/// and encode. +@immutable +sealed class JsonMaybe { + const JsonMaybe(); + + /// An omitted JSON property. + const factory JsonMaybe.absent() = JsonAbsent; + + /// A present JSON property with [value] (which may be `null`). + const factory JsonMaybe.value(T? value) = JsonValue; + + /// Alias for [JsonMaybe.value]. + const factory JsonMaybe.present(T? value) = JsonValue; + + /// A present JSON property with an explicit `null` value. + const factory JsonMaybe.nullValue() = JsonValue.nullValue; + + /// Whether the key was present in the JSON map. + bool get isPresent; + + /// Whether the key was omitted from the JSON map. + bool get isAbsent => !isPresent; + + /// Whether the key was present with a `null` value. + bool get isNull; + + /// The property value when present, or `null` when absent or `null`. + T? get valueOrNull; +} + +/// An omitted JSON property. +@immutable +final class JsonAbsent extends JsonMaybe { + /// Creates an omitted JSON property marker. + const JsonAbsent(); + + @override + bool get isPresent => false; + + @override + bool get isNull => false; + + @override + T? get valueOrNull => null; + + @override + bool operator ==(Object other) => + identical(this, other) || other is JsonAbsent; + + @override + int get hashCode => (JsonAbsent).hashCode; + + @override + String toString() => 'JsonMaybe.absent()'; +} + +/// A present JSON property with [value] (which may be `null`). +@immutable +final class JsonValue extends JsonMaybe { + /// Creates a present JSON property with [value]. + const JsonValue(this.value); + + /// Creates a present JSON property with a `null` value. + const JsonValue.nullValue() : value = null; + + /// The present property value, or `null` when explicitly set to `null`. + final T? value; + + @override + bool get isPresent => true; + + @override + bool get isNull => value == null; + + @override + T? get valueOrNull => value; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is JsonValue && deepEquals(value, other.value)); + + @override + int get hashCode => Object.hash(JsonValue, deepHashCode(value)); + + @override + String toString() => 'JsonMaybe.value($value)'; +} diff --git a/packages/ack/test/models/json_maybe_test.dart b/packages/ack/test/models/json_maybe_test.dart new file mode 100644 index 00000000..10f01482 --- /dev/null +++ b/packages/ack/test/models/json_maybe_test.dart @@ -0,0 +1,67 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + group('JsonMaybe', () { + test('JsonMaybe.absent() state and equality', () { + const absent1 = JsonMaybe.absent(); + const absent2 = JsonAbsent(); + + expect(absent1, isA>()); + expect(absent1.isAbsent, isTrue); + expect(absent1.isPresent, isFalse); + expect(absent1.isNull, isFalse); + expect(absent1.valueOrNull, isNull); + expect(absent1, equals(absent2)); + expect(absent1.hashCode, equals(absent2.hashCode)); + expect(absent1.toString(), equals('JsonMaybe.absent()')); + }); + + test('JsonMaybe.value() / present() / nullValue() state and equality', () { + const present = JsonMaybe.value('Ada'); + const presentAlias = JsonMaybe.present('Ada'); + const explicitNull = JsonMaybe.nullValue(); + const valueNull = JsonMaybe.value(null); + + expect(present, isA>()); + expect(present.isPresent, isTrue); + expect(present.isAbsent, isFalse); + expect(present.isNull, isFalse); + expect(present.valueOrNull, equals('Ada')); + expect(present, equals(presentAlias)); + expect(present.hashCode, equals(presentAlias.hashCode)); + expect(present.toString(), equals('JsonMaybe.value(Ada)')); + + expect(explicitNull.isPresent, isTrue); + expect(explicitNull.isAbsent, isFalse); + expect(explicitNull.isNull, isTrue); + expect(explicitNull.valueOrNull, isNull); + expect(explicitNull, equals(valueNull)); + expect(explicitNull.hashCode, equals(valueNull.hashCode)); + expect(explicitNull, isNot(equals(const JsonMaybe.absent()))); + }); + + test('JsonValue deep equality on collections', () { + const listA = JsonMaybe>.value([1, 2, 3]); + const listB = JsonMaybe>.value([1, 2, 3]); + const listC = JsonMaybe>.value([1, 2, 4]); + + expect(listA, equals(listB)); + expect(listA.hashCode, equals(listB.hashCode)); + expect(listA, isNot(equals(listC))); + }); + + test('exhaustive switch over JsonMaybe', () { + String describe(JsonMaybe field) { + return switch (field) { + JsonAbsent() => 'omitted', + JsonValue(:final value) => 'present:$value', + }; + } + + expect(describe(const JsonMaybe.absent()), equals('omitted')); + expect(describe(const JsonMaybe.nullValue()), equals('present:null')); + expect(describe(const JsonMaybe.value(42)), equals('present:42')); + }); + }); +} diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index 5057db66..2dda9881 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -319,6 +319,35 @@ ${node.className} $function(Map value) { AckConstructorParameter parameter, AckFieldNode field, ) { + if (parameter.typeRef case AckJsonMaybeTypeRef(:final valueType)) { + final keyLiteral = dartStringLiteral(field.jsonKey); + final raw = 'value[$keyLiteral]'; + final innerType = _type(valueType); + final baseValueType = _nonNullable(valueType); + final absentExpr = + parameter.defaultExpression != null && + parameter.defaultExpression != 'null' + ? parameter.defaultExpression! + : 'const ${_ack('JsonMaybe')}<$innerType>.absent()'; + final String presentExpr; + if (baseValueType is AckScalarTypeRef || + baseValueType is AckExternalTypeRef) { + final cast = + baseValueType is AckScalarTypeRef && + baseValueType.dartType == 'Object' + ? raw + : '$raw as ${_type(baseValueType)}?'; + presentExpr = '${_ack('JsonMaybe')}<$innerType>.value($cast)'; + } else { + presentExpr = + 'switch ($raw) {' + ' null => const ${_ack('JsonMaybe')}<$innerType>.value(null),' + ' final fieldValue => ${_ack('JsonMaybe')}<$innerType>.value(' + '${_fromRuntime(baseValueType, 'fieldValue')}),' + ' }'; + } + return '!value.containsKey($keyLiteral) ? $absentExpr : $presentExpr'; + } final raw = 'value[${dartStringLiteral(field.jsonKey)}]'; final defaultExpr = parameter.defaultExpression != null && @@ -352,8 +381,7 @@ ${node.className} $function(Map value) { final fieldEntries = [ for (final field in node.fields) if (discriminatorValue == null || field.jsonKey != discriminatorKey) - '${dartStringLiteral(field.jsonKey)}: ' - '${_omitIfNull(field) ? '?' : ''}${_encodeField(field)}', + _fieldRuntimeMapEntry(field), if (discriminatorKey != null && discriminatorValue != null) '${dartStringLiteral(discriminatorKey)}: ' '${dartStringLiteral(discriminatorValue)}', @@ -377,6 +405,21 @@ Map $function(${node.className} model) { }'''; } + String _fieldRuntimeMapEntry(AckFieldNode field) { + if (field.runtimeRef case AckJsonMaybeTypeRef(:final valueType)) { + final baseValueType = _nonNullable(valueType); + final encodedValue = + baseValueType is AckScalarTypeRef || + baseValueType is AckExternalTypeRef + ? 'value' + : _toRuntime(AckNullableTypeRef(baseValueType), 'value'); + return 'if (model.${field.dartName} case ${_ack('JsonValue')}(:final value)) ' + '${dartStringLiteral(field.jsonKey)}: $encodedValue'; + } + return '${dartStringLiteral(field.jsonKey)}: ' + '${_omitIfNull(field) ? '?' : ''}${_encodeField(field)}'; + } + bool _omitIfNull(AckFieldNode field) => field.nullable && !(field.isRequired && field.acceptsNull); @@ -469,6 +512,8 @@ Map $function(${node.className} model) { AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', AckMapTypeRef(:final valueType) => 'Map', + AckJsonMaybeTypeRef(:final valueType) => + '${_ack('JsonMaybe')}<${_type(valueType)}>', }; Set _declaredKeys(AckObjectModelNode node) => { diff --git a/packages/ack_generator/lib/src/builders/data_class_emitter.dart b/packages/ack_generator/lib/src/builders/data_class_emitter.dart index dbc0c2a1..824b51ca 100644 --- a/packages/ack_generator/lib/src/builders/data_class_emitter.dart +++ b/packages/ack_generator/lib/src/builders/data_class_emitter.dart @@ -289,6 +289,8 @@ ${_ack('SchemaResult')}> safeToJson() => AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', AckMapTypeRef(:final valueType) => 'Map', + AckJsonMaybeTypeRef(:final valueType) => + '${_ack('JsonMaybe')}<${_type(valueType)}>', }; String _ack(String symbol) { diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index dc8e0773..c56f54aa 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -968,6 +968,8 @@ return $_runtimeMapLiteral{ AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', AckMapTypeRef(:final valueType) => 'Map', + AckJsonMaybeTypeRef(:final valueType) => + '${_ack('JsonMaybe')}<${_type(valueType)}>', }; } diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index 3c0e9cd9..5fbd6c9f 100644 --- a/packages/ack_generator/lib/src/models/schema_model_graph.dart +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -130,6 +130,12 @@ final class AckMapTypeRef extends AckInferRef { final AckInferRef valueType; } +final class AckJsonMaybeTypeRef extends AckInferRef { + const AckJsonMaybeTypeRef(this.valueType); + + final AckInferRef valueType; +} + /// A field in a normalized object model. final class AckFieldNode { const AckFieldNode({ diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index a1daf594..a438d37d 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -1126,4 +1126,62 @@ final class CheckedRecord with _\$CheckedRecordAck { ); }, ); + + test( + 'emits JsonMaybe decode and encode presence checks for PATCH models', + () async { + await _build( + { + 'patch_models.dart': r''' +import 'package:ack/ack.dart'; + +part 'patch_models.g.dart'; + +@Schemable() +final class UserPatch with _$UserPatchAck { + const UserPatch({ + required this.nickname, + this.title = const JsonMaybe.absent(), + this.tags = const JsonMaybe.absent(), + }); + + final JsonMaybe nickname; + + @NotNull() + @Check.notEmpty() + final JsonMaybe title; + + final JsonMaybe> tags; +} +''', + }, + outputs: { + 'test_pkg|lib/patch_models.g.dart': decodedMatches( + allOf([ + _containsCode("'nickname': Ack.string().optional().nullable()"), + _containsCode( + "'title': Ack.string().notEmpty().optional().nullable(value: false)", + ), + _containsCode( + "'tags': Ack.list(Ack.string()).optional().nullable()", + ), + _containsCode("value.containsKey('nickname')"), + _containsCode( + "JsonMaybe.value(value['nickname'] as String?)", + ), + _containsCode('const JsonMaybe.absent()'), + _containsCode('const JsonMaybe.absent()'), + _containsCode( + "if (model.nickname case JsonValue(:final value)) 'nickname': value", + ), + _containsCode( + "if (model.title case JsonValue(:final value)) 'title': value", + ), + contains('JsonMaybe? nickname'), + ]), + ), + }, + ); + }, + ); } From 5f71972b78a3dc8f3e28603f1c7419f0718d55f2 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 04:40:58 -0400 Subject: [PATCH 11/29] feat(ack,ack_generator): enforce type-owned schema bounds/boundaries and deprecate AckField.schema (#185) --- .../ack/lib/src/annotations/ack_field.dart | 8 +- .../analyzer/class_model_graph_builder.dart | 265 ++++++++++++++++-- .../analyzer/schema_model_graph_builder.dart | 7 +- .../class_first_runtime_build_test.dart | 69 ++++- .../json_schema_runtime_build_test.dart | 5 +- .../type_owned_schema_build_test.dart | 8 +- .../integration/type_owned_schema_test.dart | 75 +++++ 7 files changed, 407 insertions(+), 30 deletions(-) diff --git a/packages/ack/lib/src/annotations/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 3f4d1d8e..4dc0a0a0 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -46,12 +46,18 @@ final class NotNull { @Target({TargetKind.field, TargetKind.parameter}) final class AckField { /// Creates a field annotation. - const AckField({this.name, this.schema, this.description}); + const AckField({ + this.name, + @Deprecated('Declare a static schema on the field type instead.') + this.schema, + this.description, + }); /// Explicit JSON key for this property. final String? name; /// Top-level schema-function tear-off followed by `ack_generator`. + @Deprecated('Declare a static schema on the field type instead.') final Object Function()? schema; /// Schema description for this property. diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 1ff5bfad..039db63f 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -1,4 +1,4 @@ -import 'package:ack/ack.dart' show AckModelAdapter, AckSchema; +import 'package:ack/ack.dart' show AckModelAdapter, AckSchema, JsonMaybe; import 'package:ack/annotations.dart' as annotations; import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; @@ -150,6 +150,10 @@ final class ClassModelGraphBuilder { AckModelAdapter, inPackage: 'ack', ); + static const _jsonMaybeChecker = TypeChecker.typeNamed( + JsonMaybe, + inPackage: 'ack', + ); static const _jsonSerializableChecker = TypeChecker.typeNamedLiterally( 'JsonSerializable', inPackage: 'json_annotation', @@ -646,9 +650,28 @@ final class ClassModelGraphBuilder { ); } - _rejectUnsupportedStaticType(field, field.type); - _validateMapKey(field, field.type); + if (_isJsonMaybe(field.type) && _isNullable(field.type)) { + throw InvalidGenerationSource( + '${element.name}.$name uses nullable ' + '${field.type.getDisplayString()}; use non-nullable JsonMaybe<...> ' + 'instead, which already models both absent and null values.', + element: field, + ); + } + final unwrappedFieldType = _unwrapJsonMaybeType(field.type); + _rejectUnsupportedStaticType(field, unwrappedFieldType); + _validateMapKey(field, unwrappedFieldType); final futureType = futureTypes[name]; + if (futureType?.runtimeRef case AckNullableTypeRef( + inner: AckJsonMaybeTypeRef(), + )) { + throw InvalidGenerationSource( + '${element.name}.$name uses nullable JsonMaybe; use non-nullable ' + 'JsonMaybe<...> instead, which already models both absent and null ' + 'values.', + element: field, + ); + } _recordClassFirstDependencies(element, field.type, field); final jsonKey = _jsonKey(field, parameter) ?? _rename(name, options.caseStyle); @@ -669,15 +692,19 @@ final class ClassModelGraphBuilder { } ownerByJsonKey[jsonKey] = field; + final isJsonMaybe = + futureType?.runtimeRef is AckJsonMaybeTypeRef || + _isJsonMaybe(field.type); final dartNullable = futureType?.runtimeRef is AckNullableTypeRef || _isNullable(field.type); final rejectNull = _hasAnnotation(_notNullChecker, field, parameter); - final acceptsNull = dartNullable && !rejectNull; + final acceptsNull = (dartNullable || isJsonMaybe) && !rejectNull; final presence = _effectivePresence( field, parameter: parameter, isDiscriminator: isDiscriminator, + isJsonMaybe: isJsonMaybe, ); var schema = isDiscriminator && discriminatorValue != null ? '${_ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' @@ -812,14 +839,18 @@ final class ClassModelGraphBuilder { _rejectNullableFutureCollectionElement(field, futureType.runtimeRef); final setListSchema = futureType.setListSchema; if (setListSchema != null) { + final effectiveRef = switch (futureType.runtimeRef) { + AckJsonMaybeTypeRef(:final valueType) => valueType, + final other => other, + }; return _setCodec( _applySugar(setListSchema, field, parameter), - futureType.runtimeRef, + effectiveRef, ); } return _applySugar(futureType.schemaExpression, field, parameter); } - final type = field.type; + final type = _unwrapJsonMaybeType(field.type); if (type is InterfaceType && type.isDartCoreSet && type.typeArguments.length == 1) { @@ -827,7 +858,7 @@ final class ClassModelGraphBuilder { _rejectNullableCollectionElement(field, itemType); final item = await _schemaForType(itemType, field); final list = _applySugar('${_ack('Ack')}.list($item)', field, parameter); - return _setCodec(list, _typeRef(type, field)); + return _setCodec(list, _typeRef(type, field, allowJsonMaybe: false)); } return _applySugar(await _schemaForType(type, field), field, parameter); } @@ -859,6 +890,7 @@ final class ClassModelGraphBuilder { AckMapTypeRef(:final valueType) => AckMapTypeRef( _schemaRuntimeRef(valueType), ), + AckJsonMaybeTypeRef(:final valueType) => _schemaRuntimeRef(valueType), _ => type, }; @@ -902,15 +934,35 @@ final class ClassModelGraphBuilder { _FutureGeneratedType? _futureGeneratedTypeForAnnotation( TypeAnnotation annotation, - FieldElement field, - ) { + FieldElement field, { + bool allowJsonMaybe = true, + }) { if (annotation is! NamedType) return null; final name = annotation.name.lexeme; final prefix = annotation.importPrefix?.name.lexeme; final arguments = annotation.typeArguments?.arguments ?? const []; _FutureGeneratedType? result; - if (prefix == null && name == 'List' && arguments.length == 1) { - final item = _futureGeneratedTypeForAnnotation(arguments.single, field); + if (allowJsonMaybe && + name == 'JsonMaybe' && + (prefix == null || prefix == ackPrefix) && + arguments.length == 1) { + final item = _futureGeneratedTypeForAnnotation( + arguments.single, + field, + allowJsonMaybe: false, + ); + if (item == null) return null; + result = ( + schemaExpression: item.schemaExpression, + runtimeRef: AckJsonMaybeTypeRef(item.runtimeRef), + setListSchema: item.setListSchema, + ); + } else if (prefix == null && name == 'List' && arguments.length == 1) { + final item = _futureGeneratedTypeForAnnotation( + arguments.single, + field, + allowJsonMaybe: false, + ); if (item == null) return null; result = ( schemaExpression: '${_ack('Ack')}.list(${item.schemaExpression})', @@ -918,7 +970,11 @@ final class ClassModelGraphBuilder { setListSchema: null, ); } else if (prefix == null && name == 'Set' && arguments.length == 1) { - final item = _futureGeneratedTypeForAnnotation(arguments.single, field); + final item = _futureGeneratedTypeForAnnotation( + arguments.single, + field, + allowJsonMaybe: false, + ); if (item == null) return null; final runtimeRef = AckSetTypeRef(item.runtimeRef); final listSchema = '${_ack('Ack')}.list(${item.schemaExpression})'; @@ -1088,13 +1144,15 @@ final class ClassModelGraphBuilder { ); } final runtime = _ackSchemaRuntimeType(function.returnType); - if (runtime == null || !_sameTypeIgnoringNullability(runtime, field.type)) { + final expectedFieldType = _unwrapJsonMaybeType(field.type); + if (runtime == null || + !_sameTypeIgnoringNullability(runtime, expectedFieldType)) { throw InvalidGenerationSource( '$path @AckField schema function ${function.name} produces ' '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' - 'type is ${field.type.getDisplayString()}.', + 'type is ${expectedFieldType.getDisplayString()}.', element: field, - todo: 'Return AckSchema<..., ${field.type.getDisplayString()}>.', + todo: 'Return AckSchema<..., ${expectedFieldType.getDisplayString()}>.', ); } final resolved = await _resolvedLibraryFor(function.library); @@ -1124,6 +1182,18 @@ final class ClassModelGraphBuilder { return '${prefix == null ? '' : '$prefix.'}${function.name}()'; } + /// The boundary type argument of [type] as an `AckSchema`. + DartType? _ackSchemaBoundaryType(DartType type) { + if (type is! InterfaceType) return null; + for (final candidate in [type, ...type.allSupertypes]) { + if (_ackSchemaChecker.isExactlyType(candidate) && + candidate.typeArguments.length == 2) { + return candidate.typeArguments[0]; + } + } + return null; + } + /// The runtime type argument of [type] as an `AckSchema`. DartType? _ackSchemaRuntimeType(DartType type) { if (type is! InterfaceType) return null; @@ -1136,6 +1206,57 @@ final class ClassModelGraphBuilder { return null; } + DartType? _inferredBoundaryType(DartType type) { + if (type is! InterfaceType) return null; + final provider = library.element.typeProvider; + if (_isCore(type, 'int') || + _isCore(type, 'double') || + _isCore(type, 'num')) { + return provider.numType; + } + if (_isCore(type, 'String') || + _isCore(type, 'DateTime') || + _isCore(type, 'Uri')) { + return provider.stringType; + } + if (_isCore(type, 'bool')) return provider.boolType; + if (_isCore(type, 'Duration')) return provider.intType; + if (_isCore(type, 'Object')) return provider.objectType; + if (type.isDartCoreList || type.isDartCoreSet) { + return provider.listType(provider.objectQuestionType); + } + if (type.isDartCoreMap) { + return provider.mapType(provider.stringType, provider.objectQuestionType); + } + final target = type.element; + if (target is ClassElement && _classFirstFacadeName(target) != null) { + return provider.mapType(provider.stringType, provider.objectQuestionType); + } + final ackGetter = target.getGetter(r'$ack'); + final ackReturnType = ackGetter?.returnType; + if (ackGetter != null && + ackGetter.isStatic && + ackReturnType is InterfaceType && + _ackModelAdapterChecker.isExactlyType(ackReturnType) && + ackReturnType.typeArguments.length == 3) { + return ackReturnType.typeArguments[0]; + } + final schemaGetter = target.getGetter('schema'); + if (schemaGetter != null && schemaGetter.isStatic) { + return _ackSchemaBoundaryType(schemaGetter.returnType); + } + final schemaMethod = target.getMethod('schema'); + if (schemaMethod != null && + schemaMethod.isStatic && + schemaMethod.typeParameters.length == type.typeArguments.length) { + return _ackSchemaBoundaryType( + schemaMethod.type.instantiate(type.typeArguments).returnType, + ); + } + if (target is EnumElement) return provider.stringType; + return null; + } + /// Whether a schema producing [left] can back a field of type [right]. /// /// The types must match apart from nullability: collection schemas such as @@ -1166,7 +1287,8 @@ final class ClassModelGraphBuilder { _inference.inferType( type, visibleTypeName: (type) => _visibleTypeName(type, field), - renderType: (type) => _renderType(_typeRef(type, field)), + renderType: (type) => + _renderType(_typeRef(type, field, allowJsonMaybe: false)), resolveNamed: (type) => _namedSchemaForType(type, field), unsupported: (type) => _unsupportedFieldType(field, type), rejectNullableCollectionElement: (type) => @@ -1215,7 +1337,7 @@ final class ClassModelGraphBuilder { : null; return runtimeType == null || _containsInvalidType(runtimeType) ? _jsonMapRef - : _typeRef(runtimeType, field); + : _typeRef(runtimeType, field, allowJsonMaybe: false); } /// Resolves [type] through a static `schema` that the type declares. @@ -1248,6 +1370,28 @@ final class ClassModelGraphBuilder { element: field, ); } + for (var i = 0; i < method.typeParameters.length; i++) { + final typeParam = method.typeParameters[i]; + final rawBound = typeParam.bound; + if (rawBound == null) continue; + final bound = + rawBound is TypeParameterType && + method.typeParameters.contains(rawBound.element) + ? type.typeArguments[method.typeParameters.indexOf( + rawBound.element, + )] + : rawBound; + final argument = type.typeArguments[i]; + if (!library.element.typeSystem.isSubtypeOf(argument, bound)) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, whose type parameter ' + '${typeParam.name} requires a subtype of ' + '${bound.getDisplayString()}; received ' + '${argument.getDisplayString()}.', + element: field, + ); + } + } final parameters = method.formalParameters; if (parameters.isNotEmpty && (parameters.length != type.typeArguments.length || @@ -1276,6 +1420,23 @@ final class ClassModelGraphBuilder { element: field, ); } + final expectedBoundary = _ackSchemaBoundaryType(parameterType); + final inferredBoundary = _inferredBoundaryType(argument); + if (expectedBoundary != null && + inferredBoundary != null && + !library.element.typeSystem.isSubtypeOf( + inferredBoundary, + expectedBoundary, + )) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, whose parameter ' + '${parameters[i].name} expects boundary type ' + '${expectedBoundary.getDisplayString()}, but the schema inferred ' + 'for ${argument.getDisplayString()} has boundary type ' + '${inferredBoundary.getDisplayString()}.', + element: field, + ); + } schemas.add(await _schemaForType(argument, field)); } final arguments = [ @@ -1327,6 +1488,8 @@ final class ClassModelGraphBuilder { switch (type) { case AckNullableTypeRef(:final inner): _rejectNullableFutureCollectionElement(field, inner); + case AckJsonMaybeTypeRef(:final valueType): + _rejectNullableFutureCollectionElement(field, valueType); case AckListTypeRef(:final elementType) || AckSetTypeRef(:final elementType): if (elementType is AckNullableTypeRef) { @@ -1395,15 +1558,16 @@ final class ClassModelGraphBuilder { FieldElement field, FormalParameterElement? parameter, ) { + final effectiveType = _unwrapJsonMaybeType(field.type); if (field.isOriginDeclaringFormalParameter) { final target = field.metadata.annotations.isNotEmpty ? field : (parameter ?? field); - return _inference.applyConstraints(schema, target, field.type); + return _inference.applyConstraints(schema, target, effectiveType); } - var output = _inference.applyConstraints(schema, field, field.type); + var output = _inference.applyConstraints(schema, field, effectiveType); if (parameter != null && !identical(parameter, field)) { - output = _inference.applyConstraints(output, parameter, field.type); + output = _inference.applyConstraints(output, parameter, effectiveType); } return output; } @@ -1481,35 +1645,77 @@ final class ClassModelGraphBuilder { ); } + bool _isJsonMaybe(DartType type) => + type is InterfaceType && + type.typeArguments.length == 1 && + _jsonMaybeChecker.isExactlyType(type); + + DartType _unwrapJsonMaybeType(DartType type) => + _isJsonMaybe(type) ? (type as InterfaceType).typeArguments.single : type; + /// A type argument of an application type, where `void` is also valid, /// as in `Trigger`. It is never a field or collection item type. AckInferRef _typeArgumentRef(DartType type, FieldElement field) => type is VoidType ? const AckExternalTypeRef(name: 'void') - : _typeRef(type, field); + : _typeRef(type, field, allowJsonMaybe: false); - AckInferRef _typeRef(DartType type, FieldElement field) { + AckInferRef _typeRef( + DartType type, + FieldElement field, { + bool allowJsonMaybe = true, + }) { if (type is DynamicType || type is TypeParameterType) { _unsupportedFieldType(field, type); } if (type is! InterfaceType) _unsupportedFieldType(field, type); final interfaceType = type; final nullable = _isNullable(interfaceType); + if (_isJsonMaybe(interfaceType)) { + if (!allowJsonMaybe) { + _unsupportedFieldType(field, type); + } + if (nullable) { + throw InvalidGenerationSource( + '${field.enclosingElement.name}.${field.name} uses nullable ' + '${interfaceType.getDisplayString()}; use non-nullable JsonMaybe<...> ' + 'instead, which already models both absent and null values.', + element: field, + ); + } + return AckJsonMaybeTypeRef( + _typeRef( + interfaceType.typeArguments.single, + field, + allowJsonMaybe: false, + ), + ); + } late final AckInferRef result; if (interfaceType.isDartCoreList && interfaceType.typeArguments.length == 1) { result = AckListTypeRef( - _typeRef(interfaceType.typeArguments.single, field), + _typeRef( + interfaceType.typeArguments.single, + field, + allowJsonMaybe: false, + ), ); } else if (interfaceType.isDartCoreSet && interfaceType.typeArguments.length == 1) { result = AckSetTypeRef( - _typeRef(interfaceType.typeArguments.single, field), + _typeRef( + interfaceType.typeArguments.single, + field, + allowJsonMaybe: false, + ), ); } else if (interfaceType.isDartCoreMap && interfaceType.typeArguments.length == 2) { _validateMapKey(field, interfaceType); - result = AckMapTypeRef(_typeRef(interfaceType.typeArguments[1], field)); + result = AckMapTypeRef( + _typeRef(interfaceType.typeArguments[1], field, allowJsonMaybe: false), + ); } else if (interfaceType.element.library.uri.toString() == 'dart:core' && const { 'String', @@ -1599,6 +1805,7 @@ final class ClassModelGraphBuilder { FieldElement field, { required FormalParameterElement? parameter, required bool isDiscriminator, + bool isJsonMaybe = false, }) { final inferred = _fieldPresence(parameter); final hasOptional = _hasAnnotation(_optionalChecker, field, parameter); @@ -1625,6 +1832,12 @@ final class ClassModelGraphBuilder { } } + if (isJsonMaybe) { + return hasRequired + ? AckSchemaFieldPresence.required + : AckSchemaFieldPresence.optional; + } + final AckSchemaFieldPresence? override; if (hasOptional) { override = AckSchemaFieldPresence.optional; @@ -2157,6 +2370,8 @@ final class ClassModelGraphBuilder { AckListTypeRef(:final elementType) => 'List<${_renderType(elementType)}>', AckSetTypeRef(:final elementType) => 'Set<${_renderType(elementType)}>', AckMapTypeRef(:final valueType) => 'Map', + AckJsonMaybeTypeRef(:final valueType) => + '${_ack('JsonMaybe')}<${_renderType(valueType)}>', }; String _ack(String symbol) { diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index a6c17e40..8eb4c90e 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -1127,6 +1127,11 @@ final class SchemaModelGraphBuilder { return _ackSchemaChecker.isExactlyType(type); } + AckInferRef _typeArgumentRef(DartType type, String path, Element context) => + type is VoidType + ? const AckExternalTypeRef(name: 'void') + : _typeRef(type, path, context); + AckInferRef _typeRef(DartType type, String path, Element context) { if (type is DynamicType) { return const AckNullableTypeRef(AckScalarTypeRef('Object')); @@ -1180,7 +1185,7 @@ final class SchemaModelGraphBuilder { importPrefix: _visiblePrefix(type.element, path, context), typeArguments: [ for (final argument in type.typeArguments) - _typeRef(argument, path, context), + _typeArgumentRef(argument, path, context), ], ); } diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index 971259d2..52165b47 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -147,6 +147,7 @@ final class Handwritten with _$HandwrittenAck { ); File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( r''' +// ignore_for_file: deprecated_member_use import 'package:ack/ack.dart'; import 'package:json_annotation/json_annotation.dart' show JsonKey, JsonSerializable; @@ -550,6 +551,23 @@ final class Square extends Shape with _$SquareAck { @Schemable() final legacySchema = Ack.object({'enabled': Ack.boolean()}); +@Schemable() +final class UserPatch with _$UserPatchAck { + const UserPatch({ + this.nickname = const JsonMaybe.absent(), + this.title = const JsonMaybe.absent(), + this.tags = const JsonMaybe.absent(), + }); + + final JsonMaybe nickname; + + @NotNull() + @Check.notEmpty() + final JsonMaybe title; + + final JsonMaybe> tags; +} + @JsonSerializable() final class PlainJson { const PlainJson({required this.value}); @@ -1136,10 +1154,59 @@ void main() { expect(Modern.parse({'name': 'modern'}).name, 'modern'); expect(HandwrittenSchema.parse({'enabled': true}).enabled, isTrue); }); + + test('JsonMaybe fields distinguish omitted vs explicit null on decode and encode', () { + final empty = UserPatchSchema.parse({}); + expect(empty.nickname, const JsonMaybe.absent()); + expect(empty.title, const JsonMaybe.absent()); + expect(empty.tags, const JsonMaybe>.absent()); + expect(empty.toJson(), {}); + + final cleared = UserPatchSchema.parse({'nickname': null, 'tags': null}); + expect(cleared.nickname, const JsonMaybe.nullValue()); + expect(cleared.nickname.isNull, isTrue); + expect(cleared.tags, const JsonMaybe>.nullValue()); + expect(cleared.toJson(), {'nickname': null, 'tags': null}); + + final populated = UserPatchSchema.parse({ + 'nickname': 'Ada', + 'title': 'Countess', + 'tags': ['math', 'code'], + }); + expect(populated.nickname, const JsonMaybe.value('Ada')); + expect(populated.title, const JsonMaybe.value('Countess')); + expect(populated.tags, const JsonMaybe.value(['math', 'code'])); + expect(populated.toJson(), { + 'nickname': 'Ada', + 'title': 'Countess', + 'tags': ['math', 'code'], + }); + + expect( + UserPatchSchema.safeParse({'title': null}).isFail, + isTrue, + ); + expect( + UserPatchSchema.safeParse({'title': ''}).isFail, + isTrue, + ); + + final copied = populated.copyWith( + nickname: const JsonMaybe.absent(), + tags: const JsonMaybe.nullValue(), + ); + expect(copied.toJson(), { + 'title': 'Countess', + 'tags': null, + }); + }); } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', diff --git a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart index 3585c70b..ed1ace65 100644 --- a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -175,7 +175,10 @@ void main() { } '''); - _succeeds(await _run(project, ['pub', 'get']), 'dart pub get'); + _succeeds( + await _run(project, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _succeeds( await _run(project, ['run', 'build_runner', 'build']), 'first build_runner build', diff --git a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart index 60f9f7eb..b472e985 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart @@ -186,6 +186,7 @@ final class Shelf with _$ShelfAck { final panelSchema = Ack.object({ 'header': Slot.schema, 'commands': Ack.list(Command.schema()), + 'openAll': Trigger.schema(), 'habit': Habit.schema.optional(), }); ''', @@ -275,17 +276,22 @@ void main() { final panel = Panel.parse({ 'header': 'h', 'commands': ['go'], + 'openAll': 'open', 'habit': {'name': 'run'}, }); expect(panel.header, const Slot('h')); expect(panel.commands.single, isA>()); + expect(panel.openAll, isA>()); expect(panel.habit, const Habit(name: 'run')); }); } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index 835bc6f7..f6ad1e6f 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -553,6 +553,7 @@ final sectionSchema = Ack.object({ 'header': Slot.schema, 'children': Ack.list(Slot.schema), 'open': Command.schema().optional(), + 'openAll': Trigger.schema(), 'habit': Habit.schema, 'title': Box.schema(Ack.string()), }); @@ -561,12 +562,86 @@ final sectionSchema = Ack.object({ contains('final Slot header;'), contains('final List children;'), contains('final Command? open;'), + contains('final Trigger openAll;'), contains('final Habit habit;'), contains('final Box title;'), ), ); }); + test('a generic type-owned schema enforces type parameter bounds', () async { + final errors = await _errors( + r''' +@Schemable() +final class Section with _$SectionAck { + const Section({required this.metric}); + + final Metric metric; +} +''', + types: ''' +import 'package:ack/ack.dart'; + +final class Metric { + const Metric(this.value); + final A value; + + static AckSchema> schema() => + throw UnimplementedError(); +} +''', + ); + + expect( + errors, + contains( + contains( + 'Section.metric resolves to Metric.schema, whose type parameter A ' + 'requires a subtype of num; received Object?.', + ), + ), + ); + }); + + test( + 'a generic type-owned schema enforces parameter boundary types', + () async { + final errors = await _errors( + r''' +@Schemable() +final class Section with _$SectionAck { + const Section({required this.record}); + + final RecordBox record; +} +''', + types: ''' +import 'package:ack/ack.dart'; + +final class RecordBox { + const RecordBox(this.value); + final A value; + + static AckSchema, RecordBox> schema( + AckSchema, A> value, + ) => throw UnimplementedError(); +} +''', + ); + + expect( + errors, + contains( + contains( + 'Section.record resolves to RecordBox.schema, whose parameter value ' + 'expects boundary type Map, but the schema ' + 'inferred for int has boundary type num.', + ), + ), + ); + }, + ); + test('an enum with a static schema resolves to it', () async { await _expectOutput( r''' From 90eebfa788c2775f986ab18e8dc6b29a72823b95 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 12:16:13 -0400 Subject: [PATCH 12/29] feat(ack,ack_generator): support @Schemable(schemas: [...]) + auto library schema detection and simplify 2.0 API --- packages/ack/lib/ack.dart | 6 +- .../ack/lib/src/annotations/ack_field.dart | 8 +- .../ack/lib/src/annotations/schemable.dart | 21 +- packages/ack/lib/src/models/json_maybe.dart | 3 - .../ack/lib/src/schemas/codec_schema.dart | 3 + .../extensions/string_schema_extensions.dart | 24 - packages/ack/lib/src/schemas/schema.dart | 5 - .../ack/lib/src/schemas/string_schema.dart | 26 +- .../lib/src/validation/standard_schema.dart | 257 ----------- .../codecs/codec_error_contract_test.dart | 111 +++++ packages/ack/test/models/json_maybe_test.dart | 8 +- .../encode_error_propagation_test.dart | 13 +- .../extensions/transform_extension_test.dart | 8 +- .../typed_codecs_characterization_test.dart | 4 +- .../test/validation/standard_schema_test.dart | 59 --- .../analyzer/class_model_graph_builder.dart | 421 +++++++++++++++++- .../analyzer/schema_model_graph_builder.dart | 1 + .../lib/src/inference/schema_inference.dart | 6 + .../lib/src/model_generator.dart | 38 +- .../type_owned_schema_build_test.dart | 60 +++ .../integration/type_owned_schema_test.dart | 347 +++++++++++++++ 21 files changed, 1034 insertions(+), 395 deletions(-) delete mode 100644 packages/ack/lib/src/validation/standard_schema.dart create mode 100644 packages/ack/test/codecs/codec_error_contract_test.dart delete mode 100644 packages/ack/test/validation/standard_schema_test.dart diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index c78b61ac..ba883782 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -43,15 +43,11 @@ export 'src/schemas/schema.dart' TestOperationRecordingAckSchema, TestThrowingLeafAckSchema, WrapperSchema, - createCodecSchemaInternal, - lowercaseStringSchemaInternal, - trimStringSchemaInternal, - uppercaseStringSchemaInternal; + createCodecSchemaInternal; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; // Validation results export 'src/validation/schema_result.dart'; -export 'src/validation/standard_schema.dart'; // Model annotations. Legacy top-level constraint annotations live in annotations.dart. export 'src/annotations/ack_field.dart'; export 'src/annotations/constraints.dart' show Check; diff --git a/packages/ack/lib/src/annotations/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 4dc0a0a0..3f4d1d8e 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -46,18 +46,12 @@ final class NotNull { @Target({TargetKind.field, TargetKind.parameter}) final class AckField { /// Creates a field annotation. - const AckField({ - this.name, - @Deprecated('Declare a static schema on the field type instead.') - this.schema, - this.description, - }); + const AckField({this.name, this.schema, this.description}); /// Explicit JSON key for this property. final String? name; /// Top-level schema-function tear-off followed by `ack_generator`. - @Deprecated('Declare a static schema on the field type instead.') final Object Function()? schema; /// Schema description for this property. diff --git a/packages/ack/lib/src/annotations/schemable.dart b/packages/ack/lib/src/annotations/schemable.dart index 0d73dfb2..f6d69bd1 100644 --- a/packages/ack/lib/src/annotations/schemable.dart +++ b/packages/ack/lib/src/annotations/schemable.dart @@ -21,9 +21,17 @@ enum AckUnknownPropertyPolicy { reject, discard, capture } /// /// Use this annotation on a top-level schema variable or getter to generate a /// schema-first model. Use it on a class to generate a class-first schema. -/// [name] applies only to a top-level schema; every other option applies only -/// to a class. `ack_generator` rejects an option set for the other target. -@Target({TargetKind.classType, TargetKind.topLevelVariable, TargetKind.getter}) +/// Use it on a `library` directive (`@Schemable(schemas: [...]) library;`) to +/// register custom type schemas across the library and its importers. +/// [name] applies only to a top-level schema; [schemas] applies to a class or +/// `library` directive; every other option applies only to a class. +/// `ack_generator` rejects an option set for the wrong target. +@Target({ + TargetKind.classType, + TargetKind.topLevelVariable, + TargetKind.getter, + TargetKind.library, +}) final class Schemable { /// Creates a unified schema declaration annotation. const Schemable({ @@ -35,6 +43,7 @@ final class Schemable { this.discriminatorValue, this.unknownProperties = AckUnknownPropertyPolicy.reject, this.captureField = 'additionalProperties', + this.schemas, }); /// Exact generated model name for a top-level schema declaration. @@ -61,4 +70,10 @@ final class Schemable { /// Dart field that stores captured unknown properties. final String captureField; + + /// Custom top-level schema functions registered for this class or library. + /// + /// Each entry must be a const tear-off of a zero-argument, non-generic + /// top-level function returning a two-way `AckSchema`. + final List? schemas; } diff --git a/packages/ack/lib/src/models/json_maybe.dart b/packages/ack/lib/src/models/json_maybe.dart index 1567a3cf..8c3b67c7 100644 --- a/packages/ack/lib/src/models/json_maybe.dart +++ b/packages/ack/lib/src/models/json_maybe.dart @@ -25,9 +25,6 @@ sealed class JsonMaybe { /// A present JSON property with [value] (which may be `null`). const factory JsonMaybe.value(T? value) = JsonValue; - /// Alias for [JsonMaybe.value]. - const factory JsonMaybe.present(T? value) = JsonValue; - /// A present JSON property with an explicit `null` value. const factory JsonMaybe.nullValue() = JsonValue.nullValue; diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index 29e548d1..4a83b31d 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -109,6 +109,9 @@ final class CodecSchema try { runtime = _decoder(intermediate); } catch (e, st) { + if (e is Error) { + Error.throwWithStackTrace(e, st); + } return SchemaResult.fail( SchemaTransformError( message: 'Codec decode failed: ${e.toString()}', diff --git a/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart index fa159235..686d8633 100644 --- a/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart +++ b/packages/ack/lib/src/schemas/extensions/string_schema_extensions.dart @@ -125,28 +125,4 @@ extension StringSchemaExtensions on StringSchema { /// Adds a constraint that the string must be a valid IPv6 address. StringSchema ipv6() => ip(version: 6); - - /// Trims leading and trailing whitespace from the string. - /// - /// Runs in declaration order with any constraints and refinements chained - /// before or after this call, on both `parse` and `encode`. - StringSchema trim() { - return trimStringSchemaInternal(this); - } - - /// Converts the string to lowercase. - /// - /// Runs in declaration order with any constraints and refinements chained - /// before or after this call, on both `parse` and `encode`. - StringSchema toLowerCase() { - return lowercaseStringSchemaInternal(this); - } - - /// Converts the string to uppercase. - /// - /// Runs in declaration order with any constraints and refinements chained - /// before or after this call, on both `parse` and `encode`. - StringSchema toUpperCase() { - return uppercaseStringSchemaInternal(this); - } } diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 63d6be6f..012f9674 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -17,7 +17,6 @@ import '../helpers.dart'; import '../schema_model/ack_schema_model_builder.dart'; import '../validation/schema_error.dart'; import '../validation/schema_result.dart'; -import '../validation/standard_schema.dart'; part '../json_schema/json_schema_compiler.dart'; part '../json_schema/json_schema_formats.dart'; @@ -505,10 +504,6 @@ sealed class AckSchema { /// they are dispatched in `ack_schema_model_builder.dart`. Map toJsonSchema() => toSchemaModel().toJsonSchema(); - /// Returns a [StandardSchemaV1] view over this schema (`https://standardschema.dev/`). - StandardSchemaV1 get standard => - AckStandardSchema(this); - Map toMap() { return { 'type': schemaType.typeName, diff --git a/packages/ack/lib/src/schemas/string_schema.dart b/packages/ack/lib/src/schemas/string_schema.dart index b61800bc..eea0d552 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -237,16 +237,22 @@ final class StringSchema extends AckSchema baseFieldsHashCode, const ListEquality<_StringNormalizerStep>().hash(_normalizers), ); -} -@internal -StringSchema trimStringSchemaInternal(StringSchema schema) => - schema._withNormalizer(_StringNormalizer.trim); + /// Trims leading and trailing whitespace from the string. + /// + /// Runs in declaration order with any constraints and refinements chained + /// before or after this call, on both `parse` and `encode`. + StringSchema trim() => _withNormalizer(_StringNormalizer.trim); -@internal -StringSchema lowercaseStringSchemaInternal(StringSchema schema) => - schema._withNormalizer(_StringNormalizer.toLowerCase); + /// Converts the string to lowercase. + /// + /// Runs in declaration order with any constraints and refinements chained + /// before or after this call, on both `parse` and `encode`. + StringSchema toLowerCase() => _withNormalizer(_StringNormalizer.toLowerCase); -@internal -StringSchema uppercaseStringSchemaInternal(StringSchema schema) => - schema._withNormalizer(_StringNormalizer.toUpperCase); + /// Converts the string to uppercase. + /// + /// Runs in declaration order with any constraints and refinements chained + /// before or after this call, on both `parse` and `encode`. + StringSchema toUpperCase() => _withNormalizer(_StringNormalizer.toUpperCase); +} diff --git a/packages/ack/lib/src/validation/standard_schema.dart b/packages/ack/lib/src/validation/standard_schema.dart deleted file mode 100644 index 0776db69..00000000 --- a/packages/ack/lib/src/validation/standard_schema.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'package:meta/meta.dart'; - -import '../context.dart'; -import '../schemas/schema.dart'; -import '../utils/collection_utils.dart'; -import 'schema_error.dart'; -import 'schema_result.dart'; - -/// Dart contract for the Standard Schema v1 and Standard JSON Schema v1 -/// specifications (`https://standardschema.dev/`). -/// -/// Validation libraries implement or expose [StandardSchemaV1] (for example, -/// via `AckSchema.standard`) so framework, router, form, and AI/agent tooling -/// can consume schemas generically without vendor-specific adapters. -abstract interface class StandardSchemaV1 { - /// The Standard Schema specification version (`1`). - int get version; - - /// The schema library vendor identifier (for Ack, `'ack'`). - String get vendor; - - /// Validates [value] and returns either a [StandardSchemaSuccess] with the - /// parsed output or a [StandardSchemaFailure] with a list of [StandardSchemaIssue]s. - StandardSchemaResult validate(Object? value); - - /// Renders this schema as a JSON Schema map per the Standard JSON Schema - /// companion specification. - Map toJsonSchema(); -} - -/// The outcome of a [StandardSchemaV1.validate] invocation. -@immutable -sealed class StandardSchemaResult { - const StandardSchemaResult(); - - /// Creates a successful validation result. - const factory StandardSchemaResult.success(Output? value) = - StandardSchemaSuccess; - - /// Creates a failed validation result with [issues]. - factory StandardSchemaResult.failure(List issues) = - StandardSchemaFailure; - - /// Whether validation succeeded. - bool get isSuccess => this is StandardSchemaSuccess; - - /// Whether validation failed. - bool get isFailure => this is StandardSchemaFailure; - - /// The validated value when successful, or `null` when validation failed. - Output? get valueOrNull => switch (this) { - StandardSchemaSuccess(:final value) => value, - StandardSchemaFailure() => null, - }; - - /// The validation issues when failed, or `null` when validation succeeded. - List? get issues => switch (this) { - StandardSchemaSuccess() => null, - StandardSchemaFailure(:final issues) => issues, - }; -} - -/// A successful [StandardSchemaResult] wrapping the validated [value]. -@immutable -final class StandardSchemaSuccess extends StandardSchemaResult { - /// Creates a successful result. - const StandardSchemaSuccess(this.value); - - /// The validated output value (`null` only when the schema accepts `null`). - final Output? value; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StandardSchemaSuccess && - deepEquals(value, other.value)); - - @override - int get hashCode => Object.hash(StandardSchemaSuccess, deepHashCode(value)); - - @override - String toString() => 'StandardSchemaSuccess($value)'; -} - -/// A failed [StandardSchemaResult] containing one or more [issues]. -@immutable -final class StandardSchemaFailure extends StandardSchemaResult { - /// Creates a failed result with an unmodifiable copy of [issues]. - StandardSchemaFailure(Iterable issues) - : issues = List.unmodifiable(issues); - - /// The non-empty list of validation issues reported by the schema. - @override - final List issues; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StandardSchemaFailure && - deepEquals(issues, other.issues)); - - @override - int get hashCode => Object.hash(StandardSchemaFailure, deepHashCode(issues)); - - @override - String toString() => 'StandardSchemaFailure($issues)'; -} - -/// A single validation issue reported by [StandardSchemaV1.validate]. -@immutable -final class StandardSchemaIssue { - /// Creates a validation issue with a human-readable [message] and an - /// optional [path] from the root input to the failing location. - StandardSchemaIssue({required this.message, Iterable? path}) - : path = List.unmodifiable(path ?? const []); - - /// Human-readable description of the validation failure. - final String message; - - /// Path segments (`String` property keys and `int` array indices) from the - /// root value to the failing location. Empty when the issue occurred at the - /// root value. - final List path; - - /// Converts this issue to a JSON-compatible map matching the Standard Schema - /// v1 issue shape. - Map toMap() => { - 'message': message, - if (path.isNotEmpty) 'path': path, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StandardSchemaIssue && - message == other.message && - deepEquals(path, other.path)); - - @override - int get hashCode => Object.hash(message, deepHashCode(path)); - - @override - String toString() => path.isEmpty - ? 'StandardSchemaIssue(message: $message)' - : 'StandardSchemaIssue(message: $message, path: $path)'; -} - -/// [StandardSchemaV1] adapter backed by an [AckSchema]. -@immutable -final class AckStandardSchema - implements StandardSchemaV1 { - /// Creates a Standard Schema v1 view over [schema]. - const AckStandardSchema(this.schema); - - /// The underlying Ack schema. - final AckSchema schema; - - @override - int get version => 1; - - @override - String get vendor => 'ack'; - - @override - StandardSchemaResult validate(Object? value) { - return switch (schema.safeParse(value)) { - Ok(value: final parsed) => StandardSchemaResult.success(parsed), - Fail(:final error) => StandardSchemaResult.failure(_collectIssues(error)), - }; - } - - @override - Map toJsonSchema() => schema.toJsonSchema(); - - static List _collectIssues(SchemaError error) { - final issues = []; - _appendIssues(error, issues); - if (issues.isEmpty) { - issues.add( - StandardSchemaIssue( - message: error.message, - path: _extractPathSegments(error), - ), - ); - } - return issues; - } - - static void _appendIssues( - SchemaError error, - List output, - ) { - switch (error) { - case SchemaNestedError(:final errors) when errors.isNotEmpty: - for (final child in errors) { - _appendIssues(child, output); - } - case SchemaConstraintsError(:final constraints) - when constraints.isNotEmpty: - final path = _extractPathSegments(error); - for (final violation in constraints) { - output.add( - StandardSchemaIssue(message: violation.message, path: path), - ); - } - case TypeMismatchError() || - SchemaConstraintsError() || - SchemaNestedError() || - SchemaValidationError() || - SchemaTransformError() || - SchemaEncodeError(): - output.add( - StandardSchemaIssue( - message: error.message, - path: _extractPathSegments(error), - ), - ); - } - } - - static List _extractPathSegments(SchemaError error) { - final contextSegments = _segmentsFromContext(error.context); - if (contextSegments.isNotEmpty) return contextSegments; - final pointer = error.path; - if (pointer.isEmpty || pointer == '#') return const []; - final stripped = pointer.startsWith('#/') - ? pointer.substring(2) - : pointer.startsWith('/') - ? pointer.substring(1) - : pointer; - if (stripped.isEmpty) return const []; - return [ - for (final raw in stripped.split('/')) _unescapeJsonPointerSegment(raw), - ]; - } - - static List _segmentsFromContext(SchemaContext context) { - final reversed = []; - SchemaContext? current = context; - while (current != null && current.parent != null) { - final parent = current.parent!; - final rawSegment = current.pathSegment; - if (rawSegment != '') { - final segment = rawSegment ?? current.name; - final isArrayParent = - parent.value is List || parent.schema is ListSchema; - final parsedIndex = isArrayParent ? int.tryParse(segment) : null; - reversed.add(parsedIndex ?? segment); - } - current = parent; - } - return reversed.reversed.toList(growable: false); - } - - static String _unescapeJsonPointerSegment(String segment) => - segment.replaceAll('~1', '/').replaceAll('~0', '~'); -} diff --git a/packages/ack/test/codecs/codec_error_contract_test.dart b/packages/ack/test/codecs/codec_error_contract_test.dart new file mode 100644 index 00000000..8b561ebc --- /dev/null +++ b/packages/ack/test/codecs/codec_error_contract_test.dart @@ -0,0 +1,111 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + group('codec decoder error contract', () { + test('parse and safeParse propagate the original programmer error', () { + final error = StateError('decoder defect'); + final schema = Ack.string().codec( + decode: (_) => throw error, + encode: (value) => value.toString(), + ); + + expect(() => schema.safeParse('7'), throwsA(same(error))); + expect(() => schema.parse('7'), throwsA(same(error))); + }); + + test('nested objects, lists, and maps preserve decoder errors', () { + final error = StateError('nested decoder defect'); + final schema = Ack.string().codec( + decode: (_) => throw error, + encode: (value) => value.toString(), + ); + + expect( + () => Ack.object({'value': schema}).safeParse({'value': '7'}), + throwsA(same(error)), + ); + expect(() => Ack.list(schema).safeParse(['7']), throwsA(same(error))); + expect( + () => Ack.map(schema).safeParse({'value': '7'}), + throwsA(same(error)), + ); + }); + + test('one-way transforms preserve programmer errors too', () { + final error = StateError('transform defect'); + final schema = Ack.string().transform((_) => throw error); + + expect(() => schema.safeParse('7'), throwsA(same(error))); + }); + + test('anyOf does not hide a decoder error with a later branch', () { + final error = StateError('first branch defect'); + var fallbackCalled = false; + final broken = Ack.string().codec( + decode: (_) => throw error, + encode: (value) => value.toString(), + ); + final fallback = Ack.string().refine((_) { + fallbackCalled = true; + return true; + }); + + expect( + () => Ack.anyOf([broken, fallback]).safeParse('7'), + throwsA(same(error)), + ); + expect(fallbackCalled, isFalse); + }); + + test('recoverable decoder exceptions remain transform failures', () { + const exception = FormatException('not an integer'); + final schema = Ack.string().codec( + decode: (_) => throw exception, + encode: (value) => value.toString(), + ); + + final result = schema.safeParse('bad'); + + expect(result.isFail, isTrue); + expect( + result.getError(), + isA().having( + (error) => error.cause, + 'cause', + same(exception), + ), + ); + }); + + test('anyOf can still recover from a decoder exception', () { + final schema = Ack.string().codec( + decode: (_) => throw const FormatException('not an integer'), + encode: (value) => value.toString(), + ); + + expect(Ack.anyOf([schema, Ack.string()]).parse('text'), 'text'); + }); + + test('decoder errors retain their original stack trace', () { + final error = StateError('decoder defect'); + final originalStack = StackTrace.fromString('original decoder stack'); + final schema = Ack.string().codec( + decode: (_) => Error.throwWithStackTrace(error, originalStack), + encode: (value) => value.toString(), + ); + Object? caughtError; + StackTrace? caughtStack; + + try { + schema.safeParse('7'); + } catch (error, stackTrace) { + caughtError = error; + caughtStack = stackTrace; + } + + expect(caughtError, same(error)); + expect(caughtStack.toString(), originalStack.toString()); + }); + }); +} diff --git a/packages/ack/test/models/json_maybe_test.dart b/packages/ack/test/models/json_maybe_test.dart index 10f01482..db5893a9 100644 --- a/packages/ack/test/models/json_maybe_test.dart +++ b/packages/ack/test/models/json_maybe_test.dart @@ -17,9 +17,9 @@ void main() { expect(absent1.toString(), equals('JsonMaybe.absent()')); }); - test('JsonMaybe.value() / present() / nullValue() state and equality', () { + test('JsonMaybe.value() / nullValue() state and equality', () { const present = JsonMaybe.value('Ada'); - const presentAlias = JsonMaybe.present('Ada'); + const presentCopy = JsonValue('Ada'); const explicitNull = JsonMaybe.nullValue(); const valueNull = JsonMaybe.value(null); @@ -28,8 +28,8 @@ void main() { expect(present.isAbsent, isFalse); expect(present.isNull, isFalse); expect(present.valueOrNull, equals('Ada')); - expect(present, equals(presentAlias)); - expect(present.hashCode, equals(presentAlias.hashCode)); + expect(present, equals(presentCopy)); + expect(present.hashCode, equals(presentCopy.hashCode)); expect(present.toString(), equals('JsonMaybe.value(Ada)')); expect(explicitNull.isPresent, isTrue); diff --git a/packages/ack/test/schemas/encode_error_propagation_test.dart b/packages/ack/test/schemas/encode_error_propagation_test.dart index 4849a1d7..7ea3bd60 100644 --- a/packages/ack/test/schemas/encode_error_propagation_test.dart +++ b/packages/ack/test/schemas/encode_error_propagation_test.dart @@ -230,17 +230,16 @@ void main() { }); }); - group('decoder policy is unchanged', () { - test('a decoder Error still becomes a SchemaTransformError', () { + group('decoder policy matches encoder policy', () { + test('a decoder Error propagates unchanged', () { + final error = StateError('decode boom'); final schema = Ack.string().codec( - decode: (_) => throw StateError('decode boom'), + decode: (_) => throw error, encode: (value) => value.toString(), ); - final result = schema.safeParse('1'); - - expect(result.isFail, isTrue); - expect(result.getError(), isA()); + expect(() => schema.safeParse('1'), throwsA(same(error))); + expect(() => schema.parse('1'), throwsA(same(error))); }); }); } diff --git a/packages/ack/test/schemas/extensions/transform_extension_test.dart b/packages/ack/test/schemas/extensions/transform_extension_test.dart index 0371b6d3..926951df 100644 --- a/packages/ack/test/schemas/extensions/transform_extension_test.dart +++ b/packages/ack/test/schemas/extensions/transform_extension_test.dart @@ -49,16 +49,14 @@ void main() { ); test( - 'should error when transformer returns null for non-nullable output', + 'should propagate TypeError when a non-nullable transform returns null', () { final schema = Ack.string().transform((val) { return (null as dynamic); }); - final result = schema.safeParse('value'); - - expect(result.isFail, isTrue); - expect(result.getError(), isA()); + expect(() => schema.safeParse('value'), throwsA(isA())); + expect(() => schema.parse('value'), throwsA(isA())); }, ); diff --git a/packages/ack/test/typed_codecs_characterization_test.dart b/packages/ack/test/typed_codecs_characterization_test.dart index 316c934a..56bf0b0c 100644 --- a/packages/ack/test/typed_codecs_characterization_test.dart +++ b/packages/ack/test/typed_codecs_characterization_test.dart @@ -218,7 +218,7 @@ void main() { test('decoder exceptions use codec decode wording', () { final transformSchema = Ack.string().transform( - (_) => throw StateError('transform decoder failed'), + (_) => throw const FormatException('transform decoder failed'), ); final transformResult = transformSchema.safeParse('value'); @@ -228,7 +228,7 @@ void main() { expect(transformError.message, startsWith('Codec decode failed:')); final codecSchema = Ack.string().codec( - decode: (_) => throw StateError('codec decoder failed'), + decode: (_) => throw const FormatException('codec decoder failed'), encode: (value) => value.toString(), ); final codecResult = codecSchema.safeParse('value'); diff --git a/packages/ack/test/validation/standard_schema_test.dart b/packages/ack/test/validation/standard_schema_test.dart deleted file mode 100644 index 2640715c..00000000 --- a/packages/ack/test/validation/standard_schema_test.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:ack/ack.dart'; -import 'package:test/test.dart'; - -void main() { - group('StandardSchemaV1', () { - test('exposes version 1 and vendor ack', () { - final schema = Ack.string().minLength(3); - final standard = schema.standard; - - expect(standard.version, equals(1)); - expect(standard.vendor, equals('ack')); - }); - - test('returns StandardSchemaSuccess on valid input', () { - final schema = Ack.object({ - 'name': Ack.string().trim(), - 'age': Ack.integer().min(0), - }); - - final result = schema.standard.validate({'name': ' Ada ', 'age': 36}); - expect(result, isA>>()); - expect(result.isSuccess, isTrue); - expect(result.isFailure, isFalse); - expect(result.issues, isNull); - - final success = result as StandardSchemaSuccess>; - expect(success.value, equals({'name': 'Ada', 'age': 36})); - }); - - test( - 'returns StandardSchemaFailure with path segments on nested object/list errors', - () { - final schema = Ack.object({ - 'user': Ack.object({'tags': Ack.list(Ack.string().minLength(3))}), - }); - - final result = schema.standard.validate({ - 'user': { - 'tags': ['valid', 'no'], - }, - }); - - expect(result, isA>>()); - expect(result.isFailure, isTrue); - expect(result.isSuccess, isFalse); - - final issues = result.issues!; - expect(issues, isNotEmpty); - expect(issues.first.path, equals(['user', 'tags', 1])); - expect(issues.first.message, isNotEmpty); - }, - ); - - test('delegates toJsonSchema() to underlying AckSchema', () { - final schema = Ack.string().email(); - expect(schema.standard.toJsonSchema(), equals(schema.toJsonSchema())); - }); - }); -} diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 039db63f..61162b84 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -33,6 +33,13 @@ typedef _FutureGeneratedType = ({ typedef _ClassFirstDependency = ({ClassElement target, FieldElement field}); +typedef _RegisteredSchema = ({ + Element declaration, + DartType boundary, + DartType runtime, + bool isFunction, +}); + /// Builds normalized Ack model nodes from hand-written `@Schemable` classes. /// /// Analyzer elements and AST nodes are consumed here; emitters receive only @@ -188,6 +195,12 @@ final class ClassModelGraphBuilder { final Set _consumed = {}; final Map _schemaNameOwners = {}; final Map> _dependencies = {}; + final List<_RegisteredSchema> _librarySchemas = []; + final List<_RegisteredSchema> _importedLibrarySchemas = []; + final Map> _classSchemas = {}; + final Map _unionBaseByBranch = {}; + final List<_RegisteredSchema> _libraryTopLevelSchemaCandidates = []; + ClassElement? _activeClass; ResolvedLibraryResult? _inputResolved; final Map _resolvedByUri = {}; @@ -208,6 +221,7 @@ final class ClassModelGraphBuilder { for (final element in annotatedClasses) { _validateAnnotatedClass(element); } + await _initRegisteredAndLibrarySchemas(); for (final element in annotatedClasses.where((item) => item.isSealed)) { await _buildUnion(element); @@ -485,6 +499,7 @@ final class ClassModelGraphBuilder { ); } valueOwners[value] = branch; + _unionBaseByBranch[branch] = base; _validateBranchDiscriminator(branch, discriminatorKey, value); final branchNode = await _buildObject( branch, @@ -516,6 +531,28 @@ final class ClassModelGraphBuilder { AckSchemaId? unionId, String? discriminatorKey, String? discriminatorValue, + }) async { + final previousActive = _activeClass; + _activeClass = element; + try { + return await _buildObjectBody( + element, + options: options, + unionId: unionId, + discriminatorKey: discriminatorKey, + discriminatorValue: discriminatorValue, + ); + } finally { + _activeClass = previousActive; + } + } + + Future _buildObjectBody( + ClassElement element, { + required _ModelOptions options, + AckSchemaId? unionId, + String? discriminatorKey, + String? discriminatorValue, }) async { final id = _id(element); _graph.begin(id); @@ -1206,8 +1243,10 @@ final class ClassModelGraphBuilder { return null; } - DartType? _inferredBoundaryType(DartType type) { + DartType? _inferredBoundaryType(DartType type, FieldElement field) { if (type is! InterfaceType) return null; + final registered = _registeredSchemaForType(type, field); + if (registered != null) return registered.boundary; final provider = library.element.typeProvider; if (_isCore(type, 'int') || _isCore(type, 'double') || @@ -1254,6 +1293,8 @@ final class ClassModelGraphBuilder { ); } if (target is EnumElement) return provider.stringType; + final autoDetected = _uniqueLibrarySchemaCandidate(type); + if (autoDetected != null) return autoDetected.boundary; return null; } @@ -1300,6 +1341,13 @@ final class ClassModelGraphBuilder { InterfaceType interfaceType, FieldElement field, ) async { + final registered = _registeredSchemaForType(interfaceType, field); + if (registered != null) { + return _renderRegisteredSchema(registered, field); + } + if (_isOverridableBuiltInScalar(interfaceType)) { + return null; + } final target = interfaceType.element; if (target is ClassElement) { final facadeName = _classFirstFacadeName(target); @@ -1317,9 +1365,376 @@ final class ClassModelGraphBuilder { if (_schemaFirstRuntimeRef(target, field) != null) { return '${_visibleTypeName(interfaceType, field)}.\$ack.schema'; } - return await _typeOwnedSchema(interfaceType, field); + final typeOwned = await _typeOwnedSchema(interfaceType, field); + if (typeOwned != null) return typeOwned; + if (target is EnumElement) return null; + return await _autoDetectedLibrarySchema(interfaceType, field); } + Future _initRegisteredAndLibrarySchemas() async { + final libraryAnnotation = _schemableChecker.firstAnnotationOfExact( + library.element, + ); + if (libraryAnnotation != null) { + _librarySchemas.addAll( + await _parseSchemasList( + library.element, + ConstantReader(libraryAnnotation), + ), + ); + } + + final seenImportedLibraries = {}; + for (final import in library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final importedLibrary = import.importedLibrary; + if (importedLibrary == null || + importedLibrary == library.element || + !seenImportedLibraries.add(importedLibrary)) { + continue; + } + final importedAnnotation = _schemableChecker.firstAnnotationOfExact( + importedLibrary, + ); + if (importedAnnotation == null) continue; + _importedLibrarySchemas.addAll( + await _parseSchemasList( + importedLibrary, + ConstantReader(importedAnnotation), + ), + ); + } + + for (final classElement in library.classes) { + final annotation = _schemableChecker.firstAnnotationOfExact(classElement); + if (annotation == null) continue; + final parsed = await _parseSchemasList( + classElement, + ConstantReader(annotation), + ); + if (parsed.isNotEmpty) { + _classSchemas[classElement] = parsed; + } + } + + for (final variable in library.element.topLevelVariables) { + if (!variable.isOriginDeclaration || + _schemableChecker.hasAnnotationOfExact(variable)) { + continue; + } + _addLibrarySchemaCandidate( + declaration: variable, + schemaType: variable.type, + isFunction: false, + ); + } + for (final getter in library.element.getters) { + if (!getter.isOriginDeclaration || + _schemableChecker.hasAnnotationOfExact(getter)) { + continue; + } + _addLibrarySchemaCandidate( + declaration: getter, + schemaType: getter.returnType, + isFunction: false, + ); + } + for (final function in library.element.topLevelFunctions) { + if (!function.isOriginDeclaration || + function.typeParameters.isNotEmpty || + function.formalParameters.isNotEmpty) { + continue; + } + _addLibrarySchemaCandidate( + declaration: function, + schemaType: function.returnType, + isFunction: true, + ); + } + } + + void _addLibrarySchemaCandidate({ + required Element declaration, + required DartType schemaType, + required bool isFunction, + }) { + if (_containsInvalidType(schemaType) || + !_ackSchemaChecker.isAssignableFromType(schemaType)) { + return; + } + final boundary = _ackSchemaBoundaryType(schemaType); + final runtime = _ackSchemaRuntimeType(schemaType); + if (boundary == null || + runtime == null || + _containsInvalidType(boundary) || + _containsInvalidType(runtime) || + runtime is DynamicType || + (runtime is InterfaceType && _isCore(runtime, 'Object'))) { + return; + } + _libraryTopLevelSchemaCandidates.add(( + declaration: declaration, + boundary: boundary, + runtime: library.element.typeSystem.promoteToNonNull(runtime), + isFunction: isFunction, + )); + } + + Future> _parseSchemasList( + Element owner, + ConstantReader reader, + ) async { + final schemasReader = reader.read('schemas'); + if (schemasReader.isNull) return const []; + final ownerLabel = owner is LibraryElement + ? 'library' + : (owner.name ?? '@Schemable'); + final list = schemasReader.listValue; + if (list.isEmpty) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) must not be empty.', + element: owner, + ); + } + final result = <_RegisteredSchema>[]; + for (final item in list) { + final function = item.toFunctionValue(); + if (function is! TopLevelFunctionElement) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) entries must be const ' + 'tear-offs of top-level functions.', + element: owner, + ); + } + if (function.typeParameters.isNotEmpty) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} is ' + 'generic. Use a non-generic top-level function, or declare a static ' + 'schema method on the type.', + element: owner, + ); + } + if (function.formalParameters.isNotEmpty || + !_ackSchemaChecker.isAssignableFromType(function.returnType)) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' + 'have type AckSchema Function().', + element: owner, + ); + } + final boundary = _ackSchemaBoundaryType(function.returnType); + final runtime = _ackSchemaRuntimeType(function.returnType); + if (boundary == null || + runtime == null || + runtime is DynamicType || + (runtime is InterfaceType && _isCore(runtime, 'Object'))) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' + 'return a typed AckSchema for a specific runtime ' + 'type.', + element: owner, + ); + } + if (runtime is InterfaceType && _isNonOverridableCoreType(runtime)) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) cannot override core type ' + '${runtime.getDisplayString()}; use @AckField(schema: ' + '${function.name}) on the specific field instead.', + element: owner, + ); + } + final resolved = await _resolvedLibraryFor(function.library); + final declaration = resolved + .getFragmentDeclaration(function.firstFragment) + ?.node; + final bodyExpression = declaration is FunctionDeclaration + ? _functionBodyExpression(declaration.functionExpression.body) + : null; + if (bodyExpression == null) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' + 'have a statically resolvable expression or single return.', + element: owner, + ); + } + final oneWaySource = await _oneWayTransformSource(bodyExpression); + if (oneWaySource != null) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} ' + 'reaches one-way schema $oneWaySource. Migrate this .transform() ' + 'path to .codec() with an encoder.', + element: owner, + ); + } + final nonNullRuntime = library.element.typeSystem.promoteToNonNull( + runtime, + ); + for (final prior in result) { + if (_sameTypeIgnoringNullability(prior.runtime, nonNullRuntime)) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) registers multiple schemas ' + 'for ${nonNullRuntime.getDisplayString()}: ' + '${prior.declaration.name} and ${function.name}.', + element: owner, + ); + } + } + result.add(( + declaration: function, + boundary: boundary, + runtime: nonNullRuntime, + isFunction: true, + )); + } + return result; + } + + _RegisteredSchema? _registeredSchemaForType( + InterfaceType interfaceType, + FieldElement field, + ) { + final activeOwner = _activeClass; + final fieldOwner = field.enclosingElement; + for (final scope in [ + if (activeOwner != null) ...[ + if (_classSchemas[activeOwner] case final classList?) classList, + if (_unionBaseByBranch[activeOwner] case final base?) + if (_classSchemas[base] case final baseList?) baseList, + ], + if (fieldOwner is ClassElement && fieldOwner != activeOwner) + if (_classSchemas[fieldOwner] case final ownerList?) ownerList, + _librarySchemas, + ]) { + for (final entry in scope) { + if (_sameTypeIgnoringNullability(entry.runtime, interfaceType)) { + return entry; + } + } + } + final importedMatches = {}; + for (final entry in _importedLibrarySchemas) { + if (_sameTypeIgnoringNullability(entry.runtime, interfaceType)) { + importedMatches[entry.declaration.baseElement] = entry; + } + } + if (importedMatches.length > 1) { + final path = '${field.enclosingElement.name}.${field.name}'; + final display = library.element.typeSystem + .promoteToNonNull(interfaceType) + .getDisplayString(); + final names = importedMatches.values + .map((m) => m.declaration.name) + .join(', '); + throw InvalidGenerationSource( + '$path has multiple imported library schemas for $display ($names). ' + 'Disambiguate with @Schemable(schemas: [...]) or ' + '@AckField(schema: ...).', + element: field, + ); + } + if (importedMatches.length == 1) return importedMatches.values.single; + return null; + } + + _RegisteredSchema? _uniqueLibrarySchemaCandidate( + InterfaceType interfaceType, + ) { + if (_isNonOverridableCoreType(interfaceType) || + _isOverridableBuiltInScalar(interfaceType) || + interfaceType.element is EnumElement) { + return null; + } + final matches = [ + for (final candidate in _libraryTopLevelSchemaCandidates) + if (_sameTypeIgnoringNullability(candidate.runtime, interfaceType)) + candidate, + ]; + return matches.length == 1 ? matches.single : null; + } + + Future _autoDetectedLibrarySchema( + InterfaceType interfaceType, + FieldElement field, + ) async { + if (_isNonOverridableCoreType(interfaceType) || + _isOverridableBuiltInScalar(interfaceType) || + interfaceType.element is EnumElement) { + return null; + } + final matches = [ + for (final candidate in _libraryTopLevelSchemaCandidates) + if (_sameTypeIgnoringNullability(candidate.runtime, interfaceType)) + candidate, + ]; + if (matches.isEmpty) return null; + final path = '${field.enclosingElement.name}.${field.name}'; + final display = library.element.typeSystem + .promoteToNonNull(interfaceType) + .getDisplayString(); + if (matches.length > 1) { + final names = matches.map((m) => m.declaration.name).join(', '); + throw InvalidGenerationSource( + '$path has multiple top-level schemas for $display in this library ' + '($names). Disambiguate with @Schemable(schemas: [...]) or ' + '@AckField(schema: ...).', + element: field, + ); + } + final candidate = matches.single; + final resolved = await _resolvedLibraryFor(candidate.declaration.library!); + final node = resolved + .getFragmentDeclaration(candidate.declaration.firstFragment) + ?.node; + final bodyExpression = switch (node) { + VariableDeclaration() => node.initializer, + FunctionDeclaration() => _functionBodyExpression( + node.functionExpression.body, + ), + _ => null, + }; + if (bodyExpression == null) { + throw InvalidGenerationSource( + '$path resolves to top-level schema ${candidate.declaration.name}, ' + 'which must have a statically resolvable expression or single return.', + element: field, + ); + } + final oneWaySource = await _oneWayTransformSource(bodyExpression); + if (oneWaySource != null) { + throw InvalidGenerationSource( + '$path resolves to top-level schema ${candidate.declaration.name}, ' + 'which reaches one-way schema $oneWaySource. Migrate this ' + '.transform() path to .codec() with an encoder.', + element: field, + ); + } + return _renderRegisteredSchema(candidate, field); + } + + String _renderRegisteredSchema(_RegisteredSchema schema, FieldElement field) { + final prefix = _visiblePrefix(schema.declaration, field); + final qualified = + '${prefix == null ? '' : '$prefix.'}${schema.declaration.name}'; + return schema.isFunction ? '$qualified()' : qualified; + } + + bool _isOverridableBuiltInScalar(InterfaceType type) => + _isCore(type, 'DateTime') || + _isCore(type, 'Uri') || + _isCore(type, 'Duration'); + + bool _isNonOverridableCoreType(InterfaceType type) => + _isCore(type, 'String') || + _isCore(type, 'int') || + _isCore(type, 'double') || + _isCore(type, 'num') || + _isCore(type, 'bool') || + _isCore(type, 'Object') || + type.isDartCoreList || + type.isDartCoreSet || + type.isDartCoreMap || + _isJsonMaybe(type); + AckInferRef? _schemaFirstRuntimeRef( InterfaceElement target, FieldElement field, @@ -1421,7 +1836,7 @@ final class ClassModelGraphBuilder { ); } final expectedBoundary = _ackSchemaBoundaryType(parameterType); - final inferredBoundary = _inferredBoundaryType(argument); + final inferredBoundary = _inferredBoundaryType(argument, field); if (expectedBoundary != null && inferredBoundary != null && !library.element.typeSystem.isSubtypeOf( diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 8eb4c90e..16308d04 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -163,6 +163,7 @@ final class SchemaModelGraphBuilder { if (index('unknownProperties') != 0) 'unknownProperties', if (reader.read('captureField').stringValue != 'additionalProperties') 'captureField', + if (!reader.read('schemas').isNull) 'schemas', ]; if (configured.isEmpty) return; throw InvalidGenerationSource( diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 10d619a8..5427ab9c 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -106,6 +106,12 @@ final class AckSchemaInference { required void Function(InterfaceType) validateMapKey, }) async { if (type is! InterfaceType) return unsupported(type); + if (_core(type, 'DateTime') || + _core(type, 'Uri') || + _core(type, 'Duration')) { + final override = await resolveNamed(type); + if (override != null) return override; + } final scalar = _scalar(type); if (scalar != null) return scalar; if (type.element is EnumElement) { diff --git a/packages/ack_generator/lib/src/model_generator.dart b/packages/ack_generator/lib/src/model_generator.dart index b756eb78..3e35e817 100644 --- a/packages/ack_generator/lib/src/model_generator.dart +++ b/packages/ack_generator/lib/src/model_generator.dart @@ -39,6 +39,7 @@ final class AckModelGenerator extends Generator { @override Future generate(LibraryReader library, BuildStep buildStep) async { + _validateLibrarySchemable(library.element); final annotated = []; final annotatedModels = []; @@ -59,7 +60,7 @@ final class AckModelGenerator extends Generator { } else { throw InvalidGenerationSource( '@Schemable can only be applied to classes or top-level schema ' - 'variables or getters.', + 'variables and getters, or library directives.', element: element, ); } @@ -197,8 +198,43 @@ final class AckModelGenerator extends Generator { bool _hasSchemableSchema(Element element) => element is! ClassElement && + element is! LibraryElement && _schemableChecker.hasAnnotationOfExact(element); + void _validateLibrarySchemable(LibraryElement libraryElement) { + final annotation = _schemableChecker.firstAnnotationOfExact(libraryElement); + if (annotation == null) return; + final reader = ConstantReader(annotation); + int index(String option) => + reader.read(option).objectValue.getField('index')!.toIntValue()!; + final configured = [ + if (!reader.read('name').isNull) 'name', + if (!reader.read('schemaName').isNull) 'schemaName', + if (!reader.read('description').isNull) 'description', + if (index('caseStyle') != 0) 'caseStyle', + if (!reader.read('discriminatorKey').isNull) 'discriminatorKey', + if (!reader.read('discriminatorValue').isNull) 'discriminatorValue', + if (index('unknownProperties') != 0) 'unknownProperties', + if (reader.read('captureField').stringValue != 'additionalProperties') + 'captureField', + ]; + if (configured.isNotEmpty) { + throw InvalidGenerationSource( + '@Schemable on a library directive only supports schemas; received: ' + '${configured.join(', ')}.', + element: libraryElement, + ); + } + final schemasReader = reader.read('schemas'); + if (schemasReader.isNull || schemasReader.listValue.isEmpty) { + throw InvalidGenerationSource( + '@Schemable on a library directive must specify a non-empty schemas ' + 'list.', + element: libraryElement, + ); + } + } + /// Strips `./` segments so `part './user.g.dart'` matches the file next to /// the input, without treating `part 'sub/user.g.dart'` as the same path. String _normalizedPartUri(String uri) { diff --git a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart index b472e985..d788ee79 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart @@ -113,6 +113,35 @@ final class Command { encode: (command) => command.name, ); } + +final class Color { + const Color(this.hex); + + final String hex; + + @override + bool operator ==(Object other) => other is Color && other.hex == hex; + + @override + int get hashCode => hex.hashCode; +} + +final class Money { + const Money(this.cents); + + final int cents; + + @override + bool operator ==(Object other) => other is Money && other.cents == cents; + + @override + int get hashCode => cents.hashCode; +} + +AckSchema importedMoneySchema() => Ack.integer().codec( + decode: Money.new, + encode: (money) => money.cents, +); '''); File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( r''' @@ -122,6 +151,11 @@ import 'types.dart'; part 'models.g.dart'; +final colorSchema = Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + @Schemable() final class Habit with _$HabitAck { const Habit({required this.name}); @@ -131,6 +165,19 @@ final class Habit with _$HabitAck { static final schema = HabitSchema.schema; } +@Schemable(schemas: [importedMoneySchema]) +final class Palette with _$PaletteAck { + const Palette({ + required this.primary, + required this.swatches, + required this.budget, + }); + + final Color primary; + final List swatches; + final Money budget; +} + @Schemable() final class Section with _$SectionAck { const Section({ @@ -285,6 +332,19 @@ void main() { expect(panel.openAll, isA>()); expect(panel.habit, const Habit(name: 'run')); }); + + test('auto-detected library schemas and @Schemable(schemas: [...]) round-trip', () { + final json = { + 'primary': '#ff0000', + 'swatches': ['#00ff00', '#0000ff'], + 'budget': 2500, + }; + final palette = PaletteSchema.parse(json); + expect(palette.primary, const Color('#ff0000')); + expect(palette.swatches, const [Color('#00ff00'), Color('#0000ff')]); + expect(palette.budget, const Money(2500)); + expect(palette.toJson(), json); + }); } '''); diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index f6ad1e6f..02ecf1de 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -707,4 +707,351 @@ final class Routine { ), ); }); + + test( + 'automatically detects a unique top-level schema variable, getter, or function in the library', + () async { + await _expectOutput( + r''' +final colorSchema = Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +AckSchema get centsSchema => Ack.integer().codec( + decode: Cents.new, + encode: (cents) => cents.amount, +); + +AckSchema slugSchema() => Ack.string().codec( + decode: Slug.new, + encode: (slug) => slug.value, +); + +@Schemable() +final class Theme with _$ThemeAck { + const Theme({ + required this.primary, + this.secondary, + required this.palette, + required this.swatches, + required this.namedColors, + this.maybeColor = const JsonMaybe.absent(), + required this.boxedColor, + required this.price, + required this.slug, + }); + + final Color primary; + final Color? secondary; + final List palette; + final Set swatches; + final Map namedColors; + final JsonMaybe maybeColor; + final Box boxedColor; + final Cents price; + final Slug slug; +} +''', + allOf([ + _containsCode("'primary': colorSchema,"), + _containsCode("'secondary': colorSchema.optional().nullable(),"), + _containsCode("'palette': Ack.list(colorSchema),"), + _containsCode("'swatches': Ack.list(colorSchema).codec>("), + _containsCode("'namedColors': Ack.map(colorSchema),"), + _containsCode("'maybeColor': colorSchema.optional().nullable(),"), + _containsCode("'boxedColor': Box.schema(colorSchema),"), + _containsCode("'price': centsSchema,"), + _containsCode("'slug': slugSchema()"), + ]), + types: ''' +import 'package:ack/ack.dart'; + +final class Color { + const Color(this.hex); + final String hex; +} + +final class Cents { + const Cents(this.amount); + final int amount; +} + +final class Slug { + const Slug(this.value); + final String value; +} + +final class Box { + const Box(this.value); + final A value; + + static AckSchema> schema( + AckSchema value, + ) => value.codec>(decode: Box.new, encode: (box) => box.value); +} +''', + ); + }, + ); + + test( + 'rejects ambiguous auto-detected top-level schemas for the same type', + () async { + final errors = await _errors( + r''' +final hexColorSchema = Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +final namedColorSchema = Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +@Schemable() +final class Theme with _$ThemeAck { + const Theme({required this.primary}); + + final Color primary; +} +''', + types: ''' +final class Color { + const Color(this.hex); + final String hex; +} +''', + ); + + expect( + errors, + contains( + contains( + 'Theme.primary has multiple top-level schemas for Color in this ' + 'library (hexColorSchema, namedColorSchema). Disambiguate with ' + '@Schemable(schemas: [...]) or @AckField(schema: ...).', + ), + ), + ); + }, + ); + + test( + 'rejects an auto-detected top-level schema that uses a one-way transform', + () async { + final errors = await _errors( + r''' +final colorSchema = Ack.string().transform(Color.new); + +@Schemable() +final class Theme with _$ThemeAck { + const Theme({required this.primary}); + + final Color primary; +} +''', + types: ''' +final class Color { + const Color(this.hex); + final String hex; +} +''', + ); + + expect( + errors, + contains( + contains( + 'Theme.primary resolves to top-level schema colorSchema, which ' + 'reaches one-way schema transform.', + ), + ), + ); + }, + ); + + test( + '@Schemable(schemas: [...]) on a class resolves imported schemas, disambiguates local schemas, and overrides DateTime', + () async { + await _expectOutput( + r''' +AckSchema hexColorSchema() => Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +AckSchema altColorSchema() => Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +@Schemable(schemas: [hexColorSchema, importedMoneySchema, epochDateTimeSchema]) +final class Order with _$OrderAck { + const Order({ + required this.color, + required this.total, + required this.createdAt, + required this.timestamps, + }); + + final Color color; + final Money total; + final DateTime createdAt; + final List timestamps; +} +''', + allOf( + _containsCode("'color': hexColorSchema(),"), + _containsCode("'total': importedMoneySchema(),"), + _containsCode("'createdAt': epochDateTimeSchema(),"), + _containsCode("'timestamps': Ack.list(epochDateTimeSchema())"), + ), + types: ''' +import 'package:ack/ack.dart'; + +final class Color { + const Color(this.hex); + final String hex; +} + +final class Money { + const Money(this.cents); + final int cents; +} + +AckSchema importedMoneySchema() => Ack.integer().codec( + decode: Money.new, + encode: (money) => money.cents, +); + +AckSchema epochDateTimeSchema() => Ack.integer().codec( + decode: DateTime.fromMillisecondsSinceEpoch, + encode: (dt) => dt.millisecondsSinceEpoch, +); +''', + ); + }, + ); + + test( + '@Schemable(schemas: [...]) on a library directive applies across models and imported schema libraries', + () async { + await _expectOutput( + r''' +@Schemable() +final class Palette with _$PaletteAck { + const Palette({required this.primary, required this.budget}); + + final Color primary; + final Money budget; +} +''', + allOf( + _containsCode("'primary': libraryColorSchema(),"), + _containsCode("'budget': sharedMoneySchema()"), + ), + head: ''' +@Schemable(schemas: [libraryColorSchema]) +library; + +import 'package:ack/ack.dart'; +import 'types.dart'; + +part 'model.g.dart'; + +AckSchema libraryColorSchema() => Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); +''', + types: ''' +@Schemable(schemas: [sharedMoneySchema]) +library; + +import 'package:ack/ack.dart'; + +final class Color { + const Color(this.hex); + final String hex; +} + +final class Money { + const Money(this.cents); + final int cents; +} + +AckSchema sharedMoneySchema() => Ack.integer().codec( + decode: Money.new, + encode: (money) => money.cents, +); +''', + ); + }, + ); + + test( + 'rejects core primitive types and duplicate runtime types in @Schemable(schemas: [...])', + () async { + final coreErrors = await _errors(r''' +AckSchema customStringSchema() => Ack.string(); + +@Schemable(schemas: [customStringSchema]) +final class Post with _$PostAck { + const Post({required this.title}); + + final String title; +} +'''); + + expect( + coreErrors, + contains( + contains( + 'Post @Schemable(schemas: ...) cannot override core type String; ' + 'use @AckField(schema: customStringSchema) on the specific field ' + 'instead.', + ), + ), + ); + + final duplicateErrors = await _errors( + r''' +AckSchema firstColorSchema() => Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +AckSchema secondColorSchema() => Ack.string().codec( + decode: Color.new, + encode: (color) => color.hex, +); + +@Schemable(schemas: [firstColorSchema, secondColorSchema]) +final class Theme with _$ThemeAck { + const Theme({required this.primary}); + + final Color primary; +} +''', + types: ''' +final class Color { + const Color(this.hex); + final String hex; +} +''', + ); + + expect( + duplicateErrors, + contains( + contains( + 'Theme @Schemable(schemas: ...) registers multiple schemas for ' + 'Color: firstColorSchema and secondColorSchema.', + ), + ), + ); + }, + ); } From 66cb8122d23acceb9cd13b020a9fc285de9c3ef7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 12:47:54 -0400 Subject: [PATCH 13/29] docs,test(ack_generator): align READMEs, docs, and llms.txt with Ack 2.0 and use --offline in build tests --- README.md | 61 ++----- docs/api-reference/index.mdx | 81 ++++----- docs/core-concepts/json-serialization.mdx | 4 +- docs/core-concepts/typesafe-schemas.mdx | 143 +++++----------- docs/getting-started/installation.mdx | 7 +- llms.txt | 159 +++++++----------- packages/ack_generator/README.md | 156 +++++++---------- packages/ack_generator/example/example.dart | 3 +- .../ack_infer_runtime_build_test.dart | 5 +- .../class_first_cycle_build_test.dart | 2 +- ...class_first_nested_runtime_build_test.dart | 5 +- .../example_folder_build_test.dart | 8 +- .../json_serializable_build_test.dart | 15 +- .../model_generation_guide_build_test.dart | 5 +- packages/ack_mcp_dart/README.md | 6 +- 15 files changed, 257 insertions(+), 403 deletions(-) diff --git a/README.md b/README.md index 1749add0..6f5bfd80 100644 --- a/README.md +++ b/README.md @@ -123,15 +123,14 @@ if (result.isOk) { Generate immutable models for hand-written schemas with `@Schemable()`. Add `ack` to `dependencies` and `ack_generator` + `build_runner` to -`dev_dependencies`, then annotate a top-level schema. `@Schemable()` comes -from `package:ack/ack.dart`; constraint annotations such as `@MinLength()` come -from `package:ack/annotations.dart`: +`dev_dependencies`, then annotate a top-level schema. `@Schemable()`, the model +annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the +`@Check.*` constraint namespace are all exported from `package:ack/ack.dart`: ```dart import 'package:ack/ack.dart'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -160,35 +159,6 @@ named recursion, and discriminated unions. One-way transforms are rejected because a generated model must be encodable. See the [Model Code Generation guide](docs/core-concepts/typesafe-schemas.mdx). -### Legacy Ack 1.1 generation - -`@AckType()` remains available for source compatibility and keeps the Ack 1.1 -extension-type API and `.g.dart` output unchanged. It is deprecated and will be -removed in Ack 2.0: - -```dart -import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; - -part 'legacy_user.g.dart'; - -@AckType() -final userSchema = Ack.object({'name': Ack.string()}); -``` - -This still generates `UserType`, including its Map interface, typed getters, -`parse` / `safeParse`, and `.args`. New code should use `@Schemable()`. - -| Ack 1.1 source | Optional immutable-model migration | -|---|---| -| Keep `@AckType()` | Rename it to `@Schemable()` | -| Keep `part 'file.g.dart';` | Add `file.ack.dart` and `file.ack.g.dart` parts | -| Use `*Type`, Map access, and `.args` | Use the generated class, typed fields, `parse`, `fromJson`, and `toJson` | - -Legacy and modern declarations may coexist when they are unrelated. A nested -reference graph cannot cross between them; migrate that connected graph -together. - Already own the model class? Put `@Schemable()` on it to derive a codec schema from constructor-backed fields while keeping the class hand-written. A class named `Account` receives an `AccountSchema` facade for parsing, encoding, schema @@ -196,13 +166,14 @@ export, and nested composition; the backing codec remains private: ```dart import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; + +part 'account.g.dart'; @Schemable() final class Account with _$AccountAck { const Account({required this.name}); - @MinLength(2) + @Check.minLength(2) final String name; static final fromJson = AccountSchema.fromJson; @@ -212,21 +183,13 @@ final class Account with _$AccountAck { `Account.fromJson({'name': 'Ada'})` validates and constructs the model, while `account.toJson()` validates and encodes it. -A field whose type declares a static `schema` needs no annotation: a `Slot` -field resolves to `Slot.schema`, a `List` to `Ack.list(Slot.schema)`, -and a generic `Command` to `Command.schema()`. See the +Custom field types resolve automatically through a static `schema` on the type +(`Slot.schema`, `Ack.list(Slot.schema)`, `Command.schema()`), a unique +top-level `AckSchema` in the same library, `@Schemable(schemas: [...])` +on the class, union, or `library;` directive, or `@AckField(schema: ...)` on a +specific field. See the [Model Code Generation guide](docs/core-concepts/typesafe-schemas.mdx). -Upgrading from 1.7.0-beta.2: drop `ack_annotations`, import -`package:ack/ack.dart` for `@Schemable()`, and replace -`package:ack_annotations/ack_annotations.dart` with -`package:ack/annotations.dart` (and `.../format_annotations.dart` with -`package:ack/format_annotations.dart`) where you use constraint annotations. -Rename `@Pattern` to `@Matches`, and replace the deprecated `@AckInfer()` / -`@AckModel()` with `@Schemable()`. A field renamed with `@JsonKey(name: ...)` -now imports `JsonKey` from `package:json_annotation`, with `json_annotation` -in the app's dependencies. - ## Codecs Codecs decode boundary values (the JSON you receive) into rich Dart runtime types and encode them back. Ack ships built-in codecs and lets you define your own: diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 767cb3b3..8aac156b 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -311,16 +311,12 @@ Schema reference for recursive object graphs. Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builders in either direction with one annotation, `@Schemable()`: on a top-level schema it -generates a model, and on a class it generates a schema. `package:ack/ack.dart` -exports `@Schemable` and the model annotations (`@AckField`, `@Optional`, -`@Required`, `@NotNull`). Constraint annotations come from -`package:ack/annotations.dart`, imported with a prefix (`as ack`, then -`@ack.Email()`) where a name clashes with another type, and `@Uri()` / -`@DateTime()` from `package:ack/format_annotations.dart`. `@AckInfer()` and -`@AckModel()` are -deprecated spellings that keep working until 2.0.0. `AckSchema` is the -runtime schema type; there is no `@AckSchema()` annotation. After adding matching -`.ack.dart` and `.ack.g.dart` part directives, run: +generates a model, on a class it generates a schema, and on a `library;` +directive it registers shared custom schemas via `@Schemable(schemas: [...])`. +`package:ack/ack.dart` exports `@Schemable`, the model annotations (`@AckField`, +`@Optional`, `@Required`, `@NotNull`), and the `@Check.*` constraint namespace. +`AckSchema` is the runtime schema type; there is no `@AckSchema()` annotation. +After adding a `part '.g.dart';` directive, run: ```bash dart run build_runner build @@ -355,8 +351,7 @@ here. For `Ack.discriminated(...)` constraints, see ```dart import 'package:ack/ack.dart'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -390,42 +385,31 @@ constructors remain responsible for defensively copying direct inputs. `toString` Ack infers schema fields from constructor-backed fields. Required parameters, -nullable types, optional parameters, and constructor defaults determine field -presence. Use `caseStyle` for model-wide JSON naming and `@JsonKey(name: ...)` -on the field for an override; `JsonKey` comes from -`package:json_annotation/json_annotation.dart`, which the app then depends on. -Other `JsonKey` options, constructor-parameter placement, and `JsonConverter` -annotations are rejected. Instantiable models and implicit union branches must -apply the generated mixin. In `copyWith`, omission keeps the current value and -explicit `null` clears a nullable field. `copyWith` returns a typed interface, -so a wrongly typed argument is a compile-time error. +nullable types, optional parameters, `JsonMaybe`, and constructor defaults +determine field presence. Use `caseStyle` for model-wide JSON naming and +`@AckField(name: ...)` on the field for a wire-name override. Instantiable +models and implicit union branches must apply the generated mixin. In +`copyWith`, omission keeps the current value and explicit `null` clears a +nullable field. `copyWith` returns a typed interface, so a wrongly typed +argument is a compile-time error. **Inferred field types:** strings, booleans, numeric types, `DateTime`, `Uri`, -`Duration`, enums, nested lists, and sets - -**Constraint annotations:** - -- numeric: `@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative` -- strings: `@MinLength`, `@MaxLength`, `@Matches`, `@Email`, `@NotEmpty` -- string formats: `@Url`, `@Uuid`, `@Date`; `@Uri()` and `@DateTime()` from - `package:ack/format_annotations.dart` imported with a prefix - (`@formats.Uri()`) -- collections: `@MinItems`, `@MaxItems`, `@UniqueItems` - -`@Matches` replaces `@Pattern`, which remains a deprecated alias in -`package:ack/annotations.dart` only. - -**Type-owned schemas:** a field whose type declares a static `schema` resolves -to it with no annotation, also per `List` / `Set` item and as a `Map` value. A -generic type declares `static AckSchema> schema()`, which is called -with the field's type arguments; if it declares one positional parameter typed -`AckSchema` per type parameter `A`, it also receives each type argument's inferred -schema. An enum's static `schema` wins over `Ack.enumValues`. The schema must -produce the field type. - -Use `@AckField(schema: ...)` for a type you cannot edit; the non-generic -function must produce the field type. A no-op `@AckField()` is rejected. A -sealed base annotated with `@Schemable(discriminatorKey: ...)` generates a discriminated union from its +`Duration`, enums, `JsonMaybe`, nested lists, sets, and string-keyed maps + +**Constraint annotations (`@Check.*` from `package:ack/ack.dart`):** + +- numeric: `@Check.min`, `@Check.max`, `@Check.multipleOf`, `@Check.positive`, `@Check.negative` +- strings: `@Check.minLength`, `@Check.maxLength`, `@Check.matches`, `@Check.email`, `@Check.notEmpty` +- string formats: `@Check.url`, `@Check.uri`, `@Check.uuid`, `@Check.date`, `@Check.dateTime` (`@Check.datetime`), `@Check.ip`, `@Check.ipv4`, `@Check.ipv6` +- collections: `@Check.minItems`, `@Check.maxItems`, `@Check.uniqueItems` (`@Check.unique`) + +**Custom schemas:** a field of custom type `T` resolves through: +1. `@AckField(schema: ...)` on the field; +2. `@Schemable(schemas: [...])` on the class, union base, `library;` directive, or an imported `library;` directive; +3. a static `T.schema` / `T.schema(...)` on the type; +4. a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. + +A sealed base annotated with `@Schemable(discriminatorKey: ...)` generates a discriminated union from its same-library concrete branches. Unknown properties use `unknownProperties: AckUnknownPropertyPolicy.` (`reject` by default). Raw `Ack.object(..., additionalProperties: true)` schemas preserve extras. @@ -435,13 +419,14 @@ select the target map with `captureField`. ```dart import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; + +part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) final class Account with _$AccountAck { const Account({required this.displayName, this.role = 'member'}); - @MinLength(2) + @Check.minLength(2) final String displayName; final String role; diff --git a/docs/core-concepts/json-serialization.mdx b/docs/core-concepts/json-serialization.mdx index 1bb76361..a50d3c4c 100644 --- a/docs/core-concepts/json-serialization.mdx +++ b/docs/core-concepts/json-serialization.mdx @@ -86,7 +86,7 @@ if (result.isOk) { ## Parsing JSON into generated models -Once a schema is annotated with `@Schemable()` (see [Model Code Generation](./typesafe-schemas.mdx) for setup), parse JSON into a generated immutable model. Public `fromJson` / `toJson` still cross the source Ack schema; `json_serializable` only maps the already-validated runtime value onto stored fields. +Once a schema is annotated with `@Schemable()` (see [Model Code Generation](./typesafe-schemas.mdx) for setup), parse JSON into a generated immutable model. Public `fromJson` / `toJson` cross the source Ack schema and map the validated runtime value directly onto stored fields. ```dart import 'dart:convert'; @@ -149,4 +149,4 @@ conversions. validates values and encodes codec runtime values back to their boundary representation. - **Type safety:** `jsonDecode` produces `dynamic`, but successful validation guarantees the structure and types of the resulting `Map`. -- **Model conversion:** After validation, how you convert the validated map into an app model is up to you. Generated models still parse and encode through an Ack schema; `json_serializable` only maps that runtime value onto stored fields. `@Schemable()` on a top-level schema starts from the schema, while `@Schemable()` on a class starts from your class. +- **Model conversion:** After validation, how you convert the validated map into an app model is up to you. Generated models parse and encode through an Ack schema and map that runtime value directly onto stored fields in a single `.g.dart` part. `@Schemable()` on a top-level schema starts from the schema, while `@Schemable()` on a class starts from your class. diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index d0c32550..c83351f9 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -14,11 +14,9 @@ and generate a Dart model, or start with a Dart model and generate its schema. `@Required`, `@NotNull`), and the `@Check.*` constraint namespace come from `package:ack/ack.dart`. Legacy top-level constraint annotations in `package:ack/annotations.dart` and `package:ack/format_annotations.dart` are -deprecated and will be removed in 2.0.0. +deprecated. There is no `@AckSchema()` annotation. `AckSchema` is the runtime type returned by factories such as `Ack.string()` and `Ack.object()`. -`@AckInfer()` and `@AckModel()` are deprecated spellings of `@Schemable()` and -will be removed in 2.0.0. ## Install the generator @@ -324,6 +322,7 @@ values use the Dart types you already write: | `List` | `Ack.list(Ack.any())` | | `Map` | `Ack.map()` | | `Map` | `Ack.map(Ack.any().nullable())` | +| `JsonMaybe` | Optional-and-nullable ``; omitted when `JsonAbsent`, `null` when `JsonValue(null)` | `Object` means a JSON-safe value (a finite number, string, boolean, list, or string-keyed map of those values), not an arbitrary Dart instance, so a @@ -345,16 +344,26 @@ constraints apply to the outer set. Use `caseStyle` for model-wide JSON names. Supported values are `none`, `snake`, `kebab`, `pascal`, and `screamingSnake`. Use -`@JsonKey(name: 'wire_name')` on the field for a single override. `JsonKey` -comes from `package:json_annotation/json_annotation.dart`, so a library that -uses it imports that library and lists `json_annotation` in its app's -`dependencies`; `caseStyle` needs neither. Ack resolves -each wire key once and uses it for both validation and JSON mapping. Other -`JsonKey` options, constructor-parameter placement, and `JsonConverter` -annotations are rejected so the Ack schema and generated JSON mapping cannot -diverge. - -### Types that own their schema +`@AckField(name: 'wire_name')` on the field for a single override (or +`@JsonKey(name: 'wire_name')` from `package:json_annotation/json_annotation.dart`). +Ack resolves each wire key once and uses it for both validation and JSON +mapping. Other `JsonKey` options, constructor-parameter placement, and +`JsonConverter` annotations are rejected so the Ack schema and generated JSON +mapping cannot diverge. + +### Custom schemas and types that own their schema + +When a class-first field has a custom type `T` (including inside `List`, +`Set`, `Map`, `JsonMaybe`, or generic `Box`), Ack resolves +its schema in the following priority order: + +1. **`@AckField(schema: ...)`** on the field or constructor parameter; +2. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current + `library;` directive, or an imported `library;` directive; +3. **Type-owned schema (`T.schema` / `T.schema(...)`)** on the type itself; +4. **Automatic current-library detection**: a unique top-level + `AckSchema` variable, getter, or zero-argument function in the + current library. A type you own can declare its schema once, as a static member named `schema`. Any field of that type then resolves to it with no annotation on @@ -447,24 +456,29 @@ The schema must produce the field's type; generation fails otherwise, for example when `Slot.schema` produces a `String` or when a generic type offers a static field instead of a `schema()` method. Schema-first objects can reference the same members, such as `'header': Slot.schema` or -`'open': Command.schema()`; a `void` type argument is -supported only in class-first fields. +`'open': Command.schema()`. -A type with neither a static `schema` nor built-in inference fails generation -with a message that names both fixes: declare a static `schema` on the type, or -set `@AckField(schema: ...)` on the field. +### External types, library registries, and field overrides -### Custom field schemas +For a third-party type you cannot edit: -Use `@AckField` for a type you cannot edit, or when one field needs a different -schema than its type provides. `schema` is a const tear-off of a non-generic -top-level function returning an `AckSchema` whose runtime type is the field's -type. A mismatched or generic function is rejected. A no-op `@AckField()` is -rejected. +- **In the same library**: declare a unique top-level `AckSchema` + variable, getter, or zero-argument function — Ack detects it automatically. +- **Across a class, union, or library**: pass top-level schema function tear-offs + in `@Schemable(schemas: [colorSchema])` on the class, union base, or `library;` + directive (including an imported shared `library;` directive). +- **On a single field**: use `@AckField(schema: colorSchema)` when two fields of + the same type need different schemas. ```dart +@Schemable(schemas: [colorSchema]) +library; + +import 'package:ack/ack.dart'; import 'package:color_library/color.dart'; +part 'theme.g.dart'; + AckSchema colorSchema() => Ack.string().codec( decode: Color.parse, encode: (color) => color.hex, @@ -474,45 +488,20 @@ AckSchema colorSchema() => Ack.string().codec( final class Theme with _$ThemeAck { const Theme({required this.primary}); - @AckField(schema: colorSchema) final Color primary; } ``` -`@AckField` also replaces an inferred `Object` or `Map` schema when -the field needs a tighter contract, such as a function returning -`Ack.map(Ack.integer().min(0))` for a `Map` field. Non-String map -keys and `dynamic` are rejected because they do not provide a static schema; -use `Object?` for an open JSON value. +`@AckField(schema: ...)` also replaces an inferred `Object` or `Map` +schema when a single field needs a tighter contract, such as a function +returning `Ack.map(Ack.integer().min(0))` for a `Map` field. +Non-String map keys and `dynamic` are rejected because they do not provide a +static schema; use `Object?` for an open JSON value. Automatic `List` and `Set` inference also requires non-nullable element types because `Ack.list` has no nullable-item contract. An explicit `@AckField(schema: ...)` codec remains the escape hatch when a field deliberately uses a different collection representation. -`@AckField(presence: ...)` is deprecated. Migrate to field annotations: - -```dart -// Optional -@Optional() -final String? summary; - -// Required -@Required() -final String? summary; - -// Inferred: omit presence annotations - -// Schema plus presence -@Optional() -@AckField(schema: parametersSchema) -final Map parameters; -``` - -Matching legacy and new declarations are accepted with a deprecation warning. -Conflicting declarations and combining `@Optional()` with `@Required()` are -errors. `@Optional()` is allowed only when the constructor can accept a missing -value, with a discriminator exception for union branches. - ### Class-first unions Annotate a sealed base with a discriminator key. Concrete branches in the same @@ -634,60 +623,20 @@ schema-first types while new domain-owned types use class-first generation. ## Limit generation in larger projects -Use matching `generate_for` entries for both Ack phases. This reduces analyzer -work and keeps the JSON phase scoped to libraries whose `.ack.dart` input is -generated: +Scope `ack_generator` to your model files with `generate_for`: ```yaml title="build.yaml" targets: $default: builders: - ack_generator|ack_models: - generate_for: [lib/models/**.dart] - ack_generator|ack_model_json: + ack_generator|ack_generator: generate_for: [lib/models/**.dart] ``` -Both entries must cover the same annotated libraries. - ## Build checklist 1. Add `ack` to dependencies and import `package:ack/ack.dart`. 2. Add `ack_generator` and `build_runner` to dev dependencies. -3. Declare both generated parts in each annotated library. +3. Declare `part '.g.dart';` in each annotated library. 4. Put `@Schemable()` on a top-level schema or on a class. 5. Run `dart run build_runner build`. - -## Migrating from 1.7.0-beta.2 - -- The `ack_annotations` package is gone. Remove it from `pubspec.yaml`. - Import `package:ack/ack.dart` for `@Schemable()` and the model annotations, - and replace `package:ack_annotations/ack_annotations.dart` with - `package:ack/annotations.dart` in files that use constraint annotations or - the deprecated `@AckInfer()`, `@AckModel()` and `@AckType()`. Replace - `package:ack_annotations/format_annotations.dart` with - `package:ack/format_annotations.dart`. -- Rename `@Pattern(...)` to `@Matches(...)`. The old name remains a deprecated - alias in `package:ack/annotations.dart` only. Until 2.0.0, an unprefixed - import of that library hides `dart:core`'s `Pattern` in the file; import it - `as ack`, or with `hide Pattern`, where the file needs `dart:core`'s - `Pattern`. -- Replace `@AckInfer()` and `@AckModel()` with `@Schemable()`; the options keep - their names. The old spellings still generate the same code until 2.0.0, - as long as `Schemable` is in scope: generated models carry - `@Schemable.generatedJson`, so an import with `show AckInfer` must also show - `Schemable`. -- A name that `package:ack/ack.dart` or `package:ack/annotations.dart` exports - can clash with a same-named class from another library, such as - `package:uuid`'s `Uuid`, your own `Email` type, or `package:meta`'s - `Required`. Import `package:ack/annotations.dart` with a prefix - (`as ack`, then `@ack.Uuid()`), or `hide` the name from one import. -- `ack` no longer depends on `json_annotation` or re-exports `JsonKey`. A - file that renames a field with `@JsonKey(name: ...)` adds - `import 'package:json_annotation/json_annotation.dart';` and the app lists - `json_annotation` in its `dependencies`. `caseStyle` needs neither. -- `Schemable.jsonSerializable`, `AckModel.jsonSerializable`, and - `AckGeneratedJson.config` are removed; `ack_generator` owns the JSON - configuration. -- Regenerate: generated schema-first models now carry - `@Schemable.generatedJson`. diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 22b58a6b..3b9912f9 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -46,8 +46,8 @@ dev_dependencies: ``` The generator works in both directions with one annotation, `@Schemable()`, -from `package:ack/ack.dart`. Constraint annotations such as `@MinLength()` come -from `package:ack/annotations.dart`. `@Schemable()` on a top-level schema generates an +and one constraint namespace, `@Check.*`, both exported from +`package:ack/ack.dart`. `@Schemable()` on a top-level schema generates an immutable model. `@Schemable()` on a hand-written class generates a public `Schema` facade, a private Ack codec, and JSON helpers. There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. @@ -55,8 +55,7 @@ There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. ```dart import 'package:ack/ack.dart'; -part 'user.ack.dart'; -part 'user.ack.g.dart'; +part 'user.g.dart'; @Schemable() final userSchema = Ack.object({ diff --git a/llms.txt b/llms.txt index 1d564890..5fea9d55 100644 --- a/llms.txt +++ b/llms.txt @@ -1,26 +1,21 @@ # Ack -> A schema validation library for Dart and Flutter with a fluent runtime API, bidirectional codecs, and two-way model/schema code generation. +> A schema validation library for Dart and Flutter with a fluent runtime API, bidirectional codecs, JSON Schema Draft 2020-12 import/export, and two-way model/schema code generation. Ack validates untrusted boundary data with schemas built through the `Ack` factory. Annotate a top-level schema with `@Schemable()` to generate a model, or annotate a hand-written class with `@Schemable()` to generate its schema and -JSON helpers. `package:ack/ack.dart` exports `@Schemable` and the model -annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`). -Constraint annotations such as `@MinLength` and `@Email` come from -`package:ack/annotations.dart`; import it with a prefix (`as ack`, then -`@ack.Email()`) where a name clashes with another type. `@Uri()` and -`@DateTime()` come from `package:ack/format_annotations.dart`. -`@AckInfer()` and `@AckModel()` are deprecated spellings of `@Schemable()`. -There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. +JSON helpers. `package:ack/ack.dart` exports `@Schemable`, the model +annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the +`@Check.*` constraint namespace. There is no `@AckSchema()` annotation; +`AckSchema` is the runtime schema type. ## Packages -1. `ack`: core runtime validation, codecs, generated-model support, and the - code-generation annotations -2. `ack_generator`: generates models from schemas and schemas from models +1. `ack`: core runtime validation, codecs, JSON Schema Draft 2020-12 runtime, generated-model support, and code-generation annotations +2. `ack_generator`: generates models from schemas, schemas from models, and `@Schemable()` models from JSON Schema bundles 3. `ack_firebase_ai`: converts Ack schemas to Firebase AI structured-output schemas -4. `ack_json_schema_builder`: converts Ack schemas to `json_schema_builder` schemas +4. `ack_json_schema_builder`: bidirectional bridge between Ack schemas and `json_schema_builder` schemas 5. `ack_mcp_dart`: advertises MCP tool input schemas and validates typed arguments with `mcp_dart` ## Core runtime usage @@ -29,7 +24,7 @@ There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. import 'package:ack/ack.dart'; final userSchema = Ack.object({ - 'name': Ack.string().minLength(2), + 'name': Ack.string().trim().minLength(2), 'email': Ack.string().email(), 'age': Ack.integer().min(0).optional(), }); @@ -40,7 +35,7 @@ final result = userSchema.safeParse({ }); ``` -## Codecs +## Codecs and string normalizers Codecs decode a boundary value into a runtime value and encode it back. `parse` / `safeParse` decode; `encode` / `safeEncode` encode. @@ -49,24 +44,25 @@ Codecs decode a boundary value into a runtime value and encode it back. `Ack.enumCodec(values)`. - Custom: `Ack.codec(input: ..., decode: ..., encode: ...)`, or `schema.codec(decode: ..., encode: ...)` on an existing schema. +- Two-way string normalizers: `.trim()`, `.toLowerCase()`, and `.toUpperCase()` + on `StringSchema` execute in declaration order with constraints on both + `parse` and `encode` and are supported in `@Schemable()` models. - `schema.transform(fn)` is one-way. It works for runtime parsing, but it is rejected by generated models because they must encode back to the boundary. - A codec exports the JSON Schema of its boundary schema. ## Model code generation -Both directions use `@Schemable()`, the same generated parts, and the same -build command. On a top-level schema it is schema-first; on a class it is -class-first. `name` applies only to a schema; every other option applies only -to a class, and generation rejects an option set for the other target. - -Every annotated library declares both generated parts: +Both directions use `@Schemable()`, a single `part '.g.dart';` directive, +and `dart run build_runner build`. On a top-level schema it is schema-first; on +a class it is class-first; on a `library;` directive `@Schemable(schemas: [...])` +registers shared custom schemas. `name` applies only to a schema; class options +apply only to a class, and `schemas` applies to a class or `library;` directive. ```dart import 'package:ack/ack.dart'; -part 'user_schema.ack.dart'; -part 'user_schema.ack.g.dart'; +part 'user_schema.g.dart'; @Schemable() final addressSchema = Ack.object({ @@ -102,13 +98,14 @@ For class-first generation: ```dart import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; + +part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) final class Account with _$AccountAck { const Account({required this.displayName, this.role = 'member'}); - @MinLength(2) + @Check.minLength(2) final String displayName; final String role; @@ -123,26 +120,35 @@ equality. The facade exposes `schema`, `wireSchema`, `parse`, `safeParse`, `fromJson`, `encode`, `safeEncode`, `toJsonSchema`, and `toSchemaModel`. `schemaName:` overrides the exact public UpperCamelCase facade name; no public lower-camel alias is emitted. Constructor parameters determine presence and -defaults. Field types and constraint annotations determine the schema. Sealed +defaults. Field types and `@Check.*` constraint annotations determine the schema. Sealed classes use `@Schemable(discriminatorKey: ...)`; same-library concrete branches are included automatically. Use `@Optional()` or `@Required()` to override inferred key presence, `@NotNull()` to reject an explicit JSON `null` without -requiring the key, and `@AckField(schema: ...)` for a type you cannot edit. -String patterns use `@Matches(pattern)`; `@Pattern` is a deprecated alias -available only from `package:ack/annotations.dart`. +requiring the key, `JsonMaybe` to distinguish omitted keys from explicit +`null` on both parse and encode, and `@AckField(name: 'wire_name')` for a single +field's JSON key override. Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export schema descriptions. A single-line `@description` doc tag is a fallback. Untagged prose does not export. A field description applies at its property without changing a nested model's own description. -Deprecated `AckFieldPresence` remains available during migration. Unknown properties use -`unknownProperties: AckUnknownPropertyPolicy.` (`reject` by default). -Raw `Ack.object(..., additionalProperties: true)` validation preserves extras; -the class-first policy is a later projection. `discard` is for tolerant, -read-only consumers, while round-tripping models use `capture` and optionally -set `captureField`. +Unknown properties use `unknownProperties: AckUnknownPropertyPolicy.` +(`reject` by default). Raw `Ack.object(..., additionalProperties: true)` +validation preserves extras; the class-first policy is a later projection. +`discard` is for tolerant, read-only consumers, while round-tripping models use +`capture` and optionally set `captureField`. + +### Custom schema resolution + +A field of custom type `T` (including inside `List`, `Set`, `Map`, +`JsonMaybe`, or generic type-owned schemas `Box`) resolves in priority order: -A field whose type declares a static `schema` resolves to it with no -annotation, also per `List` / `Set` item and as a `Map` value: +1. **`@AckField(schema: ...)`** on the field or constructor parameter; +2. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current + `library;` directive, or an imported `library;` directive; +3. **Type-owned schema**: a static `T.schema` or `T.schema(...)` on the type; +4. **Automatic current-library detection**: a unique top-level + `AckSchema` variable, getter, or zero-argument function in the + current library. ```dart final class Slot { @@ -175,20 +181,13 @@ A generic `schema()` may instead take one positional parameter typed `AckSchema` per type parameter `A`; the generator then passes each type argument's inferred schema, as in `Box.schema(RowSchema.schema)`. Type arguments are written as they are, including `void` (`Command.schema()`). An enum with a -static `schema` resolves to it instead of `Ack.enumValues`. +static `schema` or a `@Schemable(schemas: [...])` entry resolves to it instead of `Ack.enumValues`. Extension types work the same way when they implement `Object`. The schema must produce the field type, or generation fails. A class-first model may expose its facade as `static final schema = AccountSchema.schema;`. -Schema-first objects may reference the same members (`'header': Slot.schema`), -except that a `void` type argument is supported only in class-first fields. - -For a single wire-name override, put `@JsonKey(name: 'wire_name')` on the -field, importing `JsonKey` from `package:json_annotation/json_annotation.dart` -(add `json_annotation` to the app's dependencies; `caseStyle` needs neither). -Other `JsonKey` options, constructor-parameter placement, and `JsonConverter` -annotations are rejected so Ack validation and JSON serialization cannot -diverge. +Schema-first objects may reference the same members (`'header': Slot.schema`, +`'open': Command.schema()`). Nested class-first models compose through `AddressSchema.schema`, preserving import prefixes. Import combinators and barrels must expose both `Address` and @@ -206,7 +205,7 @@ and `toJson()` instead of treating a model as its old boundary representation. - objects and empty objects; - string, integer, double, number, boolean, list, literal, and enum roots; -- built-in and custom bidirectional codecs; +- built-in and custom bidirectional codecs and two-way string normalizers; - lists, sets, and string-keyed `Map` runtime values; - `Ack.any()` fields as JSON-safe `Object` values and `Ack.map(...)` fields as `Map` values; @@ -222,16 +221,18 @@ collisions, and cross-library discriminated branches. Class-first generation supports object models, inferred scalar and enum fields, JSON-safe `Object` fields, nested lists, sets through list codecs, -string-keyed maps, custom `@AckField` schemas, `@Optional()`, `@Required()`, -`@NotNull()`, constructor defaults, case styles, `AckUnknownPropertyPolicy`, -and same-library sealed discriminated unions. It rejects `dynamic`, nullable -list or set elements, non-String map keys, recursive class-first graphs, -class-first value roots, undiscriminated `anyOf` models, missing `_$ClassAck` -mixins, and no-op `@AckField()`. - -Open JSON fields are inferred from their Dart types. `Object` means a -JSON-safe value (finite numbers, strings, booleans, lists, and string-keyed -maps), not an arbitrary Dart instance: +string-keyed maps, `JsonMaybe`, custom schemas (`@AckField(schema: ...)`, +`@Schemable(schemas: [...])`, `T.schema`, or auto-detected top-level library +schemas), `@Optional()`, `@Required()`, `@NotNull()`, `@Check.*` constraints, +constructor defaults, case styles, `AckUnknownPropertyPolicy`, and same-library +sealed discriminated unions. It rejects `dynamic`, nullable list or set +elements, non-String map keys, recursive class-first graphs, class-first value +roots, undiscriminated `anyOf` models, missing `_$ClassAck` mixins, and no-op +`@AckField()`. + +Open JSON and presence-aware fields are inferred from their Dart types. `Object` +means a JSON-safe value (finite numbers, strings, booleans, lists, and +string-keyed maps), not an arbitrary Dart instance: | Dart field | Inferred schema | | --- | --- | @@ -240,6 +241,7 @@ maps), not an arbitrary Dart instance: | `List` | `Ack.list(Ack.any())`; `List` is rejected | | `Map` | `Ack.map()` | | `Map` | `Ack.map(Ack.any().nullable())` | +| `JsonMaybe` | Optional-and-nullable ``; omitted when `JsonAbsent`, `null` when `JsonValue(null)` | | `dynamic` | rejected; use `Object?` | ## Discriminated generated models @@ -271,42 +273,6 @@ This generates a sealed `Pet` base and final `Cat` and `Dog` branches. Boundary payloads include the discriminator. Generated subtype parsing validates through the union's effective branch. -## Migration from the previous generator - -- Rename `@AckType()` to `@Schemable()` for each connected graph you opt in. -- Add both `.ack.dart` and `.ack.g.dart` part directives. -- Rename generated `UserType` usages to `User` unless a custom name is set. -- Replace map/list/scalar interface access with stored fields or `.value`. -- Replace passthrough `.args` access with `.additionalProperties`. -- Replace legacy Map access and `.args` with typed fields and - `.additionalProperties`; use `fromJson` / `toJson` at the JSON boundary. - -- Replace one-way transforms used by generated models with bidirectional codecs. -- Replace public class-first `accountSchema` calls with `AccountSchema`; use - `AccountSchema.schema` when composing another schema and - `AccountSchema.wireSchema` for the raw Map schema. -- Apply the generated `_$ClassAck` mixin; do not keep extension-based helpers. -- Replace the temporary class-first `additionalProperties` option with - `unknownProperties: AckUnknownPropertyPolicy.`. -- Regenerate all checked-in outputs with `dart run build_runner build`. - -## Migration from 1.7.0-beta.2 - -- Remove `ack_annotations`. Import `package:ack/ack.dart` for the model - annotations, and replace `package:ack_annotations/ack_annotations.dart` with - `package:ack/annotations.dart` in files that use constraints or the - deprecated spellings. Replace `package:ack_annotations/format_annotations.dart` - with `package:ack/format_annotations.dart`. -- Rename `@Pattern(...)` to `@Matches(...)`. -- Replace `@AckInfer()` and `@AckModel()` with `@Schemable()`. A library that - keeps `@AckInfer()` must still have `Schemable` in scope, because generated - models carry `@Schemable.generatedJson`. -- `ack` no longer re-exports `JsonKey`. A file that uses `@JsonKey(name: ...)` - imports `package:json_annotation/json_annotation.dart`, and the app depends - on `json_annotation`. `Schemable.jsonSerializable`, - `AckModel.jsonSerializable`, and `AckGeneratedJson.config` are removed. -- Regenerate the `.ack.dart` parts. - ## Runtime API reminders - `schema.parse(data)` throws on invalid input. @@ -314,11 +280,12 @@ validates through the union's effective branch. - `SchemaResult.getOrThrow()` returns the value or throws `AckException`. - `.optional()` allows an object field to be omitted. - `.nullable()` allows a present value to be null. -- `schema.toSchemaModel()` returns the canonical adapter/export model. -- `schema.toJsonSchema()` renders that model as JSON Schema. +- `schema.toSchemaModel()` returns the canonical adapter/export model (preserving imported Draft 2020-12 resources by default; use `toSchemaModelDraft7()` for legacy Draft-07 lowering). +- `schema.toJsonSchema()` renders that model as JSON Schema (or `toJsonSchemaDraft7()` for legacy Draft-07 lowering). - `Ack.list(...)` does not support nullable item schemas. - `Ack.lazy(...)` defaults `maxDepth` to 100. - Ack snapshots schema factory collections, and collection bounds must be valid. +- `SchemaError` and `AckSchema` are sealed hierarchies supporting exhaustive `switch` expressions. - Generated data classes use `deepEquals` and `deepHashCode` from `package:ack/ack.dart`. - `deepUnmodifiableJsonMap` snapshots nested maps, lists, and sets without validation; use it for hand-written constructors that store dynamic JSON. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 229066ad..a5a55029 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -1,20 +1,17 @@ # Ack Generator -`ack_generator` supports two modern directions through `@Schemable()`: +`ack_generator` supports two directions through `@Schemable()`: it turns a top-level Ack schema into an immutable model and derives an Ack -codec schema from a hand-written class. `@Schemable()` and the model -annotations are exported from `package:ack/ack.dart`; constraint annotations -come from `package:ack/annotations.dart`. `@AckInfer()` and `@AckModel()` are deprecated -spellings of `@Schemable()` until 2.0.0. It also retains the deprecated Ack -1.1 `@AckType()` generator unchanged. +codec schema from a hand-written class. `@Schemable()`, the model annotations +(`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the `@Check.*` +constraint namespace are all exported from `package:ack/ack.dart`. ## Schema-first usage ```dart import 'package:ack/ack.dart'; -part 'user_schema.ack.dart'; -part 'user_schema.ack.g.dart'; +part 'user_schema.g.dart'; @Schemable() final userSchema = Ack.object({ @@ -66,16 +63,14 @@ mixin: ```dart import 'package:ack/ack.dart'; -import 'package:ack/annotations.dart'; -part 'account.ack.dart'; -part 'account.ack.g.dart'; +part 'account.g.dart'; @Schemable() final class Account with _$AccountAck { const Account({required this.name}); - @MinLength(2) + @Check.minLength(2) final String name; static final fromJson = AccountSchema.fromJson; @@ -93,16 +88,27 @@ void main() { } ``` -A field whose type declares a static `schema` resolves to it without an -annotation, also as a `List` or `Set` item and as a `Map` value. A generic type -declares `static AckSchema> schema()`, which is called with the -field's type arguments, for example `Command.schema()` or -`Command.schema()`. A `schema()` that declares one positional -`AckSchema` parameter per type parameter `A` receives each type argument's -inferred schema, as in `Box.schema(RowSchema.schema)`. An enum with a -static `schema` resolves to it instead of `Ack.enumValues`. The -schema must produce the field's type. A class may also expose its own facade -with `static final schema = AccountSchema.schema;`. +### Custom schema resolution + +A field of custom type `T` (including inside `List`, `Set`, `Map`, +`JsonMaybe`, or generic type-owned schemas `Box`) resolves in priority order: + +1. **`@AckField(schema: ...)`** on the field or constructor parameter; +2. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current + `library;` directive, or an imported `library;` directive; +3. **Type-owned schema**: a static `T.schema` or `T.schema(...)` on the type; +4. **Automatic current-library detection**: a unique top-level + `AckSchema` variable, getter, or zero-argument function in the + current library. + +A generic type declares `static AckSchema> schema()`, which is called +with the field's type arguments, for example `Command.schema()` +or `Command.schema()`. A `schema()` that declares one positional +`AckSchema` parameter per type parameter `A` receives each type +argument's inferred schema, as in `Box.schema(RowSchema.schema)`. An enum +with a static `schema` or a `@Schemable(schemas: [...])` entry resolves to it +instead of `Ack.enumValues`. A class may also expose its own facade with +`static final schema = AccountSchema.schema;`. Use `@Schemable(description: ...)` and `@AckField(description: ...)` for schema descriptions. A single-line `@description` tag in a `///` or `/** */` doc @@ -122,21 +128,21 @@ parameters, read the documentation comment from the source AST and pass it as The generator supports objects, empty objects, scalar and collection roots, literals, enums, defaults, additional properties, built-in and custom -bidirectional codecs, named nested models, aliases, named `Ack.lazy` recursion, -and same-library discriminated unions. Lists, sets, and maps stored by a model -generated from a `@Schemable()` schema are copied recursively into unmodifiable -collections. Class-first parsing provides the same guarantee, including for -captured extras. Hand-written constructors and collection replacements passed -to `copyWith` remain responsible for their own defensive copies; use -`deepUnmodifiableJsonMap` for dynamic JSON maps. Raw -`Ack.object(..., additionalProperties: true)` schemas preserve extras, while a -class-first model applies its later `unknownProperties` projection. Use +bidirectional codecs, two-way string normalizers (`.trim()`, `.toLowerCase()`, +`.toUpperCase()`), `JsonMaybe` PATCH presence fields, named nested models, +aliases, named `Ack.lazy` recursion, and same-library discriminated unions. +Lists, sets, and maps stored by a model generated from a `@Schemable()` schema +are copied recursively into unmodifiable collections. Class-first parsing +provides the same guarantee, including for captured extras. Hand-written +constructors and collection replacements passed to `copyWith` remain responsible +for their own defensive copies; use `deepUnmodifiableJsonMap` for dynamic JSON +maps. Raw `Ack.object(..., additionalProperties: true)` schemas preserve extras, +while a class-first model applies its later `unknownProperties` projection. Use `discard` only for tolerant, read-only consumers and `capture` for round trips. Generation rejects shapes without a useful static, encodable model contract: -- one-way `.transform()` calls, including `.trim()`, `.toLowerCase()`, and - `.toUpperCase()`; use `.codec()` with an encoder; +- one-way `.transform()` calls; use `.codec()` with an encoder; - nullable roots; - nullable `Ack.list` item schemas and automatically inferred `List` or `Set` fields; make the collection nullable instead, or use an explicit @@ -153,64 +159,51 @@ Named model references work through direct imports, prefixes, and re-exports. Nested conversion uses each model's public `$ack` adapter so codec runtime values aren't parsed twice. -## Generated parts +## Generated part + +Every annotated library declares a single `.g.dart` part: + +```dart +part 'account.g.dart'; +``` + +`ack_generator` emits both the Ack schema/model facade and the structural JSON +helpers directly into `.g.dart` via `SharedPartBuilder` (without requiring +`json_serializable`), and coexists cleanly with other `SharedPartBuilder` +generators in the same `.g.dart` file. `copyWith` is a getter that returns a typed `$AccountCopyWith` interface. Call it as `account.copyWith(name: 'Grace')`. Omitted arguments keep their current values, explicit `null` clears nullable fields, and arguments with incompatible static types fail analysis. -Both generated parts start with `// ignore_for_file: type=lint` and +The generated part starts with `// ignore_for_file: type=lint` and `// coverage:ignore-file`, so your lint rules and coverage reports apply only to your own source. -## JSON serialization - -Every annotated library declares both parts: - -```dart -part 'account.ack.dart'; -part 'account.ack.g.dart'; -``` - -Ack owns schema validation, defaults, codecs, union dispatch, and the public -`parse` / `fromJson` / `toJson` methods. `json_serializable` generates the -structural `_$ClassFromJson` / `_$ClassToJson` helpers into the Ack JSON -part. Ack-only apps do not add `json_annotation` or `json_serializable`; -`ack_generator` activates that second phase itself and supplies its -configuration. - -When a modern-only target also uses an ordinary source-gen builder that owns -`.g.dart`, disable the unused legacy builder in that target so it remains the -sole `.g.dart` owner: - -```yaml -targets: - $default: - builders: - ack_generator:ack_generator: - enabled: false -``` - ## Supported declarations On a schema, `@Schemable()` annotates top-level schema variables and top-level schema getters; instance members and local variables are rejected. Only its `name` option applies there. +On a `library;` directive, `@Schemable(schemas: [...])` registers custom schemas +shared by all `@Schemable` classes in that library and any library that imports it. + On a class, `@Schemable()` annotates public, constructable `final class` declarations whose stored fields are final. Annotated sealed union bases remain supported, and their concrete branches must also be final. Use `@Optional()` or `@Required()` to override inferred key presence, `@NotNull()` to reject JSON `null` without -requiring the key, and `@AckField(schema: ...)` for a type you cannot give a -static `schema`. Constraint annotations include `@Matches(pattern)` for -`Ack.string().matches(pattern)`. See the +requiring the key, `JsonMaybe` to distinguish omitted keys from explicit +`null` on both parse and encode, and `@AckField(name: 'wire_name')` to override +a single field's JSON key. Constraint annotations use `@Check.*` (such as +`@Check.matches(pattern)` for `Ack.string().matches(pattern)`). See the [Model Code Generation guide](https://concepta.dev/documentation/ack/advanced/typesafe-schemas) for both directions, field inference, sealed unions, passthrough properties, and build configuration. -Open JSON fields are inferred from their Dart types. `Object` means a JSON-safe -value, not an arbitrary Dart instance: +Open JSON and presence-aware fields are inferred from their Dart types. `Object` +means a JSON-safe value, not an arbitrary Dart instance: | Dart field | Inferred schema | | --- | --- | @@ -218,15 +211,15 @@ value, not an arbitrary Dart instance: | `List` | `Ack.list(Ack.any())`; `List` is rejected | | `Map` | `Ack.map()` | | `Map` | `Ack.map(Ack.any().nullable())` | +| `JsonMaybe` | Optional-and-nullable ``; omitted when `JsonAbsent`, `null` when `JsonValue(null)` | | `dynamic` | rejected; use `Object?` | For a hand-written `Account`, class-first generation exposes an `AccountSchema` facade backed by a private `_accountSchema` codec. The facade provides parsing, safe parsing, encoding, JSON Schema/schema-model export, typed `schema`, and raw `wireSchema`. Instantiable models apply the generated -`_$AccountAck` mixin, which supplies `toJson`, a typed `copyWith` (omitted -means keep; explicit `null` clears a nullable field; a wrongly typed argument is -a compile-time error), and deep collection-aware equality. Add +`_$AccountAck` mixin, which supplies `toJson`, `safeToJson`, a typed `copyWith`, +and deep collection-aware equality. Add `static final fromJson = AccountSchema.fromJson;` when the class should expose the conventional one-argument entry point. Imported nested models compose as `prefix.AddressSchema.schema`. Across all imports and barrel exports, @@ -234,24 +227,3 @@ the conventional one-argument entry point. Imported nested models compose as generated companion (`Address` plus `AddressSchema` for class-first, or `addressSchema` plus `Address` for schema-first). Visibility may be split across multiple imports that use the same prefix. - -Class-first wire-name overrides support `@JsonKey(name: 'wire_name')` on the -field, with `JsonKey` imported from `package:json_annotation/json_annotation.dart` -and `json_annotation` in the app's dependencies. Other `JsonKey` options, -constructor-parameter placement, and `JsonConverter` annotations fail -generation so schema validation and JSON mapping remain identical. - -## Deprecated AckType compatibility - -An unchanged Ack 1.1 declaration still uses `part 'file.g.dart';` and generates -the same `*Type`, `.args`, Map, `parse`, and `safeParse` APIs. `AckType` is frozen -until its removal in Ack 2.0. New connected model graphs must use -`@Schemable()`; nested references between legacy and modern graphs are rejected -with a migration diagnostic. - -| Legacy | Modern opt-in | -|---|---| -| `@AckType()` | `@Schemable()` | -| `file.g.dart` | `file.ack.dart` + `file.ack.g.dart` | -| `UserType.parse(...)` | `User.parse(...)` or `User.fromJson(...)` | -| Map access and `.args` | Typed fields, `.additionalProperties`, and `toJson()` | diff --git a/packages/ack_generator/example/example.dart b/packages/ack_generator/example/example.dart index e8fc2c51..ce9b27e0 100644 --- a/packages/ack_generator/example/example.dart +++ b/packages/ack_generator/example/example.dart @@ -4,8 +4,7 @@ // analyzes without running `build_runner`. A real library adds them: // // ```dart -// part 'example.ack.dart'; // the model class and its parse/JSON API -// part 'example.ack.g.dart'; // the structural field mapping +// part 'example.g.dart'; // ``` // // Then run: diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index 7003976e..54b7f5ba 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -586,7 +586,10 @@ void main() { } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', diff --git a/packages/ack_generator/test/integration/class_first_cycle_build_test.dart b/packages/ack_generator/test/integration/class_first_cycle_build_test.dart index 26e4a13b..fa46e83a 100644 --- a/packages/ack_generator/test/integration/class_first_cycle_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_cycle_build_test.dart @@ -70,7 +70,7 @@ final class Child with _$ChildAck { } '''); - final pubGet = await _run(temporary, ['pub', 'get']); + final pubGet = await _run(temporary, ['pub', 'get', '--offline']); expect( pubGet.exitCode, 0, diff --git a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart index 01b0a7dc..f8c81d35 100644 --- a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart @@ -434,7 +434,10 @@ void main() { } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); final runtimeDependencies = await _run(temporary, [ 'pub', 'deps', diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 30907574..7b371270 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -102,8 +102,8 @@ dependency_overrides: ); _expectSuccess( - await _run(temporaryExample, ['pub', 'get']), - 'dart pub get', + await _run(temporaryExample, ['pub', 'get', '--offline']), + 'dart pub get --offline', ); _expectSuccess( await _run(temporaryExample, ['run', 'build_runner', 'build']), @@ -204,8 +204,8 @@ dependency_overrides: ); _expectSuccess( - await _run(temporaryExample, ['pub', 'get']), - 'dart pub get', + await _run(temporaryExample, ['pub', 'get', '--offline']), + 'dart pub get --offline', ); final schemaFile = File( diff --git a/packages/ack_generator/test/integration/json_serializable_build_test.dart b/packages/ack_generator/test/integration/json_serializable_build_test.dart index 81099198..f85fb20e 100644 --- a/packages/ack_generator/test/integration/json_serializable_build_test.dart +++ b/packages/ack_generator/test/integration/json_serializable_build_test.dart @@ -74,7 +74,10 @@ final userSchema = Ack.object({ }); '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'clean build_runner build', @@ -172,7 +175,10 @@ void main() { } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', @@ -267,7 +273,10 @@ void main() { } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'clean build_runner build', diff --git a/packages/ack_generator/test/integration/model_generation_guide_build_test.dart b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart index c9ae742f..c3230152 100644 --- a/packages/ack_generator/test/integration/model_generation_guide_build_test.dart +++ b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart @@ -114,7 +114,10 @@ void main() { } '''); - _expectSuccess(await _run(temporary, ['pub', 'get']), 'dart pub get'); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); _expectSuccess( await _run(temporary, ['run', 'build_runner', 'build']), 'build_runner build', diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index 969430e8..5e002ac2 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -59,9 +59,11 @@ Both registration helpers return `RegisteredTool` and forward `title`, `description`, `annotations`, and `meta`. Callbacks may be synchronous or asynchronous and receive the original `RequestHandlerExtra`. -Use `server.registerAckModelTool('search', model: SearchSchema.model, +Use `server.registerAckModelTool('search', model: Search.$ack, callback: (args, extra) { ... })` with an `AckModelAdapter` -(such as a generated model adapter) to receive a typed `M` instead of a map. +(such as a schema-first generated model adapter, or pass a class-first +`SearchSchema.schema` directly to `registerAckTool`) to receive a typed `M` +instead of a map. The model is constructed after ACK parses defaults and codecs. ## Bare conversion and parsing From 1ace93ba5cc50caef1aff31c20a11f646aa48051 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 13:28:57 -0400 Subject: [PATCH 14/29] chore(release): prepare Ack 2.0.0-beta.1 --- CHANGELOG.md | 83 +++++++++++++ PUBLISHING.md | 37 +++--- docs/core-concepts/codecs.mdx | 11 +- docs/core-concepts/error-handling.mdx | 6 +- docs/core-concepts/validation.mdx | 17 ++- docs/getting-started/installation.mdx | 8 +- docs/guides/json-schema-integration.mdx | 115 +++++++----------- example/README.md | 3 +- example/pubspec.yaml | 4 +- packages/ack/CHANGELOG.md | 51 ++++++++ packages/ack/pubspec.yaml | 2 +- packages/ack_firebase_ai/CHANGELOG.md | 12 ++ packages/ack_firebase_ai/README.md | 4 +- packages/ack_firebase_ai/pubspec.yaml | 4 +- packages/ack_generator/CHANGELOG.md | 36 ++++++ .../src/json_schema/json_schema_library.dart | 2 +- packages/ack_generator/pubspec.yaml | 4 +- packages/ack_json_schema_builder/CHANGELOG.md | 17 +++ packages/ack_json_schema_builder/README.md | 37 +++--- packages/ack_json_schema_builder/pubspec.yaml | 4 +- packages/ack_mcp_dart/CHANGELOG.md | 7 ++ packages/ack_mcp_dart/README.md | 4 +- packages/ack_mcp_dart/pubspec.yaml | 4 +- 23 files changed, 327 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22807095..0a926146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,89 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2.0.0-beta.1 + +### Breaking + +* **Single-pass `.g.dart` code generation (`#189`)**: `ack_generator` now emits + a single `.g.dart` part via `SharedPartBuilder` (`ack_generator:ack`) + with direct JSON serialization (`toJson` / `fromJson`). Consumer packages no + longer need `json_serializable` or `json_annotation` dependencies, nor a + separate `.ack.dart` / `.ack.g.dart` two-part setup. Replace + `part '.ack.dart';` + `part '.ack.g.dart';` with a single + `part '.g.dart';`. +* **Removed legacy 1.x generator annotations and shims (`#188`)**: Removed + `@AckType`, `@AckInfer`, `@AckModel`, `JsonKeyCaseStyle`, `AckTypeDef`, and + generated `*Type` extension-type model generation. Use `@Schemable()` on both + top-level schemas and hand-written classes/unions. +* **Removed `StandardSchemaV1` (`#129`)**: Removed the unused TypeScript-origin + `StandardSchemaV1` interface and extensions (`standard_schema.dart`). + `AckSchema` with `SchemaResult` and `AckSchemaModel` is + the single canonical schema and shape contract across Ack and all adapters. +* **Unified `@Check.*` constraint annotation namespace (`#191`)**: Added + `final class Check` (exported from both `package:ack/ack.dart` and + `package:ack/annotations.dart`) with `const Check.min(...)`, `Check.max(...)`, + `Check.multipleOf(...)`, `Check.positive()`, `Check.negative()`, + `Check.minLength(...)`, `Check.maxLength(...)`, `Check.matches(...)`, + `Check.notEmpty()`, `Check.email()`, `Check.url()`, `Check.uuid()`, + `Check.date()`, `Check.dateTime()`, `Check.uri()`, `Check.ip()`, + `Check.ipv4()`, `Check.ipv6()`, `Check.minItems(...)`, `Check.maxItems(...)`, + and `Check.uniqueItems()` / `Check.unique()`. Legacy top-level constraint + annotation classes (`@Min`, `@MinLength`, `@Email`, etc.) are deprecated. +* **Sealed `SchemaError` and `AckSchema` hierarchies**: `SchemaError` and + `AckSchema` are now `sealed class`es so `switch (error)` and `switch (schema)` + are exhaustively checked by the Dart analyzer without a wildcard branch. +* **Decoder `Error` propagation (`#163`)**: `CodecSchema` now rethrows + programmer `Error`s (`TypeError`, `StateError`, etc.) with their original + stack trace during `parse` / `safeParse` (matching `encode` / `safeEncode`), + catching only `Exception`s as `SchemaTransformError`. +* **JSON Schema Draft 2020-12 default export and `format` annotation mode + (`#186`, `#171`)**: `Ack.fromJsonSchema()` now defaults to `assertFormats: false` + (matching JSON Schema Draft 2020-12's default Core/Applicator/Validation + vocabularies; pass `assertFormats: true` or declare the `format-assertion` + vocabulary in `$vocabulary` to enforce format assertions). `schema.toJsonSchema()` + and `schema.toSchemaModel()` (and `toJsonSchemaBuilder()` in + `ack_json_schema_builder`) now preserve imported Draft 2020-12 resources by + default; use `toJsonSchemaDraft7()`, `toSchemaModelDraft7()`, or + `toJsonSchemaBuilderDraft7()` when legacy Draft-07 `definitions` lowering is + required. + +### Added + +* **`JsonMaybe` PATCH tri-state presence (`#148`)**: Added + `sealed class JsonMaybe` (`JsonAbsent`, `JsonValue`) with + `const JsonMaybe.absent()`, `const JsonMaybe.value(T? value)`, and + `const JsonMaybe.nullValue()`, plus full `@Schemable()` class-first codegen + support so omitted keys decode to `JsonAbsent`, explicit `null` decodes to + `JsonValue(null)`, and `toJson()` omits keys whose value is `JsonAbsent`. +* **Custom schema resolution via `@Schemable(schemas: [...])` and automatic + same-library detection (`#185`)**: Class-first `@Schemable()` models now + resolve third-party or custom value types (`Money`, `LatLng`, `Decimal`, etc.) + via: + 1. `@AckField(schema: ...)` (undeprecated for field-specific overrides) + 2. `@Schemable(schemas: [...])` on the class, enclosing sealed union, current + `library;` directive, or an imported `library;` directive + 3. Type-owned `T.schema` / `T.schema<...>()` + 4. Automatic detection of a unique top-level `AckSchema` + variable, getter, or zero-argument function in the current library. +* **Native `@AckField(name: ...)` wire key override (`#189`)**: Override a + field's JSON wire key directly with `@AckField(name: 'wire_key')` without + importing `package:json_annotation`. +* **Two-way `StringSchema` normalizers**: `.trim()`, `.toLowerCase()`, and + `.toUpperCase()` now live directly on `StringSchema`, run in declaration order + alongside constraints during both `parse` and `encode`, preserve + `StringSchema` chaining, work in `@Schemable()` models, and export cleanly to + JSON Schema as `type: 'string'` without `'x-transformed': true`. +* **Full JSON Schema Draft 2020-12 runtime and `.schema.json` model generator + (`#186`, `#171`, `#167`)**: `Ack.fromJsonSchema()` now supports the complete + Draft 2020-12 validation runtime (`$dynamicRef`, `$dynamicAnchor`, + `unevaluatedProperties`, `unevaluatedItems`, `prefixItems`, + `dependentRequired`, `dependentSchemas`, `patternProperties`, `minContains`, + `maxContains`, bundled offline 2020-12 meta-schemas, and the full Draft + 2020-12 format suite when `assertFormats: true`). `ack_generator` adds the + `ack_generator:ack_json_schema` builder to compile `lib/**/*.schema.json` + into `.schema.g.dart` typed or value-model libraries. + ## 1.7.0-beta.7 * Strict JSON Schema import (`Ack.fromJsonSchema()`) now supports `multipleOf` diff --git a/PUBLISHING.md b/PUBLISHING.md index f54544e4..3eaf745e 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -12,20 +12,17 @@ new compatible features/packages, and major when any stable public API breaks. For 1.7.0, the imported-pattern Unicode behavior change is a deliberately accepted exception to this major-version policy. -The next candidate is **1.7.0-beta.7**. It adds `multipleOf` validation, -built-in string and numeric `format` assertions, `x-*` vendor extension -annotation tolerance, and Draft-07 `$schema` URI support to strict JSON Schema -import (`Ack.fromJsonSchema()`), and speeds up imported-schema compilation and -validation, `toJsonSchema()`, and collection parse/encode results. -The beta.5 imported-pattern change to ECMA-262 Unicode mode still applies when -upgrading from stable 1.6.2 or earlier 1.7.0 betas; review imported patterns, -including identity escapes and expressions that count UTF-16 code units. Native -`Ack.string().matches()` keeps its Dart regex contract. The beta.3 annotation -and generator migration still applies to consumers coming from earlier -versions; beta.7 adds no new generator migration. The API check compares all -five packages against 1.6.2; generator compatibility also requires the consumer -build and runtime tests because the API check cannot inspect generated consumer -code. +The next candidate is **2.0.0-beta.1**. It introduces single-pass `.g.dart` +code generation without a runtime `json_serializable` delegation step, the +unified `@Check.*` constraint annotation namespace, custom schema resolution via +`@Schemable(schemas: [...])` and automatic same-library detection, `JsonMaybe` +PATCH presence semantics, two-way `StringSchema` normalizers (`.trim()`, +`.toLowerCase()`, `.toUpperCase()`), sealed `SchemaError` and `AckSchema` +hierarchies, full JSON Schema Draft 2020-12 runtime validation and `.schema.json` +model generation with default Draft 2020-12 preservation on `toJsonSchema()`, +and removal of deprecated 1.x compatibility shims (`StandardSchemaV1`, +`@AckType`, `@AckInfer`, `@AckModel`, and `.ack.dart` / `.ack.g.dart` dual-part +generation). The shared API baseline for this release is 1.6.2. Confirm that all five packages have published 1.6.2 versions before preparing the release. @@ -60,7 +57,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 3. Preview/prepare a coordinated version without creating commits or tags: ```sh - dart run melos version --manual-version=ack:1.7.0-beta.7 --yes --no-git-commit-version + dart run melos version --manual-version=ack:2.0.0-beta.1 --yes --no-git-commit-version ``` Use the named flag, not a positional package argument: the positional form @@ -87,7 +84,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 7. Validate the complete release from that clean commit: ```sh - dart scripts/verify_release_tag.dart v1.7.0-beta.7 --skip-ancestry + dart scripts/verify_release_tag.dart v2.0.0-beta.1 --skip-ancestry dart run melos run ci dart scripts/api_check.dart 1.6.2 dart scripts/publish_dry_run.dart @@ -141,9 +138,9 @@ After the release commit's CI/preflight succeeds: ```sh git fetch origin main --tags # Use the exact reviewed release merge commit, not an arbitrary later main head. -git tag -a v1.7.0-beta.7 -m 'Ack 1.7.0 beta 7' -dart scripts/verify_release_tag.dart v1.7.0-beta.7 -git push origin v1.7.0-beta.7 +git tag -a v2.0.0-beta.1 -m 'Ack 2.0.0 beta 1' +dart scripts/verify_release_tag.dart v2.0.0-beta.1 +git push origin v2.0.0-beta.1 ``` Pushing the tag triggers `.github/workflows/release.yml`; there is no Melos @@ -173,4 +170,4 @@ After all five exact versions are visible, create the GitHub Release from the existing tag using the prepared release notes and mark it as a prerelease for beta versions. Before the first subsequent code change, begin a new unreleased changelog section instead of editing the -1.7.0-beta.7 notes. +2.0.0-beta.1 notes. diff --git a/docs/core-concepts/codecs.mdx b/docs/core-concepts/codecs.mdx index 98468197..26bff102 100644 --- a/docs/core-concepts/codecs.mdx +++ b/docs/core-concepts/codecs.mdx @@ -103,11 +103,12 @@ Errors surface as typed [`SchemaError`](./error-handling.mdx) values: - `SchemaEncodeError` — encoding failed (for example, a one-way `transform`, or a runtime invariant violation such as a non-UTC `DateTime` for `Ack.datetime()`). -An `Error` thrown by an `encode` function is a programming bug, not bad data, so -it propagates out of `safeEncode` with its original stack trace at every nesting -level — inside an object, list, map, or union, too. Throw an `Exception` (or -express the rule with an `output:` schema) to signal a value the codec cannot -encode. +An `Error` thrown by a `decode` (or `transform`) or `encode` function is a +programming bug, not bad data, so it propagates out of `parse` / `safeParse` and +`encode` / `safeEncode` with its original stack trace at every nesting level — +inside an object, list, map, or union, too. Throw an `Exception` (or express the +rule with an `input:` or `output:` schema) to signal a value the codec cannot +decode or encode. ## Codecs and JSON Schema diff --git a/docs/core-concepts/error-handling.mdx b/docs/core-concepts/error-handling.mdx index 39604d71..1c0240f2 100644 --- a/docs/core-concepts/error-handling.mdx +++ b/docs/core-concepts/error-handling.mdx @@ -195,7 +195,7 @@ Raised when `encode()` or `safeEncode()` can't turn a runtime value back into it ### Working with error types -Use pattern matching to handle specific error types: +`SchemaError` (and `AckSchema`) is a `sealed class`, so you can use exhaustive pattern matching without a wildcard branch: ```dart final result = schema.safeParse(data); @@ -211,13 +211,11 @@ if (result.isFail) { case SchemaNestedError(): print('Nested validation failed at ${error.errors.length} locations'); case SchemaValidationError(): - print('Custom validation failed: ${error.message}'); + print('Custom or JSON Schema validation failed: ${error.message}'); case SchemaTransformError(): print('Transformation failed: ${error.cause}'); case SchemaEncodeError(): print('Could not encode value: ${error.message}'); - default: - print('Validation failed: ${error.message}'); } } ``` diff --git a/docs/core-concepts/validation.mdx b/docs/core-concepts/validation.mdx index f10897c8..fca108ac 100644 --- a/docs/core-concepts/validation.mdx +++ b/docs/core-concepts/validation.mdx @@ -162,26 +162,31 @@ Ack.string().ip(version: 4) // IPv4 only Ack.string().ip(version: 6) // IPv6 only ``` -## String transformations +## String normalizers -These methods modify the output value during parsing rather than adding a validation constraint. +These methods live directly on `StringSchema` and normalize the string in +declaration order alongside constraints during both `parse` and `encode`. +Because they return `StringSchema`, you can continue chaining string +constraints (`Ack.string().trim().minLength(2)`), use them inside `@Schemable()` +models, and export them to JSON Schema as `type: 'string'` without +`'x-transformed': true`. ### `trim()` -Removes leading and trailing whitespace. +Removes leading and trailing whitespace on parse and encode. ```dart -Ack.string().trim() +Ack.string().trim().minLength(2) // " hello " → "hello" ``` ### `toLowerCase()` -Converts the string to lowercase. +Converts the string to lowercase on parse and encode. ```dart Ack.string().toLowerCase() // "HELLO" → "hello" ``` ### `toUpperCase()` -Converts the string to uppercase. +Converts the string to uppercase on parse and encode. ```dart Ack.string().toUpperCase() // "hello" → "HELLO" diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 3b9912f9..dd153904 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -17,11 +17,11 @@ flutter pub add ack ``` The unversioned commands select the latest stable release. To select the -1.7.0-beta.7 prerelease, add it to your `pubspec.yaml`: +2.0.0-beta.1 prerelease, add it to your `pubspec.yaml`: ```yaml dependencies: - ack: ^1.7.0-beta.7 + ack: ^2.0.0-beta.1 ``` ## Optional code generator @@ -38,10 +38,10 @@ Or add them to your `pubspec.yaml`: ```yaml dependencies: - ack: ^1.7.0-beta.7 + ack: ^2.0.0-beta.1 dev_dependencies: - ack_generator: ^1.7.0-beta.7 + ack_generator: ^2.0.0-beta.1 build_runner: ^2.4.0 ``` diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index 9e278395..cd07ae27 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -68,32 +68,31 @@ Exports containing unsupported keywords are rejected. ### Importing `format` -Ack's strict supported-subset importer recognizes Ack's exported string formats -(`date-time`, `date`, `email`, `uuid`, `uri`, `ipv4`, `ipv6`) and standard -numeric format ranges (`int32`, `int64`, `uint32`, `uint64`, `float`, `double`), -and validates them automatically: +By default, `Ack.fromJsonSchema()` treats `format` as an annotation (matching +JSON Schema Draft 2020-12's default Core/Applicator/Validation vocabularies). +Pass `assertFormats: true` (or declare the Draft 2020-12 `format-assertion` +vocabulary in `$vocabulary`) to enable runtime format assertions for the full +Draft 2020-12 format suite (`date-time`, `date`, `time`, `duration`, `email`, +`idn-email`, `hostname`, `idn-hostname`, `ipv4`, `ipv6`, `uri`, `uri-reference`, +`iri`, `iri-reference`, `uuid`, `uri-template`, `json-pointer`, +`relative-json-pointer`, `regex`) as well as standard numeric format bounds +(`int32`, `int64`, `uint32`, `uint64`, `float`, `double`): ```dart final timestamp = Ack.fromJsonSchema( {'type': 'string', 'format': 'date-time'}, + assertFormats: true, ); timestamp.parse('2024-02-29T12:34:56.123456789+02:00'); ``` -Other format labels remain unsupported and produce an `unsupported_keyword` -diagnostic at their source location. This is Ack's strict import subset, not a -claim that draft 2020-12 requires format assertions or that Ack implements the -full Format-Assertion vocabulary. A format-only schema still accepts values of -other types because `format` does not imply a `type`; add `type` when needed. - Date-time validation uses RFC 3339 syntax, calendar dates, offsets, and the bundled list of announced positive leap-second dates. The list is updated in Ack releases when IERS announces new leap seconds; validation never fetches data at runtime. Parsing preserves the original string, including its fractional precision and offset. It does not convert it to a Dart `DateTime`. -Export retains `format` in imported schemas. Reimport through the same strict -Ack importer to keep validation behavior. External JSON Schema validators may +Export retains `format` in imported schemas. External JSON Schema validators may treat `format` as an annotation unless configured to assert it. ### Supported import subset @@ -102,14 +101,14 @@ treat `format` as an annotation unless configured to assert it. | --- | --- | | Schemas and types | Boolean schemas, `type` (including lists and `null`) | | Equality | `enum`, `const`, including nested JSON values and empty enums | -| Objects | `properties`, `required`, boolean/schema `additionalProperties`, `propertyNames`, `minProperties`, `maxProperties` | -| Arrays | Single-schema `items`, `contains`, `minItems`, `maxItems`, `uniqueItems` | +| Objects | `properties`, `required`, boolean/schema `additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, `maxProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedProperties` | +| Arrays | Single-schema `items`, `prefixItems`, `contains`, `minContains`, `maxContains`, `minItems`, `maxItems`, `uniqueItems`, `unevaluatedItems` | | Strings | `minLength`, `maxLength` (Unicode code points), `pattern` (unanchored ECMA-262 search in Unicode mode) | | Numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` (exact decimal division) | | Composition | `anyOf`, `allOf`, exclusive `oneOf`, `not`, `if`/`then`/`else`; sibling assertions are intersected | -| References | `$ref`, `$id`, `$anchor`, `$defs`, `definitions`; local/cross-document JSON Pointers and structural recursion | -| Annotations | `title`, `description`, `default`, `examples`, `$comment`, `readOnly`, `writeOnly`, `deprecated`, `x-*` vendor extensions | -| Format | `date-time`, `date`, `email`, `uuid`, `uri`, `ipv4`, `ipv6` (strings); `int32`, `int64`, `uint32`, `uint64`, `float`, `double` (numbers) | +| References | `$ref`, `$id`, `$anchor`, `$defs`, `definitions`, `$dynamicRef`, `$dynamicAnchor`, bundled offline 2020-12 meta-schemas; local/cross-document JSON Pointers and structural recursion | +| Annotations | `title`, `description`, `default`, `examples`, `$comment`, `readOnly`, `writeOnly`, `deprecated`, `contentEncoding`, `contentMediaType`, `contentSchema`, `x-*` vendor extensions | +| Format | Full Draft 2020-12 format suite and standard numeric formats (`int32`, `int64`, `uint32`, `uint64`, `float`, `double`); asserted when `assertFormats: true` or `$vocabulary` enables `format-assertion` | `pattern` compiles with ECMA-262's `u` flag, as the JSON Schema Test Suite requires and Ajv does by default. `.` matches one code point, so @@ -128,20 +127,16 @@ decimal digits of the number, as the JSON Schema Test Suite requires, so `0.3` and `0.6` are multiples of `0.1`, `1e308` is a multiple of `0.5`, and an unrounded binary-float sum such as `0.1 + 0.2` (`0.30000000000000004`) is not. -This is deliberately not a complete JSON Schema validator. Unsupported -keywords include `patternProperties`, `prefixItems`, -`minContains`, `maxContains`, dependent/unevaluated keywords, dynamic -references, and custom vocabulary declarations. Non-`x-*` unknown extension -keywords and unsupported format labels are also reported. References to the -2020-12 meta-schema fail strict import. -The meta-schema itself is not validated. +Custom non-standard `$vocabulary` URIs that require assertion semantics and +non-`x-*` unknown keywords fail import with `unsupported_keyword`. -### References and bundles +### References, default Draft 2020-12 export, and Draft-7 lowering Supply decoded documents by retrieval URI. Relative keys and references resolve against `baseUri`; nested `$id` values establish new resource scopes. When no base is provided, the importer uses `ack-import:///root.json`. The importer -never reads files or fetches URLs. +never reads files or fetches URLs (standard `https://json-schema.org/draft/2020-12/...` +meta-schemas are bundled offline). ```dart final AckSchema schema = Ack.fromJsonSchema( @@ -161,31 +156,20 @@ final AckSchema schema = Ack.fromJsonSchema( ); ``` -Exports are self-contained, Draft-7-compatible documents with root-scoped -`definitions` and rewritten references. Original IDs, reference spellings, -and document layout are not preserved. Validation equivalence is the contract, -not textual identity. Imported schemas can be nested inside native objects and -combined with other imported or lazy schemas without losing reference scope. -Explicit JSON Pointers may also target schemas inside extension containers, -such as A2UI's `#/components/Text`. Such containers are not scanned as schemas -unless referenced. Empty and duplicate enums are normalized to valid Draft-7 -representations without changing the values they accept. - -### Import failures and diagnostics - -Unsupported features throw -`JsonSchemaImportException`, whose `diagnostics` identify each source document, -keyword, JSON Pointer, reason code, and explanation. Assertion diagnostics -cover the validation graph reachable from the imported root. Unsupported -assertions in unused definitions or unrelated supplied documents do not make -the import fail. Schema resource IDs still need to be unambiguous. - -Malformed supported keywords, unresolved references, duplicate resource IDs, -unsupported dialects, and reference cycles that do not descend into an instance -are errors. The importer checks schema structure and the keywords it compiles; -it is not full meta-schema validation of unused definitions or unsupported -vocabularies. Structural recursion is supported without adding an arbitrary -validation-depth constraint. +In Ack 2.0, `schema.toJsonSchema()` and `schema.toSchemaModel()` **preserve +imported Draft 2020-12 resources by default** (`$defs`, `$dynamicRef`, +`unevaluatedProperties`, `prefixItems`, etc.). When you need a legacy +self-contained Draft-7 document with root-scoped `definitions` and rewritten +references, call `schema.toJsonSchemaDraft7()` or `schema.toSchemaModelDraft7()` +(which throws `UnsupportedError` if the imported schema relies on 2020-12-only +semantics). + +### Generating Dart models from `.schema.json` files + +With `ack_generator`, any `lib/**/*.schema.json` asset is compiled by the +`ack_generator:ack_json_schema` builder into `.schema.g.dart`, emitting +typed `@Schemable()` Dart models when representable or a validated value-model +wrapper around `Ack.fromJsonSchema(...)` for dynamic/open schemas. ### Validation errors @@ -240,31 +224,18 @@ final AckSchema ackSchema = final builderSchema = ackSchema.toJsonSchemaBuilder(); ``` -`toAckSchema({baseUri, documents})` is strict and returns the validator directly. -It accepts `Map` bundles and asserts supported `date-time` -formats through Ack's importer. - -MCP registration compatibility is deferred to separate work; importing a -schema does not establish compatibility with a tool-registration API. +`toAckSchema({baseUri, documents, assertFormats})` returns the validator +directly and accepts `Map` bundles. `toJsonSchemaBuilder()` +preserves imported Draft 2020-12 resources by default; use +`toJsonSchemaBuilderDraft7()` when Draft-7 lowering is required. ### A2UI compatibility and verification Offline regression fixtures pin five representative A2UI v0.9 protocol schemas -to upstream commit `f5e945a93da36a5d126228f2ad6fbe68809ef8f7`. - -The basic catalog is **not an exact supported import**: it additionally uses -unevaluated-property checks, conditionals, formats, and an extension -keyword. Import therefore fails with diagnostics. The client event schema -imports successfully and validates its action timestamp as `date-time`, while -client capabilities report the three meta-schema references. Test the specific -catalog your application uses; successful validation of examples does not prove -complete catalog conformance. - -The supported-subset fixture contains 678 upstream JSON Schema instance cases -across 205 groups, including supplied remote documents. A separate optional -date-time fixture adds 43 cases. The supported-subset tests check parse, -encode, and re-imported exports without network access. Unsupported cases are -not silently skipped within that fixture. +to upstream commit `f5e945a93da36a5d126228f2ad6fbe68809ef8f7`. All five A2UI +v0.9 schemas (including the basic catalog with `unevaluatedProperties`, +conditionals, and meta-schema references) import and validate cleanly in Ack 2.0, +and the full upstream JSON Schema Draft 2020-12 test suite passes offline. ## Generating JSON schemas diff --git a/example/README.md b/example/README.md index 8912cebc..2673dcb5 100644 --- a/example/README.md +++ b/example/README.md @@ -2,8 +2,7 @@ This package demonstrates both code-generation directions with `@Schemable()`: schemas converted to immutable models, and hand-written classes converted to -codec schemas. Annotated examples declare both `.ack.dart` -and `.ack.g.dart` parts. +codec schemas. Annotated examples declare a single `.g.dart` part. ## Included examples diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 03e2df87..6930ba85 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -9,10 +9,10 @@ environment: sdk: '>=3.9.0 <4.0.0' dependencies: - ack: ^1.2.0 + ack: ^2.0.0-beta.1 dev_dependencies: - ack_generator: ^1.2.0 + ack_generator: ^2.0.0-beta.1 build_runner: ^2.4.0 dart_code_metrics_presets: ^2.22.0 lints: ^5.0.0 diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index ea2248e1..ce1998e9 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,54 @@ +## 2.0.0-beta.1 + +### Breaking + +* Remove legacy 1.x generator annotations (`@AckType`, `@AckInfer`, `@AckModel`, + `JsonKeyCaseStyle`, and `@Schemable.generatedJson`). Use `@Schemable()` on + both top-level schemas and hand-written classes/unions (`#188`, `#189`). +* Remove unused `StandardSchemaV1` (`standard_schema.dart`). `AckSchema`, + `SchemaResult`, and `AckSchemaModel` form the single canonical schema and + shape contract (`#129`). +* Seal `SchemaError` and `AckSchema` so pattern matching across all error and + schema subtypes is exhaustive without a wildcard branch. +* Rethrow programmer `Error`s (`TypeError`, `StateError`, etc.) with their + original stack trace during `CodecSchema` decode (`parse` / `safeParse`), + matching `encode` / `safeEncode` (`#163`). +* `Ack.fromJsonSchema()` now defaults to `assertFormats: false` to match JSON + Schema Draft 2020-12's default Core/Applicator/Validation vocabularies; pass + `assertFormats: true` (or declare the `format-assertion` vocabulary in + `$vocabulary`) to enforce format assertions (`#171`, `#186`). +* `schema.toJsonSchema()` and `schema.toSchemaModel()` now preserve imported + Draft 2020-12 resources by default (`$defs`, `$dynamicRef`, + `unevaluatedProperties`, `prefixItems`, etc.). Use `toJsonSchemaDraft7()` or + `toSchemaModelDraft7()` for legacy Draft-07 `definitions` lowering (`#171`). +* Deprecate legacy top-level constraint annotation classes (`@Min`, `@Max`, + `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, + `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, + `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems`) in + favor of `@Check.*` (`#191`). + +### Added + +* Add unified `@Check.*` constraint annotation namespace (`final class Check`) + exported from both `package:ack/ack.dart` and `package:ack/annotations.dart` + (`#191`). +* Add `sealed class JsonMaybe` (`JsonAbsent`, `JsonValue`) with + `JsonMaybe.absent()`, `JsonMaybe.value(T? value)`, and `JsonMaybe.nullValue()` + for PATCH tri-state presence (`#148`). +* Add `@Schemable(schemas: [...])` (supported on classes, sealed unions, and + `library;` directives) and `@AckField(name: ..., schema: ...)` for custom + schema registration and field wire-name overrides (`#185`, `#189`). +* Make `.trim()`, `.toLowerCase()`, and `.toUpperCase()` two-way normalizers + directly on `StringSchema` that execute in declaration order alongside + constraints during both `parse` and `encode`, preserve `StringSchema` + chaining, and export as `type: 'string'` without `'x-transformed': true`. +* Support the complete JSON Schema Draft 2020-12 validation runtime in + `Ack.fromJsonSchema()`, including `$dynamicRef`, `$dynamicAnchor`, + `unevaluatedProperties`, `unevaluatedItems`, `prefixItems`, + `dependentRequired`, `dependentSchemas`, `patternProperties`, `minContains`, + `maxContains`, bundled offline 2020-12 meta-schemas, and the full Draft + 2020-12 format suite (`#171`, `#186`). + ## 1.7.0-beta.7 ### Added diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index 89665a77..cb891ba8 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -1,6 +1,6 @@ name: ack description: Schema validation and bidirectional codecs for Dart and Flutter -version: 1.7.0-beta.7 +version: 2.0.0-beta.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues homepage: https://concepta.dev/ack diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index 7ee77e10..14a20b9a 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2.0.0-beta.1 + +### Breaking + +* Requires `ack: ^2.0.0-beta.1` and aligns with the coordinated Ack 2.0.0-beta.1 + release. + +### Changed + +* Regenerate Firebase AI response JSON Schema fixtures for two-way `StringSchema` + normalizer exports (`Ack.string().trim()`, `.toLowerCase()`, `.toUpperCase()`). + ## 1.7.0-beta.7 ### Changed diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index 8cfd8585..c9c0cbf1 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -23,8 +23,8 @@ Always validate model output with the same ACK schema after generation. Firebase ```yaml dependencies: - ack: ^1.2.0 - ack_firebase_ai: ^1.7.0-beta.7 + ack: ^2.0.0-beta.1 + ack_firebase_ai: ^2.0.0-beta.1 firebase_ai: ^3.12.2 ``` diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index 118120db..fc426a7f 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_firebase_ai description: Firebase AI (Gemini) schema converter for the Ack validation library -version: 1.7.0-beta.7 +version: 2.0.0-beta.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -16,7 +16,7 @@ environment: flutter: '>=3.35.0' dependencies: - ack: ^1.2.0 + ack: ^2.0.0-beta.1 flutter: sdk: flutter diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 8f7ebd94..082c6918 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,39 @@ +## 2.0.0-beta.1 + +### Breaking + +* Requires `ack: ^2.0.0-beta.1`. +* **Single-pass `.g.dart` builder (`#189`)**: `ack_generator:ack` is now a + `SharedPartBuilder` that emits `.g.dart` directly with generated + `toJson()` / `fromJson()` helpers. The two-part `.ack.dart` and `.ack.g.dart` + builders (`ack_generator`, `ack_json`) and runtime `json_serializable` + delegation are removed. Replace `part '.ack.dart';` and + `part '.ack.g.dart';` with a single `part '.g.dart';`. +* Remove legacy `@AckType`, `@AckInfer`, `@AckModel`, and `*Type` extension-type + model generation (`#188`). + +### Added + +* Support `@Check.*` constraint annotations from `package:ack/ack.dart` across + field declarations and primary/ generative constructor parameters (`#191`). +* Support `JsonMaybe` fields in `@Schemable()` class-first models for PATCH + tri-state presence (`#148`). +* Support `@AckField(name: '...')` directly without `package:json_annotation` + (`#189`). +* Support custom schema resolution on `@Schemable()` class fields via: + 1. `@AckField(schema: ...)` + 2. `@Schemable(schemas: [...])` on the class, enclosing sealed union, current + `library;` directive, or an imported `library;` directive + 3. Type-owned `T.schema` / `T.schema<...>()` + 4. Automatic detection of a unique top-level `AckSchema` + variable, getter, or zero-argument function in the current library (`#185`). +* Support two-way `StringSchema` normalizers (`.trim()`, `.toLowerCase()`, + `.toUpperCase()`) in schema-first `@Schemable()` models. +* Add `ack_generator:ack_json_schema` builder and `generateAckSchemaLibrary()` + API to compile `lib/**/*.schema.json` Draft 2020-12 documents into + `.schema.g.dart` typed models or validated value-model libraries + (`#167`). + ## 1.7.0-beta.7 ### Changed diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart index c603f027..89d2de60 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -133,7 +133,7 @@ final class AckJsonSchemaLibrary { /// /// [documentUri] identifies the document in diagnostics and resolves its /// references. [documents] supplies offline external resources. [partStem] -/// names typed model parts (`.ack.dart`); value-model fallback needs no +/// names typed model parts (`.g.dart`); value-model fallback needs no /// parts. Set [includeParts] to `false` for a plain typed schema library. /// /// Throws an [AckJsonSchemaGenerationException] for invalid or unresolvable diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 45d25eca..166373cd 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_generator description: Generates immutable models from Ack schemas and validated schemas from Dart classes -version: 1.7.0-beta.7 +version: 2.0.0-beta.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -28,7 +28,7 @@ dependencies: pub_semver: ^2.1.4 # Ack packages (versions are overridden locally by Melos) - ack: ^1.7.0-beta.3 + ack: ^2.0.0-beta.1 dev_dependencies: build_runner: ^2.1.7 diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index ec134cdc..d21f0a4d 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -1,3 +1,20 @@ +## 2.0.0-beta.1 + +### Breaking + +* Requires `ack: ^2.0.0-beta.1`. +* `toAckSchema()` now defaults to `assertFormats: false` (matching + `Ack.fromJsonSchema()` and JSON Schema Draft 2020-12 default annotation + semantics); pass `assertFormats: true` to enforce format assertions (`#171`). +* `toJsonSchemaBuilder()` now preserves imported Draft 2020-12 resources by + default; use `toJsonSchemaBuilderDraft7()` when legacy Draft-07 `definitions` + lowering is required (`#171`). + +### Added + +* Add `assertFormats` parameter to `toAckSchema()` and add + `toJsonSchemaBuilderDraft7()` on `AckSchema` (`#171`). + ## 1.7.0-beta.7 ### Changed diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index e0dec833..90d7d55b 100644 --- a/packages/ack_json_schema_builder/README.md +++ b/packages/ack_json_schema_builder/README.md @@ -25,21 +25,26 @@ final value = schema.parse({'name': 'Ada'}); final exported = schema.toJsonSchemaBuilder(); ``` -Imports use ACK's strict draft 2020-12 importer. Supply +Imports use ACK's Draft 2020-12 and Draft-07 importer. Supply cross-document references through `documents: {...}` and an -optional `baseUri`; no network requests are made. Strict imports reject +optional `baseUri`; no network requests are made. Imports reject unsupported features with `JsonSchemaImportException`; its diagnostics identify -the source keyword and location. `toAckSchema()` returns the executable -validator. Exports preserve supported validation behavior, not textual -round-trip identity. - -The subset includes objects, arrays, primitives, enum/const, numeric bounds, -length constraints, `anyOf`/`allOf`/exclusive `oneOf`/`not`, and recursive -references. Formats, patterns, multiples, dynamic references, and meta-schema -validation are not supported. Exports contain only enforced assertions. -The published A2UI basic catalog also requires unsupported unevaluated-property -checks and conditionals, so strict import rejects it. Full A2UI support and MCP -registration compatibility remain separate work. +the source keyword and location. `toAckSchema({baseUri, documents, assertFormats})` +returns the executable validator. Exports preserve imported Draft 2020-12 +resources by default (or lower compatible schemas to Draft-07 via +`toJsonSchemaBuilderDraft7()`). + +Supported keywords include objects (`properties`, `required`, +`additionalProperties`, `patternProperties`, `propertyNames`, `minProperties`, +`maxProperties`, `dependentRequired`, `dependentSchemas`, +`unevaluatedProperties`), arrays (`items`, `prefixItems`, `contains`, +`minContains`, `maxContains`, `minItems`, `maxItems`, `uniqueItems`, +`unevaluatedItems`), primitives, `enum`/`const`, numeric bounds and `multipleOf`, +length constraints, ECMA-262 Unicode `pattern`, `anyOf`/`allOf`/exclusive +`oneOf`/`not`, `if`/`then`/`else`, `$ref`/`$defs`/`definitions`, +`$dynamicRef`/`$dynamicAnchor`, offline 2020-12 meta-schemas, `x-*` vendor +extensions, and opt-in `assertFormats: true` for built-in string and numeric +formats. See the [import guide](https://concepta.dev/documentation/ack/guides/json-schema-integration) for the complete support matrix and reference/diagnostic behavior. @@ -47,14 +52,14 @@ for the complete support matrix and reference/diagnostic behavior. ```yaml dependencies: - ack: ^1.7.0-beta.7 - ack_json_schema_builder: ^1.7.0-beta.7 + ack: ^2.0.0-beta.1 + ack_json_schema_builder: ^2.0.0-beta.1 json_schema_builder: ^0.1.3 ``` ### Compatibility -Requires `ack: >=1.6.0 <2.0.0` and +Requires `ack: ^2.0.0-beta.1` and `json_schema_builder: >=0.1.3 <1.0.0` as peer dependencies. Report [compatibility issues](https://github.com/btwld/ack/issues). diff --git a/packages/ack_json_schema_builder/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index a82bce9a..14a47059 100644 --- a/packages/ack_json_schema_builder/pubspec.yaml +++ b/packages/ack_json_schema_builder/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_json_schema_builder description: JSON Schema Builder converter for the Ack validation library -version: 1.7.0-beta.7 +version: 2.0.0-beta.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -13,7 +13,7 @@ environment: sdk: '>=3.9.0 <4.0.0' dependencies: - ack: ^1.6.0 + ack: ^2.0.0-beta.1 json_schema_builder: ^0.1.3 dev_dependencies: diff --git a/packages/ack_mcp_dart/CHANGELOG.md b/packages/ack_mcp_dart/CHANGELOG.md index 12b78098..fea90368 100644 --- a/packages/ack_mcp_dart/CHANGELOG.md +++ b/packages/ack_mcp_dart/CHANGELOG.md @@ -1,3 +1,10 @@ +## 2.0.0-beta.1 + +### Breaking + +* Requires `ack: ^2.0.0-beta.1` and aligns with the coordinated Ack 2.0.0-beta.1 + release. + ## 1.7.0-beta.7 ### Changed diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index 5e002ac2..ffdc63ef 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -15,8 +15,8 @@ Transport, negotiation, cancellation, and lifecycle remain in `mcp_dart`. ```yaml dependencies: - ack: ^1.2.0 - ack_mcp_dart: ^1.7.0-beta.7 + ack: ^2.0.0-beta.1 + ack_mcp_dart: ^2.0.0-beta.1 mcp_dart: ^2.4.2 ``` diff --git a/packages/ack_mcp_dart/pubspec.yaml b/packages/ack_mcp_dart/pubspec.yaml index 69d1ef2b..c7ce363a 100644 --- a/packages/ack_mcp_dart/pubspec.yaml +++ b/packages/ack_mcp_dart/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_mcp_dart description: MCP tool schemas and typed argument validation for Ack and mcp_dart -version: 1.7.0-beta.7 +version: 2.0.0-beta.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -13,7 +13,7 @@ environment: sdk: '>=3.9.0 <4.0.0' dependencies: - ack: ^1.2.0 + ack: ^2.0.0-beta.1 mcp_dart: ^2.4.2 dev_dependencies: From 7bde538969ae91521b3ea4ada6e34de38bc5d303 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 14:42:14 -0400 Subject: [PATCH 15/29] ci: remove consolidated json_schema_format_import_test from Chrome list --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aadbc076..44c475f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,7 @@ jobs: test/schemas/extensions/list_schema_extensions_test.dart \ test/utils/collection_utils_test.dart \ test/json_schema_import_test.dart \ - test/json_schema_import_api_test.dart \ - test/json_schema_format_import_test.dart + test/json_schema_import_api_test.dart - name: Test JSON Schema import bridge in Chrome working-directory: packages/ack_json_schema_builder From 5b11c5ab5abe4b7c523909e04d0586226c9847e1 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 18:24:31 -0400 Subject: [PATCH 16/29] feat(ack,ack_generator)!: rename @Check to @Validate and polish class-first _Schema --- CHANGELOG.md | 19 +-- PUBLISHING.md | 18 +-- README.md | 16 +-- docs/api-reference/index.mdx | 60 ++++----- docs/core-concepts/typesafe-schemas.mdx | 99 ++++++++------- docs/getting-started/installation.mdx | 12 +- example/lib/class_first_models.dart | 7 +- llms.txt | 36 +++--- packages/ack/CHANGELOG.md | 8 +- packages/ack/lib/ack.dart | 2 +- .../ack/lib/src/annotations/constraints.dart | 95 +++++++------- .../src/annotations/format_constraints.dart | 4 +- .../ack/test/annotations/ack_model_test.dart | 117 +++++++++--------- .../ack/test/annotations/exports_test.dart | 14 +-- packages/ack_generator/CHANGELOG.md | 5 +- packages/ack_generator/README.md | 40 +++--- packages/ack_generator/example/example.dart | 4 +- .../analyzer/class_model_graph_builder.dart | 50 +++++++- .../lib/src/inference/schema_inference.dart | 82 +++++++----- .../class_first_runtime_build_test.dart | 8 +- .../integration/class_model_emitter_test.dart | 44 +++---- .../integration/class_model_graph_test.dart | 110 ++++++++++++++-- .../model_generation_guide_build_test.dart | 1 + .../type_owned_schema_build_test.dart | 2 +- .../integration/type_owned_schema_test.dart | 25 ++-- .../test/src/ack_exports_test.dart | 2 +- 26 files changed, 538 insertions(+), 342 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a926146..4daff5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,15 +22,16 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline `StandardSchemaV1` interface and extensions (`standard_schema.dart`). `AckSchema` with `SchemaResult` and `AckSchemaModel` is the single canonical schema and shape contract across Ack and all adapters. -* **Unified `@Check.*` constraint annotation namespace (`#191`)**: Added - `final class Check` (exported from both `package:ack/ack.dart` and - `package:ack/annotations.dart`) with `const Check.min(...)`, `Check.max(...)`, - `Check.multipleOf(...)`, `Check.positive()`, `Check.negative()`, - `Check.minLength(...)`, `Check.maxLength(...)`, `Check.matches(...)`, - `Check.notEmpty()`, `Check.email()`, `Check.url()`, `Check.uuid()`, - `Check.date()`, `Check.dateTime()`, `Check.uri()`, `Check.ip()`, - `Check.ipv4()`, `Check.ipv6()`, `Check.minItems(...)`, `Check.maxItems(...)`, - and `Check.uniqueItems()` / `Check.unique()`. Legacy top-level constraint +* **Unified `@Validate.*` constraint annotation namespace (`#191`)**: Added + `final class Validate` (exported from both `package:ack/ack.dart` and + `package:ack/annotations.dart`) with `const Validate.min(...)`, + `Validate.max(...)`, `Validate.multipleOf(...)`, `Validate.positive()`, + `Validate.negative()`, `Validate.minLength(...)`, `Validate.maxLength(...)`, + `Validate.matches(...)`, `Validate.notEmpty()`, `Validate.email()`, + `Validate.url()`, `Validate.uuid()`, `Validate.date()`, `Validate.dateTime()`, + `Validate.uri()`, `Validate.ip()`, `Validate.ipv4()`, `Validate.ipv6()`, + `Validate.minItems(...)`, `Validate.maxItems(...)`, and + `Validate.uniqueItems()` / `Validate.unique()`. Legacy top-level constraint annotation classes (`@Min`, `@MinLength`, `@Email`, etc.) are deprecated. * **Sealed `SchemaError` and `AckSchema` hierarchies**: `SchemaError` and `AckSchema` are now `sealed class`es so `switch (error)` and `switch (schema)` diff --git a/PUBLISHING.md b/PUBLISHING.md index 3eaf745e..ae8a006b 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -14,15 +14,15 @@ accepted exception to this major-version policy. The next candidate is **2.0.0-beta.1**. It introduces single-pass `.g.dart` code generation without a runtime `json_serializable` delegation step, the -unified `@Check.*` constraint annotation namespace, custom schema resolution via -`@Schemable(schemas: [...])` and automatic same-library detection, `JsonMaybe` -PATCH presence semantics, two-way `StringSchema` normalizers (`.trim()`, -`.toLowerCase()`, `.toUpperCase()`), sealed `SchemaError` and `AckSchema` -hierarchies, full JSON Schema Draft 2020-12 runtime validation and `.schema.json` -model generation with default Draft 2020-12 preservation on `toJsonSchema()`, -and removal of deprecated 1.x compatibility shims (`StandardSchemaV1`, -`@AckType`, `@AckInfer`, `@AckModel`, and `.ack.dart` / `.ack.g.dart` dual-part -generation). +unified `@Validate.*` constraint annotation namespace, custom schema resolution +via `@Schemable(schemas: [...])` and automatic same-library detection, +`JsonMaybe` PATCH presence semantics, two-way `StringSchema` normalizers +(`.trim()`, `.toLowerCase()`, `.toUpperCase()`), sealed `SchemaError` and +`AckSchema` hierarchies, full JSON Schema Draft 2020-12 runtime validation and +`.schema.json` model generation with default Draft 2020-12 preservation on +`toJsonSchema()`, and removal of deprecated 1.x compatibility shims +(`StandardSchemaV1`, `@AckType`, `@AckInfer`, `@AckModel`, and `.ack.dart` / +`.ack.g.dart` dual-part generation). The shared API baseline for this release is 1.6.2. Confirm that all five packages have published 1.6.2 versions before preparing the release. diff --git a/README.md b/README.md index 6f5bfd80..3c0e6376 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Generate immutable models for hand-written schemas with `@Schemable()`. Add `ack` to `dependencies` and `ack_generator` + `build_runner` to `dev_dependencies`, then annotate a top-level schema. `@Schemable()`, the model annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the -`@Check.*` constraint namespace are all exported from `package:ack/ack.dart`: +`@Validate.*` constraint namespace are all exported from `package:ack/ack.dart`: ```dart import 'package:ack/ack.dart'; @@ -160,9 +160,10 @@ because a generated model must be encodable. See the [Model Code Generation guide](docs/core-concepts/typesafe-schemas.mdx). Already own the model class? Put `@Schemable()` on it to derive a codec schema from -constructor-backed fields while keeping the class hand-written. A class named -`Account` receives an `AccountSchema` facade for parsing, encoding, schema -export, and nested composition; the backing codec remains private: +constructor-backed fields (including Dart 3.12+ primary constructors) while +keeping the class hand-written. A class named `Account` receives a private +`_accountSchema` codec and a public `AccountSchema` facade for parsing, encoding, +schema export, and nested composition: ```dart import 'package:ack/ack.dart'; @@ -173,15 +174,16 @@ part 'account.g.dart'; final class Account with _$AccountAck { const Account({required this.name}); - @Check.minLength(2) + @Validate.minLength(2) final String name; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` -`Account.fromJson({'name': 'Ada'})` validates and constructs the model, while -`account.toJson()` validates and encodes it. +`Account.fromJson({'name': 'Ada'})` (or `Account.schema.parse(...)`) validates +and constructs the model, while `account.toJson()` validates and encodes it. Custom field types resolve automatically through a static `schema` on the type (`Slot.schema`, `Ack.list(Slot.schema)`, `Command.schema()`), a unique diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 8aac156b..b100853f 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -314,7 +314,7 @@ either direction with one annotation, `@Schemable()`: on a top-level schema it generates a model, on a class it generates a schema, and on a `library;` directive it registers shared custom schemas via `@Schemable(schemas: [...])`. `package:ack/ack.dart` exports `@Schemable`, the model annotations (`@AckField`, -`@Optional`, `@Required`, `@NotNull`), and the `@Check.*` constraint namespace. +`@Optional`, `@Required`, `@NotNull`), and the `@Validate.*` constraint namespace. `AckSchema` is the runtime schema type; there is no `@AckSchema()` annotation. After adding a `part '.g.dart';` directive, run: @@ -380,31 +380,32 @@ constructors remain responsible for defensively copying direct inputs. **Target**: Public, constructable classes **Generates**: A public `Schema` facade backed by a private raw -`wireSchema` and typed codec, plus the `_$ClassAck` mixin for `toJson`, -`safeToJson`, `copyWith`, and deep collection-aware `==` / `hashCode` / -`toString` - -Ack infers schema fields from constructor-backed fields. Required parameters, -nullable types, optional parameters, `JsonMaybe`, and constructor defaults -determine field presence. Use `caseStyle` for model-wide JSON naming and -`@AckField(name: ...)` on the field for a wire-name override. Instantiable -models and implicit union branches must apply the generated mixin. In -`copyWith`, omission keeps the current value and explicit `null` clears a -nullable field. `copyWith` returns a typed interface, so a wrongly typed -argument is a compile-time error. +`wireSchema` and typed codec (`_Schema`), plus the `_$ClassAck` mixin +for `toJson`, `safeToJson`, `copyWith`, and deep collection-aware `==` / +`hashCode` / `toString` + +Ack infers schema fields from constructor-backed fields (including Dart 3.12+ +primary constructors). Required parameters, nullable types, optional parameters, +`JsonMaybe`, and constructor defaults determine field presence. Use +`caseStyle` for model-wide JSON naming and `@AckField(name: ...)` on the field +or primary constructor parameter for a wire-name override. Instantiable models +and implicit union branches must apply the generated mixin. In `copyWith`, +omission keeps the current value and explicit `null` clears a nullable field. +`copyWith` returns a typed interface, so a wrongly typed argument is a +compile-time error. **Inferred field types:** strings, booleans, numeric types, `DateTime`, `Uri`, `Duration`, enums, `JsonMaybe`, nested lists, sets, and string-keyed maps -**Constraint annotations (`@Check.*` from `package:ack/ack.dart`):** +**Constraint annotations (`@Validate.*` from `package:ack/ack.dart`):** -- numeric: `@Check.min`, `@Check.max`, `@Check.multipleOf`, `@Check.positive`, `@Check.negative` -- strings: `@Check.minLength`, `@Check.maxLength`, `@Check.matches`, `@Check.email`, `@Check.notEmpty` -- string formats: `@Check.url`, `@Check.uri`, `@Check.uuid`, `@Check.date`, `@Check.dateTime` (`@Check.datetime`), `@Check.ip`, `@Check.ipv4`, `@Check.ipv6` -- collections: `@Check.minItems`, `@Check.maxItems`, `@Check.uniqueItems` (`@Check.unique`) +- numeric: `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative` +- strings: `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.email`, `@Validate.notEmpty` +- string formats: `@Validate.url`, `@Validate.uri`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime` (`@Validate.datetime`), `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6` +- collections: `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` (`@Validate.unique`) **Custom schemas:** a field of custom type `T` resolves through: -1. `@AckField(schema: ...)` on the field; +1. `@AckField(schema: ...)` on the field or constructor parameter; 2. `@Schemable(schemas: [...])` on the class, union base, `library;` directive, or an imported `library;` directive; 3. a static `T.schema` / `T.schema(...)` on the type; 4. a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. @@ -426,29 +427,32 @@ part 'account.g.dart'; final class Account with _$AccountAck { const Account({required this.displayName, this.role = 'member'}); - @Check.minLength(2) + @Validate.minLength(2) final String displayName; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } -final account = AccountSchema.parse({'display_name': 'Ada'}); +final account = Account.schema.parse({'display_name': 'Ada'})!; print(account.toJson()); -print(AccountSchema.toJsonSchema()); +print(Account.schema.toJsonSchema()); ``` The facade exposes `schema`, `wireSchema`, `parse`, `safeParse`, `fromJson`, `encode`, `safeEncode`, `toJsonSchema`, and `toSchemaModel`. The backing -`_accountSchema` is library-private and no public lower-camel alias is +`_accountSchema` is library-private (`static final schema = _accountSchema;` +attaches it directly to `Account.schema`) and no public lower-camel alias is generated. Set `schemaName: 'WireAccountSchema'` to choose the exact facade class name. -Nested class-first fields compose through `AddressSchema.schema`. Imports with -combinators must expose both names (`show Address, AddressSchema`). A -schema-first `@Schemable()` declaration may also compose the facade explicitly, -and class-first models can use schema-first generated model types on a clean -build. +Nested class-first fields compose through `AddressSchema.schema` (or +`Address.schema` when declared on `Address`). Imports with combinators can +expose just `show Address` when `Address.schema` is declared, or +`show Address, AddressSchema` otherwise. A schema-first `@Schemable()` +declaration may also compose the facade explicitly, and class-first models can +use schema-first generated model types on a clean build. A class-first `@Schemable()` cannot share a class with `@JsonSerializable()`, and `name` is rejected on a class. Class-first value roots, automatic recursive diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index c83351f9..3d7735da 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -11,7 +11,7 @@ and generate a Dart model, or start with a Dart model and generate its schema. | A Dart class | `@Schemable()` | An Ack codec schema and JSON helpers | `@Schemable()`, the model annotations (`@AckField`, `@Optional`, -`@Required`, `@NotNull`), and the `@Check.*` constraint namespace come from +`@Required`, `@NotNull`), and the `@Validate.*` constraint namespace come from `package:ack/ack.dart`. Legacy top-level constraint annotations in `package:ack/annotations.dart` and `package:ack/format_annotations.dart` are deprecated. @@ -54,7 +54,7 @@ final orderSchema = Ack.object({ 'total': Ack.double().positive(), }); -// Class-first: write Account; Ack generates AccountSchema. +// Class-first: write Account; Ack generates _accountSchema and AccountSchema. @Schemable(caseStyle: AckCaseStyle.snake) final class Account with _$AccountAck { const Account({ @@ -65,16 +65,17 @@ final class Account with _$AccountAck { this.role = 'member', }); - @Check.minLength(2) + @Validate.minLength(2) final String displayName; - @Check.email() + @Validate.email() final String email; final String? middleName; final Uri? website; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` @@ -172,30 +173,35 @@ This generates a sealed `Pet` base plus `Cat` and `Dog` branches. ## Class-first: `@Schemable()` on a class Use class-first generation when the Dart class is the primary artifact. Ack -reads constructor-backed fields and generates a codec schema whose runtime -value is your class: +reads constructor-backed fields (including Dart 3.12+ primary constructors) and +generates a codec schema whose runtime value is your class: ```dart -final profile = ProfileSchema.parse(payload); // Profile -final result = ProfileSchema.safeParse(payload); -final json = profile.toJson(); +@Schemable(caseStyle: AckCaseStyle.snake) +final class Profile({ + @Validate.minLength(2) required final String displayName, + @Validate.email() required final String email, + final String role = 'member', +}) with _$ProfileAck { + static final schema = _profileSchema; + static final fromJson = ProfileSchema.fromJson; +} ``` -Ack generates a public `ProfileSchema` facade and keeps the codec itself in a -library-private `_profileSchema` variable. The facade is the only public schema -entry point: +Ack generates a library-private `_profileSchema` codec variable and a public +`ProfileSchema` facade: - `ProfileSchema.parse` and `safeParse` validate input; - `ProfileSchema.fromJson` is the one-argument map convenience; - `ProfileSchema.encode` and `safeEncode` validate while encoding; - `ProfileSchema.toJsonSchema()` exports the boundary JSON Schema; - `ProfileSchema.toSchemaModel()` exports Ack's canonical schema model; -- `ProfileSchema.schema` is the typed model codec used for composition; +- `ProfileSchema.schema` is the typed model codec (`_profileSchema`) used for composition; - `ProfileSchema.wireSchema` is the raw structural `Map` schema. Every instantiable `@Schemable` class and implicit sealed-union branch must be declared `final class`, initialize its fields through an unnamed generative -constructor, contain only final stored fields, and apply its +or primary constructor, contain only final stored fields, and apply its generated `_$ClassAck` mixin. The mixin supplies `toJson()`, `safeToJson()`, a typed `copyWith` where omission keeps the current value, explicit `null` clears a nullable field, and a wrongly typed argument is a compile-time error, @@ -209,15 +215,23 @@ replacements passed to `copyWith` must defensively copy mutable inputs in the source class when that guarantee is required. Generated `toJson()` and `safeToJson()` methods delegate through that same -facade. Ack cannot inject a constructor into a hand-written class, so the -recommended conventional entry point is an inferred static tear-off: +facade. Ack cannot inject static members or constructors into a hand-written +class, so the recommended conventions are one-line static tear-offs/fields: ```dart +static final schema = _profileSchema; static final fromJson = ProfileSchema.fromJson; ``` -This is a callable static field. If a framework specifically requires a -constructor, use: +`static final schema = _profileSchema;` (or `ProfileSchema.schema`) attaches the +generated codec directly to `Profile.schema` so callers can write +`Profile.schema.parse(...)`, `Profile.schema.toJsonSchema()`, or +`Ack.list(Profile.schema)`, and other libraries only need to import `Profile`. +Both lines are optional: if omitted, `ProfileSchema` still provides every static +entry point and nested `@Schemable()` models still compose automatically. +Generation rejects a `schema` static whose type produces another class. + +If a framework specifically requires a factory constructor for `fromJson`, use: ```dart factory Profile.fromJson(Map json) => @@ -231,15 +245,6 @@ static final Profile Function(Map) fromJson = ProfileSchema.fromJson; ``` -The same convention gives the class a `schema` of its own, so other code can -write `Profile.schema`: - -```dart -static final schema = ProfileSchema.schema; -``` - -Generation rejects a `schema` static whose type produces another class. - Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export schema descriptions. A single-line `@description` tag in a `///` or `/** */` doc comment is a fallback. Only the text after the tag, on the same line, is exported. Untagged @@ -331,25 +336,25 @@ Map values may be `null` only when the value type is nullable. `List` stays rejected because `Ack.list` has no nullable-item contract. Parsed `Object` values are recursively unmodifiable, like other stored collections. -Constraint annotations use `@Check.*` and follow the field type: +Constraint annotations use `@Validate.*` and follow the field type: -- numeric: `@Check.min`, `@Check.max`, `@Check.multipleOf`, `@Check.positive`, `@Check.negative`; -- strings: `@Check.minLength`, `@Check.maxLength`, `@Check.matches`, `@Check.email`, `@Check.notEmpty`; -- string formats: `@Check.url`, `@Check.uri`, `@Check.uuid`, `@Check.date`, `@Check.dateTime` (`@Check.datetime`), `@Check.ip`, `@Check.ipv4`, `@Check.ipv6`; -- lists and sets: `@Check.minItems`, `@Check.maxItems`, `@Check.uniqueItems` (`@Check.unique`). +- numeric: `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative`; +- strings: `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.email`, `@Validate.notEmpty`; +- string formats: `@Validate.url`, `@Validate.uri`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime` (`@Validate.datetime`), `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6`; +- lists and sets: `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` (`@Validate.unique`). -`@Check.matches` is the annotation form of `Ack.string().matches(...)`. Numeric +`@Validate.matches` is the annotation form of `Ack.string().matches(...)`. Numeric constraint values must be finite. On a `Set>` field the collection constraints apply to the outer set. Use `caseStyle` for model-wide JSON names. Supported values are `none`, `snake`, `kebab`, `pascal`, and `screamingSnake`. Use -`@AckField(name: 'wire_name')` on the field for a single override (or -`@JsonKey(name: 'wire_name')` from `package:json_annotation/json_annotation.dart`). +`@AckField(name: 'wire_name')` on the field or primary constructor parameter for +a single override (or `@JsonKey(name: 'wire_name')` from +`package:json_annotation/json_annotation.dart`). Ack resolves each wire key once and uses it for both validation and JSON -mapping. Other `JsonKey` options, constructor-parameter placement, and -`JsonConverter` annotations are rejected so the Ack schema and generated JSON -mapping cannot diverge. +mapping. Other `JsonKey` options and `JsonConverter` annotations are rejected +so the Ack schema and generated JSON mapping cannot diverge. ### Custom schemas and types that own their schema @@ -559,7 +564,8 @@ inputs without validating them. ### Reusing generated schemas -Nested class-first fields compose through the target facade automatically: +Nested class-first fields compose through the target facade (or `Address.schema` +when declared on the class) automatically: ```dart import 'address.dart' as address; @@ -573,7 +579,7 @@ final class Order with _$OrderAck { // Generated field schema: address.AddressSchema.schema ``` -Schema-first declarations can use the same facade explicitly: +Schema-first declarations can use `Address.schema` or the facade explicitly: ```dart @Schemable() @@ -594,9 +600,16 @@ visible in the annotated library itself. A type reached only through a `typedef` from another library, or a codec's runtime type from a library you did not import, fails generation with the library to import. -The complete import route must expose each authored declaration and generated -companion. For class-first composition that means the hand-written model and -its facade: +When importing with `show` / `hide` combinators, if a class-first model declares +`static final schema = _addressSchema;`, importing libraries only need to expose +`Address`: + +```dart +import 'address.dart' show Address; +``` + +If `Address` does not declare a static `schema`, expose both the hand-written +model and its generated facade: ```dart import 'address.dart' show Address, AddressSchema; diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index dd153904..aad2e42a 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -46,11 +46,12 @@ dev_dependencies: ``` The generator works in both directions with one annotation, `@Schemable()`, -and one constraint namespace, `@Check.*`, both exported from +and one constraint namespace, `@Validate.*`, both exported from `package:ack/ack.dart`. `@Schemable()` on a top-level schema generates an -immutable model. `@Schemable()` on a hand-written class generates a public -`Schema` facade, a private Ack codec, and JSON helpers. -There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. +immutable model. `@Schemable()` on a hand-written class generates a +`_Schema` codec, a public `Schema` facade, and JSON +helpers. There is no `@AckSchema()` annotation; `AckSchema` is the runtime +schema type. ```dart import 'package:ack/ack.dart'; @@ -66,8 +67,11 @@ final userSchema = Ack.object({ @Schemable() final class Account with _$AccountAck { const Account({required this.name}); + + @Validate.minLength(2) final String name; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index f133d77f..5bcb3d9a 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -15,12 +15,13 @@ final class Account with _$AccountAck { }); /// The name shown to other users. - @Check.minLength(2) + @Validate.minLength(2) @AckField(description: 'The name shown to other users.') final String displayName; final Uri? website; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } @@ -39,8 +40,8 @@ sealed class Pet with _$PetAck { final class Cat extends Pet with _$CatAck { const Cat({required super.id, required this.lives}); - @Check.min(1) - @Check.max(9) + @Validate.min(1) + @Validate.max(9) final int lives; } diff --git a/llms.txt b/llms.txt index 5fea9d55..4ca0a219 100644 --- a/llms.txt +++ b/llms.txt @@ -7,7 +7,7 @@ factory. Annotate a top-level schema with `@Schemable()` to generate a model, or annotate a hand-written class with `@Schemable()` to generate its schema and JSON helpers. `package:ack/ack.dart` exports `@Schemable`, the model annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the -`@Check.*` constraint namespace. There is no `@AckSchema()` annotation; +`@Validate.*` constraint namespace. There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. ## Packages @@ -94,7 +94,8 @@ Generated object models provide: - a public static `User.$ack` adapter for nested generated models; - an unmodifiable `additionalProperties` map for passthrough objects. -For class-first generation: +For class-first generation (supporting both standard constructors and Dart 3.12+ +primary constructors): ```dart import 'package:ack/ack.dart'; @@ -105,10 +106,11 @@ part 'account.g.dart'; final class Account with _$AccountAck { const Account({required this.displayName, this.role = 'member'}); - @Check.minLength(2) + @Validate.minLength(2) final String displayName; final String role; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` @@ -120,7 +122,7 @@ equality. The facade exposes `schema`, `wireSchema`, `parse`, `safeParse`, `fromJson`, `encode`, `safeEncode`, `toJsonSchema`, and `toSchemaModel`. `schemaName:` overrides the exact public UpperCamelCase facade name; no public lower-camel alias is emitted. Constructor parameters determine presence and -defaults. Field types and `@Check.*` constraint annotations determine the schema. Sealed +defaults. Field types and `@Validate.*` constraint annotations determine the schema. Sealed classes use `@Schemable(discriminatorKey: ...)`; same-library concrete branches are included automatically. Use `@Optional()` or `@Required()` to override inferred key presence, `@NotNull()` to reject an explicit JSON `null` without @@ -185,17 +187,19 @@ static `schema` or a `@Schemable(schemas: [...])` entry resolves to it instead o Extension types work the same way when they implement `Object`. The schema must produce the field type, or generation fails. A class-first model -may expose its facade as `static final schema = AccountSchema.schema;`. -Schema-first objects may reference the same members (`'header': Slot.schema`, -`'open': Command.schema()`). - -Nested class-first models compose through `AddressSchema.schema`, preserving -import prefixes. Import combinators and barrels must expose both `Address` and -`AddressSchema`. Schema-first declarations may explicitly use the facade, and -class-first fields may use schema-first generated model types; both directions -work from a clean build. Nullable/list/set wrappers are preserved. Automatic -recursive class-first graphs are rejected; use schema-first named `Ack.lazy` -for recursive contracts. +may expose its generated codec as `static final schema = _accountSchema;` (or +`AccountSchema.schema`). Schema-first objects may reference the same members +(`'header': Slot.schema`, `'open': Command.schema()`). + +Nested class-first models compose through `AddressSchema.schema` (or +`Address.schema` when declared on `Address`), preserving import prefixes. Import +combinators and barrels can expose just `Address` when `Address.schema` is +declared, or both `Address` and `AddressSchema` otherwise. Schema-first +declarations may explicitly use `Address.schema` or the facade, and class-first +fields may use schema-first generated model types; both directions work from a +clean build. Nullable/list/set wrappers are preserved. Automatic recursive +class-first graphs are rejected; use schema-first named `Ack.lazy` for recursive +contracts. Generated models do not implement `Map`, `List`, or scalar interfaces. Scalar and collection roots are value models with a `.value` field. Use model fields @@ -223,7 +227,7 @@ Class-first generation supports object models, inferred scalar and enum fields, JSON-safe `Object` fields, nested lists, sets through list codecs, string-keyed maps, `JsonMaybe`, custom schemas (`@AckField(schema: ...)`, `@Schemable(schemas: [...])`, `T.schema`, or auto-detected top-level library -schemas), `@Optional()`, `@Required()`, `@NotNull()`, `@Check.*` constraints, +schemas), `@Optional()`, `@Required()`, `@NotNull()`, `@Validate.*` constraints, constructor defaults, case styles, `AckUnknownPropertyPolicy`, and same-library sealed discriminated unions. It rejects `dynamic`, nullable list or set elements, non-String map keys, recursive class-first graphs, class-first value diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index ce1998e9..060b8eb9 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -25,13 +25,13 @@ `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems`) in - favor of `@Check.*` (`#191`). + favor of `@Validate.*` (`#191`). ### Added -* Add unified `@Check.*` constraint annotation namespace (`final class Check`) - exported from both `package:ack/ack.dart` and `package:ack/annotations.dart` - (`#191`). +* Add unified `@Validate.*` constraint annotation namespace + (`final class Validate`) exported from both `package:ack/ack.dart` and + `package:ack/annotations.dart` (`#191`). * Add `sealed class JsonMaybe` (`JsonAbsent`, `JsonValue`) with `JsonMaybe.absent()`, `JsonMaybe.value(T? value)`, and `JsonMaybe.nullValue()` for PATCH tri-state presence (`#148`). diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index ba883782..6b5aeab8 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -50,5 +50,5 @@ export 'src/validation/schema_error.dart'; export 'src/validation/schema_result.dart'; // Model annotations. Legacy top-level constraint annotations live in annotations.dart. export 'src/annotations/ack_field.dart'; -export 'src/annotations/constraints.dart' show Check; +export 'src/annotations/constraints.dart' show Validate; export 'src/annotations/schemable.dart'; diff --git a/packages/ack/lib/src/annotations/constraints.dart b/packages/ack/lib/src/annotations/constraints.dart index 156b4256..bf1136b3 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -1,24 +1,21 @@ import 'package:meta/meta_meta.dart'; -/// Unified namespace for Ack field and parameter constraint annotations. +/// Unified namespace for Ack field and parameter validation annotations. /// /// Mirrors the fluent schema constraint methods so model files only need /// `import 'package:ack/ack.dart';`: /// /// ```dart /// @Schemable() -/// final class User with _$UserAck { -/// const User({required this.email, required this.name}); -/// -/// @Check.email() -/// final String email; -/// -/// @Check.minLength(2) -/// final String name; +/// final class User({ +/// @Validate.email() required final String email, +/// @Validate.minLength(2) required final String name, +/// }) with _$UserAck { +/// static final schema = _userSchema; /// } /// ``` @Target({TargetKind.field, TargetKind.parameter}) -final class Check { +final class Validate { /// The constraint identifier inspected by `ack_generator`. final String kind; @@ -26,77 +23,77 @@ final class Check { final Object? value; /// Adds `.min(value)` to an inferred numeric schema. - const Check.min(num this.value) : kind = 'min'; + const Validate.min(num this.value) : kind = 'min'; /// Adds `.max(value)` to an inferred numeric schema. - const Check.max(num this.value) : kind = 'max'; + const Validate.max(num this.value) : kind = 'max'; /// Adds `.multipleOf(value)` to an inferred numeric schema. - const Check.multipleOf(num this.value) : kind = 'multipleOf'; + const Validate.multipleOf(num this.value) : kind = 'multipleOf'; /// Adds `.positive()` to an inferred numeric schema. - const Check.positive() : kind = 'positive', value = null; + const Validate.positive() : kind = 'positive', value = null; /// Adds `.negative()` to an inferred numeric schema. - const Check.negative() : kind = 'negative', value = null; + const Validate.negative() : kind = 'negative', value = null; /// Adds `.minLength(length)` to an inferred string schema. - const Check.minLength(int length) : kind = 'minLength', value = length; + const Validate.minLength(int length) : kind = 'minLength', value = length; /// Adds `.maxLength(length)` to an inferred string schema. - const Check.maxLength(int length) : kind = 'maxLength', value = length; + const Validate.maxLength(int length) : kind = 'maxLength', value = length; /// Adds `.matches(pattern)` to an inferred string schema. - const Check.matches(String pattern) : kind = 'matches', value = pattern; + const Validate.matches(String pattern) : kind = 'matches', value = pattern; /// Adds `.notEmpty()` to an inferred string schema. - const Check.notEmpty() : kind = 'notEmpty', value = null; + const Validate.notEmpty() : kind = 'notEmpty', value = null; /// Adds `.email()` to an inferred string schema. - const Check.email() : kind = 'email', value = null; + const Validate.email() : kind = 'email', value = null; /// Adds `.url()` to an inferred string schema. - const Check.url() : kind = 'url', value = null; + const Validate.url() : kind = 'url', value = null; /// Adds `.uri()` to an inferred string schema. - const Check.uri() : kind = 'uri', value = null; + const Validate.uri() : kind = 'uri', value = null; /// Adds `.uuid()` to an inferred string schema. - const Check.uuid() : kind = 'uuid', value = null; + const Validate.uuid() : kind = 'uuid', value = null; /// Adds `.date()` to an inferred string schema. - const Check.date() : kind = 'date', value = null; + const Validate.date() : kind = 'date', value = null; /// Adds `.datetime()` to an inferred string schema. - const Check.dateTime() : kind = 'dateTime', value = null; + const Validate.dateTime() : kind = 'dateTime', value = null; /// Adds `.datetime()` to an inferred string schema. - const Check.datetime() : kind = 'datetime', value = null; + const Validate.datetime() : kind = 'datetime', value = null; /// Adds `.ip()` to an inferred string schema. - const Check.ip({int? version}) : kind = 'ip', value = version; + const Validate.ip({int? version}) : kind = 'ip', value = version; /// Adds `.ipv4()` to an inferred string schema. - const Check.ipv4() : kind = 'ipv4', value = null; + const Validate.ipv4() : kind = 'ipv4', value = null; /// Adds `.ipv6()` to an inferred string schema. - const Check.ipv6() : kind = 'ipv6', value = null; + const Validate.ipv6() : kind = 'ipv6', value = null; /// Adds `.minItems(count)` to an inferred collection schema. - const Check.minItems(int count) : kind = 'minItems', value = count; + const Validate.minItems(int count) : kind = 'minItems', value = count; /// Adds `.maxItems(count)` to an inferred collection schema. - const Check.maxItems(int count) : kind = 'maxItems', value = count; + const Validate.maxItems(int count) : kind = 'maxItems', value = count; /// Adds `.unique()` to an inferred collection schema. - const Check.uniqueItems() : kind = 'uniqueItems', value = null; + const Validate.uniqueItems() : kind = 'uniqueItems', value = null; /// Adds `.unique()` to an inferred collection schema. - const Check.unique() : kind = 'unique', value = null; + const Validate.unique() : kind = 'unique', value = null; } /// Adds `.min(value)` to an inferred numeric schema. -@Deprecated('Use @Check.min(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.min(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Min { const Min(this.value); @@ -105,7 +102,7 @@ final class Min { } /// Adds `.max(value)` to an inferred numeric schema. -@Deprecated('Use @Check.max(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.max(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Max { const Max(this.value); @@ -114,7 +111,7 @@ final class Max { } /// Adds `.multipleOf(value)` to an inferred numeric schema. -@Deprecated('Use @Check.multipleOf(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.multipleOf(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MultipleOf { const MultipleOf(this.value); @@ -123,21 +120,21 @@ final class MultipleOf { } /// Adds `.positive()` to an inferred numeric schema. -@Deprecated('Use @Check.positive() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.positive() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Positive { const Positive(); } /// Adds `.negative()` to an inferred numeric schema. -@Deprecated('Use @Check.negative() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.negative() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Negative { const Negative(); } /// Adds `.minLength(length)` to an inferred string schema. -@Deprecated('Use @Check.minLength(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.minLength(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MinLength { const MinLength(this.length); @@ -146,7 +143,7 @@ final class MinLength { } /// Adds `.maxLength(length)` to an inferred string schema. -@Deprecated('Use @Check.maxLength(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.maxLength(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MaxLength { const MaxLength(this.length); @@ -155,7 +152,7 @@ final class MaxLength { } /// Adds `.matches(pattern)` to an inferred string schema. -@Deprecated('Use @Check.matches(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.matches(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Matches { const Matches(this.pattern); @@ -164,42 +161,42 @@ final class Matches { } /// Adds `.email()` to an inferred string schema. -@Deprecated('Use @Check.email() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.email() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Email { const Email(); } /// Adds `.url()` to an inferred string schema. -@Deprecated('Use @Check.url() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.url() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Url { const Url(); } /// Adds `.uuid()` to an inferred string schema. -@Deprecated('Use @Check.uuid() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.uuid() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Uuid { const Uuid(); } /// Adds `.date()` to an inferred string schema. -@Deprecated('Use @Check.date() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.date() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Date { const Date(); } /// Adds `.notEmpty()` to an inferred string schema. -@Deprecated('Use @Check.notEmpty() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.notEmpty() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class NotEmpty { const NotEmpty(); } /// Adds `.minItems(count)` to an inferred collection schema. -@Deprecated('Use @Check.minItems(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.minItems(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MinItems { const MinItems(this.count); @@ -208,7 +205,7 @@ final class MinItems { } /// Adds `.maxItems(count)` to an inferred collection schema. -@Deprecated('Use @Check.maxItems(...) from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.maxItems(...) from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class MaxItems { const MaxItems(this.count); @@ -217,7 +214,7 @@ final class MaxItems { } /// Adds `.unique()` to an inferred collection schema. -@Deprecated('Use @Check.uniqueItems() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.uniqueItems() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class UniqueItems { const UniqueItems(); diff --git a/packages/ack/lib/src/annotations/format_constraints.dart b/packages/ack/lib/src/annotations/format_constraints.dart index 56d51167..457acd87 100644 --- a/packages/ack/lib/src/annotations/format_constraints.dart +++ b/packages/ack/lib/src/annotations/format_constraints.dart @@ -1,14 +1,14 @@ import 'package:meta/meta_meta.dart'; /// Adds `.uri()` to an inferred string schema. -@Deprecated('Use @Check.uri() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.uri() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class Uri { const Uri(); } /// Adds `.datetime()` to an inferred string schema. -@Deprecated('Use @Check.dateTime() from package:ack/ack.dart instead.') +@Deprecated('Use @Validate.dateTime() from package:ack/ack.dart instead.') @Target({TargetKind.field, TargetKind.parameter}) final class DateTime { const DateTime(); diff --git a/packages/ack/test/annotations/ack_model_test.dart b/packages/ack/test/annotations/ack_model_test.dart index c90240d4..b9a7d5aa 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -72,64 +72,67 @@ void main() { expect(notNull, isA()); }); - test('Check.* constraint annotations are const and expose kind + value', () { - const checks = [ - Check.min(1), - Check.max(9.5), - Check.multipleOf(2), - Check.positive(), - Check.negative(), - Check.minLength(1), - Check.maxLength(100), - Check.matches(r'^[a-z]+$'), - Check.notEmpty(), - Check.email(), - Check.url(), - Check.uri(), - Check.uuid(), - Check.date(), - Check.dateTime(), - Check.datetime(), - Check.ip(), - Check.ip(version: 4), - Check.ipv4(), - Check.ipv6(), - Check.minItems(1), - Check.maxItems(10), - Check.uniqueItems(), - Check.unique(), - ]; + test( + 'Validate.* constraint annotations are const and expose kind + value', + () { + const checks = [ + Validate.min(1), + Validate.max(9.5), + Validate.multipleOf(2), + Validate.positive(), + Validate.negative(), + Validate.minLength(1), + Validate.maxLength(100), + Validate.matches(r'^[a-z]+$'), + Validate.notEmpty(), + Validate.email(), + Validate.url(), + Validate.uri(), + Validate.uuid(), + Validate.date(), + Validate.dateTime(), + Validate.datetime(), + Validate.ip(), + Validate.ip(version: 4), + Validate.ipv4(), + Validate.ipv6(), + Validate.minItems(1), + Validate.maxItems(10), + Validate.uniqueItems(), + Validate.unique(), + ]; - expect( - checks.map((c) => (c.kind, c.value)).toList(), - const <(String, Object?)>[ - ('min', 1), - ('max', 9.5), - ('multipleOf', 2), - ('positive', null), - ('negative', null), - ('minLength', 1), - ('maxLength', 100), - ('matches', r'^[a-z]+$'), - ('notEmpty', null), - ('email', null), - ('url', null), - ('uri', null), - ('uuid', null), - ('date', null), - ('dateTime', null), - ('datetime', null), - ('ip', null), - ('ip', 4), - ('ipv4', null), - ('ipv6', null), - ('minItems', 1), - ('maxItems', 10), - ('uniqueItems', null), - ('unique', null), - ], - ); - }); + expect( + checks.map((c) => (c.kind, c.value)).toList(), + const <(String, Object?)>[ + ('min', 1), + ('max', 9.5), + ('multipleOf', 2), + ('positive', null), + ('negative', null), + ('minLength', 1), + ('maxLength', 100), + ('matches', r'^[a-z]+$'), + ('notEmpty', null), + ('email', null), + ('url', null), + ('uri', null), + ('uuid', null), + ('date', null), + ('dateTime', null), + ('datetime', null), + ('ip', null), + ('ip', 4), + ('ipv4', null), + ('ipv6', null), + ('minItems', 1), + ('maxItems', 10), + ('uniqueItems', null), + ('unique', null), + ], + ); + }, + ); test('legacy constraint sugar annotations remain const data', () { const annotations = [ diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index 4d056473..6630dc8c 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -16,25 +16,25 @@ final class Contact { final class SingleImportContact { const SingleImportContact(this.email, this.id); - @Check.email() + @Validate.email() final Email email; - @Check.uuid() + @Validate.uuid() final Uuid id; } void main() { - test('package:ack/ack.dart exports the model and Check annotations', () { + test('package:ack/ack.dart exports the model and Validate annotations', () { const values = [ Schemable(caseStyle: AckCaseStyle.snake), AckField(description: 'id'), Required(), Optional(), NotNull(), - Check.email(), - Check.minLength(2), - Check.matches(r'^\d+$'), - ack.Check.uuid(), + Validate.email(), + Validate.minLength(2), + Validate.matches(r'^\d+$'), + ack.Validate.uuid(), ]; expect(values, hasLength(9)); diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 082c6918..112da4b8 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -14,8 +14,9 @@ ### Added -* Support `@Check.*` constraint annotations from `package:ack/ack.dart` across - field declarations and primary/ generative constructor parameters (`#191`). +* Support `@Validate.*` constraint annotations from `package:ack/ack.dart` + across field declarations and primary/generative constructor parameters, as + well as `static final schema = _Schema;` on class-first models (`#191`). * Support `JsonMaybe` fields in `@Schemable()` class-first models for PATCH tri-state presence (`#148`). * Support `@AckField(name: '...')` directly without `package:json_annotation` diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index a5a55029..b73b87d5 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -3,7 +3,7 @@ `ack_generator` supports two directions through `@Schemable()`: it turns a top-level Ack schema into an immutable model and derives an Ack codec schema from a hand-written class. `@Schemable()`, the model annotations -(`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the `@Check.*` +(`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the `@Validate.*` constraint namespace are all exported from `package:ack/ack.dart`. ## Schema-first usage @@ -58,8 +58,8 @@ names must be unchanged UpperCamelCase identifiers. ## Class-first usage -In a class-first library, keep the model in source and apply the generated -mixin: +In a class-first library, keep the model in source (using a standard or Dart 3.12+ +primary constructor) and apply the generated mixin: ```dart import 'package:ack/ack.dart'; @@ -70,21 +70,22 @@ part 'account.g.dart'; final class Account with _$AccountAck { const Account({required this.name}); - @Check.minLength(2) + @Validate.minLength(2) final String name; + static final schema = _accountSchema; static final fromJson = AccountSchema.fromJson; } ``` -After generation, the public facade and model JSON methods use the same Ack -codec boundary: +After generation, `Account.schema`, the public `AccountSchema` facade, and model +JSON methods all use the same Ack codec boundary: ```dart void main() { final account = Account.fromJson({'name': 'Ada'}); print(account.toJson()); - print(AccountSchema.toJsonSchema()); + print(Account.schema.toJsonSchema()); } ``` @@ -107,8 +108,8 @@ or `Command.schema()`. A `schema()` that declares one positional `AckSchema` parameter per type parameter `A` receives each type argument's inferred schema, as in `Box.schema(RowSchema.schema)`. An enum with a static `schema` or a `@Schemable(schemas: [...])` entry resolves to it -instead of `Ack.enumValues`. A class may also expose its own facade with -`static final schema = AccountSchema.schema;`. +instead of `Ack.enumValues`. A class may also expose its own generated codec +with `static final schema = _accountSchema;` (or `AccountSchema.schema`). Use `@Schemable(description: ...)` and `@AckField(description: ...)` for schema descriptions. A single-line `@description` tag in a `///` or `/** */` doc @@ -196,8 +197,8 @@ their concrete branches must also be final. Use `@Optional()` or `@Required()` to override inferred key presence, `@NotNull()` to reject JSON `null` without requiring the key, `JsonMaybe` to distinguish omitted keys from explicit `null` on both parse and encode, and `@AckField(name: 'wire_name')` to override -a single field's JSON key. Constraint annotations use `@Check.*` (such as -`@Check.matches(pattern)` for `Ack.string().matches(pattern)`). See the +a single field's JSON key. Constraint annotations use `@Validate.*` (such as +`@Validate.matches(pattern)` for `Ack.string().matches(pattern)`). See the [Model Code Generation guide](https://concepta.dev/documentation/ack/advanced/typesafe-schemas) for both directions, field inference, sealed unions, passthrough properties, and build configuration. @@ -219,11 +220,12 @@ For a hand-written `Account`, class-first generation exposes an provides parsing, safe parsing, encoding, JSON Schema/schema-model export, typed `schema`, and raw `wireSchema`. Instantiable models apply the generated `_$AccountAck` mixin, which supplies `toJson`, `safeToJson`, a typed `copyWith`, -and deep collection-aware equality. Add -`static final fromJson = AccountSchema.fromJson;` when the class should expose -the conventional one-argument entry point. Imported nested models compose as -`prefix.AddressSchema.schema`. Across all imports and barrel exports, -`show`/`hide` combinators must expose both the authored declaration and its -generated companion (`Address` plus `AddressSchema` for class-first, or -`addressSchema` plus `Address` for schema-first). Visibility may be split -across multiple imports that use the same prefix. +and deep collection-aware equality. Add `static final schema = _accountSchema;` +and `static final fromJson = AccountSchema.fromJson;` when the class should +expose the schema and one-argument JSON constructor directly. Imported nested +models compose as `prefix.AddressSchema.schema` (or `prefix.Address.schema` when +`Address.schema` is declared and `AddressSchema` is hidden by a combinator). +Across all imports and barrel exports, `show`/`hide` combinators must expose the +authored declaration and either `Address.schema` or its generated companion +(`AddressSchema` for class-first, or `Address` for schema-first). Visibility may +be split across multiple imports that use the same prefix. diff --git a/packages/ack_generator/example/example.dart b/packages/ack_generator/example/example.dart index ce9b27e0..c951a237 100644 --- a/packages/ack_generator/example/example.dart +++ b/packages/ack_generator/example/example.dart @@ -42,10 +42,10 @@ final settingsSchema = Ack.object({ final class Product { const Product({required this.displayName, required this.priceCents}); - @Check.notEmpty() + @Validate.notEmpty() final String displayName; - @Check.min(0) + @Validate.min(0) final int priceCents; } diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 61162b84..6cc77db9 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -938,10 +938,18 @@ final class ClassModelGraphBuilder { Future<_FutureGeneratedType> _futureGeneratedType(FieldElement field) async { final resolved = await _resolvedLibraryFor(field.library); AstNode? node = resolved.getFragmentDeclaration(field.firstFragment)?.node; - while (node != null && node is! FieldDeclaration) { + while (node != null && + node is! FieldDeclaration && + node is! NormalFormalParameter) { node = node.parent; } - final annotation = node is FieldDeclaration ? node.fields.type : null; + final annotation = switch (node) { + FieldDeclaration() => node.fields.type, + SimpleFormalParameter() => node.type, + FieldFormalParameter() => node.type, + SuperFormalParameter() => node.type, + _ => null, + }; final futureType = annotation == null ? null : _futureGeneratedTypeForAnnotation(annotation, field); @@ -1353,6 +1361,11 @@ final class ClassModelGraphBuilder { final facadeName = _classFirstFacadeName(target); if (facadeName != null) { final prefix = _visiblePrefix(target, field); + if (target.library != library.element && + !_isClassFirstFacadeVisible(target, facadeName, prefix: prefix)) { + final typeOwned = await _typeOwnedSchema(interfaceType, field); + if (typeOwned != null) return typeOwned; + } _validateClassFirstFacadeImport( target, facadeName, @@ -1867,6 +1880,9 @@ final class ClassModelGraphBuilder { return null; } if (_containsInvalidType(returnType)) { + if (target is ClassElement && _classFirstFacadeName(target) != null) { + return expression; + } throw InvalidGenerationSource( '$path resolves to ${target.name}.schema, whose type does not resolve ' 'yet. Declare the type of ${target.name}.schema explicitly.', @@ -1928,6 +1944,36 @@ final class ClassModelGraphBuilder { ); } + bool _isClassFirstFacadeVisible( + ClassElement target, + String facadeName, { + required String? prefix, + }) { + if (target.library == library.element) return true; + final modelName = target.name!; + var modelVisible = false; + var facadeVisible = false; + for (final import in library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final importPrefix = import.prefix?.element.name; + if (importPrefix != prefix) continue; + final candidate = prefix == null + ? import.namespace.get2(modelName) + : import.namespace.getPrefixed2(prefix, modelName); + if (candidate?.baseElement == target.baseElement) { + modelVisible = true; + } + if (importExposesGeneratedCompanion( + import, + definingLibrary: target.library, + generatedName: facadeName, + )) { + facadeVisible = true; + } + } + return modelVisible && facadeVisible; + } + void _validateClassFirstFacadeImport( ClassElement target, String facadeName, { diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 5427ab9c..b3442de4 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -22,8 +22,8 @@ final class AckSchemaInference { final String? ackPrefix; - static const _check = TypeChecker.typeNamed( - annotations.Check, + static const _validate = TypeChecker.typeNamed( + annotations.Validate, inPackage: 'ack', ); static const _min = TypeChecker.typeNamed(annotations.Min, inPackage: 'ack'); @@ -180,44 +180,44 @@ final class AckSchemaInference { final value = metadata.computeConstantValue(); final valueType = value?.type; if (value == null || valueType == null) continue; - if (_check.isExactlyType(valueType)) { + if (_validate.isExactlyType(valueType)) { final kind = value.getField('kind')!.toStringValue()!; switch (kind) { case 'min': _require( declaration, type, - '@Check.min', + '@Validate.min', isNumeric, - '@Check.minLength', + '@Validate.minLength', ); output = - '$output.min(${_number(declaration, '@Check.min', value)})'; + '$output.min(${_number(declaration, '@Validate.min', value)})'; case 'max': _require( declaration, type, - '@Check.max', + '@Validate.max', isNumeric, - '@Check.maxLength', + '@Validate.maxLength', ); output = - '$output.max(${_number(declaration, '@Check.max', value)})'; + '$output.max(${_number(declaration, '@Validate.max', value)})'; case 'multipleOf': _require( declaration, type, - '@Check.multipleOf', + '@Validate.multipleOf', isNumeric, 'numeric field', ); output = - '$output.multipleOf(${_number(declaration, '@Check.multipleOf', value)})'; + '$output.multipleOf(${_number(declaration, '@Validate.multipleOf', value)})'; case 'positive': _require( declaration, type, - '@Check.positive', + '@Validate.positive', isNumeric, 'numeric field', ); @@ -226,7 +226,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.negative', + '@Validate.negative', isNumeric, 'numeric field', ); @@ -235,9 +235,9 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.minLength', + '@Validate.minLength', isString, - '@Check.min', + '@Validate.min', ); output = '$output.minLength(${value.getField('value')!.toIntValue()})'; @@ -245,9 +245,9 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.maxLength', + '@Validate.maxLength', isString, - '@Check.max', + '@Validate.max', ); output = '$output.maxLength(${value.getField('value')!.toIntValue()})'; @@ -255,7 +255,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.matches', + '@Validate.matches', isString, 'String field', ); @@ -265,7 +265,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.notEmpty', + '@Validate.notEmpty', isString, 'String field', ); @@ -274,22 +274,34 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.email', + '@Validate.email', isString, 'String field', ); output = '$output.email()'; case 'url': - _require(declaration, type, '@Check.url', isString, 'String field'); + _require( + declaration, + type, + '@Validate.url', + isString, + 'String field', + ); output = '$output.url()'; case 'uri': - _require(declaration, type, '@Check.uri', isString, 'String field'); + _require( + declaration, + type, + '@Validate.uri', + isString, + 'String field', + ); output = '$output.uri()'; case 'uuid': _require( declaration, type, - '@Check.uuid', + '@Validate.uuid', isString, 'String field', ); @@ -298,7 +310,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.date', + '@Validate.date', isString, 'String field', ); @@ -307,18 +319,24 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.$kind', + '@Validate.$kind', isString, 'String field', ); output = '$output.datetime()'; case 'ip': - _require(declaration, type, '@Check.ip', isString, 'String field'); + _require( + declaration, + type, + '@Validate.ip', + isString, + 'String field', + ); final version = value.getField('value')?.toIntValue(); if (version != null) { if (version != 4 && version != 6) { throw InvalidGenerationSource( - '${_qualifiedName(declaration)} has @Check.ip(version: ' + '${_qualifiedName(declaration)} has @Validate.ip(version: ' '$version); version must be 4 or 6.', element: declaration, ); @@ -331,7 +349,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.ipv4', + '@Validate.ipv4', isString, 'String field', ); @@ -340,7 +358,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.ipv6', + '@Validate.ipv6', isString, 'String field', ); @@ -349,7 +367,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.minItems', + '@Validate.minItems', isCollection, 'List or Set field', ); @@ -360,7 +378,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.maxItems', + '@Validate.maxItems', isCollection, 'List or Set field', ); @@ -371,7 +389,7 @@ final class AckSchemaInference { _require( declaration, type, - '@Check.$kind', + '@Validate.$kind', isCollection, 'List or Set field', ); diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index 52165b47..bbc62d1a 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -203,7 +203,7 @@ final class Profile with _$ProfileAck { final Uri? website; final String? nickname; final String role; - @Check.uniqueItems() + @Validate.uniqueItems() final Set tags; @AckField(schema: colorSchema) final Color color; @@ -291,7 +291,7 @@ final class ImmutableCollections with _$ImmutableCollectionsAck { }); final List> matrix; - @Check.uniqueItems() + @Validate.uniqueItems() final Set labels; @AckField(schema: groupsSchema) final Map> groups; @@ -307,7 +307,7 @@ final class Example with _$ExampleAck { @Optional() @NotNull() - @Check.notEmpty() + @Validate.notEmpty() final String? title; static final fromJson = ExampleSchema.fromJson; @@ -562,7 +562,7 @@ final class UserPatch with _$UserPatchAck { final JsonMaybe nickname; @NotNull() - @Check.notEmpty() + @Validate.notEmpty() final JsonMaybe title; final JsonMaybe> tags; diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index a438d37d..8b9e1175 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -1008,7 +1008,7 @@ final class Account with _\$AccountAck { }); test( - '@Check.* constraints work with single package:ack/ack.dart import alongside same-named types and constructor parameters', + '@Validate.* constraints work with single package:ack/ack.dart import alongside same-named types and constructor parameters', () async { await _build( { @@ -1043,52 +1043,52 @@ final class CheckedRecord with _\$CheckedRecordAck { required this.score, required this.debt, required this.tags, - @Check.minLength(2) @Check.maxLength(20) required this.handle, + @Validate.minLength(2) @Validate.maxLength(20) required this.handle, }); - @Check.email() + @Validate.email() final String email; - @Check.uuid() + @Validate.uuid() final String id; - @Check.url() + @Validate.url() final String website; - @Check.uri() + @Validate.uri() final String resourceUri; - @Check.date() + @Validate.date() final String birthDate; - @Check.dateTime() + @Validate.dateTime() final String createdAt; - @Check.ip(version: 4) + @Validate.ip(version: 4) final String clientIp; - @Check.ipv4() + @Validate.ipv4() final String v4; - @Check.ipv6() + @Validate.ipv6() final String v6; - @Check.notEmpty() - @Check.matches(r'^[A-Z]+\$') + @Validate.notEmpty() + @Validate.matches(r'^[A-Z]+\$') final String code; - @Check.min(0) - @Check.max(100) - @Check.multipleOf(5) - @Check.positive() + @Validate.min(0) + @Validate.max(100) + @Validate.multipleOf(5) + @Validate.positive() final int score; - @Check.negative() + @Validate.negative() final double debt; - @Check.minItems(1) - @Check.maxItems(5) - @Check.uniqueItems() + @Validate.minItems(1) + @Validate.maxItems(5) + @Validate.uniqueItems() final List tags; final String handle; @@ -1148,7 +1148,7 @@ final class UserPatch with _$UserPatchAck { final JsonMaybe nickname; @NotNull() - @Check.notEmpty() + @Validate.notEmpty() final JsonMaybe title; final JsonMaybe> tags; diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 7ef7c218..9752bc50 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -1355,7 +1355,58 @@ final class User with _\$UserAck { }); test( - 'rejects invalid @Check.* constraints on mismatched field types or values', + 'allows an imported class-first model with hidden facade when it exposes static schema', + () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': ''' +import 'package:ack/ack.dart'; +import 'address.dart' show Address; + +part 'model.g.dart'; + +@Schemable() +final class Order with _\$OrderAck { + const Order({required this.address}); + + final Address address; +} +''', + 'test_pkg|lib/address.dart': ''' +import 'package:ack/ack.dart'; + +part 'address.g.dart'; + +@Schemable() +final class Address with _\$AddressAck { + const Address({required this.city}); + + final String city; + + static final schema = _addressSchema; +} +''', + }, + generateFor: const { + 'test_pkg|lib/model.dart', + 'test_pkg|lib/address.dart', + }, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/address.g.dart': decodedMatches(anything), + 'test_pkg|lib/model.g.dart': decodedMatches( + contains('Address.schema'), + ), + }, + ); + }, + ); + + test( + 'rejects invalid @Validate.* constraints on mismatched field types or values', () async { await _expectFailure( ''' @@ -1363,11 +1414,11 @@ final class User with _\$UserAck { final class User with _\$UserAck { const User({required this.name}); - @Check.min(1) + @Validate.min(1) final String name; } ''', - ['User.name', '@Check.min', 'String', '@Check.minLength'], + ['User.name', '@Validate.min', 'String', '@Validate.minLength'], ); await _expectFailure( ''' @@ -1375,22 +1426,67 @@ final class User with _\$UserAck { final class User with _\$UserAck { const User({required this.ip}); - @Check.ip(version: 5) + @Validate.ip(version: 5) final String ip; } ''', - ['User.ip', '@Check.ip(version: 5)', 'version must be 4 or 6.'], + ['User.ip', '@Validate.ip(version: 5)', 'version must be 4 or 6.'], ); await _expectFailure( ''' @Schemable() final class User with _\$UserAck { - const User({@Check.minLength(1) required this.age}); + const User({@Validate.minLength(1) required this.age}); final int age; } ''', - ['User.age', '@Check.minLength', 'int', '@Check.min'], + ['User.age', '@Validate.minLength', 'int', '@Validate.min'], + ); + }, + ); + + test( + 'resolves constructor parameter annotations alongside same-build schema-first generated model', + () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/model.dart': ''' +import 'package:ack/ack.dart'; + +part 'model.g.dart'; + +@Schemable() +final addressSchema = Ack.object({'city': Ack.string()}); + +@Schemable() +final class Order with _\$OrderAck { + const Order({ + @AckField(name: 'shipping_address') required this.shipping, + @Validate.minItems(1) this.history = const [], + }); + + final Address shipping; + final List
history; + + static final schema = _orderSchema; +} +''', + }, + generateFor: const {'test_pkg|lib/model.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/model.g.dart': decodedMatches( + allOf([ + contains("'shipping_address': Address.\$ack.schema"), + contains('Ack.list(Address.\$ack.schema)'), + contains('.minItems(1)'), + ]), + ), + }, ); }, ); diff --git a/packages/ack_generator/test/integration/model_generation_guide_build_test.dart b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart index c3230152..75c582fa 100644 --- a/packages/ack_generator/test/integration/model_generation_guide_build_test.dart +++ b/packages/ack_generator/test/integration/model_generation_guide_build_test.dart @@ -101,6 +101,7 @@ void main() { 'role': 'member', }; expect(account.toJson(), encoded); + expect(Account.schema.parse(json)!.displayName, 'Ada'); expect(AccountSchema.parse(json).displayName, 'Ada'); expect(AccountSchema.safeParse({...json, 'email': 'invalid'}).isFail, isTrue); expect(AccountSchema.encode(account), encoded); diff --git a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart index d788ee79..61c68842 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart @@ -162,7 +162,7 @@ final class Habit with _$HabitAck { final String name; - static final schema = HabitSchema.schema; + static final schema = _habitSchema; } @Schemable(schemas: [importedMoneySchema]) diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index 02ecf1de..86be83ea 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -487,16 +487,18 @@ final class Color { ); }); - test('a Schemable class may expose its facade as a static schema', () async { - await _expectOutput( - r''' + test( + 'a Schemable class may expose its backing schema or facade as a static schema', + () async { + await _expectOutput( + r''' @Schemable() final class Habit with _$HabitAck { const Habit({required this.name}); final String name; - static final schema = HabitSchema.schema; + static final schema = _habitSchema; } @Schemable() @@ -506,12 +508,13 @@ final class Routine with _$RoutineAck { final List habits; } ''', - allOf( - _containsCode("Ack.object({'habits': Ack.list(HabitSchema.schema)})"), - isNot(contains('Habit.schema')), - ), - ); - }); + allOf( + _containsCode("Ack.object({'habits': Ack.list(HabitSchema.schema)})"), + isNot(contains('Habit.schema')), + ), + ); + }, + ); test('a Schemable class rejects a static schema of another type', () async { final errors = await _errors(r''' @@ -545,7 +548,7 @@ final class Habit with _\$HabitAck { final String name; - static final schema = HabitSchema.schema; + static final schema = _habitSchema; } @Schemable() diff --git a/packages/ack_generator/test/src/ack_exports_test.dart b/packages/ack_generator/test/src/ack_exports_test.dart index e13b169b..3a33862d 100644 --- a/packages/ack_generator/test/src/ack_exports_test.dart +++ b/packages/ack_generator/test/src/ack_exports_test.dart @@ -41,7 +41,7 @@ void main() { outputs: { 'test_pkg|lib/probe.exports': decodedMatches( 'AckCaseStyle,AckField,AckUnknownPropertyPolicy,' - 'Check,NotNull,Optional,Required,Schemable', + 'NotNull,Optional,Required,Schemable,Validate', ), }, ); From b66b26f8266df9e59182d1f9fe16ff4a227da78c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 10 Oct 2026 21:50:43 -0400 Subject: [PATCH 17/29] refactor!: complete 2.0 reference design audit, API cleanup, and code simplification --- .github/workflows/preflight.yml | 7 +- CHANGELOG.md | 36 +-- README.md | 2 +- docs/api-reference/index.mdx | 40 ++-- docs/core-concepts/codecs.mdx | 1 - docs/core-concepts/typesafe-schemas.mdx | 12 +- .../creating-schema-converter-packages.mdx | 12 +- docs/guides/json-schema-integration.mdx | 13 +- example/lib/codecs_example.dart | 6 +- example/lib/schema_types_edge_cases.g.dart | 18 +- example/lib/schema_types_transforms.g.dart | 6 +- llms.txt | 3 +- packages/ack/CHANGELOG.md | 7 +- packages/ack/lib/ack.dart | 7 +- packages/ack/lib/format_annotations.dart | 4 - packages/ack/lib/src/ack.dart | 18 -- .../ack/lib/src/annotations/constraints.dart | 128 ---------- .../src/annotations/format_constraints.dart | 15 -- .../list_unique_items_constraint.dart | 21 +- .../src/constraints/pattern_constraint.dart | 62 +---- packages/ack/lib/src/helpers.dart | 1 - .../src/json_schema/json_schema_compiler.dart | 100 ++++---- .../src/json_schema/json_schema_formats.dart | 16 +- .../src/schema_model/ack_schema_model.dart | 42 +--- .../ack/lib/src/schemas/any_of_schema.dart | 11 - .../schemas/discriminated_object_schema.dart | 12 - .../ack/lib/src/schemas/fluent_schema.dart | 10 +- .../lib/src/schemas/imported_json_schema.dart | 43 ++-- packages/ack/lib/src/schemas/lazy_schema.dart | 7 - packages/ack/lib/src/schemas/list_schema.dart | 11 - packages/ack/lib/src/schemas/map_schema.dart | 11 - packages/ack/lib/src/schemas/num_schema.dart | 13 +- .../ack/lib/src/schemas/object_schema.dart | 12 - packages/ack/lib/src/schemas/schema.dart | 30 ++- .../ack/lib/src/schemas/string_schema.dart | 54 +---- .../src/schemas/testing/testing_schemas.dart | 194 ---------------- .../ack/lib/src/utils/collection_utils.dart | 29 --- packages/ack/lib/src/utils/default_utils.dart | 4 - packages/ack/lib/src/utils/json_utils.dart | 24 +- .../ack/test/annotations/ack_model_test.dart | 31 +-- .../annotations/dart_core_names_test.dart | 32 ++- .../ack/test/annotations/exports_test.dart | 41 ++-- packages/ack/test/annotations/own_types.dart | 5 + packages/ack/test/default_utils_test.dart | 88 ------- packages/ack/test/helpers_test.dart | 74 ------ .../schema_model/ack_schema_model_test.dart | 9 - .../encode_error_propagation_test.dart | 20 +- .../test/schemas/schema_equality_test.dart | 55 ++++- .../typed_codecs_characterization_test.dart | 134 +++++------ ...irebase_ai_response_json_schema_cases.dart | 2 - packages/ack_generator/CHANGELOG.md | 22 +- packages/ack_generator/lib/ack_generator.dart | 16 +- .../analyzer/class_model_graph_builder.dart | 219 ++++-------------- .../lib/src/analyzer/schema_first_names.dart | 74 +----- .../analyzer/schema_model_graph_builder.dart | 60 ++--- .../lib/src/builders/class_model_emitter.dart | 20 +- .../lib/src/builders/data_class_emitter.dart | 148 ++++++++---- .../lib/src/builders/model_emitter.dart | 117 ++-------- .../lib/src/inference/schema_inference.dart | 146 +----------- .../src/json_schema/json_schema_builder.dart | 6 +- .../src/json_schema/json_schema_library.dart | 53 ++--- .../lib/src/models/schema_model_graph.dart | 58 ++--- .../lib/src/utils/string_literal.dart | 77 ++++++ packages/ack_generator/pubspec.yaml | 6 +- .../class_first_runtime_build_test.dart | 6 +- .../integration/class_model_emitter_test.dart | 76 +++--- .../integration/class_model_graph_test.dart | 82 +++---- .../integration/inference_library_test.dart | 6 +- .../integration/inference_literal_test.dart | 2 +- .../json_schema/fixtures/mapping.schema.dart | 2 +- .../fixtures/mapping_preserve.schema.dart | 2 +- .../json_schema/json_schema_library_test.dart | 2 +- packages/ack_json_schema_builder/CHANGELOG.md | 1 + .../lib/ack_json_schema_builder.dart | 7 - .../test/to_json_schema_builder_test.dart | 25 +- packages/ack_mcp_dart/CHANGELOG.md | 2 + packages/ack_mcp_dart/README.md | 16 +- packages/ack_mcp_dart/lib/ack_mcp_dart.dart | 104 +++------ .../test/mcp_server_integration_test.dart | 38 +-- .../ack_mcp_dart/test/support/fixtures.dart | 2 + pubspec.yaml | 3 - scripts/src/workspace_packages.dart | 4 +- test/scripts/api_check_test.dart | 6 +- 83 files changed, 913 insertions(+), 2028 deletions(-) delete mode 100644 packages/ack/lib/format_annotations.dart delete mode 100644 packages/ack/lib/src/annotations/format_constraints.dart delete mode 100644 packages/ack/lib/src/schemas/testing/testing_schemas.dart delete mode 100644 packages/ack/lib/src/utils/default_utils.dart delete mode 100644 packages/ack/test/default_utils_test.dart diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index a96f6beb..b3678964 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -192,13 +192,12 @@ jobs: - name: Resolve workspace dependencies run: dart pub get - # `.ack.dart` holds the generated models and `.ack.g.dart` holds the JSON - # mapping. Both are generated, so a clean rebuild must reproduce them. - # The `*.g.dart` glob below also matches `*.ack.g.dart`. + # `.g.dart` holds the generated Ack models, schemas, and JSON mapping. + # A clean rebuild must reproduce them identically. - name: Delete the committed generator output run: | set -euo pipefail - git ls-files -z -- '*.ack.dart' '*.g.dart' \ + git ls-files -z -- '*.g.dart' \ | xargs -0 --no-run-if-empty rm -- - name: Regenerate from a clean tree diff --git a/CHANGELOG.md b/CHANGELOG.md index 4daff5de..8cc962b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,17 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Breaking * **Single-pass `.g.dart` code generation (`#189`)**: `ack_generator` now emits - a single `.g.dart` part via `SharedPartBuilder` (`ack_generator:ack`) - with direct JSON serialization (`toJson` / `fromJson`). Consumer packages no - longer need `json_serializable` or `json_annotation` dependencies, nor a - separate `.ack.dart` / `.ack.g.dart` two-part setup. Replace - `part '.ack.dart';` + `part '.ack.g.dart';` with a single - `part '.g.dart';`. -* **Removed legacy 1.x generator annotations and shims (`#188`)**: Removed - `@AckType`, `@AckInfer`, `@AckModel`, `JsonKeyCaseStyle`, `AckTypeDef`, and - generated `*Type` extension-type model generation. Use `@Schemable()` on both - top-level schemas and hand-written classes/unions. + a single `.g.dart` part via `SharedPartBuilder` + (`ack_generator:ack_generator`) with direct JSON serialization (`toJson` / + `fromJson`). Consumer packages no longer need `json_serializable` or + `json_annotation` dependencies, nor a separate `.ack.dart` / `.ack.g.dart` + two-part setup. Replace `part '.ack.dart';` + + `part '.ack.g.dart';` with a single `part '.g.dart';`. +* **Removed legacy 1.x generator annotations and shims (`#188`, `#189`)**: + Removed `@AckType`, `@AckInfer`, `@AckModel`, `JsonKeyCaseStyle`, `AckTypeDef`, + generated `*Type` extension-type model generation, and the legacy + `@JsonKey(name: ...)` fallback in `ack_generator` (use `@AckField(name: ...)`). + Use `@Schemable()` on both top-level schemas and hand-written classes/unions. * **Removed `StandardSchemaV1` (`#129`)**: Removed the unused TypeScript-origin `StandardSchemaV1` interface and extensions (`standard_schema.dart`). `AckSchema` with `SchemaResult` and `AckSchemaModel` is @@ -31,8 +32,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline `Validate.url()`, `Validate.uuid()`, `Validate.date()`, `Validate.dateTime()`, `Validate.uri()`, `Validate.ip()`, `Validate.ipv4()`, `Validate.ipv6()`, `Validate.minItems(...)`, `Validate.maxItems(...)`, and - `Validate.uniqueItems()` / `Validate.unique()`. Legacy top-level constraint - annotation classes (`@Min`, `@MinLength`, `@Email`, etc.) are deprecated. + `Validate.uniqueItems()` / `Validate.unique()`. Removed the 18 `@Deprecated` + 1.x top-level constraint classes (`@Min`, `@Max`, `@Email`, etc.) and + `package:ack/format_annotations.dart` in favor of `@Validate.*`. +* **Removed redundant codec and adapter helpers**: Removed `Ack.enumCodec` in + favor of `Ack.enumValues`, `registerAckModelTool` in `ack_mcp_dart` in favor + of `registerAckTool` with `Model.schema`, and `convertAckSchemaModelToBuilder` + in `ack_json_schema_builder` in favor of `schema.toJsonSchemaBuilder()`. * **Sealed `SchemaError` and `AckSchema` hierarchies**: `SchemaError` and `AckSchema` are now `sealed class`es so `switch (error)` and `switch (schema)` are exhaustively checked by the Dart analyzer without a wildcard branch. @@ -84,8 +90,10 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline `dependentRequired`, `dependentSchemas`, `patternProperties`, `minContains`, `maxContains`, bundled offline 2020-12 meta-schemas, and the full Draft 2020-12 format suite when `assertFormats: true`). `ack_generator` adds the - `ack_generator:ack_json_schema` builder to compile `lib/**/*.schema.json` - into `.schema.g.dart` typed or value-model libraries. + `ack_generator:ack_json_schema` builder, which emits `.schema.dart` from + `lib/**/*.schema.json` for `ack_generator:ack_generator` to compile into + `.schema.g.dart` typed or value-model libraries (and + `generateAckSchemaLibrary` is available for programmatic generation). ## 1.7.0-beta.7 diff --git a/README.md b/README.md index 3c0e6376..64cd45e5 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ final when = Ack.datetime(); final dt = when.parse('2026-01-01T00:00:00Z'); // DateTime final iso = when.encode(dt); // back to an ISO 8601 String -// Other built-ins: Ack.date(), Ack.uri(), Ack.duration(), Ack.enumCodec(...) +// Other built-ins: Ack.date(), Ack.uri(), Ack.duration() // Custom bidirectional codec final csv = Ack.codec>( diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index b100853f..a7d6df9a 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -25,20 +25,17 @@ Entry point for creating schemas. See [Schema Types](../core-concepts/schemas.md types. Parses enum `.name` strings into typed enum values. Pass typed enum values to `encode` or `safeEncode` for the reverse direction. **Preferred over `enumString` when a Dart enum exists.** -- `Ack.enumCodec(List values)`: Like `enumValues`, but returns a - `CodecSchema` instead of an `EnumSchema`. Use this when - downstream code expects every value-shape to be a `CodecSchema` (e.g. a - registry of codecs). The decode and encode functions are identity — the - underlying `EnumSchema` still maps between `T` and the enum's `.name`. - `Ack.enumString(List values)`: Creates a `StringSchema` constrained to the given values. For ad-hoc string lists without a backing Dart enum. - `Ack.anyOf(List schemas)`: Creates an `AnyOfSchema` for union types. - `Ack.any()`: Creates an `AnySchema` that accepts any non-null JSON-safe value. Chain `.nullable()` to allow `null`. - `Ack.fromJsonSchema(Object document, {Uri? baseUri, Map - documents = const {}})`: Strictly imports a decoded draft 2020-12 - JSON Schema. Referenced documents must be supplied explicitly; Ack never - fetches files or URLs. The supported `date-time` format is asserted; see + documents = const {}, bool assertFormats = false})`: Strictly imports a + decoded draft 2020-12 or Draft-7 JSON Schema. Referenced documents must be + supplied explicitly; Ack never fetches files or URLs. `format` is treated as + an annotation by default unless `assertFormats: true` (or the schema's + `$vocabulary` requires `format-assertion`); see [format import](../guides/json-schema-integration.mdx#importing-format). - `Ack.map(AckSchema valueSchema)`: Creates a `MapSchema` for a JSON object with arbitrary string keys whose values all match `valueSchema`. Use @@ -46,7 +43,7 @@ Entry point for creating schemas. See [Schema Types](../core-concepts/schemas.md - `Ack.instance()`: Creates a schema that checks a value is a Dart instance of `T` (runtime type check; handy as a codec `output` schema). - `Ack.codec(...)` / `Ack.date()` / `Ack.datetime()` / `Ack.uri()` / - `Ack.duration()` / `Ack.enumCodec(...)`: Create codecs. See + `Ack.duration()`: Create codecs. See [Codecs](../core-concepts/codecs.mdx). - `Ack.lazy(String name, AckSchema Function() builder, {int maxDepth = 100})`: Creates a memoized deferred schema reference for recursive schema graphs. @@ -84,10 +81,10 @@ Base class for all schema types. - `SchemaResult safeParse(Object? data, {String? debugName})`: Validates `data` and returns a `SchemaResult`. Invalid input and recoverable `Exception` - values thrown by constraint/refinement callbacks become failures. `Error` - values from those callbacks are rethrown with their original stack trace. - Codec/transform decoder failures, including `Error` values, become - `SchemaTransformError` failures. + values thrown by constraint, refinement, or `CodecSchema` `decode`/`encode` + callbacks become validation failures (`SchemaValidationError` / + `SchemaTransformError` / `SchemaEncodeError`), while programmer `Error` values + are rethrown with their original stack trace. - `Runtime? parse(Object? data, {String? debugName})`: Validates `data` and returns the value; throws `AckException` on failure. - `SchemaResult safeParseAs(Object? data, TOut Function(Runtime?) map, {String? debugName})`: Parses and maps the validated value to `TOut`. Mapper `Exception`s become `SchemaTransformError` failures; mapper `Error` values are rethrown with their original stack trace. - `TOut parseAs(Object? data, TOut Function(Runtime?) map, {String? debugName})`: Throwing variant of `safeParseAs`. @@ -121,10 +118,16 @@ For non-`num` boundary types (e.g. numeric strings), use ### Utility Methods -- `Map toJsonSchema()`: Returns a Draft-7 JSON Schema map via - the canonical `AckSchemaModel` boundary. +- `Map toJsonSchema()`: Returns a JSON Schema map via the + canonical `AckSchemaModel` boundary, preserving imported Draft 2020-12 + resources by default and emitting Draft-7 for native Ack schemas. +- `Map toJsonSchemaDraft7()`: Lowers imported schemas into the + legacy Draft-7 `definitions` / `$ref` representation when possible. - `AckSchemaModel toSchemaModel()`: Returns the canonical, target-independent - boundary model for schema adapters, including export warnings. + boundary model for schema adapters (preserving imported Draft 2020-12 + resources by default), including export warnings. +- `AckSchemaModel toSchemaModelDraft7()`: Returns the canonical boundary model + with imported schemas lowered to Draft-7 `definitions` / `$ref` when possible. - `Map toMap()`: Serializes the schema for debugging. See also [Schema Types](../core-concepts/schemas.mdx) for detailed usage examples. @@ -486,9 +489,9 @@ Schema for polymorphic validation based on a discriminator field. Canonical export model for Ack schemas. -- Created by `schema.toSchemaModel()`. +- Created by `schema.toSchemaModel()` (preserving imported Draft 2020-12 resources by default) or `schema.toSchemaModelDraft7()` (lowering imported schemas to Draft-7 `definitions` / `$ref`). - Represents the boundary/export shape, JSON-compatible defaults, discriminator metadata, target-independent constraints, and export warnings. -- `schema.toSchemaModel().toJsonSchema()` returns the same Draft-7 map as `schema.toJsonSchema()`. +- `schema.toSchemaModel().toJsonSchema()` returns the same JSON Schema map as `schema.toJsonSchema()` (and `schema.toSchemaModelDraft7().toJsonSchema()` matches `schema.toJsonSchemaDraft7()`). - Adapters that need a JSON map should call `schema.toJsonSchema()`; adapters for non-JSON targets should convert from `AckSchemaModel` rather than traversing `AckSchema` subclasses directly. ### `AnySchema` @@ -536,7 +539,6 @@ reverse direction. See [Codecs](../core-concepts/codecs.mdx). them) - `Ack.uri()` → `CodecSchema` (absolute URI string ↔ `Uri`) - `Ack.duration()` → `CodecSchema` (milliseconds ↔ `Duration`) -- `Ack.enumCodec(List values)` → `CodecSchema` (enum `.name` ↔ enum value) ### Optional schemas diff --git a/docs/core-concepts/codecs.mdx b/docs/core-concepts/codecs.mdx index 26bff102..b18faf6d 100644 --- a/docs/core-concepts/codecs.mdx +++ b/docs/core-concepts/codecs.mdx @@ -34,7 +34,6 @@ Ack ships codecs for the most common boundary conversions: | `Ack.datetime()` | ISO 8601 `String` ↔ `DateTime` | must be UTC; leap-second strings are rejected | | `Ack.uri()` | `String` ↔ `Uri` | absolute URI (scheme + host) | | `Ack.duration()` | milliseconds `int` ↔ `Duration` | whole milliseconds | -| `Ack.enumCodec(values)` | enum-name `String` ↔ enum value | one of `values` | ```dart final schema = Ack.object({ diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index 3d7735da..6a323326 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -12,9 +12,7 @@ and generate a Dart model, or start with a Dart model and generate its schema. `@Schemable()`, the model annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the `@Validate.*` constraint namespace come from -`package:ack/ack.dart`. Legacy top-level constraint annotations in -`package:ack/annotations.dart` and `package:ack/format_annotations.dart` are -deprecated. +`package:ack/ack.dart` (and `package:ack/annotations.dart`). There is no `@AckSchema()` annotation. `AckSchema` is the runtime type returned by factories such as `Ack.string()` and `Ack.object()`. @@ -350,11 +348,9 @@ constraints apply to the outer set. Use `caseStyle` for model-wide JSON names. Supported values are `none`, `snake`, `kebab`, `pascal`, and `screamingSnake`. Use `@AckField(name: 'wire_name')` on the field or primary constructor parameter for -a single override (or `@JsonKey(name: 'wire_name')` from -`package:json_annotation/json_annotation.dart`). -Ack resolves each wire key once and uses it for both validation and JSON -mapping. Other `JsonKey` options and `JsonConverter` annotations are rejected -so the Ack schema and generated JSON mapping cannot diverge. +a single wire-name override. Ack resolves each wire key once and uses it for both +validation and JSON mapping. `JsonConverter` annotations are rejected so the Ack +schema and generated JSON mapping cannot diverge. ### Custom schemas and types that own their schema diff --git a/docs/guides/creating-schema-converter-packages.mdx b/docs/guides/creating-schema-converter-packages.mdx index 4e312302..acab7b31 100644 --- a/docs/guides/creating-schema-converter-packages.mdx +++ b/docs/guides/creating-schema-converter-packages.mdx @@ -880,15 +880,13 @@ void main() { expect(result.anyOf, hasLength(2)); }); - test('throws UnsupportedError for unsupported schema types', () { + test('omits runtime refinements with a warning (or throws in strict mode)', () { final schema = Ack.string().refine((s) => s.startsWith('A')); - // Refinements should not be supported - // Check if it throws or handles gracefully - expect( - () => schema.toSchema(), - throwsUnsupportedError, - ); + // toSchemaModel() omits runtime-only refinements and records a + // `refinement_omitted` warning; strict adapter modes can throw StateError. + final model = schema.toSchemaModel(); + expect(model.warnings.map((w) => w.code), contains('refinement_omitted')); }); test('handles empty object schema', () { diff --git a/docs/guides/json-schema-integration.mdx b/docs/guides/json-schema-integration.mdx index cd07ae27..1afd9b17 100644 --- a/docs/guides/json-schema-integration.mdx +++ b/docs/guides/json-schema-integration.mdx @@ -2,7 +2,7 @@ title: JSON Schema Integration --- -Your Ack [schema](../core-concepts/schemas.mdx) already describes your data's shape — so you can export it as JSON Schema (Draft-7) and reuse it to document an API, drive a form library, or define an LLM tool, all from one source of truth. +Your Ack [schema](../core-concepts/schemas.mdx) already describes your data's shape — so you can import and export JSON Schema (preserving Draft 2020-12 resources for imported schemas and emitting Draft-7 for native schemas) and reuse it to document an API, drive a form library, or define an LLM tool, all from one source of truth. ## Importing published schemas @@ -166,10 +166,13 @@ semantics). ### Generating Dart models from `.schema.json` files -With `ack_generator`, any `lib/**/*.schema.json` asset is compiled by the -`ack_generator:ack_json_schema` builder into `.schema.g.dart`, emitting -typed `@Schemable()` Dart models when representable or a validated value-model -wrapper around `Ack.fromJsonSchema(...)` for dynamic/open schemas. +With `ack_generator`, enable `ack_generator:ack_json_schema` in `build.yaml` to +emit `.schema.dart` (with `part '.schema.g.dart';`) from any +`lib/**/*.schema.json` asset, which `ack_generator:ack_generator` then compiles +into `.schema.g.dart` (emitting typed `@Schemable()` Dart models when +representable or a validated value-model wrapper around `Ack.fromJsonSchema(...)` +for dynamic/open schemas; `generateAckSchemaLibrary()` is also available for +programmatic generation). ### Validation errors diff --git a/example/lib/codecs_example.dart b/example/lib/codecs_example.dart index 26472c41..5f579d21 100644 --- a/example/lib/codecs_example.dart +++ b/example/lib/codecs_example.dart @@ -7,7 +7,7 @@ library; import 'package:ack/ack.dart'; -/// Priority levels, used with `Ack.enumCodec`. +/// Priority levels, used with `Ack.enumValues`. enum Priority { low, medium, high } /// An event whose fields use built-in codecs: @@ -16,14 +16,14 @@ enum Priority { low, medium, high } /// - `due`: `YYYY-MM-DD` string <-> local-midnight `DateTime` (`Ack.date`) /// - `website`: string <-> `Uri` (`Ack.uri`) /// - `timeout`: milliseconds `int` <-> `Duration` (`Ack.duration`) -/// - `priority`: enum-name string <-> `Priority` (`Ack.enumCodec`) +/// - `priority`: enum-name string <-> `Priority` (`Ack.enumValues`) final eventSchema = Ack.object({ 'name': Ack.string(), 'startsAt': Ack.datetime(), 'due': Ack.date(), 'website': Ack.uri(), 'timeout': Ack.duration(), - 'priority': Ack.enumCodec(Priority.values), + 'priority': Ack.enumValues(Priority.values), }); /// A custom bidirectional codec: comma-separated string <-> `List`. diff --git a/example/lib/schema_types_edge_cases.g.dart b/example/lib/schema_types_edge_cases.g.dart index b6b8078c..d393991a 100644 --- a/example/lib/schema_types_edge_cases.g.dart +++ b/example/lib/schema_types_edge_cases.g.dart @@ -99,9 +99,9 @@ final class Product { static Product _fromAckRuntime(Map value) => Product( name: value['name'] as String, - tags: (value['tags'] as List).map((item) => item as String).toList(), - scores: (value['scores'] as List).map((item) => item as int).toList(), - flags: (value['flags'] as List).map((item) => item as bool).toList(), + tags: (value['tags'] as List).cast(), + scores: (value['scores'] as List).cast(), + flags: (value['flags'] as List).cast(), ); Map _toAckRuntime() => { @@ -219,7 +219,7 @@ final class Grid { static Grid _fromAckRuntime(Map value) => Grid( name: value['name'] as String, matrix: (value['matrix'] as List) - .map((item) => (item as List).map((item) => item as int).toList()) + .map((item) => (item as List).cast()) .toList(), ); @@ -907,18 +907,14 @@ final class TaggedItem { static TaggedItem _fromAckRuntime(Map value) => TaggedItem( name: value['name'] as String, - requiredTags: (value['requiredTags'] as List) - .map((item) => item as String) - .toList(), + requiredTags: (value['requiredTags'] as List).cast(), optionalTags: switch (value['optionalTags']) { null => null, - final fieldValue => - (fieldValue as List).map((item) => item as String).toList(), + final fieldValue => (fieldValue as List).cast(), }, nullableTags: switch (value['nullableTags']) { null => null, - final fieldValue => - (fieldValue as List).map((item) => item as String).toList(), + final fieldValue => (fieldValue as List).cast(), }, ); diff --git a/example/lib/schema_types_transforms.g.dart b/example/lib/schema_types_transforms.g.dart index 86006278..393adba1 100644 --- a/example/lib/schema_types_transforms.g.dart +++ b/example/lib/schema_types_transforms.g.dart @@ -228,16 +228,14 @@ final class Profile { birthday: value['birthday'] as DateTime, lastLogin: value['lastLogin'] as DateTime, timeout: value['timeout'] as Duration, - links: (value['links'] as List).map((item) => item as Uri).toList(), + links: (value['links'] as List).cast(), favoriteColor: value['favoriteColor'] as Color, slug: value['slug'] as String, accent: ColorModel.$ack.fromRuntime(value['accent'] as Color), colors: (value['colors'] as List) .map((item) => ColorModel.$ack.fromRuntime(item as Color)) .toList(), - customColors: (value['customColors'] as List) - .map((item) => item as Color) - .toList(), + customColors: (value['customColors'] as List).cast(), tagList: value['tagList'] as TagList, ); diff --git a/llms.txt b/llms.txt index 4ca0a219..d868e0fd 100644 --- a/llms.txt +++ b/llms.txt @@ -40,8 +40,7 @@ final result = userSchema.safeParse({ Codecs decode a boundary value into a runtime value and encode it back. `parse` / `safeParse` decode; `encode` / `safeEncode` encode. -- Built in: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, and - `Ack.enumCodec(values)`. +- Built in: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, and `Ack.duration()`. - Custom: `Ack.codec(input: ..., decode: ..., encode: ...)`, or `schema.codec(decode: ..., encode: ...)` on an existing schema. - Two-way string normalizers: `.trim()`, `.toLowerCase()`, and `.toUpperCase()` diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 060b8eb9..8a5b6a38 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -21,11 +21,12 @@ Draft 2020-12 resources by default (`$defs`, `$dynamicRef`, `unevaluatedProperties`, `prefixItems`, etc.). Use `toJsonSchemaDraft7()` or `toSchemaModelDraft7()` for legacy Draft-07 `definitions` lowering (`#171`). -* Deprecate legacy top-level constraint annotation classes (`@Min`, `@Max`, +* Remove the 18 `@Deprecated` 1.x top-level constraint classes (`@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, - `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems`) in - favor of `@Validate.*` (`#191`). + `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems`) and + `package:ack/format_annotations.dart` in favor of `@Validate.*` (`#191`). +* Remove `Ack.enumCodec` in favor of `Ack.enumValues`. ### Added diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 6b5aeab8..816b7543 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -40,15 +40,14 @@ export 'src/schemas/schema.dart' ImportedJsonSchema, Refinement, SchemaOperation, - TestOperationRecordingAckSchema, - TestThrowingLeafAckSchema, WrapperSchema, - createCodecSchemaInternal; + createCodecSchemaInternal, + jsonMapOrNull; export 'src/validation/ack_exception.dart'; export 'src/validation/schema_error.dart'; // Validation results export 'src/validation/schema_result.dart'; -// Model annotations. Legacy top-level constraint annotations live in annotations.dart. +// Model annotations export 'src/annotations/ack_field.dart'; export 'src/annotations/constraints.dart' show Validate; export 'src/annotations/schemable.dart'; diff --git a/packages/ack/lib/format_annotations.dart b/packages/ack/lib/format_annotations.dart deleted file mode 100644 index d7419451..00000000 --- a/packages/ack/lib/format_annotations.dart +++ /dev/null @@ -1,4 +0,0 @@ -/// Format annotations that conflict with Dart core type names. -library; - -export 'src/annotations/format_constraints.dart'; diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 0b6aa7b4..e42fcdfe 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -1,7 +1,6 @@ import 'common_types.dart'; import 'constraints/pattern_constraint.dart'; import 'constraints/string_literal_constraint.dart'; -import 'schemas/extensions/ack_schema_extensions.dart'; import 'schemas/extensions/string_schema_extensions.dart'; import 'schemas/schema.dart'; @@ -87,21 +86,6 @@ final class Ack { return EnumSchema(values: List.unmodifiable(values)); } - /// Creates a bidirectional codec for Dart enums, with boundary `String` and - /// runtime [T]. - /// - /// This is a thin wrapper around [enumValues] that returns a - /// [CodecSchema] instead of an [EnumSchema], for uniform composition with - /// other codecs (e.g. when assembling a registry of `CodecSchema` values - /// for many value shapes). The decode and encode functions are identity - /// because [EnumSchema] already maps between [T] and the enum's `.name`. - /// - /// Prefer [enumValues] when you need [EnumSchema]-specific affordances - /// (e.g. adding constraints, copying with new flags). Prefer [enumCodec] - /// when downstream code expects every value-shape to be a `CodecSchema`. - static CodecSchema enumCodec(List values) => - enumValues(values).codec(decode: _identity, encode: _identity); - /// Creates a string schema that only accepts one of the given [values]. static StringSchema enumString(List values) { _requireNonEmpty(values, 'values'); @@ -265,8 +249,6 @@ final class Ack { } } -T _identity(T value) => value; - bool _isLocalMidnightDate(DateTime value) { if (value.isUtc) return false; return value.hour == 0 && diff --git a/packages/ack/lib/src/annotations/constraints.dart b/packages/ack/lib/src/annotations/constraints.dart index bf1136b3..04214127 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -91,131 +91,3 @@ final class Validate { /// Adds `.unique()` to an inferred collection schema. const Validate.unique() : kind = 'unique', value = null; } - -/// Adds `.min(value)` to an inferred numeric schema. -@Deprecated('Use @Validate.min(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Min { - const Min(this.value); - - final num value; -} - -/// Adds `.max(value)` to an inferred numeric schema. -@Deprecated('Use @Validate.max(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Max { - const Max(this.value); - - final num value; -} - -/// Adds `.multipleOf(value)` to an inferred numeric schema. -@Deprecated('Use @Validate.multipleOf(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class MultipleOf { - const MultipleOf(this.value); - - final num value; -} - -/// Adds `.positive()` to an inferred numeric schema. -@Deprecated('Use @Validate.positive() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Positive { - const Positive(); -} - -/// Adds `.negative()` to an inferred numeric schema. -@Deprecated('Use @Validate.negative() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Negative { - const Negative(); -} - -/// Adds `.minLength(length)` to an inferred string schema. -@Deprecated('Use @Validate.minLength(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class MinLength { - const MinLength(this.length); - - final int length; -} - -/// Adds `.maxLength(length)` to an inferred string schema. -@Deprecated('Use @Validate.maxLength(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class MaxLength { - const MaxLength(this.length); - - final int length; -} - -/// Adds `.matches(pattern)` to an inferred string schema. -@Deprecated('Use @Validate.matches(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Matches { - const Matches(this.pattern); - - final String pattern; -} - -/// Adds `.email()` to an inferred string schema. -@Deprecated('Use @Validate.email() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Email { - const Email(); -} - -/// Adds `.url()` to an inferred string schema. -@Deprecated('Use @Validate.url() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Url { - const Url(); -} - -/// Adds `.uuid()` to an inferred string schema. -@Deprecated('Use @Validate.uuid() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Uuid { - const Uuid(); -} - -/// Adds `.date()` to an inferred string schema. -@Deprecated('Use @Validate.date() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Date { - const Date(); -} - -/// Adds `.notEmpty()` to an inferred string schema. -@Deprecated('Use @Validate.notEmpty() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class NotEmpty { - const NotEmpty(); -} - -/// Adds `.minItems(count)` to an inferred collection schema. -@Deprecated('Use @Validate.minItems(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class MinItems { - const MinItems(this.count); - - final int count; -} - -/// Adds `.maxItems(count)` to an inferred collection schema. -@Deprecated('Use @Validate.maxItems(...) from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class MaxItems { - const MaxItems(this.count); - - final int count; -} - -/// Adds `.unique()` to an inferred collection schema. -@Deprecated('Use @Validate.uniqueItems() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class UniqueItems { - const UniqueItems(); -} diff --git a/packages/ack/lib/src/annotations/format_constraints.dart b/packages/ack/lib/src/annotations/format_constraints.dart deleted file mode 100644 index 457acd87..00000000 --- a/packages/ack/lib/src/annotations/format_constraints.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:meta/meta_meta.dart'; - -/// Adds `.uri()` to an inferred string schema. -@Deprecated('Use @Validate.uri() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class Uri { - const Uri(); -} - -/// Adds `.datetime()` to an inferred string schema. -@Deprecated('Use @Validate.dateTime() from package:ack/ack.dart instead.') -@Target({TargetKind.field, TargetKind.parameter}) -final class DateTime { - const DateTime(); -} diff --git a/packages/ack/lib/src/constraints/list_unique_items_constraint.dart b/packages/ack/lib/src/constraints/list_unique_items_constraint.dart index 6e9f3bcc..910b11c5 100644 --- a/packages/ack/lib/src/constraints/list_unique_items_constraint.dart +++ b/packages/ack/lib/src/constraints/list_unique_items_constraint.dart @@ -15,9 +15,7 @@ class ListUniqueItemsConstraint extends Constraint> ); @override - bool isValid(List value) { - return _findDuplicates(value) == null; - } + bool isValid(List value) => hasUniqueJsonItems(value); @override String buildMessage(List value) { @@ -39,6 +37,23 @@ class ListUniqueItemsConstraint extends Constraint> // has no type-specific fields beyond constraintKey and description. } +/// Returns whether all items in [value] are unique under JSON instance equality. +bool hasUniqueJsonItems(List value) { + if (value.length <= 1) return true; + + final seenByHash = >{}; + for (final item in value) { + final hash = _jsonInstanceHashCode(item); + final bucket = seenByHash.putIfAbsent(hash, () => []); + if (bucket.any((seen) => _jsonInstanceEquals(seen, item))) { + return false; + } + bucket.add(item); + } + + return true; +} + /// Finds duplicate items in [value] using JSON instance equality. /// /// Returns a list of unique values that appear more than once (in order of diff --git a/packages/ack/lib/src/constraints/pattern_constraint.dart b/packages/ack/lib/src/constraints/pattern_constraint.dart index b73eaa2b..3ef81336 100644 --- a/packages/ack/lib/src/constraints/pattern_constraint.dart +++ b/packages/ack/lib/src/constraints/pattern_constraint.dart @@ -1,24 +1,21 @@ -import 'dart:convert'; - import 'package:collection/collection.dart'; import '../helpers.dart'; import 'constraint.dart'; /// Type of pattern matching operation. -enum PatternType { regex, enumString, notEnumString, format } +enum PatternType { regex, enumString, format } /// A generic constraint for string pattern/format validations. /// -/// Handles regex matching, checking against a list of allowed/disallowed enum strings, +/// Handles regex matching, checking against a list of allowed enum strings, /// and validating against predefined formats (like date, email) using either regex /// or custom validation functions. class PatternConstraint extends Constraint with Validator, JsonSchemaSpec { final PatternType type; final RegExp? pattern; // For PatternType.regex - final List? - allowedValues; // For PatternType.enumString, PatternType.notEnumString + final List? allowedValues; // For PatternType.enumString final bool Function(String value)? formatValidator; // For PatternType.format final String? example; // Optional example for documentation/error messages @@ -48,9 +45,7 @@ class PatternConstraint extends Constraint this.customMessageBuilder, }) : assert( (type == PatternType.regex && pattern != null) || - ((type == PatternType.enumString || - type == PatternType.notEnumString) && - allowedValues != null) || + (type == PatternType.enumString && allowedValues != null) || (type == PatternType.format && formatValidator != null), 'Pattern, allowedValues, or formatValidator must be provided based on type.', ); @@ -105,15 +100,6 @@ class PatternConstraint extends Constraint customMessageBuilder: (v) => 'Invalid UUID format, got "$v".', ); - static PatternConstraint hexColor() => PatternConstraint( - type: PatternType.regex, - pattern: RegExp(r'^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$'), - constraintKey: 'string_format_hexcolor', - description: 'Must be a valid hex color code (e.g., #RRGGBB or #RGB).', - example: '#FF0000', - customMessageBuilder: (v) => 'Invalid hex color format, got "$v".', - ); - static PatternConstraint uri() => PatternConstraint( type: PatternType.format, formatValidator: isValidUriFormat, @@ -133,17 +119,6 @@ class PatternConstraint extends Constraint }, ); - static PatternConstraint notEnumString( - List disallowedValues, - ) => PatternConstraint( - type: PatternType.notEnumString, - allowedValues: disallowedValues, - constraintKey: 'string_not_enum', - description: 'Must not be one of: ${disallowedValues.join(", ")}.', - customMessageBuilder: (v) => - 'Value "$v" is disallowed. Cannot be one of: ${disallowedValues.map((e) => '"$e"').join(', ')}.', - ); - static PatternConstraint startsWith(String prefix) => PatternConstraint( type: PatternType.regex, pattern: RegExp('^${RegExp.escape(prefix)}'), @@ -214,28 +189,10 @@ class PatternConstraint extends Constraint 'Invalid time format (HH:MM:SS), got "$value".', ); - static PatternConstraint jsonString() => PatternConstraint( - type: PatternType.format, - formatValidator: (v) { - try { - jsonDecode(v); - return true; - } on FormatException { - // Expected for invalid JSON - return false; - } - // Let other exceptions (OutOfMemoryError, stack overflow, etc.) propagate - }, - constraintKey: 'string_format_json', - description: 'Must be a valid JSON formatted string.', - customMessageBuilder: (v) => 'Invalid JSON string format.', - ); - @override bool isValid(String value) => switch (type) { PatternType.regex => pattern!.hasMatch(value), PatternType.enumString => allowedValues!.contains(value), - PatternType.notEnumString => !allowedValues!.contains(value), PatternType.format => formatValidator!(value), }; @@ -256,8 +213,6 @@ class PatternConstraint extends Constraint ); return 'Value "$nonNullValue" is not one of the allowed values: ${allowedValues!.map((e) => '"$e"').join(', ')}.$suggestion'; }(), - PatternType.notEnumString => - 'Value "$nonNullValue" is disallowed. Cannot be one of: ${allowedValues!.map((e) => '"$e"').join(', ')}.', PatternType.format => 'Value "$nonNullValue" is not a valid ${constraintKey.replaceFirst("string_format_", "")}${example != null ? " (e.g., $example)" : ""}.', }; @@ -291,9 +246,6 @@ class PatternConstraint extends Constraint return {'pattern': pattern!.pattern}; }(), PatternType.enumString => {'enum': allowedValues}, - PatternType.notEnumString => { - 'not': {'enum': allowedValues}, - }, PatternType.format => () { final standardFormat = _keyToFormat[constraintKey]; if (standardFormat != null) { @@ -341,12 +293,6 @@ final _uuidPattern = RegExp( r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$', ); -/// Validates the email format shared by native strings and JSON Schema. -bool isValidEmailFormat(String value) => _emailPattern.hasMatch(value); - -/// Validates the UUID format shared by native strings and JSON Schema. -bool isValidUuidFormat(String value) => _uuidPattern.hasMatch(value); - /// Validates the URI format shared by native strings and JSON Schema. bool isValidUriFormat(String value) { final u = Uri.tryParse(value); diff --git a/packages/ack/lib/src/helpers.dart b/packages/ack/lib/src/helpers.dart index 26153900..0bfb6b3f 100644 --- a/packages/ack/lib/src/helpers.dart +++ b/packages/ack/lib/src/helpers.dart @@ -1,6 +1,5 @@ // Re-export utilities for backward compatibility export 'utils/collection_utils.dart'; -export 'utils/default_utils.dart'; export 'utils/discriminated_branch_utils.dart'; export 'utils/json_utils.dart'; export 'utils/string_utils.dart'; diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index 8a19d5af..13c93aa4 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -1,12 +1,9 @@ part of '../schemas/schema.dart'; -String _importPointerToken(String token) => - token.replaceAll('~', '~0').replaceAll('/', '~1'); - String _keywordPointer(_ImportedNode node, String keyword) { final pointer = keyword.isEmpty ? node.pointer - : '${node.pointer}/${_importPointerToken(keyword)}'; + : '${node.pointer}/${escapeJsonPointerToken(keyword)}'; // Internal pointers stay unencoded for lookup. Encode only the public // fragment, including literal '%' and '#' characters (RFC 6901 section 6). return '#${Uri.encodeFull(pointer.substring(1)).replaceAll('#', '%23')}'; @@ -141,6 +138,7 @@ final class _JsonSchemaCompiler { static final standardDialect = Uri.parse( 'https://json-schema.org/draft/2020-12/schema', ); + static final _anchorPattern = RegExp(r'^[A-Za-z_][-A-Za-z0-9._]*$'); static const validationVocabularyUri = 'https://json-schema.org/draft/2020-12/vocab/validation'; static const formatAssertionVocabularyUri = @@ -288,16 +286,14 @@ final class _JsonSchemaCompiler { if (source is! Map) return node; if (source.containsKey(r'$anchor')) { final anchor = source[r'$anchor']; - if (anchor is! String || - !RegExp(r'^[A-Za-z_][-A-Za-z0-9._]*$').hasMatch(anchor)) { + if (anchor is! String || !_anchorPattern.hasMatch(anchor)) { _fail(node, r'$anchor', 'Invalid anchor name.'); } _register(anchors, base.replace(fragment: anchor), node, r'$anchor'); } if (source.containsKey(r'$dynamicAnchor')) { final anchor = source[r'$dynamicAnchor']; - if (anchor is! String || - !RegExp(r'^[A-Za-z_][-A-Za-z0-9._]*$').hasMatch(anchor)) { + if (anchor is! String || !_anchorPattern.hasMatch(anchor)) { _fail(node, r'$dynamicAnchor', 'Invalid dynamic anchor name.'); } node.dynamicAnchorName = anchor; @@ -308,7 +304,7 @@ final class _JsonSchemaCompiler { for (final entry in source.entries) { final key = entry.key; final value = entry.value; - final path = '$pointer/${_importPointerToken(key)}'; + final path = '$pointer/${escapeJsonPointerToken(key)}'; if (mapKeywords.contains(key)) { if (value is! Map) { _fail(node, key, 'Expected a map of schemas.'); @@ -317,7 +313,7 @@ final class _JsonSchemaCompiler { _index( child.value ?? _invalidSchema(node, key), document, - '$path/${_importPointerToken(child.key)}', + '$path/${escapeJsonPointerToken(child.key)}', base, dialect: dialect, ); @@ -373,6 +369,37 @@ final class _JsonSchemaCompiler { } } + void _checkRequiredVocabularies( + _ImportedNode node, + String key, + Map vocabulary, + ) { + for (final entry in vocabulary.entries) { + if (entry.value == true && !knownVocabularies.contains(entry.key)) { + _fail( + node, + key, + 'Unknown required vocabulary: ${entry.key}.', + code: 'unsupported_vocabulary', + ); + } + } + } + + List _requireUniqueStringList( + _ImportedNode node, + String key, + Object? value, + String message, + ) { + if (value is! List || + value.any((v) => v is! String) || + value.toSet().length != value.length) { + _fail(node, key, message); + } + return List.unmodifiable(value); + } + void compile(_ImportedNode root) { for (final node in locations.values) { _configureDialect(node); @@ -412,16 +439,7 @@ final class _JsonSchemaCompiler { code: 'unsupported_dialect', ); } - for (final entry in vocabulary.entries) { - if (entry.value == true && !knownVocabularies.contains(entry.key)) { - _fail( - node, - r'$schema', - 'Unknown required vocabulary: ${entry.key}.', - code: 'unsupported_vocabulary', - ); - } - } + _checkRequiredVocabularies(node, r'$schema', vocabulary); node.validationVocabulary = vocabulary.containsKey(validationVocabularyUri); node.formatAssertion = assertFormats || vocabulary.containsKey(formatAssertionVocabularyUri); @@ -498,7 +516,7 @@ final class _JsonSchemaCompiler { } else { return null; } - pointer = '$pointer/${_importPointerToken(token)}'; + pointer = '$pointer/${escapeJsonPointerToken(token)}'; if (locations[(resource.documentUri, pointer)] case final indexed?) { base = indexed.baseUri; } @@ -566,16 +584,7 @@ final class _JsonSchemaCompiler { value.values.any((required) => required is! bool)) { _fail(node, key, 'Expected a map of vocabulary URIs to booleans.'); } - for (final entry in value.entries) { - if (entry.value == true && !knownVocabularies.contains(entry.key)) { - _fail( - node, - key, - 'Unknown required vocabulary: ${entry.key}.', - code: 'unsupported_vocabulary', - ); - } - } + _checkRequiredVocabularies(node, key, value); node.keywords[key] = value; } else if (key == r'$ref' || key == r'$dynamicRef') { if (value is! String) _fail(node, key, 'Expected a URI string.'); @@ -624,7 +633,7 @@ final class _JsonSchemaCompiler { key == 'dependentSchemas') { final compiledMap = { for (final name in (value as Map).keys) - name: _child(node, '$key/${_importPointerToken(name)}'), + name: _child(node, '$key/${escapeJsonPointerToken(name)}'), }; node.maps[key] = compiledMap; switch (key) { @@ -709,27 +718,26 @@ final class _JsonSchemaCompiler { node.keywords[key] = value; node.types = values; } else if (key == 'required') { - if (value is! List || - value.any((v) => v is! String) || - value.toSet().length != value.length) { - _fail(node, key, 'Expected a list of unique property names.'); - } node.keywords[key] = value; - node.required = List.unmodifiable(value); + node.required = _requireUniqueStringList( + node, + key, + value, + 'Expected a list of unique property names.', + ); } else if (key == 'dependentRequired') { - if (value is! Map || - value.values.any( - (names) => - names is! List || - names.any((name) => name is! String) || - names.toSet().length != names.length, - )) { + if (value is! Map) { _fail(node, key, 'Expected a map of unique property-name lists.'); } node.keywords[key] = value; node.dependentRequired = { for (final entry in value.entries) - entry.key: List.unmodifiable(entry.value! as List), + entry.key: _requireUniqueStringList( + node, + key, + entry.value, + 'Expected a map of unique property-name lists.', + ), }; } else if (key == 'enum') { if (value is! List) { diff --git a/packages/ack/lib/src/json_schema/json_schema_formats.dart b/packages/ack/lib/src/json_schema/json_schema_formats.dart index 531abf19..09e04562 100644 --- a/packages/ack/lib/src/json_schema/json_schema_formats.dart +++ b/packages/ack/lib/src/json_schema/json_schema_formats.dart @@ -34,7 +34,7 @@ bool _isValidJsonSchemaFormat(String format, String value) => switch (format) { 'idn-email' => _isEmail(value, international: true), 'hostname' => _isHostname(value, international: false), 'idn-hostname' => _isHostname(value, international: true), - 'ipv4' => _isIpv4(value), + 'ipv4' => StringIpConstraint.isIpv4(value), 'ipv6' => _isIpv6(value), 'uri' => _isUri(value, absolute: true, international: false), 'uri-reference' => _isUri(value, absolute: false, international: false), @@ -112,16 +112,6 @@ bool _isDuration(String value) { return true; } -bool _isIpv4(String value) { - final parts = value.split('.'); - if (parts.length != 4) return false; - for (final part in parts) { - if (!RegExp(r'^(0|[1-9][0-9]{0,2})$').hasMatch(part)) return false; - if (int.parse(part) > 255) return false; - } - return true; -} - bool _isIpv6(String value) { // Uri.parseIPv6Address differs across supported Dart SDKs (notably for // whitespace and IPv4 tails), so validate the RFC 3986 grammar ourselves. @@ -134,7 +124,7 @@ bool _isIpv6(String value) { final separator = value.lastIndexOf(':'); if (separator < 0) return false; final tail = value.substring(separator + 1); - if (!_isIpv4(tail)) return false; + if (!StringIpConstraint.isIpv4(tail)) return false; address = value.substring(0, separator); if (address.endsWith(':')) address += ':'; if (address.contains('.')) return false; @@ -394,7 +384,7 @@ bool _isEmail(String value, {required bool international}) { if (literal.toLowerCase().startsWith('ipv6:')) { return _isIpv6(literal.substring(5)); } - return _isIpv4(literal); + return StringIpConstraint.isIpv4(literal); } return _isHostname(domain, international: international); } diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 8a54c88f..ba9446bb 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -1,6 +1,7 @@ import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; +import '../utils/json_utils.dart'; import 'ack_schema_model_warning.dart'; const _unset = Object(); @@ -477,7 +478,9 @@ final class AckRefSchemaModel extends AckSchemaModel { @override Map toJsonSchema() { - final refJson = {r'$ref': '#/definitions/${_jsonPointerToken(refName)}'}; + final refJson = { + r'$ref': '#/definitions/${escapeJsonPointerToken(refName)}', + }; if (nullable) return finishTypeJson(refJson); final commonJson = _common.toJson(); @@ -509,8 +512,6 @@ final class AckStringSchemaModel extends AckSchemaModel { this.minLength, this.maxLength, this.pattern, - this.formatMinimum, - this.formatMaximum, super.title, super.description, super.nullable, @@ -527,8 +528,6 @@ final class AckStringSchemaModel extends AckSchemaModel { this.minLength, this.maxLength, this.pattern, - this.formatMinimum, - this.formatMaximum, }) : super._(common); @override @@ -538,21 +537,6 @@ final class AckStringSchemaModel extends AckSchemaModel { final int? minLength; final int? maxLength; final String? pattern; - final String? formatMinimum; - final String? formatMaximum; - - @override - Object? get _metadataEquality => { - if (formatMinimum != null) 'formatMinimum': formatMinimum, - if (formatMaximum != null) 'formatMaximum': formatMaximum, - }; - - List? get allowedStringValues { - if (constValue case final value?) return [value]; - final values = enumValues; - if (values == null || values.isEmpty) return null; - return values.map((value) => value.toString()).toList(); - } @override Map toJsonSchema() => finishTypeJson({ @@ -575,8 +559,6 @@ final class AckStringSchemaModel extends AckSchemaModel { minLength: minLength, maxLength: maxLength, pattern: pattern, - formatMinimum: formatMinimum, - formatMaximum: formatMaximum, ); AckStringSchemaModel _copyWith({ @@ -586,8 +568,6 @@ final class AckStringSchemaModel extends AckSchemaModel { int? minLength, int? maxLength, String? pattern, - String? formatMinimum, - String? formatMaximum, }) { return AckStringSchemaModel._( _common, @@ -599,8 +579,6 @@ final class AckStringSchemaModel extends AckSchemaModel { minLength: minLength ?? this.minLength, maxLength: maxLength ?? this.maxLength, pattern: pattern ?? this.pattern, - formatMinimum: formatMinimum ?? this.formatMinimum, - formatMaximum: formatMaximum ?? this.formatMaximum, ); } @@ -635,14 +613,6 @@ final class AckStringSchemaModel extends AckSchemaModel { handled.add('pattern'); next = next._copyWith(pattern: value); } - if (keywords['formatMinimum'] case final String value) { - handled.add('formatMinimum'); - next = next._copyWith(formatMinimum: value); - } - if (keywords['formatMaximum'] case final String value) { - handled.add('formatMaximum'); - next = next._copyWith(formatMaximum: value); - } return next._withUnhandledKeywords(keywords, handled); } @@ -1300,7 +1270,3 @@ num? _readNumKeyword(Map keywords, String key) { final value = keywords[key]; return value is num ? value : null; } - -String _jsonPointerToken(String value) { - return value.replaceAll('~', '~0').replaceAll('/', '~1'); -} diff --git a/packages/ack/lib/src/schemas/any_of_schema.dart b/packages/ack/lib/src/schemas/any_of_schema.dart index 76a9f293..f174b5b4 100644 --- a/packages/ack/lib/src/schemas/any_of_schema.dart +++ b/packages/ack/lib/src/schemas/any_of_schema.dart @@ -158,17 +158,6 @@ final class AnyOfSchema extends AckSchema ); } - @override - Map toMap() { - return { - 'type': schemaType.typeName, - 'isNullable': isNullable, - 'description': description, - 'constraints': constraints.map((c) => c.toMap()).toList(), - 'schemas': schemas.length, - }; - } - @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index b3ff0081..f4f0090d 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -473,18 +473,6 @@ final class DiscriminatedObjectSchema ); } - @override - Map toMap() { - return { - 'type': schemaType.typeName, - 'isNullable': isNullable, - 'description': description, - 'constraints': constraints.map((c) => c.toMap()).toList(), - 'discriminatorKey': discriminatorKey, - 'schemas': schemas.length, - }; - } - @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/ack/lib/src/schemas/fluent_schema.dart b/packages/ack/lib/src/schemas/fluent_schema.dart index 85bb77e6..bc37e0cd 100644 --- a/packages/ack/lib/src/schemas/fluent_schema.dart +++ b/packages/ack/lib/src/schemas/fluent_schema.dart @@ -38,11 +38,17 @@ mixin FluentSchema< /// Marks the schema as nullable. @override - Schema nullable({bool value = true}) => copyWith(isNullable: value); + Schema nullable({bool value = true}) { + if (isNullable == value) return this as Schema; + return copyWith(isNullable: value); + } /// Marks the schema as optional so the field can be omitted from an object. @override - Schema optional({bool value = true}) => copyWith(isOptional: value); + Schema optional({bool value = true}) { + if (isOptional == value) return this as Schema; + return copyWith(isOptional: value); + } /// Sets the description for the schema. @override diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index 7925711b..92472d8a 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -44,14 +44,13 @@ final class ImportedJsonSchema extends AckSchema ImportedJsonSchema._( this._root, { required List<_ImportedNode> externalRoots, - required bool assertFormats, + required this.assertFormats, bool? isNullable, super.isOptional, String? description, super.constraints, super.refinements, }) : _externalRoots = externalRoots, - _assertFormats = assertFormats, super( isNullable: isNullable ?? _checkImportedNode(_root, null) == null, description: description ?? _root.keywords['description'] as String?, @@ -59,7 +58,9 @@ final class ImportedJsonSchema extends AckSchema final _ImportedNode _root; final List<_ImportedNode> _externalRoots; - final bool _assertFormats; + final bool assertFormats; + + Object get rawSchema => _root.source; static final _assertionDialect = Uri.parse( 'urn:ack:json-schema:2020-12:format-assertion', @@ -74,7 +75,7 @@ final class ImportedJsonSchema extends AckSchema if (names.containsKey(node)) return; final name = '$prefix${names.length}'; names[node] = name; - refs[node] = '#/definitions/${_importPointerToken(name)}'; + refs[node] = '#/definitions/${escapeJsonPointerToken(name)}'; for (final child in node.dependencies) { visit(child); } @@ -96,7 +97,7 @@ final class ImportedJsonSchema extends AckSchema return { if (source is Map) ...source, r'$id': (syntheticId ?? node.baseUri).toString(), - if (_assertFormats && + if (assertFormats && node.dialectUri == _JsonSchemaCompiler.standardDialect) r'$schema': _assertionDialect.toString(), if (source is bool) 'allOf': [source], @@ -114,7 +115,7 @@ final class ImportedJsonSchema extends AckSchema r'$schema', () => _JsonSchemaCompiler.standardDialect.toString(), ); - if (_externalRoots.isNotEmpty || _assertFormats) { + if (_externalRoots.isNotEmpty || assertFormats) { final definitions = { ...?root[r'$defs'] as Map?, }; @@ -136,7 +137,7 @@ final class ImportedJsonSchema extends AckSchema }; } } - if (_assertFormats) { + if (assertFormats) { var name = '_ack_format_assertion_dialect'; while (definitions.containsKey(name)) { name = '${name}_'; @@ -245,7 +246,7 @@ final class ImportedJsonSchema extends AckSchema keywordLocation: [ ...violation.evaluationPath, if (violation.keyword.isNotEmpty) violation.keyword, - ].map((token) => '/${_importPointerToken(token)}').join(), + ].map((token) => '/${escapeJsonPointerToken(token)}').join(), ), ); } @@ -262,7 +263,7 @@ final class ImportedJsonSchema extends AckSchema }) => ImportedJsonSchema._( _root, externalRoots: _externalRoots, - assertFormats: _assertFormats, + assertFormats: assertFormats, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -272,6 +273,20 @@ final class ImportedJsonSchema extends AckSchema @override SchemaType get schemaType => SchemaType.any; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! ImportedJsonSchema) return false; + + return baseFieldsEqual(other) && + assertFormats == other.assertFormats && + deepEquals(rawSchema, other.rawSchema); + } + + @override + int get hashCode => + Object.hash(baseFieldsHashCode, assertFormats, deepHashCode(rawSchema)); } final class _ImportViolation { @@ -608,14 +623,8 @@ _ImportEvaluation _evaluateImportedNode( 'maxItems', ); if (keyword != null) return fail(keyword); - if (node.uniqueItems) { - for (var i = 0; i < value.length; i++) { - for (var j = 0; j < i; j++) { - if (deepEquals(value[i], value[j])) { - return fail('uniqueItems'); - } - } - } + if (node.uniqueItems && !hasUniqueJsonItems(value)) { + return fail('uniqueItems'); } } final prefixItems = node.prefixItems ?? const <_ImportedNode>[]; diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index a79e61a2..43024e2c 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -185,13 +185,6 @@ final class LazySchema ); } - @override - Map toMap() => { - ...super.toMap(), - 'name': name, - 'maxDepth': maxDepth, - }; - @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/ack/lib/src/schemas/list_schema.dart b/packages/ack/lib/src/schemas/list_schema.dart index 71734ccf..84d4aca1 100644 --- a/packages/ack/lib/src/schemas/list_schema.dart +++ b/packages/ack/lib/src/schemas/list_schema.dart @@ -178,17 +178,6 @@ final class ListSchema ); } - @override - Map toMap() { - return { - 'type': schemaType.typeName, - 'isNullable': isNullable, - 'description': description, - 'constraints': constraints.map((c) => c.toMap()).toList(), - 'itemSchema': itemSchema.schemaType.typeName, - }; - } - @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/ack/lib/src/schemas/map_schema.dart b/packages/ack/lib/src/schemas/map_schema.dart index 29a8d9ac..faf61dc7 100644 --- a/packages/ack/lib/src/schemas/map_schema.dart +++ b/packages/ack/lib/src/schemas/map_schema.dart @@ -175,17 +175,6 @@ final class MapSchema ); } - @override - Map toMap() { - return { - 'type': schemaType.typeName, - 'isNullable': isNullable, - 'description': description, - 'constraints': constraints.map((c) => c.toMap()).toList(), - 'valueSchema': valueSchema.schemaType.typeName, - }; - } - @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/ack/lib/src/schemas/num_schema.dart b/packages/ack/lib/src/schemas/num_schema.dart index f425fcef..7099fa35 100644 --- a/packages/ack/lib/src/schemas/num_schema.dart +++ b/packages/ack/lib/src/schemas/num_schema.dart @@ -15,8 +15,10 @@ sealed class NumSchema extends AckSchema { @protected SchemaResult applyConstraintsAndRefinements( T value, - SchemaContext context, - ) { + SchemaContext context, { + Iterable>? constraints, + Iterable>? refinements, + }) { if (value is double && !value.isFinite) { final constraint = NumberFiniteConstraint(); final error = constraint.validate(value); @@ -29,7 +31,12 @@ sealed class NumSchema extends AckSchema { ); } - return super.applyConstraintsAndRefinements(value, context); + return super.applyConstraintsAndRefinements( + value, + context, + constraints: constraints, + refinements: refinements, + ); } } diff --git a/packages/ack/lib/src/schemas/object_schema.dart b/packages/ack/lib/src/schemas/object_schema.dart index 6060806f..58a79dcd 100644 --- a/packages/ack/lib/src/schemas/object_schema.dart +++ b/packages/ack/lib/src/schemas/object_schema.dart @@ -377,18 +377,6 @@ final class ObjectSchema extends AckSchema ); } - @override - Map toMap() { - return { - 'type': schemaType.typeName, - 'isNullable': isNullable, - 'description': description, - 'constraints': constraints.map((c) => c.toMap()).toList(), - 'properties': properties.length, - 'additionalProperties': additionalProperties, - }; - } - @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 012f9674..59394b17 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -9,8 +9,10 @@ import 'package:unorm_dart/unorm_dart.dart' as unorm; import '../common_types.dart'; import '../constraints/comparison_constraint.dart'; import '../constraints/constraint.dart'; +import '../constraints/list_unique_items_constraint.dart'; import '../constraints/number_finite_constraint.dart'; import '../constraints/pattern_constraint.dart'; +import '../constraints/string_ip_constraint.dart'; import '../constraints/validators.dart'; import '../context.dart'; import '../helpers.dart'; @@ -40,7 +42,6 @@ part 'num_schema.dart'; part 'object_schema.dart'; part 'schema_type.dart'; part 'string_schema.dart'; -part 'testing/testing_schemas.dart'; part 'wrapper_schema.dart'; typedef Refinement = ({bool Function(T value) validate, String message}); @@ -154,10 +155,12 @@ sealed class AckSchema { @protected SchemaResult applyConstraintsAndRefinements( Runtime value, - SchemaContext context, - ) { + SchemaContext context, { + Iterable>? constraints, + Iterable>? refinements, + }) { final constraintViolations = []; - for (final constraint in _constraints) { + for (final constraint in constraints ?? _constraints) { if (constraint is! Validator) continue; try { final violation = constraint.validate(value); @@ -179,11 +182,15 @@ sealed class AckSchema { ), ); } - return _runRefinements(value, context); + return _runRefinements(value, context, refinements: refinements); } - SchemaResult _runRefinements(Runtime value, SchemaContext context) { - for (final refinement in _refinements) { + SchemaResult _runRefinements( + Runtime value, + SchemaContext context, { + Iterable>? refinements, + }) { + for (final refinement in refinements ?? _refinements) { final bool isValid; try { isValid = refinement.validate(value); @@ -504,15 +511,6 @@ sealed class AckSchema { /// they are dispatched in `ack_schema_model_builder.dart`. Map toJsonSchema() => toSchemaModel().toJsonSchema(); - Map toMap() { - return { - 'type': schemaType.typeName, - 'isNullable': isNullable, - 'description': description, - 'constraints': _constraints.map((c) => c.toMap()).toList(), - }; - } - /// Compares base schema fields for equality. @protected bool baseFieldsEqual(AckSchema other) { diff --git a/packages/ack/lib/src/schemas/string_schema.dart b/packages/ack/lib/src/schemas/string_schema.dart index eea0d552..5b464ab4 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -144,54 +144,12 @@ final class StringSchema extends AckSchema required int refinementStart, required int refinementEnd, }) { - if (constraintStart < constraintEnd) { - final constraintViolations = []; - for (var i = constraintStart; i < constraintEnd; i++) { - final constraint = _constraints[i]; - if (constraint is! Validator) continue; - try { - final violation = constraint.validate(value); - if (violation != null) constraintViolations.add(violation); - } catch (error, stackTrace) { - return _failFromThrown( - 'Constraint "${constraint.constraintKey}" threw: $error', - context, - error, - stackTrace, - ); - } - } - if (constraintViolations.isNotEmpty) { - return SchemaResult.fail( - SchemaConstraintsError( - constraints: constraintViolations, - context: context, - ), - ); - } - } - - for (var i = refinementStart; i < refinementEnd; i++) { - final refinement = _refinements[i]; - final bool isValid; - try { - isValid = refinement.validate(value); - } catch (error, stackTrace) { - return _failFromThrown( - 'Refinement threw: $error', - context, - error, - stackTrace, - ); - } - if (!isValid) { - return SchemaResult.fail( - SchemaValidationError(message: refinement.message, context: context), - ); - } - } - - return SchemaResult.ok(value); + return applyConstraintsAndRefinements( + value, + context, + constraints: _constraints.getRange(constraintStart, constraintEnd), + refinements: _refinements.getRange(refinementStart, refinementEnd), + ); } @override diff --git a/packages/ack/lib/src/schemas/testing/testing_schemas.dart b/packages/ack/lib/src/schemas/testing/testing_schemas.dart deleted file mode 100644 index 207815fb..00000000 --- a/packages/ack/lib/src/schemas/testing/testing_schemas.dart +++ /dev/null @@ -1,194 +0,0 @@ -part of 'package:ack/src/schemas/schema.dart'; - -/// Testing-only schema used to simulate unsupported conversions in -/// integration packages. -@visibleForTesting -final class TestUnsupportedAckSchema extends AckSchema - with FluentSchema { - const TestUnsupportedAckSchema({ - super.isNullable, - super.isOptional, - super.description, - super.constraints, - super.refinements, - }); - - @override - @protected - SchemaResult validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) { - final nullResult = handleNullInput(value, context); - if (nullResult != null) return nullResult; - - return applyConstraintsAndRefinements(value!, context); - } - - @override - TestUnsupportedAckSchema copyWith({ - bool? isNullable, - bool? isOptional, - String? description, - List>? constraints, - List>? refinements, - }) { - return TestUnsupportedAckSchema( - isNullable: isNullable ?? this.isNullable, - isOptional: isOptional ?? this.isOptional, - description: description ?? this.description, - constraints: constraints ?? this.constraints, - refinements: refinements ?? this.refinements, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - if (other is! TestUnsupportedAckSchema) return false; - - return baseFieldsEqual(other); - } - - @override - SchemaType get schemaType => SchemaType.any; - - @override - int get hashCode => baseFieldsHashCode; -} - -/// Testing-only schema that records parse, validate, and encode operations. -@visibleForTesting -final class TestOperationRecordingAckSchema extends AckSchema - with FluentSchema { - const TestOperationRecordingAckSchema({ - required this.parseOperations, - required this.validateOperations, - required this.encodeOperations, - super.isNullable, - super.isOptional, - super.description, - super.constraints, - super.refinements, - }); - - final List parseOperations; - final List validateOperations; - final List encodeOperations; - - @override - SchemaType get schemaType => SchemaType.string; - - @override - SchemaResult parseWithContext(Object? value, SchemaContext context) { - parseOperations.add(context.operation); - return validateRuntimeWithContext(value, context); - } - - @override - SchemaResult validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) { - validateOperations.add(context.operation); - return SchemaResult.ok(value as String); - } - - @override - SchemaResult encodeWithContext(String value, SchemaContext context) { - encodeOperations.add(context.operation); - final validated = validateRuntimeWithContext(value, context); - if (validated.isFail) return SchemaResult.fail(validated.getError()); - return SchemaResult.ok(value); - } - - @override - TestOperationRecordingAckSchema copyWith({ - bool? isNullable, - bool? isOptional, - String? description, - List>? constraints, - List>? refinements, - }) { - return TestOperationRecordingAckSchema( - parseOperations: parseOperations, - validateOperations: validateOperations, - encodeOperations: encodeOperations, - isNullable: isNullable ?? this.isNullable, - isOptional: isOptional ?? this.isOptional, - description: description ?? this.description, - constraints: constraints ?? this.constraints, - refinements: refinements ?? this.refinements, - ); - } - - @override - Map toJsonSchema() => const {'type': 'string'}; - - @override - bool operator ==(Object other) => - other is TestOperationRecordingAckSchema && - identical(parseOperations, other.parseOperations) && - identical(validateOperations, other.validateOperations) && - identical(encodeOperations, other.encodeOperations) && - baseFieldsEqual(other); - - @override - int get hashCode => Object.hash( - baseFieldsHashCode, - identityHashCode(parseOperations), - identityHashCode(validateOperations), - identityHashCode(encodeOperations), - ); -} - -/// Testing-only non-codec schema whose encode throws an ordinary [Exception]. -@visibleForTesting -final class TestThrowingLeafAckSchema extends AckSchema - with FluentSchema { - const TestThrowingLeafAckSchema({ - super.isNullable, - super.isOptional, - super.description, - super.constraints, - super.refinements, - }); - - @override - SchemaType get schemaType => SchemaType.string; - - @override - SchemaResult validateRuntimeWithContext( - Object? value, - SchemaContext context, - ) => SchemaResult.ok(value as String); - - @override - SchemaResult encodeWithContext(String value, SchemaContext context) { - throw const FormatException('leaf encode refused'); - } - - @override - TestThrowingLeafAckSchema copyWith({ - bool? isNullable, - bool? isOptional, - String? description, - List>? constraints, - List>? refinements, - }) { - return TestThrowingLeafAckSchema( - isNullable: isNullable ?? this.isNullable, - isOptional: isOptional ?? this.isOptional, - description: description ?? this.description, - constraints: constraints ?? this.constraints, - refinements: refinements ?? this.refinements, - ); - } - - @override - bool operator ==(Object other) => - other is TestThrowingLeafAckSchema && baseFieldsEqual(other); - - @override - int get hashCode => baseFieldsHashCode; -} diff --git a/packages/ack/lib/src/utils/collection_utils.dart b/packages/ack/lib/src/utils/collection_utils.dart index 32c73166..63f4e71a 100644 --- a/packages/ack/lib/src/utils/collection_utils.dart +++ b/packages/ack/lib/src/utils/collection_utils.dart @@ -187,32 +187,3 @@ int deepHashCode(Object? value) { return Object.hash(value.runtimeType, value.hashCode); } - -extension IterableExtensions on Iterable { - /// Returns duplicate elements in this iterable. - /// The order of duplicates in the returned iterable is based on their second appearance. - Iterable get duplicates { - final seen = {}; - final duplicatesFound = []; - for (final element in this) { - if (!seen.add(element)) { - // .add returns false if element was already present - duplicatesFound.add(element); - } - } - - return duplicatesFound; - } - - /// Checks if there are any duplicate elements in this iterable. - bool get hasDuplicates => duplicates.isNotEmpty; - - /// Returns the first element matching [test], or `null` if none found. - T? firstWhereOrNull(bool Function(T element) test) { - for (final element in this) { - if (test(element)) return element; - } - - return null; - } -} diff --git a/packages/ack/lib/src/utils/default_utils.dart b/packages/ack/lib/src/utils/default_utils.dart deleted file mode 100644 index 352e1413..00000000 --- a/packages/ack/lib/src/utils/default_utils.dart +++ /dev/null @@ -1,4 +0,0 @@ -/// Utilities for safely handling default values in schemas. -library; - -export 'collection_utils.dart' show cloneDefault; diff --git a/packages/ack/lib/src/utils/json_utils.dart b/packages/ack/lib/src/utils/json_utils.dart index ce7a8875..1c7a7375 100644 --- a/packages/ack/lib/src/utils/json_utils.dart +++ b/packages/ack/lib/src/utils/json_utils.dart @@ -10,24 +10,6 @@ String prettyJson(Object? json) { } } -/// Merges two maps recursively. -/// If keys conflict: -/// - If both values are maps, they are recursively merged. -/// - Otherwise, the value from [map2] overwrites the value from [map1]. -Map deepMerge( - Map map1, - Map map2, -) { - final result = Map.of(map1); - for (final key in map2.keys) { - final value1 = result[key]; - final value2 = map2[key]; - if (value1 is Map && value2 is Map) { - result[key] = deepMerge(value1, value2); - } else { - result[key] = value2; - } - } - - return result; -} +/// Escapes a single RFC 6901 JSON Pointer reference token. +String escapeJsonPointerToken(String token) => + token.replaceAll('~', '~0').replaceAll('/', '~1'); diff --git a/packages/ack/test/annotations/ack_model_test.dart b/packages/ack/test/annotations/ack_model_test.dart index b9a7d5aa..1aea498f 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:ack/annotations.dart'; import 'package:test/test.dart'; @@ -19,6 +17,7 @@ void main() { expect(defaults.captureField, 'additionalProperties'); const configured = Schemable( + name: 'WireUser', schemaName: 'WireUserSchema', caseStyle: AckCaseStyle.snake, discriminatorKey: 'type', @@ -26,6 +25,7 @@ void main() { unknownProperties: AckUnknownPropertyPolicy.capture, captureField: 'args', ); + expect(configured.name, 'WireUser'); expect(configured.schemaName, 'WireUserSchema'); expect(configured.caseStyle, AckCaseStyle.snake); expect(configured.discriminatorKey, 'type'); @@ -133,31 +133,4 @@ void main() { ); }, ); - - test('legacy constraint sugar annotations remain const data', () { - const annotations = [ - Min(1), - Max(9), - MultipleOf(2), - Positive(), - Negative(), - MinLength(1), - MaxLength(100), - Matches(r'^[a-z]+$'), - Email(), - NotEmpty(), - MinItems(1), - MaxItems(10), - UniqueItems(), - ]; - - expect((annotations[0] as Min).value, 1); - expect((annotations[1] as Max).value, 9); - expect((annotations[2] as MultipleOf).value, 2); - expect((annotations[5] as MinLength).length, 1); - expect((annotations[6] as MaxLength).length, 100); - expect((annotations[7] as Matches).pattern, r'^[a-z]+$'); - expect((annotations[10] as MinItems).count, 1); - expect((annotations[11] as MaxItems).count, 10); - }); } diff --git a/packages/ack/test/annotations/dart_core_names_test.dart b/packages/ack/test/annotations/dart_core_names_test.dart index 6670b1ae..4e963a88 100644 --- a/packages/ack/test/annotations/dart_core_names_test.dart +++ b/packages/ack/test/annotations/dart_core_names_test.dart @@ -1,11 +1,33 @@ import 'package:ack/ack.dart'; +import 'package:ack/annotations.dart'; import 'package:test/test.dart'; +@Schemable(name: 'Endpoint') +final class _Endpoint { + const _Endpoint({required this.uri, required this.createdAt}); + + @AckField(name: 'endpoint_uri') + @Validate.uri() + final Uri uri; + + @Validate.dateTime() + final DateTime createdAt; +} + void main() { - test('package:ack/ack.dart leaves dart:core Pattern visible', () { - final Pattern pattern = RegExp('a'); - const annotation = Schemable(name: 'Banana'); + test( + 'package:ack/ack.dart and annotations.dart leave dart:core Pattern, Uri, and DateTime visible', + () { + final Pattern pattern = RegExp('a'); + const annotation = Schemable(name: 'Banana'); + final endpoint = _Endpoint( + uri: Uri.parse('https://example.com'), + createdAt: DateTime.utc(2026, 10, 1), + ); - expect(pattern.allMatches(annotation.name!.toLowerCase()), hasLength(3)); - }); + expect(pattern.allMatches(annotation.name!.toLowerCase()), hasLength(3)); + expect(endpoint.uri.host, 'example.com'); + expect(endpoint.createdAt.year, 2026); + }, + ); } diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index 6630dc8c..9699f9f4 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -1,21 +1,23 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:ack/ack.dart'; import 'package:ack/annotations.dart' as ack; import 'package:test/test.dart'; import 'own_types.dart'; +@ack.Schemable(caseStyle: ack.AckCaseStyle.snake) final class Contact { const Contact(this.email); - @ack.Email() + @ack.AckField(name: 'email_address', description: 'Primary email') + @ack.Validate.email() final Email email; } +@Schemable() final class SingleImportContact { const SingleImportContact(this.email, this.id); + @AckField(description: 'Contact email') @Validate.email() final Email email; @@ -42,13 +44,14 @@ void main() { }); test( - 'constraint names from package:ack/ack.dart leave room for app types', + 'constraint names from package:ack/ack.dart and annotations.dart leave room for app types', () { const uuid = Uuid('7c9e6679'); const email = Email('ada@example.com'); const url = Url('https://example.com'); const date = Date('2026-10-01'); const min = Min(1); + const max = Max(10); const pattern = Pattern('^a'); const contact = SingleImportContact(email, uuid); @@ -59,6 +62,7 @@ void main() { url.href, date.iso, min.value, + max.value, pattern.source, contact.email.address, contact.id.value, @@ -69,6 +73,7 @@ void main() { 'https://example.com', '2026-10-01', 1, + 10, '^a', 'ada@example.com', '7c9e6679', @@ -77,17 +82,21 @@ void main() { }, ); - test('a prefixed annotations import sits beside a same-named app type', () { - const contact = Contact(Email('ada@example.com')); - - expect(contact.email.address, 'ada@example.com'); - expect(const ack.Email(), isA()); - }); - - test('Matches annotation exposes pattern', () { - const annotation = ack.Matches('^a'); + test( + 'a prefixed annotations import exposes Schemable, AckField, and Validate', + () { + const contact = Contact(Email('ada@example.com')); + const schemable = ack.Schemable(name: 'Contact'); + const field = ack.AckField(name: 'email_address', description: 'Primary'); + const validate = ack.Validate.matches('^a'); - expect(annotation, isA()); - expect(annotation.pattern, '^a'); - }); + expect(contact.email.address, 'ada@example.com'); + expect(schemable.name, 'Contact'); + expect(field.name, 'email_address'); + expect(field.description, 'Primary'); + expect(validate, isA()); + expect(validate.kind, 'matches'); + expect(validate.value, '^a'); + }, + ); } diff --git a/packages/ack/test/annotations/own_types.dart b/packages/ack/test/annotations/own_types.dart index 234760c6..11783eff 100644 --- a/packages/ack/test/annotations/own_types.dart +++ b/packages/ack/test/annotations/own_types.dart @@ -26,6 +26,11 @@ final class Min { final int value; } +final class Max { + const Max(this.value); + final int value; +} + final class Pattern { const Pattern(this.source); final String source; diff --git a/packages/ack/test/default_utils_test.dart b/packages/ack/test/default_utils_test.dart deleted file mode 100644 index 3f96175a..00000000 --- a/packages/ack/test/default_utils_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:ack/src/utils/default_utils.dart'; -import 'package:test/test.dart'; - -void main() { - group('cloneDefault', () { - test('clones Map literals into an unmodifiable JsonMap', () { - final original = {'count': 1}; - - final cloned = cloneDefault(original) as Map; - - expect(cloned, equals({'count': 1})); - expect(cloned, isNot(same(original))); - expect(() => cloned['count'] = 2, throwsUnsupportedError); - - original['count'] = 2; - expect(cloned['count'], 1); - }); - - test('deep clones nested maps and lists', () { - final original = { - 'a': [ - 1, - {'b': 2}, - ], - 'nested': { - 'c': [3], - }, - }; - - final cloned = cloneDefault(original) as Map; - - final clonedList = cloned['a'] as List; - final clonedInnerMap = clonedList[1] as Map; - final clonedNested = cloned['nested'] as Map; - - expect(() => clonedList.add(42), throwsUnsupportedError); - expect(() => clonedInnerMap['b'] = 3, throwsUnsupportedError); - expect(() => clonedNested['c'] = [], throwsUnsupportedError); - - (original['a'] as List)[1] = {'b': 99}; - (original['nested'] as Map)['c'] = [4]; - - final innerMap = - (cloned['a'] as List)[1] as Map; - expect(innerMap['b'], 2); - expect((cloned['nested'] as Map)['c'], equals([3])); - }); - - test('clones maps with non-string keys without losing key identity', () { - final original = { - 1: ['a'], - }; - - final cloned = cloneDefault(original) as Map; - final clonedList = cloned[1] as List; - - expect(() => cloned[1] = ['b'], throwsUnsupportedError); - expect(() => clonedList.add('c'), throwsUnsupportedError); - - (original[1] as List).add('b'); - expect(clonedList, equals(['a'])); - }); - - test('clones sets into detached unmodifiable snapshots', () { - final nested = ['a']; - final original = {nested}; - - final cloned = cloneDefault(original) as Set; - - nested.add('b'); - original.add('later'); - - expect(cloned, { - ['a'], - }); - expect(() => cloned.add('later'), throwsUnsupportedError); - expect( - () => (cloned.single as List).add('later'), - throwsUnsupportedError, - ); - }); - - test('returns primitives as-is', () { - const primitive = 'hello'; - expect(identical(cloneDefault(primitive), primitive), isTrue); - }); - }); -} diff --git a/packages/ack/test/helpers_test.dart b/packages/ack/test/helpers_test.dart index b6bf41e6..ab2c040f 100644 --- a/packages/ack/test/helpers_test.dart +++ b/packages/ack/test/helpers_test.dart @@ -100,80 +100,6 @@ void main() { expect(buildDidYouMeanSuggestion('active', allowed), ''); }); }); - - group('deepMerge', () { - test('should merge maps with no overlapping keys', () { - final map1 = {'a': 1}; - final map2 = {'b': 2}; - expect(deepMerge(map1, map2), {'a': 1, 'b': 2}); - }); - - test('should overwrite non-map values from the second map', () { - final map1 = {'a': 1, 'b': 1}; - final map2 = {'b': 2, 'c': 3}; - expect(deepMerge(map1, map2), {'a': 1, 'b': 2, 'c': 3}); - }); - - test('should recursively merge nested maps', () { - final map1 = { - 'a': 1, - 'nested': {'x': 10, 'y': 20}, - }; - final map2 = { - 'b': 2, - 'nested': {'y': 30, 'z': 40}, - }; - expect(deepMerge(map1, map2), { - 'a': 1, - 'b': 2, - 'nested': {'x': 10, 'y': 30, 'z': 40}, - }); - }); - - test('should handle one map being empty', () { - final map = {'a': 1}; - expect(deepMerge(map, {}), map); - expect(deepMerge({}, map), map); - }); - }); - - group('IterableExtensions', () { - group('duplicates', () { - test('should return duplicate elements', () { - final list = [1, 2, 2, 3, 4, 4, 4]; - expect(list.duplicates, equals([2, 4, 4])); - }); - - test('should return an empty iterable if there are no duplicates', () { - final list = [1, 2, 3, 4, 5]; - expect(list.duplicates, isEmpty); - }); - }); - - group('hasDuplicates', () { - test('should return true if there are duplicates', () { - final list = [1, 2, 3, 2]; - expect(list.hasDuplicates, isTrue); - }); - - test('should return false if there are no duplicates', () { - final list = [1, 2, 3, 4]; - expect(list.hasDuplicates, isFalse); - }); - }); - - group('firstWhereOrNull', () { - test('should return the first element that satisfies the test', () { - final list = [1, 2, 3, 4, 5]; - expect(list.firstWhereOrNull((e) => e > 3), 4); - }); - - test('should return null if no element satisfies the test', () { - final list = [1, 2, 3, 4, 5]; - expect(list.firstWhereOrNull((e) => e > 5), isNull); - }); - }); - }); } class _NonSerializable {} diff --git a/packages/ack/test/schema_model/ack_schema_model_test.dart b/packages/ack/test/schema_model/ack_schema_model_test.dart index f0d0ecb0..c4e6923d 100644 --- a/packages/ack/test/schema_model/ack_schema_model_test.dart +++ b/packages/ack/test/schema_model/ack_schema_model_test.dart @@ -164,15 +164,6 @@ void main() { expect(ordered.toJsonSchema(), unordered.toJsonSchema()); expect(ordered, isNot(unordered)); - - const dateBounded = AckStringSchemaModel( - format: 'date', - formatMinimum: '2026-01-01', - ); - const dateUnbounded = AckStringSchemaModel(format: 'date'); - - expect(dateBounded.toJsonSchema(), dateUnbounded.toJsonSchema()); - expect(dateBounded, isNot(dateUnbounded)); }); }); } diff --git a/packages/ack/test/schemas/encode_error_propagation_test.dart b/packages/ack/test/schemas/encode_error_propagation_test.dart index 7ea3bd60..00b4a92e 100644 --- a/packages/ack/test/schemas/encode_error_propagation_test.dart +++ b/packages/ack/test/schemas/encode_error_propagation_test.dart @@ -1,5 +1,4 @@ import 'package:ack/ack.dart'; -import 'package:ack/src/schemas/schema.dart' show TestThrowingLeafAckSchema; import 'package:test/test.dart'; CodecSchema _throwingCodec(Object error) { @@ -191,17 +190,18 @@ void main() { ); }); - test('a non-codec child Exception is still attributed to #/leaf', () { - final schema = Ack.object({'leaf': const TestThrowingLeafAckSchema()}); + test('a leaf Error thrown during object encoding propagates untouched', () { + final thrown = StateError('unexpected leaf encode state'); + final schema = Ack.object({ + 'leaf': Ack.string().codec( + decode: (v) => v, + encode: (_) => throw thrown, + ), + }); - final result = schema.safeEncode({'leaf': 'v'}); + final captured = _captureThrow(() => schema.safeEncode({'leaf': 'v'})); - expect(result.isFail, isTrue); - expect( - _flatten(result.getError()).any((e) => e.path == '#/leaf'), - isTrue, - reason: _describe(result.getError()), - ); + expect(captured.error, same(thrown)); }); }); diff --git a/packages/ack/test/schemas/schema_equality_test.dart b/packages/ack/test/schemas/schema_equality_test.dart index 6a415099..e9065564 100644 --- a/packages/ack/test/schemas/schema_equality_test.dart +++ b/packages/ack/test/schemas/schema_equality_test.dart @@ -223,10 +223,57 @@ void main() { expect(Ack.datetime(), equals(Ack.datetime())); expect(Ack.uri(), equals(Ack.uri())); expect(Ack.duration(), equals(Ack.duration())); - expect( - Ack.enumCodec(TestColor.values), - equals(Ack.enumCodec(TestColor.values)), - ); + }); + }); + + group('ImportedJsonSchema', () { + test('equal raw schemas and options are equal', () { + final a = Ack.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer', 'minimum': 0}, + }, + 'required': ['name'], + }); + final b = Ack.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer', 'minimum': 0}, + }, + 'required': ['name'], + }); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('different raw schemas are not equal', () { + final a = Ack.fromJsonSchema({'type': 'string', 'minLength': 1}); + final b = Ack.fromJsonSchema({'type': 'string', 'minLength': 2}); + + expect(a, isNot(equals(b))); + }); + + test('different assertFormats flags are not equal', () { + final a = Ack.fromJsonSchema({ + 'type': 'string', + 'format': 'email', + }, assertFormats: false); + final b = Ack.fromJsonSchema({ + 'type': 'string', + 'format': 'email', + }, assertFormats: true); + + expect(a, isNot(equals(b))); + }); + + test('different base schema fields are not equal', () { + final a = Ack.fromJsonSchema({'type': 'string'}).nullable(); + final b = Ack.fromJsonSchema({'type': 'string'}); + + expect(a, isNot(equals(b))); }); }); diff --git a/packages/ack/test/typed_codecs_characterization_test.dart b/packages/ack/test/typed_codecs_characterization_test.dart index 56bf0b0c..08b3de6d 100644 --- a/packages/ack/test/typed_codecs_characterization_test.dart +++ b/packages/ack/test/typed_codecs_characterization_test.dart @@ -1,12 +1,7 @@ import 'package:ack/ack.dart'; // These symbols are intentionally hidden from the public ack.dart export; // the internal characterization tests below reach into the source path. -import 'package:ack/src/schemas/schema.dart' - show - AnyAckSchema, - SchemaOperation, - TestOperationRecordingAckSchema, - WrapperSchema; +import 'package:ack/src/schemas/schema.dart' show AnyAckSchema, WrapperSchema; import 'package:test/test.dart'; final class _Event { @@ -35,6 +30,24 @@ final class _StartsWithConstraint extends Constraint String buildMessage(String value) => 'Expected value to start with $prefix'; } +final class _RecordingConstraint extends Constraint + with Validator { + _RecordingConstraint(this.events, {required this.passes}) + : super(constraintKey: 'recording', description: 'Records validation'); + + final List events; + final bool Function(String value) passes; + + @override + bool isValid(String value) { + events.add('constraint:$value'); + return passes(value); + } + + @override + String buildMessage(String value) => 'Constraint rejected $value'; +} + void main() { group('AckSchema type model', () { test('Ack.string is AckSchema', () { @@ -250,54 +263,6 @@ void main() { }); }); - group('Ack.enumCodec', () { - test('returns a CodecSchema with String boundary and enum runtime', () { - final schema = Ack.enumCodec(_Role.values); - expect(schema, isA>()); - }); - - test('parses .name into the typed enum value', () { - final schema = Ack.enumCodec(_Role.values); - // Static-type check: boundary is String, runtime is _Role. - // The String → _Role mapping is performed inside the underlying - // EnumSchema (via `.name` lookup), not by the codec's identity - // decoder. - const String boundaryInput = 'admin'; - final _Role? parsed = schema.parse(boundaryInput); - expect(parsed, _Role.admin); - expect(schema.parse('member'), _Role.member); - }); - - test('encodes the enum value back to .name', () { - final schema = Ack.enumCodec(_Role.values); - // Static-type check: encoder takes _Role, returns String. - const _Role runtimeValue = _Role.admin; - final String? encoded = schema.encode(runtimeValue); - expect(encoded, 'admin'); - expect(schema.encode(_Role.member), 'member'); - }); - - test('round-trips through the codec without losing identity', () { - final schema = Ack.enumCodec(_Role.values); - for (final value in _Role.values) { - final encoded = schema.encode(value); - expect(schema.parse(encoded), value); - } - }); - - test('rejects strings outside the allowed values', () { - final schema = Ack.enumCodec(_Role.values); - final result = schema.safeParse('owner'); - expect(result.isFail, true); - }); - - test('rejects non-string boundary input', () { - final schema = Ack.enumCodec(_Role.values); - final result = schema.safeParse(42); - expect(result.isFail, true); - }); - }); - group('DefaultSchema wrapper', () { test('parse(null) returns runtime default', () { final schema = Ack.string().withDefault('fallback'); @@ -490,36 +455,45 @@ void main() { }); }); - group('SchemaContext.operation', () { - test('parse path observes SchemaOperation.parse', () { - final parseOperations = []; - final validateOperations = []; - final schema = TestOperationRecordingAckSchema( - parseOperations: parseOperations, - validateOperations: validateOperations, - encodeOperations: [], - ); + group('Constraint and refinement execution order', () { + test('parse and encode run constraints before refinements', () { + final events = []; + final schema = Ack.string() + .withConstraint(_RecordingConstraint(events, passes: (_) => true)) + .refine((value) { + events.add('refine:$value'); + return true; + }); - schema.parse('x'); + expect(schema.parse('x'), 'x'); + expect(events, ['constraint:x', 'refine:x']); - expect(parseOperations, [SchemaOperation.parse]); - expect(validateOperations, [SchemaOperation.parse]); + events.clear(); + expect(schema.encode('y'), 'y'); + expect(events, ['constraint:y', 'refine:y']); }); - test('encode path observes SchemaOperation.encode', () { - final validateOperations = []; - final encodeOperations = []; - final schema = TestOperationRecordingAckSchema( - parseOperations: [], - validateOperations: validateOperations, - encodeOperations: encodeOperations, - ); - - schema.encode('x'); - - expect(encodeOperations, [SchemaOperation.encode]); - expect(validateOperations, [SchemaOperation.encode]); - }); + test( + 'failing constraint short-circuits refinements on parse and encode', + () { + final events = []; + final schema = Ack.string() + .withConstraint( + _RecordingConstraint(events, passes: (value) => value != 'bad'), + ) + .refine((value) { + events.add('refine:$value'); + return true; + }); + + expect(schema.safeParse('bad').isFail, true); + expect(events, ['constraint:bad']); + + events.clear(); + expect(schema.safeEncode('bad').isFail, true); + expect(events, ['constraint:bad']); + }, + ); }); group('Sealed SchemaError exhaustiveness', () { diff --git a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart index 2edc2bf1..a073183d 100644 --- a/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart +++ b/packages/ack_firebase_ai/test/support/firebase_ai_response_json_schema_cases.dart @@ -268,8 +268,6 @@ List firebaseAiResponseJsonSchemaCases() => [ minLength: 5, maxLength: 5, pattern: r'^[a-z]+$', - formatMinimum: 'ready', - formatMaximum: 'ready', extensions: {'x-ack-test': true}, ), ), diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 112da4b8..26d15146 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -3,14 +3,15 @@ ### Breaking * Requires `ack: ^2.0.0-beta.1`. -* **Single-pass `.g.dart` builder (`#189`)**: `ack_generator:ack` is now a - `SharedPartBuilder` that emits `.g.dart` directly with generated +* **Single-pass `.g.dart` builder (`#189`)**: `ack_generator:ack_generator` is + now a `SharedPartBuilder` that emits `.g.dart` directly with generated `toJson()` / `fromJson()` helpers. The two-part `.ack.dart` and `.ack.g.dart` - builders (`ack_generator`, `ack_json`) and runtime `json_serializable` - delegation are removed. Replace `part '.ack.dart';` and - `part '.ack.g.dart';` with a single `part '.g.dart';`. -* Remove legacy `@AckType`, `@AckInfer`, `@AckModel`, and `*Type` extension-type - model generation (`#188`). + builders and runtime `json_serializable` delegation are removed. Replace + `part '.ack.dart';` and `part '.ack.g.dart';` with a single + `part '.g.dart';`. +* Remove legacy `@AckType`, `@AckInfer`, `@AckModel`, `*Type` extension-type + model generation, and the legacy `@JsonKey(name: ...)` fallback in favor of + `@AckField(name: ...)` (`#188`, `#189`). ### Added @@ -31,9 +32,10 @@ * Support two-way `StringSchema` normalizers (`.trim()`, `.toLowerCase()`, `.toUpperCase()`) in schema-first `@Schemable()` models. * Add `ack_generator:ack_json_schema` builder and `generateAckSchemaLibrary()` - API to compile `lib/**/*.schema.json` Draft 2020-12 documents into - `.schema.g.dart` typed models or validated value-model libraries - (`#167`). + API: `ack_generator:ack_json_schema` emits `.schema.dart` from + `lib/**/*.schema.json` Draft 2020-12 documents, which + `ack_generator:ack_generator` compiles into `.schema.g.dart` typed + models or validated value-model libraries (`#167`). ## 1.7.0-beta.7 diff --git a/packages/ack_generator/lib/ack_generator.dart b/packages/ack_generator/lib/ack_generator.dart index fac5f9c8..613e0524 100644 --- a/packages/ack_generator/lib/ack_generator.dart +++ b/packages/ack_generator/lib/ack_generator.dart @@ -1,5 +1,11 @@ -// Export the builders for build.yaml -export 'src/builder.dart' show ackGenerator, ackModelBuilder; - -// Re-exports the annotations for convenience alongside the builder entrypoint. -export 'package:ack/annotations.dart'; +// Export the builders for build.yaml and programmatic JSON Schema generation. +export 'src/builder.dart' show ackGenerator, ackJsonSchemaBuilder; +export 'src/json_schema/json_schema_generation_exception.dart' + show AckJsonSchemaDiagnostic, AckJsonSchemaGenerationException; +export 'src/json_schema/json_schema_library.dart' + show + AckJsonSchemaLibrary, + AckJsonSchemaLibraryOptions, + AckJsonSchemaModelMode, + AckUnknownPropertiesMode, + generateAckSchemaLibrary; diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 6cc77db9..6819f896 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -70,75 +70,6 @@ final class ClassModelGraphBuilder { '==', }; - static const _dartKeywords = { - 'abstract', - 'as', - 'assert', - 'async', - 'await', - 'base', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'covariant', - 'default', - 'deferred', - 'do', - 'dynamic', - 'else', - 'enum', - 'export', - 'extends', - 'extension', - 'external', - 'factory', - 'false', - 'final', - 'finally', - 'for', - 'get', - 'hide', - 'if', - 'implements', - 'import', - 'in', - 'interface', - 'is', - 'late', - 'library', - 'mixin', - 'new', - 'null', - 'of', - 'on', - 'operator', - 'part', - 'required', - 'rethrow', - 'return', - 'sealed', - 'set', - 'show', - 'static', - 'super', - 'switch', - 'sync', - 'this', - 'throw', - 'true', - 'try', - 'typedef', - 'var', - 'void', - 'when', - 'while', - 'with', - 'yield', - }; - static const _oneWayTransformMethods = {'transform'}; static const _schemableChecker = TypeChecker.typeNamed( @@ -191,7 +122,6 @@ final class ClassModelGraphBuilder { final String? ackPrefix; AckSchemaInference get _inference => AckSchemaInference(ackPrefix: ackPrefix); final AckModelGraph _graph = AckModelGraph(); - final Set _explicit = {}; final Set _consumed = {}; final Map _schemaNameOwners = {}; final Map> _dependencies = {}; @@ -216,7 +146,6 @@ final class ClassModelGraphBuilder { } _inputResolved = resolved; _resolvedByUri[libraryElement.uri] = resolved; - _explicit.addAll(annotatedClasses); for (final element in annotatedClasses) { _validateAnnotatedClass(element); @@ -516,8 +445,6 @@ final class ClassModelGraphBuilder { AckUnionModelNode( id: baseId, className: base.name!, - boundaryType: _jsonMapRef, - runtimeRef: AckExternalTypeRef(name: base.name!), discriminatorKey: discriminatorKey, branches: branches, description: _classDescription(base, baseOptions), @@ -613,7 +540,7 @@ final class ClassModelGraphBuilder { final parameters = {}; final constructorParameters = []; for (final parameter in constructor.formalParameters) { - _rejectJsonKeyOnParameter(element, parameter); + _rejectJsonKey(element, parameter); final field = _parameterField(parameter) ?? fields[parameter.name]; final fieldName = field?.name; if (fieldName == null) { @@ -634,7 +561,6 @@ final class ClassModelGraphBuilder { typeRef: futureTypes[fieldName]?.runtimeRef ?? _typeRef(parameter.type, field!), - isSuper: parameter is SuperFormalParameterElement, defaultExpression: parameter.defaultValueCode, ), ); @@ -778,8 +704,6 @@ final class ClassModelGraphBuilder { final node = AckObjectModelNode( id: id, className: element.name!, - boundaryType: _jsonMapRef, - runtimeRef: AckExternalTypeRef(name: element.name!), fields: nodes, constructorParameters: constructorParameters, unknownPropertyPolicy: switch (options.unknownProperties) { @@ -791,7 +715,6 @@ final class ClassModelGraphBuilder { AckUnknownPropertyPolicy.capture, }, captureFieldName: captureFieldName, - captureJsonKey: captureJsonKey, unionId: unionId, discriminatorKey: discriminatorKey, discriminatorValue: discriminatorValue, @@ -906,7 +829,7 @@ final class ClassModelGraphBuilder { AckNullableTypeRef(:final inner) => inner, _ => runtimeRef, }; - final rendered = _renderType(_schemaRuntimeRef(setType)); + final rendered = _schemaRuntimeRef(setType).render(corePrefix: ackPrefix); return '$listSchema.codec<$rendered>(' 'decode: (list) => list.toSet(), ' 'encode: (set) => set.toList(growable: false),' @@ -1029,13 +952,13 @@ final class ClassModelGraphBuilder { setListSchema: listSchema, ); } else { - final target = _futureAckInferTarget( + final target = _futureGeneratedTarget( className: name, prefix: prefix, field: field, ); if (target == null) return null; - final visibleName = prefix == null ? name : '$prefix.$name'; + final visibleName = qualifySymbol(prefix, name); result = ( schemaExpression: '$visibleName.\$ack.schema', runtimeRef: AckModelTypeRef( @@ -1058,7 +981,7 @@ final class ClassModelGraphBuilder { ); } - Element? _futureAckInferTarget({ + Element? _futureGeneratedTarget({ required String className, required String? prefix, required FieldElement field, @@ -1069,9 +992,9 @@ final class ClassModelGraphBuilder { String? hiddenDeclaration; Element? candidateFor(Element element) { - final declaration = _ackInferDeclaration(element); + final declaration = _schemableDeclaration(element); if (declaration == null || - _generatedAckInferClassName(declaration) != className) { + _generatedSchemaClassName(declaration) != className) { return null; } return declaration.baseElement; @@ -1138,7 +1061,7 @@ final class ClassModelGraphBuilder { return matches.single; } - Element? _ackInferDeclaration(Element element) { + Element? _schemableDeclaration(Element element) { final declaration = switch (element) { GetterElement(isOriginVariable: true) => element.variable.baseElement, TopLevelVariableElement() => element.baseElement, @@ -1150,7 +1073,7 @@ final class ClassModelGraphBuilder { : null; } - String _generatedAckInferClassName(Element declaration) { + String _generatedSchemaClassName(Element declaration) { final annotation = _schemableChecker.firstAnnotationOfExact(declaration)!; final custom = ConstantReader(annotation).read('name'); return ackInferModelClassName( @@ -1336,8 +1259,11 @@ final class ClassModelGraphBuilder { _inference.inferType( type, visibleTypeName: (type) => _visibleTypeName(type, field), - renderType: (type) => - _renderType(_typeRef(type, field, allowJsonMaybe: false)), + renderType: (type) => _typeRef( + type, + field, + allowJsonMaybe: false, + ).render(ackPrefix: ackPrefix), resolveNamed: (type) => _namedSchemaForType(type, field), unsupported: (type) => _unsupportedFieldType(field, type), rejectNullableCollectionElement: (type) => @@ -1869,7 +1795,7 @@ final class ClassModelGraphBuilder { } final arguments = [ for (final argument in type.typeArguments) - _renderType(_typeArgumentRef(argument, field)), + _typeArgumentRef(argument, field).render(ackPrefix: ackPrefix), ]; final name = _visibleTypeName(type, field); expression = arguments.isEmpty @@ -1924,7 +1850,10 @@ final class ClassModelGraphBuilder { case AckListTypeRef(:final elementType) || AckSetTypeRef(:final elementType): if (elementType is AckNullableTypeRef) { - _nullableCollectionElementError(field, _renderType(elementType)); + _nullableCollectionElementError( + field, + elementType.render(ackPrefix: ackPrefix), + ); } _rejectNullableFutureCollectionElement(field, elementType); case AckMapTypeRef(:final valueType): @@ -2524,7 +2453,7 @@ final class ClassModelGraphBuilder { final facadeName = _facadeName(element, options); final backingName = ackClassSchemaBackingName(element.name!); if (!RegExp(r'^[A-Z][A-Za-z0-9_$]*$').hasMatch(facadeName) || - _dartKeywords.contains(facadeName)) { + isDartKeyword(facadeName)) { throw InvalidGenerationSource( 'Invalid ${_annotationName(element)} schema facade name "$facadeName" on ' '${element.name}; schemaName must be a public UpperCamel identifier.', @@ -2547,8 +2476,6 @@ final class ClassModelGraphBuilder { AckClassModelMetadata( facadeName: facadeName, backingName: backingName, - caseStyle: options.caseStyle, - hasExplicitAnnotation: _explicit.contains(element), copyWithSupertypes: { for (final supertype in element.allSupertypes) if (supertype.element case final ClassElement parent) @@ -2636,74 +2563,32 @@ final class ClassModelGraphBuilder { } String? _jsonKey(FieldElement field, [FormalParameterElement? parameter]) { - String? ackFieldName; - final ackField = _firstAnnotation(_ackFieldChecker, field, parameter); - if (ackField != null) { - final nameReader = ConstantReader(ackField).read('name'); - if (!nameReader.isNull) { - ackFieldName = nameReader.stringValue; - if (ackFieldName.trim().isEmpty) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField.name must ' - 'not be blank.', - element: field, - ); - } - } - } - final annotation = _jsonKeyChecker.firstAnnotationOfExact(field); - if (annotation == null) return ackFieldName; - final reader = ConstantReader(annotation); - const unsupportedOptions = [ - 'defaultValue', - 'disallowNullValue', - 'explicitJsonNullWhenNonNullField', - 'fromJson', - 'ignore', - 'includeFromJson', - 'includeIfNull', - 'includeToJson', - 'readValue', - 'required', - 'toJson', - 'unknownEnumValue', - ]; - final configuredUnsupported = [ - for (final option in unsupportedOptions) - if (!reader.read(option).isNull) option, - ]; - if (configuredUnsupported.isNotEmpty) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} uses unsupported ' - '@JsonKey options: ${configuredUnsupported.join(', ')}. ' - '${_annotationName(field.enclosingElement)} supports only ' - '@JsonKey(name: ...) on fields so validation and JSON serialization ' - 'cannot diverge.', - element: field, - ); + _rejectJsonKey(field.enclosingElement as ClassElement, field); + if (parameter != null) { + _rejectJsonKey(field.enclosingElement as ClassElement, parameter); } - final value = reader.read('name'); - final jsonKeyName = value.isNull ? null : value.stringValue; - if (ackFieldName != null && jsonKeyName != null) { + final ackField = _firstAnnotation(_ackFieldChecker, field, parameter); + if (ackField == null) return null; + final nameReader = ConstantReader(ackField).read('name'); + if (nameReader.isNull) return null; + final ackFieldName = nameReader.stringValue; + if (ackFieldName.trim().isEmpty) { throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} sets both ' - '@AckField(name: ...) and @JsonKey(name: ...). Use @AckField(name: ...) ' - 'only.', + '${field.enclosingElement.name}.${field.name} @AckField.name must ' + 'not be blank.', element: field, ); } - return ackFieldName ?? jsonKeyName; + return ackFieldName; } - void _rejectJsonKeyOnParameter( - ClassElement owner, - FormalParameterElement parameter, - ) { - if (!_jsonKeyChecker.hasAnnotationOfExact(parameter)) return; + void _rejectJsonKey(ClassElement owner, Element element) { + if (!_jsonKeyChecker.hasAnnotationOfExact(element)) return; throw InvalidGenerationSource( - '${owner.name}.${parameter.name} places @JsonKey on a constructor ' - 'parameter. Put @JsonKey(name: ...) on the field instead.', - element: parameter, + '${owner.name}.${element.name} uses @JsonKey, which is not supported on ' + '${_annotationName(owner)} classes in Ack 2.0. Use @AckField(name: ...) ' + 'instead.', + element: element, ); } @@ -2752,10 +2637,8 @@ final class ClassModelGraphBuilder { bool _isNullable(DartType type) => type.nullabilitySuffix == NullabilitySuffix.question; - String _visibleTypeName(InterfaceType type, FieldElement field) { - final prefix = _visiblePrefix(type.element, field); - return '${prefix == null ? '' : '$prefix.'}${type.element.name}'; - } + String _visibleTypeName(InterfaceType type, FieldElement field) => + qualifySymbol(_visiblePrefix(type.element, field), type.element.name!); /// The import prefix that names [target] in this library, or null when an /// unprefixed name reaches it. @@ -2812,7 +2695,7 @@ final class ClassModelGraphBuilder { }) { if (name.startsWith('_') || !RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(name) || - _dartKeywords.contains(name)) { + isDartKeyword(name)) { throw InvalidGenerationSource( '${element.name}.$name is not a valid public $memberKind.', element: element, @@ -2820,25 +2703,7 @@ final class ClassModelGraphBuilder { } } - String _renderType(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => '${_renderType(inner)}?', - AckScalarTypeRef(:final dartType) => dartType, - AckExternalTypeRef(:final visibleName, :final typeArguments) => - typeArguments.isEmpty - ? visibleName - : '$visibleName<${typeArguments.map(_renderType).join(', ')}>', - AckModelTypeRef(:final visibleName) => visibleName, - AckListTypeRef(:final elementType) => 'List<${_renderType(elementType)}>', - AckSetTypeRef(:final elementType) => 'Set<${_renderType(elementType)}>', - AckMapTypeRef(:final valueType) => 'Map', - AckJsonMaybeTypeRef(:final valueType) => - '${_ack('JsonMaybe')}<${_renderType(valueType)}>', - }; - - String _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; - } + String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); AckSchemaId _id(ClassElement element) => AckSchemaId( libraryUri: library.element.uri, diff --git a/packages/ack_generator/lib/src/analyzer/schema_first_names.dart b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart index 3873699d..3564da12 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_first_names.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart @@ -1,3 +1,5 @@ +import '../utils/string_literal.dart'; + /// Member names every schema-first model generates, so a field cannot use /// them. const ackSchemaFirstReservedMembers = { @@ -16,76 +18,6 @@ const ackSchemaFirstReservedMembers = { 'runtimeType', }; -/// Dart reserved words, which cannot be field names. -const ackDartReservedWords = { - 'abstract', - 'as', - 'assert', - 'async', - 'await', - 'base', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'covariant', - 'default', - 'deferred', - 'do', - 'dynamic', - 'else', - 'enum', - 'export', - 'extends', - 'extension', - 'external', - 'factory', - 'false', - 'final', - 'finally', - 'for', - 'get', - 'hide', - 'if', - 'implements', - 'import', - 'in', - 'interface', - 'is', - 'late', - 'library', - 'mixin', - 'new', - 'null', - 'of', - 'on', - 'operator', - 'part', - 'required', - 'rethrow', - 'return', - 'sealed', - 'set', - 'show', - 'static', - 'super', - 'switch', - 'sync', - 'this', - 'throw', - 'true', - 'try', - 'typedef', - 'var', - 'void', - 'when', - 'while', - 'with', - 'yield', -}; - /// Why [key] cannot be a schema-first field name, or `null` when it can. /// /// [capturesExtras] is whether the model stores unknown properties in its @@ -98,7 +30,7 @@ String? ackSchemaFirstFieldNameProblem( return "cannot start with '_' (private Dart member)."; } if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(key) || - ackDartReservedWords.contains(key)) { + isDartKeyword(key)) { return 'cannot be represented as a Dart field name.'; } if (ackSchemaFirstReservedMembers.contains(key)) { diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 16308d04..6b4c4fe3 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -10,6 +10,7 @@ import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; +import '../utils/string_literal.dart'; import 'generated_companion_visibility.dart'; import 'schema_first_names.dart'; @@ -54,7 +55,6 @@ final class SchemaModelGraphBuilder { SchemaModelGraphBuilder(this.library); static const _reservedMembers = ackSchemaFirstReservedMembers; - static const _dartKeywords = ackDartReservedWords; static const _oneWayTransformMethods = {'transform'}; @@ -271,7 +271,7 @@ final class SchemaModelGraphBuilder { _localDeclaration(chain.reference!) != null) { node = await _aliasNode(declaration, chain.reference!, path); } else if (chain.reference != null && - _isCrossLibraryAckInfer(chain.reference!)) { + _isCrossLibrarySchemable(chain.reference!)) { throw InvalidGenerationSource( '$path aliases a cross-library ' '${_annotationLabel(_referencedElement(chain.reference!)!)} schema. ' @@ -362,12 +362,9 @@ final class SchemaModelGraphBuilder { return AckObjectModelNode( id: declaration.id, className: declaration.className, - boundaryType: source.boundaryType, - runtimeRef: source.runtimeRef, fields: source.fields, unknownPropertyPolicy: source.unknownPropertyPolicy, captureFieldName: source.captureFieldName, - captureJsonKey: source.captureJsonKey, constructorParameters: source.constructorParameters, description: source.description, ); @@ -379,12 +376,13 @@ final class SchemaModelGraphBuilder { element: declaration.element, ); } + final valueSource = source as AckValueModelNode; return AckValueModelNode( id: declaration.id, className: declaration.className, - boundaryType: source.boundaryType, - runtimeRef: source.runtimeRef, - description: source.description, + boundaryType: valueSource.boundaryType, + runtimeRef: valueSource.runtimeRef, + description: valueSource.description, ); } @@ -416,17 +414,13 @@ final class SchemaModelGraphBuilder { ); } final jsonKey = (entry.key as SimpleStringLiteral).value; - _rejectInvalidMemberName(jsonKey, path, declaration.element); - if (_reservedMembers.contains(jsonKey)) { - throw InvalidGenerationSource( - '$path.$jsonKey conflicts with generated/Object member "$jsonKey".', - element: declaration.element, - ); - } - if (additionalProperties && jsonKey == 'additionalProperties') { + final problem = ackSchemaFirstFieldNameProblem( + jsonKey, + capturesExtras: additionalProperties, + ); + if (problem != null) { throw InvalidGenerationSource( - '$path.$jsonKey conflicts with the generated additional-properties ' - 'member.', + '$path.$jsonKey $problem', element: declaration.element, ); } @@ -456,16 +450,9 @@ final class SchemaModelGraphBuilder { ), ); } - final types = _schemaTypes( - declaration.expression, - path, - declaration.element, - ); return AckObjectModelNode( id: declaration.id, className: declaration.className, - boundaryType: types.boundary, - runtimeRef: types.runtime, fields: fields, constructorParameters: _schemaFirstConstructorParameters( fields, @@ -475,7 +462,6 @@ final class SchemaModelGraphBuilder { ? AckUnknownPropertyPolicy.capture : AckUnknownPropertyPolicy.reject, captureFieldName: additionalProperties ? 'additionalProperties' : null, - captureJsonKey: additionalProperties ? 'additionalProperties' : null, description: _description(declaration.expression), ); } @@ -553,13 +539,10 @@ final class SchemaModelGraphBuilder { AckObjectModelNode( id: branch.id, className: branch.className, - boundaryType: branch.boundaryType, - runtimeRef: branch.runtimeRef, fields: branch.fields, constructorParameters: branch.constructorParameters, unknownPropertyPolicy: branch.unknownPropertyPolicy, captureFieldName: branch.captureFieldName, - captureJsonKey: branch.captureJsonKey, unionId: declaration.id, discriminatorKey: discriminatorKey, discriminatorValue: value, @@ -574,16 +557,9 @@ final class SchemaModelGraphBuilder { element: declaration.element, ); } - final types = _schemaTypes( - declaration.expression, - path, - declaration.element, - ); return AckUnionModelNode( id: declaration.id, className: declaration.className, - boundaryType: types.boundary, - runtimeRef: types.runtime, discriminatorKey: discriminatorKey, branches: branches, description: _description(declaration.expression), @@ -769,7 +745,7 @@ final class SchemaModelGraphBuilder { if (element is! TopLevelVariableElement && element is! GetterElement) { return null; } - if (_hasAckInfer(element)) return null; + if (_hasSchemable(element)) return null; if (depth >= _maxReferenceDepth) { throw InvalidGenerationSource( '$path exceeds schema reference depth $_maxReferenceDepth.', @@ -904,7 +880,7 @@ final class SchemaModelGraphBuilder { runtimeRef: runtime, ); } - if (!_hasAckInfer(element)) return null; + if (!_hasSchemable(element)) return null; final declaration = _propertyDeclaration(element); final name = declaration.name; final owningLibrary = declaration.library; @@ -1347,11 +1323,11 @@ final class SchemaModelGraphBuilder { return element == null ? null : _declarationsByElement[element.baseElement]; } - bool _isCrossLibraryAckInfer(Expression expression) { + bool _isCrossLibrarySchemable(Expression expression) { final element = _referencedElement(expression); if (element == null) return false; if (_declarationsByElement[element.baseElement] != null) return false; - return _hasAckInfer(element); + return _hasSchemable(element); } String? _expressionPrefix(Expression expression) { @@ -1362,7 +1338,7 @@ final class SchemaModelGraphBuilder { return null; } - bool _hasAckInfer(Element element) { + bool _hasSchemable(Element element) { final declaration = _propertyDeclaration(element); return _schemableChecker.hasAnnotationOfExact(declaration); } @@ -1577,7 +1553,7 @@ final class SchemaModelGraphBuilder { ); } if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(jsonKey) || - _dartKeywords.contains(jsonKey)) { + isDartKeyword(jsonKey)) { throw InvalidGenerationSource( '$path.$jsonKey cannot be represented as a Dart field name.', element: element, diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index 2dda9881..e39c3227 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -501,20 +501,7 @@ Map $function(${node.className} model) { _ => expression, }; - String _type(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => '${_type(inner)}?', - AckScalarTypeRef(:final dartType) => dartType, - AckExternalTypeRef(:final visibleName, :final typeArguments) => - typeArguments.isEmpty - ? visibleName - : '$visibleName<${typeArguments.map(_type).join(', ')}>', - AckModelTypeRef(:final visibleName) => visibleName, - AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', - AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', - AckMapTypeRef(:final valueType) => 'Map', - AckJsonMaybeTypeRef(:final valueType) => - '${_ack('JsonMaybe')}<${_type(valueType)}>', - }; + String _type(AckInferRef type) => type.render(ackPrefix: ackPrefix); Set _declaredKeys(AckObjectModelNode node) => { for (final field in node.fields) field.jsonKey, @@ -532,8 +519,5 @@ Map $function(${node.className} model) { return metadata; } - String _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; - } + String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); } diff --git a/packages/ack_generator/lib/src/builders/data_class_emitter.dart b/packages/ack_generator/lib/src/builders/data_class_emitter.dart index 824b51ca..9df1f088 100644 --- a/packages/ack_generator/lib/src/builders/data_class_emitter.dart +++ b/packages/ack_generator/lib/src/builders/data_class_emitter.dart @@ -1,5 +1,8 @@ +import 'package:code_builder/code_builder.dart'; + import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; +import '../utils/string_literal.dart'; /// One generated `copyWith` parameter, typed by the emitter that owns type /// rendering. @@ -89,6 +92,60 @@ ${includeValueMembers ? '${copyWithContract(className: className, parameters: co }'''; } + /// Emits `copyWith`, `operator ==`, `hashCode`, and `toString` for a + /// schema-first model class. + List valueMethods({ + required String className, + required List fields, + required bool hasParameters, + }) => [ + Method( + (m) => m + ..name = 'copyWith' + ..type = MethodType.getter + ..returns = refer('${ackCopyWithInterfaceName(className)}<$className>') + ..docs.add( + '/// Creates a copy of this model with selected fields replaced.', + ) + ..lambda = true + ..body = Code( + _copyWithCreation(className, hasParameters: hasParameters), + ), + ), + Method( + (m) => m + ..name = 'operator ==' + ..annotations.add(refer('override')) + ..returns = refer('bool') + ..requiredParameters.add( + Parameter( + (p) => p + ..name = 'other' + ..type = refer('Object'), + ), + ) + ..lambda = true + ..body = Code(_directEqualsExpression(className, fields)), + ), + Method( + (m) => m + ..name = 'hashCode' + ..annotations.add(refer('override')) + ..type = MethodType.getter + ..returns = refer('int') + ..lambda = true + ..body = Code(_hashExpression(fields)), + ), + Method( + (m) => m + ..name = 'toString' + ..annotations.add(refer('override')) + ..returns = refer('String') + ..lambda = true + ..body = literalString(_toStringPreview(className, fields)).code, + ), + ]; + /// The typed `copyWith` interface and its implementation, plus the private /// sentinel type when a nullable field needs one. /// @@ -160,10 +217,11 @@ $sentinelField$sourceField @override required bool hasParameters, bool castSelf = false, }) { - final implementationName = ackCopyWithImplementationName(className); - final creation = hasParameters - ? '$implementationName(${castSelf ? 'this as $className' : 'this'})' - : 'const $implementationName()'; + final creation = _copyWithCreation( + className, + hasParameters: hasParameters, + castSelf: castSelf, + ); return ''' /// Creates a copy of this model with selected fields replaced. ${ackCopyWithInterfaceName(className)}<$className> get copyWith => $creation;'''; @@ -174,17 +232,13 @@ ${ackCopyWithInterfaceName(className)}<$className> get copyWith => $creation;''' required List fields, bool castSelf = false, }) { - final hashes = [ - 'runtimeType', - for (final field in fields) - _hash('${castSelf ? 'self' : 'this'}.${field.dartName}'), - ]; if (castSelf && fields.isNotEmpty) { final fieldEquals = [ for (final field in fields) _equals('self.${field.dartName}', 'other.${field.dartName}'), ]; final equality = fieldEquals.isEmpty ? 'true' : fieldEquals.join(' && '); + final hashes = _hashExpression(fields, receiver: 'self'); return ''' @override bool operator ==(Object other) { @@ -199,22 +253,16 @@ bool operator ==(Object other) { @override int get hashCode { final self = this as $className; - return Object.hashAll([${hashes.join(', ')}]); + return $hashes; }'''; } - final comparisons = [ - 'other is $className', - 'runtimeType == other.runtimeType', - for (final field in fields) - _equals('this.${field.dartName}', 'other.${field.dartName}'), - ]; return ''' @override bool operator ==(Object other) => - identical(this, other) || (${comparisons.join(' && ')}); + ${_directEqualsExpression(className, fields)}; @override -int get hashCode => Object.hashAll([${hashes.join(', ')}]);'''; +int get hashCode => ${_hashExpression(fields)};'''; } String toStringMethod({ @@ -234,12 +282,9 @@ String toString() { return '$className(${parts.join(', ')})'; }'''; } - final parts = [ - for (final field in fields) '${field.dartName}: \$${field.dartName}', - ]; return ''' @override -String toString() => '$className(${parts.join(', ')})';'''; +String toString() => '${_toStringPreview(className, fields)}';'''; } String jsonMembers({required String className, required String facadeName}) { @@ -256,6 +301,43 @@ ${_ack('SchemaResult')}> safeToJson() => $facadeName.safeEncode(this as $className);'''; } + String _copyWithCreation( + String className, { + required bool hasParameters, + bool castSelf = false, + }) { + final implementationName = ackCopyWithImplementationName(className); + return hasParameters + ? '$implementationName(${castSelf ? 'this as $className' : 'this'})' + : 'const $implementationName()'; + } + + String _directEqualsExpression(String className, List fields) { + final comparisons = [ + 'other is $className', + 'runtimeType == other.runtimeType', + for (final field in fields) + _equals(field.dartName, 'other.${field.dartName}'), + ]; + return 'identical(this, other) || (${comparisons.join(' && ')})'; + } + + String _hashExpression(List fields, {String? receiver}) { + final prefix = receiver == null ? '' : '$receiver.'; + final hashes = [ + 'runtimeType', + for (final field in fields) _hash('$prefix${field.dartName}'), + ]; + return 'Object.hashAll([${hashes.join(', ')}])'; + } + + String _toStringPreview(String className, List fields) { + final parts = [ + for (final field in fields) '${field.dartName}: \$${field.dartName}', + ]; + return '$className(${parts.join(', ')})'; + } + String _optional(String type) => type.endsWith('?') ? type : '$type?'; String _copyWithArgument(AckCopyWithParameter parameter) { @@ -278,23 +360,7 @@ ${_ack('SchemaResult')}> safeToJson() => String _hash(String expression) => '${_ack('deepHashCode')}($expression)'; - String _type(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => '${_type(inner)}?', - AckScalarTypeRef(:final dartType) => dartType, - AckExternalTypeRef(:final visibleName, :final typeArguments) => - typeArguments.isEmpty - ? visibleName - : '$visibleName<${typeArguments.map(_type).join(', ')}>', - AckModelTypeRef(:final visibleName) => visibleName, - AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', - AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', - AckMapTypeRef(:final valueType) => 'Map', - AckJsonMaybeTypeRef(:final valueType) => - '${_ack('JsonMaybe')}<${_type(valueType)}>', - }; - - String _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; - } + String _type(AckInferRef type) => type.render(ackPrefix: ackPrefix); + + String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); } diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index c56f54aa..0ba354a3 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -1,9 +1,8 @@ import 'package:code_builder/code_builder.dart'; -import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; -import 'data_class_emitter.dart'; import '../utils/string_literal.dart'; +import 'data_class_emitter.dart'; /// Emits immutable model declarations solely from a normalized model graph. final class AckModelEmitter { @@ -540,7 +539,7 @@ ${_ack('AckModelAdapter')}( Code _copyWithContract(String className, _AckDataClass data) { final byField = {for (final field in data.fields) field.dartName: field}; return Code( - AckDataClassEmitter(ackPrefix: ackPrefix).copyWithContract( + _dataClasses.copyWithContract( className: className, parameters: [ for (final parameter in data.constructorParameters) @@ -569,76 +568,12 @@ ${_ack('AckModelAdapter')}( runtimeRef: node.runtimeRef, ); - List _valueMembers(String className, _AckDataClass data) { - final fields = data.fields; - final comparisons = [ - 'other is $className', - 'runtimeType == other.runtimeType', - for (final field in fields) - '${_ack('deepEquals')}(${field.dartName}, other.${field.dartName})', - ]; - final hashes = [ - 'runtimeType', - for (final field in fields) '${_ack('deepHashCode')}(${field.dartName})', - ]; - final toStringPreview = [ - for (final field in fields) '${field.dartName}: \$${field.dartName}', - ].join(', '); - final implementation = ackCopyWithImplementationName(className); - return [ - Method( - (m) => m - ..name = 'copyWith' - ..type = MethodType.getter - ..returns = refer( - '${ackCopyWithInterfaceName(className)}<$className>', - ) - ..docs.add( - '/// Creates a copy of this model with selected fields replaced.', - ) - ..lambda = true - ..body = Code( - data.constructorParameters.isEmpty - ? 'const $implementation()' - : '$implementation(this)', - ), - ), - Method( - (m) => m - ..name = 'operator ==' - ..annotations.add(refer('override')) - ..returns = refer('bool') - ..requiredParameters.add( - Parameter( - (p) => p - ..name = 'other' - ..type = refer('Object'), - ), - ) - ..lambda = true - ..body = Code( - 'identical(this, other) || (${comparisons.join(' && ')})', - ), - ), - Method( - (m) => m - ..name = 'hashCode' - ..annotations.add(refer('override')) - ..type = MethodType.getter - ..returns = refer('int') - ..lambda = true - ..body = Code('Object.hashAll([${hashes.join(', ')}])'), - ), - Method( - (m) => m - ..name = 'toString' - ..annotations.add(refer('override')) - ..returns = refer('String') - ..lambda = true - ..body = literalString('$className($toStringPreview)').code, - ), - ]; - } + List _valueMembers(String className, _AckDataClass data) => + _dataClasses.valueMethods( + className: className, + fields: data.fields, + hasParameters: data.constructorParameters.isNotEmpty, + ); AckFieldNode _syntheticField(AckConstructorParameter parameter) => AckFieldNode( @@ -813,10 +748,19 @@ return $_runtimeMapLiteral{ '$expression == null ? null : ${_fromRuntime(inner, '$expression!')}', AckModelTypeRef(:final runtimeRef, :final visibleName) => '$visibleName.\$ack.fromRuntime($expression as ${_type(runtimeRef)})', + AckListTypeRef(:final elementType) + when !_requiresRuntimeConversion(elementType) => + '($expression as List).cast<${_type(elementType)}>()', AckListTypeRef(:final elementType) => '($expression as List).map((item) => ${_fromRuntime(elementType, 'item')}).toList()', + AckSetTypeRef(:final elementType) + when !_requiresRuntimeConversion(elementType) => + '($expression as Set).cast<${_type(elementType)}>()', AckSetTypeRef(:final elementType) => '($expression as Set).map((item) => ${_fromRuntime(elementType, 'item')}).toSet()', + AckMapTypeRef(:final valueType) + when !_requiresRuntimeConversion(valueType) => + '($expression as Map).cast()', AckMapTypeRef(:final valueType) => '($expression as Map).map((key, item) => MapEntry(key as String, ${_fromRuntime(valueType, 'item')}))', _ => '$expression as ${_type(type)}', @@ -859,6 +803,8 @@ return $_runtimeMapLiteral{ 'List<${_type(elementType)}>.unmodifiable($expression.map((item) => ${_immutableCopy(elementType, 'item')}))', AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>.unmodifiable($expression.map((item) => ${_immutableCopy(elementType, 'item')}))', + AckMapTypeRef(:final valueType) when !_requiresImmutableCopy(valueType) => + 'Map.unmodifiable($expression)', AckMapTypeRef(:final valueType) => 'Map.unmodifiable($expression.map((key, item) => MapEntry(key, ${_immutableCopy(valueType, 'item')})))', _ => expression, @@ -956,22 +902,7 @@ return $_runtimeMapLiteral{ }; } - String _type(AckInferRef type) { - return switch (type) { - AckNullableTypeRef(:final inner) => '${_type(inner)}?', - AckScalarTypeRef(:final dartType) => dartType, - AckExternalTypeRef(:final visibleName, :final typeArguments) => - typeArguments.isEmpty - ? visibleName - : '$visibleName<${typeArguments.map(_type).join(', ')}>', - AckModelTypeRef(:final visibleName) => visibleName, - AckListTypeRef(:final elementType) => 'List<${_type(elementType)}>', - AckSetTypeRef(:final elementType) => 'Set<${_type(elementType)}>', - AckMapTypeRef(:final valueType) => 'Map', - AckJsonMaybeTypeRef(:final valueType) => - '${_ack('JsonMaybe')}<${_type(valueType)}>', - }; - } + String _type(AckInferRef type) => type.render(ackPrefix: ackPrefix); List _docs(AckModelNode node, String kind) => [ '/// $kind generated from `${node.id.declarationName}`.', @@ -986,10 +917,10 @@ return $_runtimeMapLiteral{ line.trimRight().isEmpty ? '///' : '/// ${line.trimRight()}', ]; - String _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; - } + AckDataClassEmitter get _dataClasses => + AckDataClassEmitter(ackPrefix: ackPrefix); + + String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); static const _runtimeMapType = 'Map'; static const _runtimeMapLiteral = ''; diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index b3442de4..0be2ef8e 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -1,7 +1,4 @@ -// ignore_for_file: deprecated_member_use - import 'package:ack/annotations.dart' as annotations; -import 'package:ack/format_annotations.dart' as formats; import 'package:analyzer/dart/analysis/utilities.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/constant/value.dart'; @@ -26,66 +23,6 @@ final class AckSchemaInference { annotations.Validate, inPackage: 'ack', ); - static const _min = TypeChecker.typeNamed(annotations.Min, inPackage: 'ack'); - static const _max = TypeChecker.typeNamed(annotations.Max, inPackage: 'ack'); - static const _multipleOf = TypeChecker.typeNamed( - annotations.MultipleOf, - inPackage: 'ack', - ); - static const _positive = TypeChecker.typeNamed( - annotations.Positive, - inPackage: 'ack', - ); - static const _negative = TypeChecker.typeNamed( - annotations.Negative, - inPackage: 'ack', - ); - static const _minLength = TypeChecker.typeNamed( - annotations.MinLength, - inPackage: 'ack', - ); - static const _maxLength = TypeChecker.typeNamed( - annotations.MaxLength, - inPackage: 'ack', - ); - static const _matches = TypeChecker.typeNamed( - annotations.Matches, - inPackage: 'ack', - ); - static const _email = TypeChecker.typeNamed( - annotations.Email, - inPackage: 'ack', - ); - static const _url = TypeChecker.typeNamed(annotations.Url, inPackage: 'ack'); - static const _uri = TypeChecker.typeNamed(formats.Uri, inPackage: 'ack'); - static const _uuid = TypeChecker.typeNamed( - annotations.Uuid, - inPackage: 'ack', - ); - static const _date = TypeChecker.typeNamed( - annotations.Date, - inPackage: 'ack', - ); - static const _dateTime = TypeChecker.typeNamed( - formats.DateTime, - inPackage: 'ack', - ); - static const _notEmpty = TypeChecker.typeNamed( - annotations.NotEmpty, - inPackage: 'ack', - ); - static const _minItems = TypeChecker.typeNamed( - annotations.MinItems, - inPackage: 'ack', - ); - static const _maxItems = TypeChecker.typeNamed( - annotations.MaxItems, - inPackage: 'ack', - ); - static const _uniqueItems = TypeChecker.typeNamed( - annotations.UniqueItems, - inPackage: 'ack', - ); static const _ackField = TypeChecker.typeNamed( annotations.AckField, inPackage: 'ack', @@ -395,84 +332,6 @@ final class AckSchemaInference { ); addCollectionConstraint('.unique()', isUnique: true); } - } else if (_min.isExactlyType(valueType)) { - _require(declaration, type, '@Min', isNumeric, '@MinLength'); - output = '$output.min(${_number(declaration, '@Min', value)})'; - } else if (_max.isExactlyType(valueType)) { - _require(declaration, type, '@Max', isNumeric, '@MaxLength'); - output = '$output.max(${_number(declaration, '@Max', value)})'; - } else if (_multipleOf.isExactlyType(valueType)) { - _require(declaration, type, '@MultipleOf', isNumeric, 'numeric field'); - output = - '$output.multipleOf(${_number(declaration, '@MultipleOf', value)})'; - } else if (_positive.isExactlyType(valueType)) { - _require(declaration, type, '@Positive', isNumeric, 'numeric field'); - output = '$output.positive()'; - } else if (_negative.isExactlyType(valueType)) { - _require(declaration, type, '@Negative', isNumeric, 'numeric field'); - output = '$output.negative()'; - } else if (_minLength.isExactlyType(valueType)) { - _require(declaration, type, '@MinLength', isString, '@Min'); - output = '$output.minLength(${value.getField('length')!.toIntValue()})'; - } else if (_maxLength.isExactlyType(valueType)) { - _require(declaration, type, '@MaxLength', isString, '@Max'); - output = '$output.maxLength(${value.getField('length')!.toIntValue()})'; - } else if (_matches.isExactlyType(valueType)) { - _require(declaration, type, '@Matches', isString, 'String field'); - output = - '$output.matches(${dartStringLiteral(value.getField('pattern')!.toStringValue()!)})'; - } else if (_email.isExactlyType(valueType)) { - _require(declaration, type, '@Email', isString, 'String field'); - output = '$output.email()'; - } else if (_url.isExactlyType(valueType)) { - _require(declaration, type, '@Url', isString, 'String field'); - output = '$output.url()'; - } else if (_uri.isExactlyType(valueType)) { - _require(declaration, type, '@Uri', isString, 'String field'); - output = '$output.uri()'; - } else if (_uuid.isExactlyType(valueType)) { - _require(declaration, type, '@Uuid', isString, 'String field'); - output = '$output.uuid()'; - } else if (_date.isExactlyType(valueType)) { - _require(declaration, type, '@Date', isString, 'String field'); - output = '$output.date()'; - } else if (_dateTime.isExactlyType(valueType)) { - _require(declaration, type, '@DateTime', isString, 'String field'); - output = '$output.datetime()'; - } else if (_notEmpty.isExactlyType(valueType)) { - _require(declaration, type, '@NotEmpty', isString, 'String field'); - output = '$output.notEmpty()'; - } else if (_minItems.isExactlyType(valueType)) { - _require( - declaration, - type, - '@MinItems', - isCollection, - 'List or Set field', - ); - addCollectionConstraint( - '.minItems(${value.getField('count')!.toIntValue()})', - ); - } else if (_maxItems.isExactlyType(valueType)) { - _require( - declaration, - type, - '@MaxItems', - isCollection, - 'List or Set field', - ); - addCollectionConstraint( - '.maxItems(${value.getField('count')!.toIntValue()})', - ); - } else if (_uniqueItems.isExactlyType(valueType)) { - _require( - declaration, - type, - '@UniqueItems', - isCollection, - 'List or Set field', - ); - addCollectionConstraint('.unique()', isUnique: true); } } if (setConstraints.isNotEmpty) { @@ -566,10 +425,7 @@ final class AckSchemaInference { bool _nullable(DartType type) => type.nullabilitySuffix == NullabilitySuffix.question; - String _ack(String symbol) { - final prefix = ackPrefix; - return prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; - } + String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); String _number(Element declaration, String annotation, DartObject value) { final field = value.getField('value')!; diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart b/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart index cd2657e2..eb58e151 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_builder.dart @@ -5,11 +5,11 @@ import 'package:pub_semver/pub_semver.dart'; import 'json_schema_library.dart'; -/// Generates `.schema.dart` `@AckInfer` declarations from each +/// Generates `.schema.dart` `@Schemable` declarations from each /// `.schema.json` draft 2020-12 bundle. /// -/// The generated library declares the model parts that `ack_models` and -/// `ack_model_json` then fill in the same build. +/// The generated library declares the `.schema.g.dart` part that +/// `ack_generator` fills in the same build. final class AckJsonSchemaBuilder implements Builder { /// Creates the builder with its `build.yaml` [options]. AckJsonSchemaBuilder(this.options); diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart index 89d2de60..085a00c1 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -5,6 +5,7 @@ import 'package:dart_style/dart_style.dart'; import 'package:pub_semver/pub_semver.dart'; import '../analyzer/schema_first_names.dart'; +import '../utils/string_literal.dart'; import 'json_schema_generation_exception.dart'; /// How generated models treat properties a JSON Schema object doesn't declare. @@ -506,7 +507,7 @@ final class _Translator { final keys = keywords.toSet(); final description = node['description']; String describe(String code) => description is String - ? '$code.describe(${_literal(description)})' + ? '$code.describe(${dartStringLiteral(description)})' : code; if (keys.isEmpty) { @@ -716,7 +717,7 @@ final class _Translator { if (type == 'number') 'a number' else 'a $type', ]; return 'ack.Ack.any().refine((value) => $test, ' - 'message: ${_literal('Expected ${names.join(' or ')}.')})'; + 'message: ${dartStringLiteral('Expected ${names.join(' or ')}.')})'; } _Expr? _enum( @@ -759,7 +760,9 @@ final class _Translator { return null; } return _Expr( - describe('ack.Ack.enumString([${strings.map(_literal).join(', ')}])'), + describe( + 'ack.Ack.enumString([${strings.map(dartStringLiteral).join(', ')}])', + ), nullable: allowsNull && values.contains(null), ); } @@ -785,7 +788,7 @@ final class _Translator { ); return null; } - return _Expr(describe('ack.Ack.literal(${_literal(value)})')); + return _Expr(describe('ack.Ack.literal(${dartStringLiteral(value)})')); } String? _string(Map node, String pointer) { @@ -801,7 +804,7 @@ final class _Translator { ); return null; } - buffer.write('.matches(${_literal(pattern)})'); + buffer.write('.matches(${dartStringLiteral(pattern)})'); } return buffer.toString(); } @@ -975,7 +978,7 @@ final class _Translator { ); if (field == null) continue; entries.add( - '${_literal(key)}: ${field.code}' + '${dartStringLiteral(key)}: ${field.code}' '${field.nullable ? '.nullable()' : ''}' '${required.contains(key) ? '' : '.optional()'}', ); @@ -999,7 +1002,7 @@ final class _Translator { _Declaration( hoistName, description is String - ? '$code.describe(${_literal(description)})' + ? '$code.describe(${dartStringLiteral(description)})' : code, ), ); @@ -1069,7 +1072,7 @@ String _emit( } for (final declaration in translator.declarations) { buffer - ..writeln('@ack.Schemable(name: ${_literal(declaration.name)})') + ..writeln('@ack.Schemable(name: ${dartStringLiteral(declaration.name)})') ..writeln('final ${_variable(declaration.name)} = ${declaration.code};') ..writeln(); } @@ -1125,8 +1128,8 @@ String _emitFallback( if (rootName != null) (rootName, null), ...definitions, ]; - final uri = _literal(documentUri.toString()); - final lookupUri = _literal( + final uri = dartStringLiteral(documentUri.toString()); + final lookupUri = dartStringLiteral( documentUri.resolve('__ack_json_schema_generated__.json').toString(), ); final buffer = StringBuffer() @@ -1151,13 +1154,15 @@ String _emitFallback( ..writeln("import 'package:ack/ack.dart' as ack;") ..writeln() ..writeln('final Object _jsonSchemaDocument =') - ..writeln(' jsonDecode(${_literal(jsonEncode(document))}) as Object;') + ..writeln( + ' jsonDecode(${dartStringLiteral(jsonEncode(document))}) as Object;', + ) ..writeln('final Map _jsonSchemaDocuments = {') ..writeln(' Uri.parse($uri): _jsonSchemaDocument,'); for (final entry in documents.entries) { buffer.writeln( - ' Uri.parse(${_literal(entry.key.toString())}): ' - 'jsonDecode(${_literal(jsonEncode(entry.value))}) as Object,', + ' Uri.parse(${dartStringLiteral(entry.key.toString())}): ' + 'jsonDecode(${dartStringLiteral(jsonEncode(entry.value))}) as Object,', ); } buffer @@ -1173,7 +1178,7 @@ String _emitFallback( 'if (entry.key != Uri.parse($uri)) entry.key: entry.value}' '$formatOption)' : 'ack.Ack.fromJsonSchema(' - "{r'\$ref': ${_literal(documentUri.replace(fragment: '/\$defs/${_token(definition)}').toString())}}, " + "{r'\$ref': ${dartStringLiteral(documentUri.replace(fragment: '/\$defs/${_token(definition)}').toString())}}, " 'baseUri: Uri.parse($lookupUri), ' 'documents: _jsonSchemaDocuments$formatOption)'; buffer @@ -1226,26 +1231,6 @@ String _commentText(String value) => value .replaceAll('\u2028', r'\u2028') .replaceAll('\u2029', r'\u2029'); -/// A single-quoted Dart string literal with every special character escaped, -/// so descriptions, patterns, and keys stay one simple literal. -String _literal(String value) { - final buffer = StringBuffer("'"); - for (final rune in value.runes) { - buffer.write(switch (rune) { - 0x5C => r'\\', - 0x27 => r"\'", - 0x24 => r'\$', - 0x0A => r'\n', - 0x0D => r'\r', - 0x09 => r'\t', - < 0x20 => '\\u{${rune.toRadixString(16)}}', - _ => String.fromCharCode(rune), - }); - } - buffer.write("'"); - return buffer.toString(); -} - String _num(num value) => value is int || value != value.truncate() ? '$value' : '${value.toInt()}'; diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index 5fbd6c9f..462413d1 100644 --- a/packages/ack_generator/lib/src/models/schema_model_graph.dart +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -1,3 +1,5 @@ +import '../utils/string_literal.dart'; + /// Stable identity for a schema declaration across libraries. final class AckSchemaId { const AckSchemaId({required this.libraryUri, required this.declarationName}); @@ -40,7 +42,6 @@ final class AckConstructorParameter { required this.kind, required this.fieldName, required this.typeRef, - this.isSuper = false, this.defaultExpression, }); @@ -48,7 +49,6 @@ final class AckConstructorParameter { final AckConstructorParameterKind kind; final String fieldName; final AckInferRef typeRef; - final bool isSuper; final String? defaultExpression; } @@ -58,6 +58,29 @@ final class AckConstructorParameter { /// analysis layer. Emitters consume this structural representation instead. sealed class AckInferRef { const AckInferRef(); + + /// Renders this structural type reference as a Dart type annotation. + String render({String? corePrefix, String? ackPrefix}) { + final prefix = ackPrefix ?? corePrefix; + return switch (this) { + AckNullableTypeRef(:final inner) => + '${inner.render(corePrefix: prefix)}?', + AckScalarTypeRef(:final dartType) => dartType, + AckExternalTypeRef(:final visibleName, :final typeArguments) => + typeArguments.isEmpty + ? visibleName + : '$visibleName<${typeArguments.map((item) => item.render(corePrefix: prefix)).join(', ')}>', + AckModelTypeRef(:final visibleName) => visibleName, + AckListTypeRef(:final elementType) => + 'List<${elementType.render(corePrefix: prefix)}>', + AckSetTypeRef(:final elementType) => + 'Set<${elementType.render(corePrefix: prefix)}>', + AckMapTypeRef(:final valueType) => + 'Map', + AckJsonMaybeTypeRef(:final valueType) => + '${qualifySymbol(prefix, 'JsonMaybe')}<${valueType.render(corePrefix: prefix)}>', + }; + } } /// A nullable structural type reference. @@ -86,10 +109,7 @@ final class AckExternalTypeRef extends AckInferRef { final String? importPrefix; final List typeArguments; - String get visibleName { - final prefix = importPrefix; - return prefix == null || prefix.isEmpty ? name : '$prefix.$name'; - } + String get visibleName => qualifySymbol(importPrefix, name); } /// A reference to another generated Ack model. @@ -106,10 +126,7 @@ final class AckModelTypeRef extends AckInferRef { final AckInferRef runtimeRef; final String? importPrefix; - String get visibleName { - final prefix = importPrefix; - return prefix == null || prefix.isEmpty ? className : '$prefix.$className'; - } + String get visibleName => qualifySymbol(importPrefix, className); } final class AckListTypeRef extends AckInferRef { @@ -184,15 +201,11 @@ final class AckClassModelMetadata { const AckClassModelMetadata({ required this.facadeName, required this.backingName, - required this.caseStyle, - this.hasExplicitAnnotation = true, this.copyWithSupertypes = const [], }); final String facadeName; final String backingName; - final String caseStyle; - final bool hasExplicitAnnotation; final List copyWithSupertypes; } @@ -201,15 +214,11 @@ sealed class AckModelNode { const AckModelNode({ required this.id, required this.className, - required this.boundaryType, - required this.runtimeRef, this.description, }); final AckSchemaId id; final String className; - final AckInferRef boundaryType; - final AckInferRef runtimeRef; final String? description; } @@ -218,13 +227,10 @@ final class AckObjectModelNode extends AckModelNode { AckObjectModelNode({ required super.id, required super.className, - required super.boundaryType, - required super.runtimeRef, required Iterable fields, Iterable constructorParameters = const [], this.unknownPropertyPolicy = AckUnknownPropertyPolicy.reject, this.captureFieldName, - this.captureJsonKey, this.unionId, this.discriminatorKey, this.discriminatorValue, @@ -236,7 +242,6 @@ final class AckObjectModelNode extends AckModelNode { final List constructorParameters; final AckUnknownPropertyPolicy unknownPropertyPolicy; final String? captureFieldName; - final String? captureJsonKey; final AckSchemaId? unionId; final String? discriminatorKey; final String? discriminatorValue; @@ -255,10 +260,13 @@ final class AckValueModelNode extends AckModelNode { const AckValueModelNode({ required super.id, required super.className, - required super.boundaryType, - required super.runtimeRef, + required this.boundaryType, + required this.runtimeRef, super.description, }); + + final AckInferRef boundaryType; + final AckInferRef runtimeRef; } /// A sealed class generated from `Ack.discriminated(...)`. @@ -266,8 +274,6 @@ final class AckUnionModelNode extends AckModelNode { AckUnionModelNode({ required super.id, required super.className, - required super.boundaryType, - required super.runtimeRef, required this.discriminatorKey, required Map branches, super.description, diff --git a/packages/ack_generator/lib/src/utils/string_literal.dart b/packages/ack_generator/lib/src/utils/string_literal.dart index f3614732..6eec0b7e 100644 --- a/packages/ack_generator/lib/src/utils/string_literal.dart +++ b/packages/ack_generator/lib/src/utils/string_literal.dart @@ -14,3 +14,80 @@ String dartStringLiteral(String value) { }); return "'$escaped'"; } + +/// Qualifies [symbol] with [prefix] when [prefix] is non-null and non-empty. +String qualifySymbol(String? prefix, String symbol) => + prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; + +/// Whether [value] is a reserved word, built-in identifier, or contextual +/// keyword that cannot be used as an unqualified Dart member or type name. +bool isDartKeyword(String value) => _dartKeywords.contains(value); + +const _dartKeywords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 166373cd..1c7a7aad 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -17,14 +17,9 @@ dependencies: # Core code generation dependencies analyzer: ">=10.0.0 <11.0.0" build: ^4.0.0 - build_config: ^1.3.0 source_gen: ^4.2.4 code_builder: ^4.10.0 - collection: ^1.18.0 dart_style: ^3.1.0 - logging: ^1.3.0 - meta: ^1.15.0 - package_config: ^2.1.0 pub_semver: ^2.1.4 # Ack packages (versions are overridden locally by Melos) @@ -35,6 +30,7 @@ dev_dependencies: build_test: ^3.1.0 json_annotation: ^4.12.0 json_serializable: ^6.14.1 + logging: ^1.3.0 test: ^1.25.15 path: ^1.9.0 # Code quality diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index bbc62d1a..ca0eb4ae 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -147,10 +147,8 @@ final class Handwritten with _$HandwrittenAck { ); File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( r''' -// ignore_for_file: deprecated_member_use import 'package:ack/ack.dart'; -import 'package:json_annotation/json_annotation.dart' - show JsonKey, JsonSerializable; +import 'package:json_annotation/json_annotation.dart' show JsonSerializable; import 'alpha.dart' as alpha; import 'beta.dart' as beta; @@ -215,7 +213,7 @@ final class Profile with _$ProfileAck { final class Account with _$AccountAck { const Account({required this.firstName, required this.imageUrl}); final String firstName; - @JsonKey(name: 'avatar') + @AckField(name: 'avatar') final Uri imageUrl; static final fromJson = AccountSchema.fromJson; diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 8b9e1175..5b7ab6f2 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -60,10 +60,10 @@ final class Email { final class Contact with _\$ContactAck { const Contact({required this.address, required this.id}); - @ack.Email() + @ack.Validate.email() final String address; - @ack.Uuid() + @ack.Validate.uuid() final String id; } ''', @@ -121,7 +121,7 @@ part 'grid.g.dart'; final class Grid with _\$GridAck { const Grid({required this.cells}); - @MinItems(2) + @Validate.minItems(2) final Set> cells; } ''', @@ -347,7 +347,6 @@ final class Square extends Shape with _\$SquareAck { 'formats.dart': ''' $_imports -import 'package:ack/format_annotations.dart' as formats; part 'formats.g.dart'; @Schemable() @@ -360,15 +359,15 @@ final class Formats with _\$FormatsAck { required this.timestamp, }); - @Url() + @Validate.url() final String url; - @formats.Uri() + @Validate.uri() final String uri; - @Uuid() + @Validate.uuid() final String uuid; - @Date() + @Validate.date() final String date; - @formats.DateTime() + @Validate.dateTime() final String timestamp; } ''', @@ -491,13 +490,13 @@ final class Profile with _\$ProfileAck { required this.tags, }); - @MinLength(1) - @MaxLength(500) + @Validate.minLength(1) + @Validate.maxLength(500) final String bio; final Uri? website; final String? nickname; final String role; - @MinItems(1) + @Validate.minItems(1) final Set tags; } ''', @@ -643,48 +642,43 @@ final class Record with _\$RecordAck { }, ); - test( - 'computes case-style, AckField(name:), and JsonKey schema keys once', - () async { - await _build( - { - 'account.dart': - ''' + test('computes case-style and AckField(name:) schema keys once', () async { + await _build( + { + 'account.dart': + ''' $_imports -import 'package:json_annotation/json_annotation.dart' show JsonKey; part 'account.g.dart'; @Schemable(caseStyle: AckCaseStyle.snake) final class Account with _\$AccountAck { const Account({ required this.firstName, - required this.imageUrl, + @AckField(name: 'avatar') required this.imageUrl, required this.handleName, }); final String firstName; - @JsonKey(name: 'avatar') final String imageUrl; @AckField(name: 'handle') final String handleName; } ''', - }, - outputs: { - 'test_pkg|lib/account.g.dart': decodedMatches( - allOf([ - contains("'first_name': Ack.string()"), - contains("'avatar': Ack.string()"), - contains("'handle': Ack.string()"), - isNot(contains("'firstName':")), - isNot(contains("'image_url':")), - isNot(contains("'handle_name':")), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/account.g.dart': decodedMatches( + allOf([ + contains("'first_name': Ack.string()"), + contains("'avatar': Ack.string()"), + contains("'handle': Ack.string()"), + isNot(contains("'firstName':")), + isNot(contains("'image_url':")), + isNot(contains("'handle_name':")), + ]), + ), + }, + ); + }); test( 'preserves prefixed field types and same-named import identity', @@ -762,8 +756,8 @@ sealed class Pet with _\$PetAck { @Schemable(discriminatorValue: 'cat') final class Cat extends Pet with _\$CatAck { const Cat({required super.id, required this.lives}); - @Min(1) - @Max(9) + @Validate.min(1) + @Validate.max(9) final int lives; } @@ -867,7 +861,7 @@ final class Example with _\$ExampleAck { @Optional() @NotNull() - @NotEmpty() + @Validate.notEmpty() final String? title; } diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 9752bc50..b997d031 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -103,11 +103,13 @@ final class Link with _\$LinkAck { final class Range with _\$RangeAck { const Range({required this.limit}); - @Max(double.infinity) + @Validate.max(double.infinity) final double limit; } ''', - ['Range.limit has @Max(Infinity); the value must be a finite number.'], + [ + 'Range.limit has @Validate.max(Infinity); the value must be a finite number.', + ], ); await _expectFailure( ''' @@ -115,11 +117,13 @@ final class Range with _\$RangeAck { final class Range with _\$RangeAck { const Range({required this.step}); - @MultipleOf(double.nan) + @Validate.multipleOf(double.nan) final double step; } ''', - ['Range.step has @MultipleOf(NaN); the value must be a finite number.'], + [ + 'Range.step has @Validate.multipleOf(NaN); the value must be a finite number.', + ], ); }); @@ -280,11 +284,11 @@ final class Item with _\$ItemAck { final class User with _\$UserAck { const User({required this.name}); - @Min(1) + @Validate.min(1) final String name; } ''', - ['@Min', 'String', '@MinLength'], + ['@Validate.min', 'String', '@Validate.minLength'], ); }); @@ -295,11 +299,11 @@ final class User with _\$UserAck { final class User with _\$UserAck { const User({required this.age}); - @MinLength(1) + @Validate.minLength(1) final int age; } ''', - ['@MinLength', 'int', '@Min'], + ['@Validate.minLength', 'int', '@Validate.min'], ); }); @@ -310,11 +314,11 @@ final class User with _\$UserAck { final class User with _\$UserAck { const User({required this.name}); - @UniqueItems() + @Validate.uniqueItems() final String name; } ''', - ['@UniqueItems', 'String', 'List or Set'], + ['@Validate.uniqueItems', 'String', 'List or Set'], ); }); @@ -805,57 +809,25 @@ final class User with _\$UserAck { ); }); - test('rejects every unsupported JsonKey serialization option', () async { + test('rejects @JsonKey on fields', () async { await _expectFailure( ''' -enum Role { member, unknown } - -Role decodeRole(Object? value) => Role.member; -String encodeRole(Role value) => value.name; -Object? readRole(Map map, String key) => map[key]; - @Schemable() final class User with _\$UserAck { - const User({required this.role}); - - @JsonKey( - name: 'wire_role', - defaultValue: Role.member, - disallowNullValue: true, - explicitJsonNullWhenNonNullField: true, - fromJson: decodeRole, - ignore: true, - includeFromJson: false, - includeIfNull: false, - includeToJson: false, - readValue: readRole, - required: true, - toJson: encodeRole, - unknownEnumValue: Role.unknown, - ) - final Role role; + const User({required this.name}); + + @JsonKey(name: 'wire_name') + final String name; } ''', [ - 'User.role', - '@JsonKey', - 'defaultValue', - 'disallowNullValue', - 'explicitJsonNullWhenNonNullField', - 'fromJson', - 'ignore', - 'includeFromJson', - 'includeIfNull', - 'includeToJson', - 'readValue', - 'required', - 'toJson', - 'unknownEnumValue', + 'User.name', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckField(name: ...) instead.', ], ); }); - test('requires JsonKey name overrides on the field', () async { + test('rejects @JsonKey on constructor parameters', () async { await _expectFailure( ''' @Schemable() @@ -865,7 +837,10 @@ final class User with _\$UserAck { final String name; } ''', - ['User.name', '@JsonKey', 'constructor parameter', 'field'], + [ + 'User.name', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckField(name: ...) instead.', + ], ); }); @@ -1303,7 +1278,10 @@ final class User with _\$UserAck { final String name; } ''', - ['User.name', 'sets both @AckField(name: ...) and @JsonKey(name: ...)'], + [ + 'User.name', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckField(name: ...) instead.', + ], ); }, ); diff --git a/packages/ack_generator/test/integration/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index e45f0815..18b93058 100644 --- a/packages/ack_generator/test/integration/inference_library_test.dart +++ b/packages/ack_generator/test/integration/inference_library_test.dart @@ -61,7 +61,7 @@ import 'package:ack/annotations.dart'; void widget({ /// The item title. - @MinLength(2) + @Validate.minLength(2) required String title, }) {} ''', @@ -134,7 +134,7 @@ void widget({required List priorities}) {} 'test_pkg|lib/widget.dart': ''' import 'package:ack/annotations.dart'; -void widget({@MinItems(2) required Set tags}) {} +void widget({@Validate.minItems(2) required Set tags}) {} ''', }, generateFor: const {'test_pkg|lib/widget.dart'}, @@ -158,7 +158,7 @@ void widget({@MinItems(2) required Set tags}) {} 'test_pkg|lib/widget.dart': ''' import 'package:ack/annotations.dart'; -void widget({@MaxItems(3) required Set> cells}) {} +void widget({@Validate.maxItems(3) required Set> cells}) {} ''', }, generateFor: const {'test_pkg|lib/widget.dart'}, diff --git a/packages/ack_generator/test/integration/inference_literal_test.dart b/packages/ack_generator/test/integration/inference_literal_test.dart index 8625ab3b..f74e9217 100644 --- a/packages/ack_generator/test/integration/inference_literal_test.dart +++ b/packages/ack_generator/test/integration/inference_literal_test.dart @@ -77,7 +77,7 @@ void main() { ''' import 'package:ack/annotations.dart'; -void widget({@Matches(${example.literal}) required String value}) {} +void widget({@Validate.matches(${example.literal}) required String value}) {} ''', }, generateFor: const {'test_pkg|lib/widget.dart'}, diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart index 63fbbbc4..fdee1ba5 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -42,7 +42,7 @@ final listingSchema = ack.Ack.object({ 'photo': photoSchema.optional(), 'cover': photoSchema.nullable().optional(), 'photos': ack.Ack.list(photoSchema).optional(), -}).describe('A listing.\nWith a second line.'); +}).describe('A listing.\u000aWith a second line.'); @ack.Schemable(name: 'Photo') final photoSchema = ack.Ack.object({ diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart index 3c1ea588..9acf764f 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -47,7 +47,7 @@ final listingSchema = ack.Ack.object({ 'photo': photoSchema.optional(), 'cover': photoSchema.nullable().optional(), 'photos': ack.Ack.list(photoSchema).optional(), -}, additionalProperties: true).describe('A listing.\nWith a second line.'); +}, additionalProperties: true).describe('A listing.\u000aWith a second line.'); @ack.Schemable(name: 'Photo') final photoSchema = ack.Ack.object({ diff --git a/packages/ack_generator/test/json_schema/json_schema_library_test.dart b/packages/ack_generator/test/json_schema/json_schema_library_test.dart index edffd4a1..2e7ea132 100644 --- a/packages/ack_generator/test/json_schema/json_schema_library_test.dart +++ b/packages/ack_generator/test/json_schema/json_schema_library_test.dart @@ -156,7 +156,7 @@ void main() { ), 'a description with special characters': ( {'type': 'string', 'description': "It's \$5\n\\ok"}, - r"ack.Ack.string().describe('It\'s\$5\n\\ok')", + r"ack.Ack.string().describe('It\'s\$5\u000a\\ok')", ), }; for (final MapEntry(key: name, value: (schema, expected)) in rows.entries) { diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index d21f0a4d..7782a742 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -9,6 +9,7 @@ * `toJsonSchemaBuilder()` now preserves imported Draft 2020-12 resources by default; use `toJsonSchemaBuilderDraft7()` when legacy Draft-07 `definitions` lowering is required (`#171`). +* Remove `convertAckSchemaModelToBuilder` in favor of `schema.toJsonSchemaBuilder()`. ### Added 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 1ae3e10e..f29572cc 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 @@ -58,10 +58,3 @@ extension JsonSchemaBuilderExtension on AckSchema { return jsb.Schema.fromMap(toJsonSchemaDraft7()); } } - -/// Converts a [AckSchemaModel] model directly to json_schema_builder [Schema] format. -/// -/// This is useful for testing or when you have a pre-built AckSchemaModel model. -jsb.Schema convertAckSchemaModelToBuilder(AckSchemaModel schema) { - return jsb.Schema.fromMap(schema.toJsonSchema()); -} 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 4f019276..72c7d9da 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 @@ -8,6 +8,10 @@ jsb.Schema _schemaFrom(Object? value) { return jsb.Schema.fromMap((value as Map).cast()); } +extension on AckSchemaModel { + jsb.Schema toJsonSchemaBuilder() => jsb.Schema.fromMap(toJsonSchema()); +} + void main() { group('toJsonSchemaBuilder()', () { group('Primitives', () { @@ -311,7 +315,7 @@ void main() { }, ); - final result = convertAckSchemaModelToBuilder(schema); + final result = schema.toJsonSchemaBuilder(); expect(result.value['type'], 'boolean'); expect(result.value['const'], true); @@ -528,10 +532,12 @@ void main() { }); test('wraps property conversion errors with path', () { - final schema = Ack.object({'bad': const TestUnsupportedAckSchema()}); + final schema = Ack.object({ + 'bad': Ack.fromJsonSchema({'type': 'string', 'format': 'email'}), + }); expect( - () => schema.toJsonSchemaBuilder(), + () => schema.toJsonSchemaBuilderDraft7(), throwsA( isA().having( (e) => e.message, @@ -571,7 +577,7 @@ void main() { ), ); - final result = convertAckSchemaModelToBuilder(schema); + final result = schema.toJsonSchemaBuilder(); final additional = _schemaFrom(result.value['additionalProperties']); expect(additional.value['type'], 'string'); @@ -588,8 +594,7 @@ void main() { ], ); - // Convert using the public helper - final result = convertAckSchemaModelToBuilder(jsonSchema); + final result = jsonSchema.toJsonSchemaBuilder(); // Verify allOf is in the output expect( @@ -615,7 +620,7 @@ void main() { ], ); - final result = convertAckSchemaModelToBuilder(jsonSchema); + final result = jsonSchema.toJsonSchemaBuilder(); final allOf = (result.value['allOf'] as List).map(_schemaFrom).toList(); expect(allOf, hasLength(2)); @@ -632,7 +637,7 @@ void main() { minProperties: 2, ); - final result = convertAckSchemaModelToBuilder(jsonSchema); + final result = jsonSchema.toJsonSchemaBuilder(); expect(result.value['minProperties'], 2); }); @@ -643,7 +648,7 @@ void main() { maxProperties: 5, ); - final result = convertAckSchemaModelToBuilder(jsonSchema); + final result = jsonSchema.toJsonSchemaBuilder(); expect(result.value['maxProperties'], 5); }); @@ -655,7 +660,7 @@ void main() { maxProperties: 10, ); - final result = convertAckSchemaModelToBuilder(jsonSchema); + final result = jsonSchema.toJsonSchemaBuilder(); expect(result.value['minProperties'], 1); expect(result.value['maxProperties'], 10); diff --git a/packages/ack_mcp_dart/CHANGELOG.md b/packages/ack_mcp_dart/CHANGELOG.md index fea90368..1728f61b 100644 --- a/packages/ack_mcp_dart/CHANGELOG.md +++ b/packages/ack_mcp_dart/CHANGELOG.md @@ -4,6 +4,8 @@ * Requires `ack: ^2.0.0-beta.1` and aligns with the coordinated Ack 2.0.0-beta.1 release. +* Remove `registerAckModelTool` from `AckMcpServer`; pass `Model.schema` (or + `model.modelSchema`) directly to `registerAckTool(name, input: Model.schema, ...)`. ## 1.7.0-beta.7 diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index ffdc63ef..442ca1fa 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -55,15 +55,13 @@ void registerTools() { } ``` -Both registration helpers return `RegisteredTool` and forward `title`, +`registerAckTool` returns `RegisteredTool` and forwards `title`, `description`, `annotations`, and `meta`. Callbacks may be synchronous or asynchronous and receive the original `RequestHandlerExtra`. -Use `server.registerAckModelTool('search', model: Search.$ack, -callback: (args, extra) { ... })` with an `AckModelAdapter` -(such as a schema-first generated model adapter, or pass a class-first -`SearchSchema.schema` directly to `registerAckTool`) to receive a typed `M` -instead of a map. +Pass a model-valued schema such as `Search.schema` (from a `@Schemable()` model +or class) to `server.registerAckTool('search', input: Search.schema, +callback: (args, extra) { ... })` to receive a typed `Search` instead of a map. The model is constructed after ACK parses defaults and codecs. ## Bare conversion and parsing @@ -100,7 +98,7 @@ Cause-backed messages (for example a codec throwing `FormatException`) become Other messages from ACK and your refinements are preserved verbatim and may contain values; use safe custom messages or `onInvalidArguments` when inputs are sensitive. Pass `onInvalidArguments` -to either registration helper to customize ACK failures. It receives the full +to `registerAckTool` to customize ACK failures. It receives the full `SchemaError`; its returned `CallToolResult` is used unchanged. ## Advertised schema vs runtime validation @@ -143,7 +141,7 @@ New emitted keywords require revisiting this dialect assumption. | Component | Supported / exercised | | --- | --- | | Dart | `>=3.9.0 <4.0.0` | -| ACK | `^1.2.0` | +| ACK | `^2.0.0-beta.1` | | mcp_dart | `^2.4.2` (tests resolved against 2.4.2) | | MCP stable discovery | `2026-07-28` | | MCP initialization | `2025-11-25`, `2025-06-18` | @@ -153,7 +151,7 @@ New emitted keywords require revisiting this dialect assumption. | Input / feature | Support | | --- | --- | | Plain object root (`type: object`) | Supported, including empty objects | -| Nullable or discriminated root | Rejected at registration; renders as composition (`anyOf` in ACK 1.2) | +| Nullable or discriminated root | Rejected at registration; renders as composition (`anyOf` in ACK) | | Primitive, list, or other composition root | Rejected at registration | | Nullable, union, discriminated, recursive, or codec **fields** | Supported inside an object | | Output schemas | Out of scope | diff --git a/packages/ack_mcp_dart/lib/ack_mcp_dart.dart b/packages/ack_mcp_dart/lib/ack_mcp_dart.dart index f61cbe64..bb464e5e 100644 --- a/packages/ack_mcp_dart/lib/ack_mcp_dart.dart +++ b/packages/ack_mcp_dart/lib/ack_mcp_dart.dart @@ -90,81 +90,31 @@ extension AckMcpServer on McpServer { ToolAnnotations? annotations, Map? meta, CallToolResult Function(SchemaError error)? onInvalidArguments, - }) => _registerAckTool( - this, - name, - input: input, - parse: (args) => parseMcpToolArguments(input, args, debugName: name), - callback: callback, - title: title, - description: description, - annotations: annotations, - meta: meta, - onInvalidArguments: onInvalidArguments, - ); - - /// Advertises [model]'s schema and passes a typed model to [callback]. - /// - /// Defaults and codecs run before the model is constructed. Registration and - /// invalid-argument handling follow [registerAckTool]. - RegisteredTool registerAckModelTool( - String name, { - required AckModelAdapter model, - required AckToolFunction callback, - String? title, - String? description, - ToolAnnotations? annotations, - Map? meta, - CallToolResult Function(SchemaError error)? onInvalidArguments, - }) => _registerAckTool( - this, - name, - input: model.schema, - parse: (args) => model.safeParse(args, debugName: name), - callback: callback, - title: title, - description: description, - annotations: annotations, - meta: meta, - onInvalidArguments: onInvalidArguments, - ); -} - -RegisteredTool _registerAckTool( - McpServer server, - String name, { - required AckSchema input, - required SchemaResult Function(Map) parse, - required AckToolFunction callback, - String? title, - String? description, - ToolAnnotations? annotations, - Map? meta, - CallToolResult Function(SchemaError error)? onInvalidArguments, -}) { - final inputSchema = input.toMcpToolInputSchema(); - return server.registerTool( - name, - inputSchema: inputSchema, - title: title, - description: description, - annotations: annotations, - meta: meta, - callback: (args, extra) { - final result = parse(args); - if (result case Ok(value: final value?)) { - return callback(value, extra); - } - final error = switch (result) { - Fail(:final error) => error, - Ok() => SchemaValidationError( - message: 'Tool arguments must parse to a non-null object.', - context: SchemaContext(name: name, schema: input, value: args), - ), - }; - return onInvalidArguments != null - ? onInvalidArguments(error) - : ackValidationErrorToCallToolResult(error, toolName: name); - }, - ); + }) { + final inputSchema = input.toMcpToolInputSchema(); + return registerTool( + name, + inputSchema: inputSchema, + title: title, + description: description, + annotations: annotations, + meta: meta, + callback: (args, extra) { + final result = parseMcpToolArguments(input, args, debugName: name); + if (result case Ok(value: final value?)) { + return callback(value, extra); + } + final error = switch (result) { + Fail(:final error) => error, + Ok() => SchemaValidationError( + message: 'Tool arguments must parse to a non-null object.', + context: SchemaContext(name: name, schema: input, value: args), + ), + }; + return onInvalidArguments != null + ? onInvalidArguments(error) + : ackValidationErrorToCallToolResult(error, toolName: name); + }, + ); + } } diff --git a/packages/ack_mcp_dart/test/mcp_server_integration_test.dart b/packages/ack_mcp_dart/test/mcp_server_integration_test.dart index a5ffe358..f01a9d92 100644 --- a/packages/ack_mcp_dart/test/mcp_server_integration_test.dart +++ b/packages/ack_mcp_dart/test/mcp_server_integration_test.dart @@ -50,9 +50,9 @@ void main() { meta: {'app': 'ack'}, callback: (args, extra) => textResult('ok'), ); - server.registerAckModelTool( + server.registerAckTool( 'model', - model: searchModel, + input: SearchInput.schema, title: 'Model', description: 'Typed search', annotations: const ToolAnnotations(readOnlyHint: true), @@ -119,9 +119,9 @@ void main() { test('model callback receives a typed model and defaults', () async { SearchInput? received; - server.registerAckModelTool( + server.registerAckTool( 'search', - model: searchModel, + input: SearchInput.schema, callback: (args, extra) { received = args; return textResult(args.query); @@ -165,13 +165,13 @@ void main() { 'model conversion exceptions are redacted and session recovers', () async { var calls = 0; - server.registerAckModelTool( + server.registerAckTool( 'number', - model: AckModelAdapter( + input: AckModelAdapter( schema: () => Ack.object({'value': Ack.string()}), fromRuntime: (args) => int.parse(args['value'] as String), toRuntime: (value) => {'value': '$value'}, - ), + ).modelSchema, callback: (args, extra) { calls++; return textResult('$args'); @@ -200,13 +200,13 @@ void main() { 'custom invalid handler receives the original exception details', () async { SchemaError? received; - server.registerAckModelTool( + server.registerAckTool( 'number', - model: AckModelAdapter( + input: AckModelAdapter( schema: () => Ack.object({'value': Ack.string()}), fromRuntime: (args) => int.parse(args['value'] as String), toRuntime: (value) => {'value': '$value'}, - ), + ).modelSchema, onInvalidArguments: (error) { received = error; return textResult('Use digits only.'); @@ -238,13 +238,13 @@ void main() { message: 'Query is unavailable', ); if (useModel) { - server.registerAckModelTool( + server.registerAckTool( 'search', - model: AckModelAdapter( + input: AckModelAdapter( schema: () => input, fromRuntime: searchModel.fromRuntime, toRuntime: searchModel.toRuntime, - ), + ).modelSchema, callback: (args, extra) { calls++; return textResult(args.query); @@ -303,13 +303,13 @@ void main() { } if (useModel) { - server.registerAckModelTool( + server.registerAckTool( 'search', - model: AckModelAdapter( + input: AckModelAdapter( schema: () => input, fromRuntime: searchModel.fromRuntime, toRuntime: searchModel.toRuntime, - ), + ).modelSchema, onInvalidArguments: invalid, callback: (args, extra) { calls++; @@ -401,13 +401,13 @@ void main() { throwsArgumentError, ); expect( - () => server.registerAckModelTool( + () => server.registerAckTool( 'badModel', - model: AckModelAdapter( + input: AckModelAdapter( schema: () => input, fromRuntime: (args) => args, toRuntime: (args) => args, - ), + ).modelSchema, callback: (args, extra) => textResult('wrong'), ), throwsArgumentError, diff --git a/packages/ack_mcp_dart/test/support/fixtures.dart b/packages/ack_mcp_dart/test/support/fixtures.dart index 48392830..f8a59dc8 100644 --- a/packages/ack_mcp_dart/test/support/fixtures.dart +++ b/packages/ack_mcp_dart/test/support/fixtures.dart @@ -9,6 +9,8 @@ class SearchInput { const SearchInput(this.query, this.limit); final String query; final int limit; + + static AckSchema get schema => searchModel.modelSchema; } final searchModel = AckModelAdapter( diff --git a/pubspec.yaml b/pubspec.yaml index 7a7a1a87..c22db492 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,9 +15,6 @@ workspace: - packages/ack_mcp_dart - example -dependencies: - meta: ^1.15.0 - dev_dependencies: melos: ^8.7.0 # Release tooling under /scripts reads and rewrites pubspecs. diff --git a/scripts/src/workspace_packages.dart b/scripts/src/workspace_packages.dart index d51bdbec..88d7a32b 100644 --- a/scripts/src/workspace_packages.dart +++ b/scripts/src/workspace_packages.dart @@ -9,4 +9,6 @@ const publishableAckPackages = [ /// First releases for packages added after the original workspace baseline. /// Earlier versions have no published API to compare against. -const ackPackageFirstReleases = {'ack_mcp_dart': '1.3.0'}; +const ackPackageFirstReleases = { + 'ack_mcp_dart': '2.0.0-beta.1', +}; diff --git a/test/scripts/api_check_test.dart b/test/scripts/api_check_test.dart index fd029239..22aca6f9 100644 --- a/test/scripts/api_check_test.dart +++ b/test/scripts/api_check_test.dart @@ -48,7 +48,7 @@ exit 23 skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, ); - for (final baseline in ['1.1.0', '1.2.0', '1.3.0']) { + for (final baseline in ['1.1.0', '1.2.0', '2.0.0-beta.1']) { test( 'new adapter API baseline $baseline respects its first release', () async { @@ -65,11 +65,11 @@ exit 23 'PATH': '${fakeBin.path}:/usr/bin:/bin', }, ); - if (baseline != '1.3.0') { + if (baseline != '2.0.0-beta.1') { expect(result.exitCode, 0); expect( result.stdout, - contains('ack_mcp_dart first releases in 1.3.0'), + contains('ack_mcp_dart first releases in 2.0.0-beta.1'), ); expect(result.stderr, isEmpty); } else { From a764f47dfbeb278ad792d06b56d5cd2049f91264 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 11 Oct 2026 12:21:44 -0400 Subject: [PATCH 18/29] refactor(ack,ack_generator): decompose model graph builders, emitters, and AckSchemaModel hierarchy --- .../src/schema_model/ack_schema_model.dart | 588 +++--- .../analyzer/class_model_graph_builder.dart | 1571 ++--------------- .../src/analyzer/custom_schema_resolver.dart | 1032 +++++++++++ .../analyzer/model_graph_builder_base.dart | 893 ++++++++++ .../analyzer/schema_model_graph_builder.dart | 765 ++------ .../lib/src/builders/class_model_emitter.dart | 98 +- .../lib/src/builders/data_class_emitter.dart | 175 ++ .../lib/src/builders/model_emitter.dart | 145 +- 8 files changed, 2621 insertions(+), 2646 deletions(-) create mode 100644 packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart create mode 100644 packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index ba9446bb..876f6316 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -205,58 +205,16 @@ sealed class AckSchemaModel { common = common.copyWith(defaultValue: keywords['default']); } - return switch (_rebuildWithCommon(common)) { - AckStringSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckIntegerSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckNumberSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckBooleanSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckArraySchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckObjectSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckAnyOfSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckOneOfSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckAllOfSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckRawSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckNullSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - AckRefSchemaModel schema => schema._withJsonSchemaKeywords( - keywords, - commonHandled, - ), - }; + return _rebuildWithCommon( + common, + )._withJsonSchemaKeywords(keywords, commonHandled); } + AckSchemaModel _withJsonSchemaKeywords( + Map keywords, + Set commonHandled, + ) => _withUnhandledKeywords(keywords, commonHandled); + Map finishTypeJson(Map typeJson) { if (!nullable) { return {...typeJson, ..._common.toJson()}; @@ -378,11 +336,6 @@ final class AckRawSchemaModel extends AckSchemaModel { @override AckRawSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckRawSchemaModel._(common, schema: schema); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) => _withUnhandledKeywords(keywords, commonHandled); } Map _relocateRootPointers( @@ -495,13 +448,6 @@ final class AckRefSchemaModel extends AckSchemaModel { @override AckRefSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckRefSchemaModel._(common, refName: refName); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - return _withUnhandledKeywords(keywords, commonHandled); - } } final class AckStringSchemaModel extends AckSchemaModel { @@ -561,65 +507,58 @@ final class AckStringSchemaModel extends AckSchemaModel { pattern: pattern, ); - AckStringSchemaModel _copyWith({ - String? format, - List? enumValues, - Object? constValue = _unset, - int? minLength, - int? maxLength, - String? pattern, - }) { - return AckStringSchemaModel._( - _common, - format: format ?? this.format, - enumValues: enumValues ?? this.enumValues, - constValue: identical(constValue, _unset) - ? this.constValue - : constValue as String?, - minLength: minLength ?? this.minLength, - maxLength: maxLength ?? this.maxLength, - pattern: pattern ?? this.pattern, - ); - } - + @override AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { final handled = {...commonHandled}; - var next = this; + var nextFormat = format; + var nextEnum = enumValues; + var nextConst = constValue; + var nextMinLength = minLength; + var nextMaxLength = maxLength; + var nextPattern = pattern; if (keywords['format'] case final String value) { handled.add('format'); - next = next._copyWith(format: value); + nextFormat = value; } if (keywords['enum'] case final List values) { handled.add('enum'); - next = next._copyWith(enumValues: List.from(values)); + nextEnum = List.from(values); } if (keywords['const'] case final String value) { handled.add('const'); - next = next._copyWith(constValue: value); + nextConst = value; } if (_readIntKeyword(keywords, 'minLength') case final value?) { handled.add('minLength'); - next = next._copyWith(minLength: value); + nextMinLength = value; } if (_readIntKeyword(keywords, 'maxLength') case final value?) { handled.add('maxLength'); - next = next._copyWith(maxLength: value); + nextMaxLength = value; } if (keywords['pattern'] case final String value) { handled.add('pattern'); - next = next._copyWith(pattern: value); + nextPattern = value; } - return next._withUnhandledKeywords(keywords, handled); + return AckStringSchemaModel._( + _common, + format: nextFormat, + enumValues: nextEnum, + constValue: nextConst, + minLength: nextMinLength, + maxLength: nextMaxLength, + pattern: nextPattern, + )._withUnhandledKeywords(keywords, handled); } } -final class AckIntegerSchemaModel extends AckSchemaModel { - const AckIntegerSchemaModel({ +sealed class _AckNumericSchemaModel extends AckSchemaModel { + const _AckNumericSchemaModel({ this.format, this.constValue, this.minimum, @@ -635,8 +574,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { super.extensions, }); - AckIntegerSchemaModel._( - _AckSchemaModelCommon common, { + _AckNumericSchemaModel._( + super.common, { this.format, this.constValue, this.minimum, @@ -644,20 +583,33 @@ final class AckIntegerSchemaModel extends AckSchemaModel { this.exclusiveMinimum, this.exclusiveMaximum, this.multipleOf, - }) : super._(common); + }) : super._(); @override final String? format; - final int? constValue; + final T? constValue; final num? minimum; final num? maximum; final num? exclusiveMinimum; final num? exclusiveMaximum; final num? multipleOf; + String get _jsonType; + + _AckNumericSchemaModel _rebuildNumeric( + _AckSchemaModelCommon common, { + required String? format, + required T? constValue, + required num? minimum, + required num? maximum, + required num? exclusiveMinimum, + required num? exclusiveMaximum, + required num? multipleOf, + }); + @override Map toJsonSchema() => finishTypeJson({ - 'type': 'integer', + 'type': _jsonType, if (format != null) 'format': format, if (constValue != null) 'const': constValue, if (minimum != null) 'minimum': minimum, @@ -668,8 +620,8 @@ final class AckIntegerSchemaModel extends AckSchemaModel { }); @override - AckIntegerSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckIntegerSchemaModel._( + _AckNumericSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => + _rebuildNumeric( common, format: format, constValue: constValue, @@ -680,78 +632,71 @@ final class AckIntegerSchemaModel extends AckSchemaModel { multipleOf: multipleOf, ); - AckIntegerSchemaModel _copyWith({ - String? format, - Object? constValue = _unset, - num? minimum, - num? maximum, - num? exclusiveMinimum, - num? exclusiveMaximum, - num? multipleOf, - }) { - return AckIntegerSchemaModel._( - _common, - format: format ?? this.format, - constValue: identical(constValue, _unset) - ? this.constValue - : constValue as int?, - minimum: minimum ?? this.minimum, - maximum: maximum ?? this.maximum, - exclusiveMinimum: exclusiveMinimum ?? this.exclusiveMinimum, - exclusiveMaximum: exclusiveMaximum ?? this.exclusiveMaximum, - multipleOf: multipleOf ?? this.multipleOf, - ); - } - + @override AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { final handled = {...commonHandled}; - var next = this; + var nextFormat = format; + var nextConst = constValue; + var nextMin = minimum; + var nextMax = maximum; + var nextExMin = exclusiveMinimum; + var nextExMax = exclusiveMaximum; + var nextMultipleOf = multipleOf; if (keywords['format'] case final String value) { handled.add('format'); - next = next._copyWith(format: value); + nextFormat = value; } - if (keywords['const'] case final int value) { + if (keywords['const'] case final T value) { handled.add('const'); - next = next._copyWith(constValue: value); + nextConst = value; } if (_readNumKeyword(keywords, 'minimum') case final value?) { handled.add('minimum'); - next = next._copyWith(minimum: value); + nextMin = value; } if (_readNumKeyword(keywords, 'maximum') case final value?) { handled.add('maximum'); - next = next._copyWith(maximum: value); + nextMax = value; } if (_readNumKeyword(keywords, 'exclusiveMinimum') case final value?) { handled.add('exclusiveMinimum'); - next = next._copyWith(exclusiveMinimum: value); + nextExMin = value; } if (_readNumKeyword(keywords, 'exclusiveMaximum') case final value?) { handled.add('exclusiveMaximum'); - next = next._copyWith(exclusiveMaximum: value); + nextExMax = value; } if (_readNumKeyword(keywords, 'multipleOf') case final value?) { handled.add('multipleOf'); - next = next._copyWith(multipleOf: value); + nextMultipleOf = value; } - return next._withUnhandledKeywords(keywords, handled); + return _rebuildNumeric( + _common, + format: nextFormat, + constValue: nextConst, + minimum: nextMin, + maximum: nextMax, + exclusiveMinimum: nextExMin, + exclusiveMaximum: nextExMax, + multipleOf: nextMultipleOf, + )._withUnhandledKeywords(keywords, handled); } } -final class AckNumberSchemaModel extends AckSchemaModel { - const AckNumberSchemaModel({ - this.format, - this.constValue, - this.minimum, - this.maximum, - this.exclusiveMinimum, - this.exclusiveMaximum, - this.multipleOf, +final class AckIntegerSchemaModel extends _AckNumericSchemaModel { + const AckIntegerSchemaModel({ + super.format, + super.constValue, + super.minimum, + super.maximum, + super.exclusiveMinimum, + super.exclusiveMaximum, + super.multipleOf, super.title, super.description, super.nullable, @@ -760,112 +705,93 @@ final class AckNumberSchemaModel extends AckSchemaModel { super.extensions, }); - AckNumberSchemaModel._( - _AckSchemaModelCommon common, { - this.format, - this.constValue, - this.minimum, - this.maximum, - this.exclusiveMinimum, - this.exclusiveMaximum, - this.multipleOf, - }) : super._(common); - - @override - final String? format; - final num? constValue; - final num? minimum; - final num? maximum; - final num? exclusiveMinimum; - final num? exclusiveMaximum; - final num? multipleOf; + AckIntegerSchemaModel._( + super.common, { + super.format, + super.constValue, + super.minimum, + super.maximum, + super.exclusiveMinimum, + super.exclusiveMaximum, + super.multipleOf, + }) : super._(); @override - Map toJsonSchema() => finishTypeJson({ - 'type': 'number', - if (format != null) 'format': format, - if (constValue != null) 'const': constValue, - if (minimum != null) 'minimum': minimum, - if (maximum != null) 'maximum': maximum, - if (exclusiveMinimum != null) 'exclusiveMinimum': exclusiveMinimum, - if (exclusiveMaximum != null) 'exclusiveMaximum': exclusiveMaximum, - if (multipleOf != null) 'multipleOf': multipleOf, - }); + String get _jsonType => 'integer'; @override - AckNumberSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckNumberSchemaModel._( - common, - format: format, - constValue: constValue, - minimum: minimum, - maximum: maximum, - exclusiveMinimum: exclusiveMinimum, - exclusiveMaximum: exclusiveMaximum, - multipleOf: multipleOf, - ); + AckIntegerSchemaModel _rebuildNumeric( + _AckSchemaModelCommon common, { + required String? format, + required int? constValue, + required num? minimum, + required num? maximum, + required num? exclusiveMinimum, + required num? exclusiveMaximum, + required num? multipleOf, + }) => AckIntegerSchemaModel._( + common, + format: format, + constValue: constValue, + minimum: minimum, + maximum: maximum, + exclusiveMinimum: exclusiveMinimum, + exclusiveMaximum: exclusiveMaximum, + multipleOf: multipleOf, + ); +} - AckNumberSchemaModel _copyWith({ - String? format, - Object? constValue = _unset, - num? minimum, - num? maximum, - num? exclusiveMinimum, - num? exclusiveMaximum, - num? multipleOf, - }) { - return AckNumberSchemaModel._( - _common, - format: format ?? this.format, - constValue: identical(constValue, _unset) - ? this.constValue - : constValue as num?, - minimum: minimum ?? this.minimum, - maximum: maximum ?? this.maximum, - exclusiveMinimum: exclusiveMinimum ?? this.exclusiveMinimum, - exclusiveMaximum: exclusiveMaximum ?? this.exclusiveMaximum, - multipleOf: multipleOf ?? this.multipleOf, - ); - } +final class AckNumberSchemaModel extends _AckNumericSchemaModel { + const AckNumberSchemaModel({ + super.format, + super.constValue, + super.minimum, + super.maximum, + super.exclusiveMinimum, + super.exclusiveMaximum, + super.multipleOf, + super.title, + super.description, + super.nullable, + super.defaultValue, + super.warnings, + super.extensions, + }); - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - final handled = {...commonHandled}; - var next = this; + AckNumberSchemaModel._( + super.common, { + super.format, + super.constValue, + super.minimum, + super.maximum, + super.exclusiveMinimum, + super.exclusiveMaximum, + super.multipleOf, + }) : super._(); - if (keywords['format'] case final String value) { - handled.add('format'); - next = next._copyWith(format: value); - } - if (keywords['const'] case final num value) { - handled.add('const'); - next = next._copyWith(constValue: value); - } - if (_readNumKeyword(keywords, 'minimum') case final value?) { - handled.add('minimum'); - next = next._copyWith(minimum: value); - } - if (_readNumKeyword(keywords, 'maximum') case final value?) { - handled.add('maximum'); - next = next._copyWith(maximum: value); - } - if (_readNumKeyword(keywords, 'exclusiveMinimum') case final value?) { - handled.add('exclusiveMinimum'); - next = next._copyWith(exclusiveMinimum: value); - } - if (_readNumKeyword(keywords, 'exclusiveMaximum') case final value?) { - handled.add('exclusiveMaximum'); - next = next._copyWith(exclusiveMaximum: value); - } - if (_readNumKeyword(keywords, 'multipleOf') case final value?) { - handled.add('multipleOf'); - next = next._copyWith(multipleOf: value); - } + @override + String get _jsonType => 'number'; - return next._withUnhandledKeywords(keywords, handled); - } + @override + AckNumberSchemaModel _rebuildNumeric( + _AckSchemaModelCommon common, { + required String? format, + required num? constValue, + required num? minimum, + required num? maximum, + required num? exclusiveMinimum, + required num? exclusiveMaximum, + required num? multipleOf, + }) => AckNumberSchemaModel._( + common, + format: format, + constValue: constValue, + minimum: minimum, + maximum: maximum, + exclusiveMinimum: exclusiveMinimum, + exclusiveMaximum: exclusiveMaximum, + multipleOf: multipleOf, + ); } final class AckBooleanSchemaModel extends AckSchemaModel { @@ -894,6 +820,7 @@ final class AckBooleanSchemaModel extends AckSchemaModel { AckBooleanSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckBooleanSchemaModel._(common, constValue: constValue); + @override AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, @@ -954,42 +881,36 @@ final class AckArraySchemaModel extends AckSchemaModel { uniqueItems: uniqueItems, ); - AckArraySchemaModel _copyWith({ - AckSchemaModel? items, - int? minItems, - int? maxItems, - bool? uniqueItems, - }) { - return AckArraySchemaModel._( - _common, - items: items ?? this.items, - minItems: minItems ?? this.minItems, - maxItems: maxItems ?? this.maxItems, - uniqueItems: uniqueItems ?? this.uniqueItems, - ); - } - + @override AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { final handled = {...commonHandled}; - var next = this; + var nextMinItems = minItems; + var nextMaxItems = maxItems; + var nextUniqueItems = uniqueItems; if (_readIntKeyword(keywords, 'minItems') case final value?) { handled.add('minItems'); - next = next._copyWith(minItems: value); + nextMinItems = value; } if (_readIntKeyword(keywords, 'maxItems') case final value?) { handled.add('maxItems'); - next = next._copyWith(maxItems: value); + nextMaxItems = value; } if (keywords['uniqueItems'] case final bool value) { handled.add('uniqueItems'); - next = next._copyWith(uniqueItems: value); + nextUniqueItems = value; } - return next._withUnhandledKeywords(keywords, handled); + return AckArraySchemaModel._( + _common, + items: items, + minItems: nextMinItems, + maxItems: nextMaxItems, + uniqueItems: nextUniqueItems, + )._withUnhandledKeywords(keywords, handled); } } @@ -1059,50 +980,40 @@ final class AckObjectSchemaModel extends AckSchemaModel { additionalProperties: additionalProperties, ); - AckObjectSchemaModel _copyWith({ - Map? properties, - List? required, - List? propertyOrdering, - int? minProperties, - int? maxProperties, - AckAdditionalPropertiesModel? additionalProperties, - }) { - return AckObjectSchemaModel._( - _common, - properties: properties ?? this.properties, - required: required ?? this.required, - propertyOrdering: propertyOrdering ?? this.propertyOrdering, - minProperties: minProperties ?? this.minProperties, - maxProperties: maxProperties ?? this.maxProperties, - additionalProperties: additionalProperties ?? this.additionalProperties, - ); - } - + @override AckSchemaModel _withJsonSchemaKeywords( Map keywords, Set commonHandled, ) { final handled = {...commonHandled}; - var next = this; + var nextMinProperties = minProperties; + var nextMaxProperties = maxProperties; + var nextAdditionalProperties = additionalProperties; if (_readIntKeyword(keywords, 'minProperties') case final value?) { handled.add('minProperties'); - next = next._copyWith(minProperties: value); + nextMinProperties = value; } if (_readIntKeyword(keywords, 'maxProperties') case final value?) { handled.add('maxProperties'); - next = next._copyWith(maxProperties: value); + nextMaxProperties = value; } if (keywords['additionalProperties'] case final bool value) { handled.add('additionalProperties'); - next = next._copyWith( - additionalProperties: value - ? const AckAdditionalPropertiesAllowed() - : const AckAdditionalPropertiesDisallowed(), - ); + nextAdditionalProperties = value + ? const AckAdditionalPropertiesAllowed() + : const AckAdditionalPropertiesDisallowed(); } - return next._withUnhandledKeywords(keywords, handled); + return AckObjectSchemaModel._( + _common, + properties: properties, + required: required, + propertyOrdering: propertyOrdering, + minProperties: nextMinProperties, + maxProperties: nextMaxProperties, + additionalProperties: nextAdditionalProperties, + )._withUnhandledKeywords(keywords, handled); } } @@ -1123,17 +1034,10 @@ final class AckNullSchemaModel extends AckSchemaModel { @override AckNullSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckNullSchemaModel._(common.copyWith(nullable: false)); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - return _withUnhandledKeywords(keywords, commonHandled); - } } -final class AckAnyOfSchemaModel extends AckSchemaModel { - const AckAnyOfSchemaModel({ +sealed class _AckCompositionSchemaModel extends AckSchemaModel { + const _AckCompositionSchemaModel({ required this.schemas, this.discriminator, super.title, @@ -1144,15 +1048,17 @@ final class AckAnyOfSchemaModel extends AckSchemaModel { super.extensions, }); - AckAnyOfSchemaModel._( - _AckSchemaModelCommon common, { + _AckCompositionSchemaModel._( + super.common, { required this.schemas, this.discriminator, - }) : super._(common); + }) : super._(); final List schemas; final AckSchemaDiscriminatorModel? discriminator; + String get _compositionKeyword; + @override Object? get _metadataEquality => { if (discriminator != null) 'discriminator': discriminator, @@ -1160,7 +1066,29 @@ final class AckAnyOfSchemaModel extends AckSchemaModel { @override Map toJsonSchema() => - finishCompositionJson('anyOf', schemas); + finishCompositionJson(_compositionKeyword, schemas); +} + +final class AckAnyOfSchemaModel extends _AckCompositionSchemaModel { + const AckAnyOfSchemaModel({ + required super.schemas, + super.discriminator, + super.title, + super.description, + super.nullable, + super.defaultValue, + super.warnings, + super.extensions, + }); + + AckAnyOfSchemaModel._( + super.common, { + required super.schemas, + super.discriminator, + }) : super._(); + + @override + String get _compositionKeyword => 'anyOf'; @override AckAnyOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => @@ -1169,19 +1097,12 @@ final class AckAnyOfSchemaModel extends AckSchemaModel { schemas: schemas, discriminator: discriminator, ); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - return _withUnhandledKeywords(keywords, commonHandled); - } } -final class AckOneOfSchemaModel extends AckSchemaModel { +final class AckOneOfSchemaModel extends _AckCompositionSchemaModel { const AckOneOfSchemaModel({ - required this.schemas, - this.discriminator, + required super.schemas, + super.discriminator, super.title, super.description, super.nullable, @@ -1191,22 +1112,13 @@ final class AckOneOfSchemaModel extends AckSchemaModel { }); AckOneOfSchemaModel._( - _AckSchemaModelCommon common, { - required this.schemas, - this.discriminator, - }) : super._(common); - - final List schemas; - final AckSchemaDiscriminatorModel? discriminator; - - @override - Object? get _metadataEquality => { - if (discriminator != null) 'discriminator': discriminator, - }; + super.common, { + required super.schemas, + super.discriminator, + }) : super._(); @override - Map toJsonSchema() => - finishCompositionJson('oneOf', schemas); + String get _compositionKeyword => 'oneOf'; @override AckOneOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => @@ -1215,18 +1127,11 @@ final class AckOneOfSchemaModel extends AckSchemaModel { schemas: schemas, discriminator: discriminator, ); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - return _withUnhandledKeywords(keywords, commonHandled); - } } -final class AckAllOfSchemaModel extends AckSchemaModel { +final class AckAllOfSchemaModel extends _AckCompositionSchemaModel { const AckAllOfSchemaModel({ - required this.schemas, + required super.schemas, super.title, super.description, super.nullable, @@ -1235,25 +1140,14 @@ final class AckAllOfSchemaModel extends AckSchemaModel { super.extensions, }); - AckAllOfSchemaModel._(_AckSchemaModelCommon common, {required this.schemas}) - : super._(common); - - final List schemas; + AckAllOfSchemaModel._(super.common, {required super.schemas}) : super._(); @override - Map toJsonSchema() => - finishCompositionJson('allOf', schemas); + String get _compositionKeyword => 'allOf'; @override AckAllOfSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => AckAllOfSchemaModel._(common, schemas: schemas); - - AckSchemaModel _withJsonSchemaKeywords( - Map keywords, - Set commonHandled, - ) { - return _withUnhandledKeywords(keywords, commonHandled); - } } int? _readIntKeyword(Map keywords, String key) { diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 6819f896..cb133ce0 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -1,6 +1,4 @@ -import 'package:ack/ack.dart' show AckModelAdapter, AckSchema, JsonMaybe; import 'package:ack/annotations.dart' as annotations; -import 'package:analyzer/dart/analysis/results.dart'; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/constant/value.dart'; import 'package:analyzer/dart/element/element.dart'; @@ -8,12 +6,13 @@ import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; +import '../inference/schema_inference.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; -import '../inference/schema_inference.dart'; import '../utils/doc_comment_utils.dart'; -import 'generated_companion_visibility.dart'; import '../utils/string_literal.dart'; +import 'custom_schema_resolver.dart'; +import 'model_graph_builder_base.dart'; typedef _ModelOptions = ({ String? schemaName, @@ -25,27 +24,14 @@ typedef _ModelOptions = ({ String captureField, }); -typedef _FutureGeneratedType = ({ - String schemaExpression, - AckInferRef runtimeRef, - String? setListSchema, -}); - typedef _ClassFirstDependency = ({ClassElement target, FieldElement field}); -typedef _RegisteredSchema = ({ - Element declaration, - DartType boundary, - DartType runtime, - bool isFunction, -}); - /// Builds normalized Ack model nodes from hand-written `@Schemable` classes. /// /// Analyzer elements and AST nodes are consumed here; emitters receive only /// structural type references and source expressions stored in [AckModelGraph]. -final class ClassModelGraphBuilder { - ClassModelGraphBuilder(this.library, {this.ackPrefix}); +final class ClassModelGraphBuilder extends ModelGraphBuilderBase { + ClassModelGraphBuilder(super.library, {super.ackPrefix}); static const _reservedMembers = { r'$ack', @@ -70,28 +56,6 @@ final class ClassModelGraphBuilder { '==', }; - static const _oneWayTransformMethods = {'transform'}; - - static const _schemableChecker = TypeChecker.typeNamed( - annotations.Schemable, - inPackage: 'ack', - ); - static const _ackFieldChecker = TypeChecker.typeNamed( - annotations.AckField, - inPackage: 'ack', - ); - static const _ackSchemaChecker = TypeChecker.typeNamed( - AckSchema, - inPackage: 'ack', - ); - static const _ackModelAdapterChecker = TypeChecker.typeNamed( - AckModelAdapter, - inPackage: 'ack', - ); - static const _jsonMaybeChecker = TypeChecker.typeNamed( - JsonMaybe, - inPackage: 'ack', - ); static const _jsonSerializableChecker = TypeChecker.typeNamedLiterally( 'JsonSerializable', inPackage: 'json_annotation', @@ -118,39 +82,20 @@ final class ClassModelGraphBuilder { inPackage: 'ack', ); - final LibraryReader library; - final String? ackPrefix; AckSchemaInference get _inference => AckSchemaInference(ackPrefix: ackPrefix); - final AckModelGraph _graph = AckModelGraph(); + late final CustomSchemaResolver _customSchemas = CustomSchemaResolver(this); final Set _consumed = {}; final Map _schemaNameOwners = {}; final Map> _dependencies = {}; - final List<_RegisteredSchema> _librarySchemas = []; - final List<_RegisteredSchema> _importedLibrarySchemas = []; - final Map> _classSchemas = {}; - final Map _unionBaseByBranch = {}; - final List<_RegisteredSchema> _libraryTopLevelSchemaCandidates = []; ClassElement? _activeClass; - ResolvedLibraryResult? _inputResolved; - final Map _resolvedByUri = {}; Future build(List annotatedClasses) async { - final libraryElement = library.element; - final resolved = await libraryElement.session.getResolvedLibraryByElement( - libraryElement, - ); - if (resolved is! ResolvedLibraryResult) { - throw InvalidGenerationSource( - 'Could not resolve ${libraryElement.uri} for Ack model generation.', - ); - } - _inputResolved = resolved; - _resolvedByUri[libraryElement.uri] = resolved; + await initInputLibrary(); for (final element in annotatedClasses) { _validateAnnotatedClass(element); } - await _initRegisteredAndLibrarySchemas(); + await _customSchemas.initRegisteredAndLibrarySchemas(); for (final element in annotatedClasses.where((item) => item.isSealed)) { await _buildUnion(element); @@ -161,13 +106,13 @@ final class ClassModelGraphBuilder { if (options.discriminatorValue != null) { throw InvalidGenerationSource( '${element.name} sets discriminatorValue but is not a concrete ' - 'branch of an annotated sealed ${_annotationName(element)} base.', + 'branch of an annotated sealed ${annotationName(element)} base.', element: element, ); } if (element.isAbstract || !element.isConstructable) { throw InvalidGenerationSource( - '${_annotationName(element)} requires a constructable class; ' + '${annotationName(element)} requires a constructable class; ' '${element.name} is abstract.', element: element, ); @@ -176,14 +121,11 @@ final class ClassModelGraphBuilder { } _rejectRecursiveClassFirstGraphs(); _validateGeneratedNames(); - return _graph; + return graph; } void _validateGeneratedNames() { - final localNames = { - for (final element in library.allElements) - if (element.name case final name?) name, - }; + final localNames = localDeclarationNames(); final generatedOwners = {}; final classes = { for (final element in library.classes) element.name!: element, @@ -208,8 +150,8 @@ final class ClassModelGraphBuilder { generatedOwners[name] = element; } - for (final node in _graph.nodes) { - final metadata = _graph.classMetadataFor(node.id); + for (final node in graph.nodes) { + final metadata = graph.classMetadataFor(node.id); if (metadata == null) { throw StateError('Missing class-first metadata for ${node.id}.'); } @@ -233,13 +175,14 @@ final class ClassModelGraphBuilder { final name = element.name ?? ''; if (name.startsWith('_')) { throw InvalidGenerationSource( - '${_annotationName(element)} requires a public class; received ' + '${annotationName(element)} requires a public class; received ' '"$name".', element: element, todo: 'Annotate a public class.', ); } - final schemable = _schemableChecker.firstAnnotationOfExact(element); + final schemable = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(element); if (schemable != null && !ConstantReader(schemable).read('name').isNull) { throw InvalidGenerationSource( '$name sets @Schemable(name: ...), which applies only to top-level ' @@ -252,18 +195,18 @@ final class ClassModelGraphBuilder { _requireFinalConcreteClass(element); if (_jsonSerializableChecker.hasAnnotationOfExact(element)) { throw InvalidGenerationSource( - '$name cannot use ${_annotationName(element)} and @JsonSerializable ' + '$name cannot use ${annotationName(element)} and @JsonSerializable ' 'together because both generate the same _\$$name JSON helpers.', element: element, todo: - 'Remove @JsonSerializable; ${_annotationName(element)} owns JSON ' + 'Remove @JsonSerializable; ${annotationName(element)} owns JSON ' 'generation.', ); } final options = _options(element)!; if (element.isSealed && options.discriminatorKey == null) { throw InvalidGenerationSource( - 'Sealed ${_annotationName(element)} $name requires discriminatorKey.', + 'Sealed ${annotationName(element)} $name requires discriminatorKey.', element: element, ); } @@ -275,7 +218,7 @@ final class ClassModelGraphBuilder { } if (element.isSealed && options.discriminatorValue != null) { throw InvalidGenerationSource( - 'Sealed ${_annotationName(element)} $name cannot set ' + 'Sealed ${annotationName(element)} $name cannot set ' 'discriminatorValue.', element: element, ); @@ -290,12 +233,12 @@ final class ClassModelGraphBuilder { final getter = element.getGetter('schema'); if (getter == null || !getter.isStatic || - _containsInvalidType(getter.returnType)) { + containsInvalidType(getter.returnType)) { return; } - final runtime = _ackSchemaRuntimeType(getter.returnType); + final runtime = ackSchemaRuntimeType(getter.returnType); if (runtime != null && - _sameTypeIgnoringNullability(runtime, element.thisType)) { + sameTypeIgnoringNullability(runtime, element.thisType)) { return; } throw InvalidGenerationSource( @@ -334,14 +277,14 @@ final class ClassModelGraphBuilder { void _rejectRedirectingFactory(ClassElement element) { final constructor = element.unnamedConstructor; if (constructor == null || !constructor.isFactory) return; - final node = _inputResolved - ?.getFragmentDeclaration(constructor.firstFragment) - ?.node; + final node = inputResolved == null + ? null + : declarationNode(inputResolved!, constructor); if (node is! ConstructorDeclaration) return; final target = node.redirectedConstructor; if (target == null) return; throw InvalidGenerationSource( - '${_annotationName(element)} ${element.name} redirects its unnamed ' + '${annotationName(element)} ${element.name} redirects its unnamed ' 'constructor to ${target.toSource()}. Redirecting factory models are ' 'not supported; declare a final class whose unnamed generative ' 'constructor initializes its fields.', @@ -387,14 +330,14 @@ final class ClassModelGraphBuilder { } if (candidates.isEmpty) { throw InvalidGenerationSource( - 'Sealed ${_annotationName(base)} ${base.name} has no concrete ' + 'Sealed ${annotationName(base)} ${base.name} has no concrete ' 'same-library branches.', element: base, ); } final baseId = _id(base); - _graph.begin(baseId); + graph.begin(baseId); _registerMetadata(base, baseOptions, baseId); _consumed.add(base); @@ -428,7 +371,7 @@ final class ClassModelGraphBuilder { ); } valueOwners[value] = branch; - _unionBaseByBranch[branch] = base; + _customSchemas.unionBaseByBranch[branch] = base; _validateBranchDiscriminator(branch, discriminatorKey, value); final branchNode = await _buildObject( branch, @@ -441,7 +384,7 @@ final class ClassModelGraphBuilder { _consumed.add(branch); } - _graph.complete( + graph.complete( AckUnionModelNode( id: baseId, className: base.name!, @@ -482,12 +425,12 @@ final class ClassModelGraphBuilder { String? discriminatorValue, }) async { final id = _id(element); - _graph.begin(id); + graph.begin(id); _registerMetadata(element, options, id); final constructor = element.unnamedConstructor; if (constructor == null || !constructor.isGenerative) { throw InvalidGenerationSource( - '${_annotationName(element)} ${element.name} requires an unnamed ' + '${annotationName(element)} ${element.name} requires an unnamed ' 'generative constructor.', element: element, ); @@ -501,7 +444,7 @@ final class ClassModelGraphBuilder { if (field.isFinal) continue; throw InvalidGenerationSource( '${element.name}.${field.name} must be final because ' - '${_annotationName(element)} generates immutable value semantics.', + '${annotationName(element)} generates immutable value semantics.', element: field, todo: 'Declare the stored field as final.', ); @@ -529,12 +472,15 @@ final class ClassModelGraphBuilder { ); } - final futureTypes = {}; + final futureTypes = {}; for (final field in fields.values) { final name = field.name; if (name == null) continue; - if (!_containsInvalidType(field.type)) continue; - futureTypes[name] = await _futureGeneratedType(field); + if (!containsInvalidType(field.type)) continue; + futureTypes[name] = await _customSchemas.futureGeneratedType( + field, + setCodec: _setCodec, + ); } final parameters = {}; @@ -597,7 +543,7 @@ final class ClassModelGraphBuilder { if (parameters.containsKey(name)) { throw InvalidGenerationSource( '${element.name}.$name is private and cannot participate in ' - '${_annotationName(element)} generation.', + '${annotationName(element)} generation.', element: field, ); } @@ -613,7 +559,7 @@ final class ClassModelGraphBuilder { ); } - if (_isJsonMaybe(field.type) && _isNullable(field.type)) { + if (isJsonMaybeType(field.type) && isNullableType(field.type)) { throw InvalidGenerationSource( '${element.name}.$name uses nullable ' '${field.type.getDisplayString()}; use non-nullable JsonMaybe<...> ' @@ -621,7 +567,7 @@ final class ClassModelGraphBuilder { element: field, ); } - final unwrappedFieldType = _unwrapJsonMaybeType(field.type); + final unwrappedFieldType = unwrapJsonMaybeType(field.type); _rejectUnsupportedStaticType(field, unwrappedFieldType); _validateMapKey(field, unwrappedFieldType); final futureType = futureTypes[name]; @@ -657,10 +603,10 @@ final class ClassModelGraphBuilder { final isJsonMaybe = futureType?.runtimeRef is AckJsonMaybeTypeRef || - _isJsonMaybe(field.type); + isJsonMaybeType(field.type); final dartNullable = futureType?.runtimeRef is AckNullableTypeRef || - _isNullable(field.type); + isNullableType(field.type); final rejectNull = _hasAnnotation(_notNullChecker, field, parameter); final acceptsNull = (dartNullable || isJsonMaybe) && !rejectNull; final presence = _effectivePresence( @@ -670,7 +616,7 @@ final class ClassModelGraphBuilder { isJsonMaybe: isJsonMaybe, ); var schema = isDiscriminator && discriminatorValue != null - ? '${_ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' + ? '${ack('Ack')}.literal(${dartStringLiteral(discriminatorValue)})' : await _fieldSchema( field, parameter: parameter, @@ -720,7 +666,7 @@ final class ClassModelGraphBuilder { discriminatorValue: discriminatorValue, description: _classDescription(element, options), ); - _graph.complete(node); + graph.complete(node); return node; } @@ -731,7 +677,7 @@ final class ClassModelGraphBuilder { ) { if (type is! InterfaceType) return; final target = type.element; - if (target is ClassElement && _classFirstFacadeName(target) != null) { + if (target is ClassElement && classFirstFacadeName(target) != null) { _dependencies.putIfAbsent(owner, () => []).add(( target: target, field: field, @@ -785,13 +731,17 @@ final class ClassModelGraphBuilder { Future _fieldSchema( FieldElement field, { FormalParameterElement? parameter, - _FutureGeneratedType? futureType, + FutureGeneratedType? futureType, }) async { - final override = _firstAnnotation(_ackFieldChecker, field, parameter); + final override = _firstAnnotation( + ModelGraphBuilderBase.ackFieldChecker, + field, + parameter, + ); if (override != null) { final reader = ConstantReader(override); if (!reader.read('schema').isNull) { - final base = await _escapeHatchExpression(field, reader); + final base = await _customSchemas.escapeHatchExpression(field, reader); return _applySugar(base, field, parameter); } } @@ -810,14 +760,14 @@ final class ClassModelGraphBuilder { } return _applySugar(futureType.schemaExpression, field, parameter); } - final type = _unwrapJsonMaybeType(field.type); + final type = unwrapJsonMaybeType(field.type); if (type is InterfaceType && type.isDartCoreSet && type.typeArguments.length == 1) { final itemType = type.typeArguments.single; _rejectNullableCollectionElement(field, itemType); final item = await _schemaForType(itemType, field); - final list = _applySugar('${_ack('Ack')}.list($item)', field, parameter); + final list = _applySugar('${ack('Ack')}.list($item)', field, parameter); return _setCodec(list, _typeRef(type, field, allowJsonMaybe: false)); } return _applySugar(await _schemaForType(type, field), field, parameter); @@ -854,407 +804,6 @@ final class ClassModelGraphBuilder { _ => type, }; - bool _containsInvalidType(DartType type) => - type is InvalidType || - (type is InterfaceType && type.typeArguments.any(_containsInvalidType)); - - Future<_FutureGeneratedType> _futureGeneratedType(FieldElement field) async { - final resolved = await _resolvedLibraryFor(field.library); - AstNode? node = resolved.getFragmentDeclaration(field.firstFragment)?.node; - while (node != null && - node is! FieldDeclaration && - node is! NormalFormalParameter) { - node = node.parent; - } - final annotation = switch (node) { - FieldDeclaration() => node.fields.type, - SimpleFormalParameter() => node.type, - FieldFormalParameter() => node.type, - SuperFormalParameter() => node.type, - _ => null, - }; - final futureType = annotation == null - ? null - : _futureGeneratedTypeForAnnotation(annotation, field); - if (futureType != null) return futureType; - final path = '${field.enclosingElement.name}.${field.name}'; - final arguments = annotation is NamedType - ? annotation.typeArguments?.arguments ?? const [] - : const []; - if (annotation is NamedType && - annotation.importPrefix == null && - annotation.name.lexeme == 'Map' && - arguments.length == 2 && - _futureGeneratedTypeForAnnotation(arguments.last, field) != null) { - throw InvalidGenerationSource( - '$path uses ${annotation.toSource()}. A model generated from a ' - '@Schemable schema in this build can be a field type directly or a ' - 'List or Set item, but not a Map value.', - element: field, - ); - } - throw InvalidGenerationSource( - '$path uses ${annotation?.toSource() ?? 'a type'}, which does not ' - 'resolve. Check that it is imported.', - element: field, - ); - } - - _FutureGeneratedType? _futureGeneratedTypeForAnnotation( - TypeAnnotation annotation, - FieldElement field, { - bool allowJsonMaybe = true, - }) { - if (annotation is! NamedType) return null; - final name = annotation.name.lexeme; - final prefix = annotation.importPrefix?.name.lexeme; - final arguments = annotation.typeArguments?.arguments ?? const []; - _FutureGeneratedType? result; - if (allowJsonMaybe && - name == 'JsonMaybe' && - (prefix == null || prefix == ackPrefix) && - arguments.length == 1) { - final item = _futureGeneratedTypeForAnnotation( - arguments.single, - field, - allowJsonMaybe: false, - ); - if (item == null) return null; - result = ( - schemaExpression: item.schemaExpression, - runtimeRef: AckJsonMaybeTypeRef(item.runtimeRef), - setListSchema: item.setListSchema, - ); - } else if (prefix == null && name == 'List' && arguments.length == 1) { - final item = _futureGeneratedTypeForAnnotation( - arguments.single, - field, - allowJsonMaybe: false, - ); - if (item == null) return null; - result = ( - schemaExpression: '${_ack('Ack')}.list(${item.schemaExpression})', - runtimeRef: AckListTypeRef(item.runtimeRef), - setListSchema: null, - ); - } else if (prefix == null && name == 'Set' && arguments.length == 1) { - final item = _futureGeneratedTypeForAnnotation( - arguments.single, - field, - allowJsonMaybe: false, - ); - if (item == null) return null; - final runtimeRef = AckSetTypeRef(item.runtimeRef); - final listSchema = '${_ack('Ack')}.list(${item.schemaExpression})'; - result = ( - schemaExpression: _setCodec(listSchema, runtimeRef), - runtimeRef: runtimeRef, - setListSchema: listSchema, - ); - } else { - final target = _futureGeneratedTarget( - className: name, - prefix: prefix, - field: field, - ); - if (target == null) return null; - final visibleName = qualifySymbol(prefix, name); - result = ( - schemaExpression: '$visibleName.\$ack.schema', - runtimeRef: AckModelTypeRef( - schemaId: AckSchemaId( - libraryUri: target.library!.uri, - declarationName: target.name!, - ), - className: name, - runtimeRef: _jsonMapRef, - importPrefix: prefix, - ), - setListSchema: null, - ); - } - if (annotation.question == null) return result; - return ( - schemaExpression: result.schemaExpression, - runtimeRef: AckNullableTypeRef(result.runtimeRef), - setListSchema: result.setListSchema, - ); - } - - Element? _futureGeneratedTarget({ - required String className, - required String? prefix, - required FieldElement field, - }) { - final matches = {}; - final candidates = {}; - final imports = []; - String? hiddenDeclaration; - - Element? candidateFor(Element element) { - final declaration = _schemableDeclaration(element); - if (declaration == null || - _generatedSchemaClassName(declaration) != className) { - return null; - } - return declaration.baseElement; - } - - if (prefix == null) { - for (final element in library.allElements) { - final candidate = candidateFor(element); - if (candidate != null) matches.add(candidate); - } - } - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importPrefix = import.prefix?.element.name; - if (importPrefix != prefix) continue; - imports.add(import); - final importedLibrary = import.importedLibrary; - final elements = { - if (importedLibrary != null) - ...LibraryReader(importedLibrary).allElements, - ...import.namespace.definedNames2.values, - }; - for (final element in elements) { - final candidate = candidateFor(element); - if (candidate != null) candidates.add(candidate); - } - } - for (final candidate in candidates) { - final definingLibrary = candidate.library; - final generatedClassVisible = - definingLibrary != null && - imports.any( - (import) => importExposesGeneratedCompanion( - import, - definingLibrary: definingLibrary, - generatedName: className, - ), - ); - if (generatedClassVisible) { - matches.add(candidate); - } else { - hiddenDeclaration = candidate.name; - } - } - if (matches.isEmpty) { - if (hiddenDeclaration != null) { - final fieldPath = '${field.enclosingElement.name}.${field.name}'; - throw InvalidGenerationSource( - '$fieldPath resolves to generated schema-first type "$className", ' - 'which is hidden by an import combinator or an upstream export ' - 'combinator. Expose $className through the full import route.', - element: field, - ); - } - return null; - } - if (matches.length > 1) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} resolves future generated ' - 'type "$className" ambiguously.', - element: field, - ); - } - return matches.single; - } - - Element? _schemableDeclaration(Element element) { - final declaration = switch (element) { - GetterElement(isOriginVariable: true) => element.variable.baseElement, - TopLevelVariableElement() => element.baseElement, - _ => null, - }; - return declaration != null && - _schemableChecker.hasAnnotationOfExact(declaration) - ? declaration - : null; - } - - String _generatedSchemaClassName(Element declaration) { - final annotation = _schemableChecker.firstAnnotationOfExact(declaration)!; - final custom = ConstantReader(annotation).read('name'); - return ackInferModelClassName( - declaration.name!, - override: custom.isNull ? null : custom.stringValue, - ); - } - - Future _escapeHatchExpression( - FieldElement field, - ConstantReader annotation, - ) async { - final function = annotation.read('schema').objectValue.toFunctionValue(); - if (function is! TopLevelFunctionElement) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField schema must ' - 'be a const tear-off of a top-level function.', - element: field, - ); - } - final path = '${field.enclosingElement.name}.${field.name}'; - if (function.typeParameters.isNotEmpty) { - throw InvalidGenerationSource( - '$path @AckField schema function ${function.name} is generic, and the ' - 'generated call cannot keep its type arguments. Use a non-generic ' - 'function, or declare a static schema method on the field type.', - element: field, - ); - } - if (function.formalParameters.isNotEmpty || - !_ackSchemaChecker.isAssignableFromType(function.returnType)) { - throw InvalidGenerationSource( - '$path @AckField top-level function must have type AckSchema ' - 'Function().', - element: field, - ); - } - final runtime = _ackSchemaRuntimeType(function.returnType); - final expectedFieldType = _unwrapJsonMaybeType(field.type); - if (runtime == null || - !_sameTypeIgnoringNullability(runtime, expectedFieldType)) { - throw InvalidGenerationSource( - '$path @AckField schema function ${function.name} produces ' - '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' - 'type is ${expectedFieldType.getDisplayString()}.', - element: field, - todo: 'Return AckSchema<..., ${expectedFieldType.getDisplayString()}>.', - ); - } - final resolved = await _resolvedLibraryFor(function.library); - final declaration = resolved - .getFragmentDeclaration(function.firstFragment) - ?.node; - final bodyExpression = declaration is FunctionDeclaration - ? _functionBodyExpression(declaration.functionExpression.body) - : null; - if (bodyExpression == null) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField function must ' - 'have a statically resolvable expression or single return.', - element: field, - ); - } - final oneWaySource = await _oneWayTransformSource(bodyExpression); - if (oneWaySource != null) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField schema ' - 'function ${function.name} reaches one-way schema $oneWaySource. ' - 'Migrate this .transform() path to .codec() with an encoder.', - element: field, - ); - } - final prefix = _visiblePrefix(function, field); - return '${prefix == null ? '' : '$prefix.'}${function.name}()'; - } - - /// The boundary type argument of [type] as an `AckSchema`. - DartType? _ackSchemaBoundaryType(DartType type) { - if (type is! InterfaceType) return null; - for (final candidate in [type, ...type.allSupertypes]) { - if (_ackSchemaChecker.isExactlyType(candidate) && - candidate.typeArguments.length == 2) { - return candidate.typeArguments[0]; - } - } - return null; - } - - /// The runtime type argument of [type] as an `AckSchema`. - DartType? _ackSchemaRuntimeType(DartType type) { - if (type is! InterfaceType) return null; - for (final candidate in [type, ...type.allSupertypes]) { - if (_ackSchemaChecker.isExactlyType(candidate) && - candidate.typeArguments.length == 2) { - return candidate.typeArguments[1]; - } - } - return null; - } - - DartType? _inferredBoundaryType(DartType type, FieldElement field) { - if (type is! InterfaceType) return null; - final registered = _registeredSchemaForType(type, field); - if (registered != null) return registered.boundary; - final provider = library.element.typeProvider; - if (_isCore(type, 'int') || - _isCore(type, 'double') || - _isCore(type, 'num')) { - return provider.numType; - } - if (_isCore(type, 'String') || - _isCore(type, 'DateTime') || - _isCore(type, 'Uri')) { - return provider.stringType; - } - if (_isCore(type, 'bool')) return provider.boolType; - if (_isCore(type, 'Duration')) return provider.intType; - if (_isCore(type, 'Object')) return provider.objectType; - if (type.isDartCoreList || type.isDartCoreSet) { - return provider.listType(provider.objectQuestionType); - } - if (type.isDartCoreMap) { - return provider.mapType(provider.stringType, provider.objectQuestionType); - } - final target = type.element; - if (target is ClassElement && _classFirstFacadeName(target) != null) { - return provider.mapType(provider.stringType, provider.objectQuestionType); - } - final ackGetter = target.getGetter(r'$ack'); - final ackReturnType = ackGetter?.returnType; - if (ackGetter != null && - ackGetter.isStatic && - ackReturnType is InterfaceType && - _ackModelAdapterChecker.isExactlyType(ackReturnType) && - ackReturnType.typeArguments.length == 3) { - return ackReturnType.typeArguments[0]; - } - final schemaGetter = target.getGetter('schema'); - if (schemaGetter != null && schemaGetter.isStatic) { - return _ackSchemaBoundaryType(schemaGetter.returnType); - } - final schemaMethod = target.getMethod('schema'); - if (schemaMethod != null && - schemaMethod.isStatic && - schemaMethod.typeParameters.length == type.typeArguments.length) { - return _ackSchemaBoundaryType( - schemaMethod.type.instantiate(type.typeArguments).returnType, - ); - } - if (target is EnumElement) return provider.stringType; - final autoDetected = _uniqueLibrarySchemaCandidate(type); - if (autoDetected != null) return autoDetected.boundary; - return null; - } - - /// Whether a schema producing [left] can back a field of type [right]. - /// - /// The types must match apart from nullability: collection schemas such as - /// `Ack.map` type their values as nullable. - bool _sameTypeIgnoringNullability(DartType left, DartType right) { - final typeSystem = library.element.typeSystem; - final a = typeSystem.promoteToNonNull(left); - final b = typeSystem.promoteToNonNull(right); - if (a is InterfaceType && b is InterfaceType) { - if (a.element.baseElement != b.element.baseElement || - a.typeArguments.length != b.typeArguments.length) { - return false; - } - for (var i = 0; i < a.typeArguments.length; i++) { - if (!_sameTypeIgnoringNullability( - a.typeArguments[i], - b.typeArguments[i], - )) { - return false; - } - } - return true; - } - return typeSystem.isSubtypeOf(a, b) && typeSystem.isSubtypeOf(b, a); - } - Future _schemaForType(DartType type, FieldElement field) => _inference.inferType( type, @@ -1264,577 +813,22 @@ final class ClassModelGraphBuilder { field, allowJsonMaybe: false, ).render(ackPrefix: ackPrefix), - resolveNamed: (type) => _namedSchemaForType(type, field), + resolveNamed: (type) => _customSchemas.namedSchemaForType( + type, + field, + activeClass: _activeClass, + schemaForType: _schemaForType, + typeRef: (t, f) => _typeRef(t, f, allowJsonMaybe: false), + typeArgumentRef: _typeArgumentRef, + ), unsupported: (type) => _unsupportedFieldType(field, type), rejectNullableCollectionElement: (type) => _nullableCollectionElementError(field, type.getDisplayString()), validateMapKey: (type) => _validateMapKey(field, type), ); - Future _namedSchemaForType( - InterfaceType interfaceType, - FieldElement field, - ) async { - final registered = _registeredSchemaForType(interfaceType, field); - if (registered != null) { - return _renderRegisteredSchema(registered, field); - } - if (_isOverridableBuiltInScalar(interfaceType)) { - return null; - } - final target = interfaceType.element; - if (target is ClassElement) { - final facadeName = _classFirstFacadeName(target); - if (facadeName != null) { - final prefix = _visiblePrefix(target, field); - if (target.library != library.element && - !_isClassFirstFacadeVisible(target, facadeName, prefix: prefix)) { - final typeOwned = await _typeOwnedSchema(interfaceType, field); - if (typeOwned != null) return typeOwned; - } - _validateClassFirstFacadeImport( - target, - facadeName, - prefix: prefix, - field: field, - ); - return '${prefix == null ? '' : '$prefix.'}$facadeName.schema'; - } - } - if (_schemaFirstRuntimeRef(target, field) != null) { - return '${_visibleTypeName(interfaceType, field)}.\$ack.schema'; - } - final typeOwned = await _typeOwnedSchema(interfaceType, field); - if (typeOwned != null) return typeOwned; - if (target is EnumElement) return null; - return await _autoDetectedLibrarySchema(interfaceType, field); - } - - Future _initRegisteredAndLibrarySchemas() async { - final libraryAnnotation = _schemableChecker.firstAnnotationOfExact( - library.element, - ); - if (libraryAnnotation != null) { - _librarySchemas.addAll( - await _parseSchemasList( - library.element, - ConstantReader(libraryAnnotation), - ), - ); - } - - final seenImportedLibraries = {}; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importedLibrary = import.importedLibrary; - if (importedLibrary == null || - importedLibrary == library.element || - !seenImportedLibraries.add(importedLibrary)) { - continue; - } - final importedAnnotation = _schemableChecker.firstAnnotationOfExact( - importedLibrary, - ); - if (importedAnnotation == null) continue; - _importedLibrarySchemas.addAll( - await _parseSchemasList( - importedLibrary, - ConstantReader(importedAnnotation), - ), - ); - } - - for (final classElement in library.classes) { - final annotation = _schemableChecker.firstAnnotationOfExact(classElement); - if (annotation == null) continue; - final parsed = await _parseSchemasList( - classElement, - ConstantReader(annotation), - ); - if (parsed.isNotEmpty) { - _classSchemas[classElement] = parsed; - } - } - - for (final variable in library.element.topLevelVariables) { - if (!variable.isOriginDeclaration || - _schemableChecker.hasAnnotationOfExact(variable)) { - continue; - } - _addLibrarySchemaCandidate( - declaration: variable, - schemaType: variable.type, - isFunction: false, - ); - } - for (final getter in library.element.getters) { - if (!getter.isOriginDeclaration || - _schemableChecker.hasAnnotationOfExact(getter)) { - continue; - } - _addLibrarySchemaCandidate( - declaration: getter, - schemaType: getter.returnType, - isFunction: false, - ); - } - for (final function in library.element.topLevelFunctions) { - if (!function.isOriginDeclaration || - function.typeParameters.isNotEmpty || - function.formalParameters.isNotEmpty) { - continue; - } - _addLibrarySchemaCandidate( - declaration: function, - schemaType: function.returnType, - isFunction: true, - ); - } - } - - void _addLibrarySchemaCandidate({ - required Element declaration, - required DartType schemaType, - required bool isFunction, - }) { - if (_containsInvalidType(schemaType) || - !_ackSchemaChecker.isAssignableFromType(schemaType)) { - return; - } - final boundary = _ackSchemaBoundaryType(schemaType); - final runtime = _ackSchemaRuntimeType(schemaType); - if (boundary == null || - runtime == null || - _containsInvalidType(boundary) || - _containsInvalidType(runtime) || - runtime is DynamicType || - (runtime is InterfaceType && _isCore(runtime, 'Object'))) { - return; - } - _libraryTopLevelSchemaCandidates.add(( - declaration: declaration, - boundary: boundary, - runtime: library.element.typeSystem.promoteToNonNull(runtime), - isFunction: isFunction, - )); - } - - Future> _parseSchemasList( - Element owner, - ConstantReader reader, - ) async { - final schemasReader = reader.read('schemas'); - if (schemasReader.isNull) return const []; - final ownerLabel = owner is LibraryElement - ? 'library' - : (owner.name ?? '@Schemable'); - final list = schemasReader.listValue; - if (list.isEmpty) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) must not be empty.', - element: owner, - ); - } - final result = <_RegisteredSchema>[]; - for (final item in list) { - final function = item.toFunctionValue(); - if (function is! TopLevelFunctionElement) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) entries must be const ' - 'tear-offs of top-level functions.', - element: owner, - ); - } - if (function.typeParameters.isNotEmpty) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) function ${function.name} is ' - 'generic. Use a non-generic top-level function, or declare a static ' - 'schema method on the type.', - element: owner, - ); - } - if (function.formalParameters.isNotEmpty || - !_ackSchemaChecker.isAssignableFromType(function.returnType)) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' - 'have type AckSchema Function().', - element: owner, - ); - } - final boundary = _ackSchemaBoundaryType(function.returnType); - final runtime = _ackSchemaRuntimeType(function.returnType); - if (boundary == null || - runtime == null || - runtime is DynamicType || - (runtime is InterfaceType && _isCore(runtime, 'Object'))) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' - 'return a typed AckSchema for a specific runtime ' - 'type.', - element: owner, - ); - } - if (runtime is InterfaceType && _isNonOverridableCoreType(runtime)) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) cannot override core type ' - '${runtime.getDisplayString()}; use @AckField(schema: ' - '${function.name}) on the specific field instead.', - element: owner, - ); - } - final resolved = await _resolvedLibraryFor(function.library); - final declaration = resolved - .getFragmentDeclaration(function.firstFragment) - ?.node; - final bodyExpression = declaration is FunctionDeclaration - ? _functionBodyExpression(declaration.functionExpression.body) - : null; - if (bodyExpression == null) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' - 'have a statically resolvable expression or single return.', - element: owner, - ); - } - final oneWaySource = await _oneWayTransformSource(bodyExpression); - if (oneWaySource != null) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) function ${function.name} ' - 'reaches one-way schema $oneWaySource. Migrate this .transform() ' - 'path to .codec() with an encoder.', - element: owner, - ); - } - final nonNullRuntime = library.element.typeSystem.promoteToNonNull( - runtime, - ); - for (final prior in result) { - if (_sameTypeIgnoringNullability(prior.runtime, nonNullRuntime)) { - throw InvalidGenerationSource( - '$ownerLabel @Schemable(schemas: ...) registers multiple schemas ' - 'for ${nonNullRuntime.getDisplayString()}: ' - '${prior.declaration.name} and ${function.name}.', - element: owner, - ); - } - } - result.add(( - declaration: function, - boundary: boundary, - runtime: nonNullRuntime, - isFunction: true, - )); - } - return result; - } - - _RegisteredSchema? _registeredSchemaForType( - InterfaceType interfaceType, - FieldElement field, - ) { - final activeOwner = _activeClass; - final fieldOwner = field.enclosingElement; - for (final scope in [ - if (activeOwner != null) ...[ - if (_classSchemas[activeOwner] case final classList?) classList, - if (_unionBaseByBranch[activeOwner] case final base?) - if (_classSchemas[base] case final baseList?) baseList, - ], - if (fieldOwner is ClassElement && fieldOwner != activeOwner) - if (_classSchemas[fieldOwner] case final ownerList?) ownerList, - _librarySchemas, - ]) { - for (final entry in scope) { - if (_sameTypeIgnoringNullability(entry.runtime, interfaceType)) { - return entry; - } - } - } - final importedMatches = {}; - for (final entry in _importedLibrarySchemas) { - if (_sameTypeIgnoringNullability(entry.runtime, interfaceType)) { - importedMatches[entry.declaration.baseElement] = entry; - } - } - if (importedMatches.length > 1) { - final path = '${field.enclosingElement.name}.${field.name}'; - final display = library.element.typeSystem - .promoteToNonNull(interfaceType) - .getDisplayString(); - final names = importedMatches.values - .map((m) => m.declaration.name) - .join(', '); - throw InvalidGenerationSource( - '$path has multiple imported library schemas for $display ($names). ' - 'Disambiguate with @Schemable(schemas: [...]) or ' - '@AckField(schema: ...).', - element: field, - ); - } - if (importedMatches.length == 1) return importedMatches.values.single; - return null; - } - - _RegisteredSchema? _uniqueLibrarySchemaCandidate( - InterfaceType interfaceType, - ) { - if (_isNonOverridableCoreType(interfaceType) || - _isOverridableBuiltInScalar(interfaceType) || - interfaceType.element is EnumElement) { - return null; - } - final matches = [ - for (final candidate in _libraryTopLevelSchemaCandidates) - if (_sameTypeIgnoringNullability(candidate.runtime, interfaceType)) - candidate, - ]; - return matches.length == 1 ? matches.single : null; - } - - Future _autoDetectedLibrarySchema( - InterfaceType interfaceType, - FieldElement field, - ) async { - if (_isNonOverridableCoreType(interfaceType) || - _isOverridableBuiltInScalar(interfaceType) || - interfaceType.element is EnumElement) { - return null; - } - final matches = [ - for (final candidate in _libraryTopLevelSchemaCandidates) - if (_sameTypeIgnoringNullability(candidate.runtime, interfaceType)) - candidate, - ]; - if (matches.isEmpty) return null; - final path = '${field.enclosingElement.name}.${field.name}'; - final display = library.element.typeSystem - .promoteToNonNull(interfaceType) - .getDisplayString(); - if (matches.length > 1) { - final names = matches.map((m) => m.declaration.name).join(', '); - throw InvalidGenerationSource( - '$path has multiple top-level schemas for $display in this library ' - '($names). Disambiguate with @Schemable(schemas: [...]) or ' - '@AckField(schema: ...).', - element: field, - ); - } - final candidate = matches.single; - final resolved = await _resolvedLibraryFor(candidate.declaration.library!); - final node = resolved - .getFragmentDeclaration(candidate.declaration.firstFragment) - ?.node; - final bodyExpression = switch (node) { - VariableDeclaration() => node.initializer, - FunctionDeclaration() => _functionBodyExpression( - node.functionExpression.body, - ), - _ => null, - }; - if (bodyExpression == null) { - throw InvalidGenerationSource( - '$path resolves to top-level schema ${candidate.declaration.name}, ' - 'which must have a statically resolvable expression or single return.', - element: field, - ); - } - final oneWaySource = await _oneWayTransformSource(bodyExpression); - if (oneWaySource != null) { - throw InvalidGenerationSource( - '$path resolves to top-level schema ${candidate.declaration.name}, ' - 'which reaches one-way schema $oneWaySource. Migrate this ' - '.transform() path to .codec() with an encoder.', - element: field, - ); - } - return _renderRegisteredSchema(candidate, field); - } - - String _renderRegisteredSchema(_RegisteredSchema schema, FieldElement field) { - final prefix = _visiblePrefix(schema.declaration, field); - final qualified = - '${prefix == null ? '' : '$prefix.'}${schema.declaration.name}'; - return schema.isFunction ? '$qualified()' : qualified; - } - - bool _isOverridableBuiltInScalar(InterfaceType type) => - _isCore(type, 'DateTime') || - _isCore(type, 'Uri') || - _isCore(type, 'Duration'); - - bool _isNonOverridableCoreType(InterfaceType type) => - _isCore(type, 'String') || - _isCore(type, 'int') || - _isCore(type, 'double') || - _isCore(type, 'num') || - _isCore(type, 'bool') || - _isCore(type, 'Object') || - type.isDartCoreList || - type.isDartCoreSet || - type.isDartCoreMap || - _isJsonMaybe(type); - - AckInferRef? _schemaFirstRuntimeRef( - InterfaceElement target, - FieldElement field, - ) { - final getter = target.getGetter(r'$ack'); - final returnType = getter?.returnType; - if (getter == null || - !getter.isStatic || - returnType is! InterfaceType || - !_ackModelAdapterChecker.isExactlyType(returnType)) { - return null; - } - final runtimeType = returnType.typeArguments.length == 3 - ? returnType.typeArguments[1] - : null; - return runtimeType == null || _containsInvalidType(runtimeType) - ? _jsonMapRef - : _typeRef(runtimeType, field, allowJsonMaybe: false); - } - - /// Resolves [type] through a static `schema` that the type declares. - /// - /// A non-generic type declares a static field or getter. A generic type - /// declares a static method with one type parameter per type argument, - /// which is called with the field's type arguments. The method may also take - /// one schema parameter per type parameter; it then receives the schema - /// inferred for each type argument. - Future _typeOwnedSchema( - InterfaceType type, - FieldElement field, - ) async { - final target = type.element; - final getter = target.getGetter('schema'); - final method = target.getMethod('schema'); - final path = '${field.enclosingElement.name}.${field.name}'; - final fieldType = library.element.typeSystem.promoteToNonNull(type); - final display = fieldType.getDisplayString(); - final String expression; - final DartType returnType; - if (getter != null && getter.isStatic) { - expression = '${_visibleTypeName(type, field)}.schema'; - returnType = getter.returnType; - } else if (method != null && method.isStatic) { - if (method.typeParameters.length != type.typeArguments.length) { - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, which must declare one ' - 'type parameter per type argument of $display.', - element: field, - ); - } - for (var i = 0; i < method.typeParameters.length; i++) { - final typeParam = method.typeParameters[i]; - final rawBound = typeParam.bound; - if (rawBound == null) continue; - final bound = - rawBound is TypeParameterType && - method.typeParameters.contains(rawBound.element) - ? type.typeArguments[method.typeParameters.indexOf( - rawBound.element, - )] - : rawBound; - final argument = type.typeArguments[i]; - if (!library.element.typeSystem.isSubtypeOf(argument, bound)) { - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, whose type parameter ' - '${typeParam.name} requires a subtype of ' - '${bound.getDisplayString()}; received ' - '${argument.getDisplayString()}.', - element: field, - ); - } - } - final parameters = method.formalParameters; - if (parameters.isNotEmpty && - (parameters.length != type.typeArguments.length || - parameters.any((parameter) => !parameter.isRequiredPositional))) { - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, which must take no ' - 'parameters or one positional AckSchema parameter per type ' - 'parameter; it takes ${parameters.length} for ' - '${type.typeArguments.length} type parameters.', - element: field, - ); - } - final instantiated = method.type.instantiate(type.typeArguments); - final schemas = []; - for (var i = 0; i < parameters.length; i++) { - final argument = type.typeArguments[i]; - final parameterType = instantiated.formalParameters[i].type; - final expected = _ackSchemaRuntimeType(parameterType); - if (expected == null || - !_sameTypeIgnoringNullability(expected, argument)) { - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, whose parameter ' - '${parameters[i].name} must be an AckSchema of ' - '${argument.getDisplayString()}; it is ' - '${parameterType.getDisplayString()}.', - element: field, - ); - } - final expectedBoundary = _ackSchemaBoundaryType(parameterType); - final inferredBoundary = _inferredBoundaryType(argument, field); - if (expectedBoundary != null && - inferredBoundary != null && - !library.element.typeSystem.isSubtypeOf( - inferredBoundary, - expectedBoundary, - )) { - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, whose parameter ' - '${parameters[i].name} expects boundary type ' - '${expectedBoundary.getDisplayString()}, but the schema inferred ' - 'for ${argument.getDisplayString()} has boundary type ' - '${inferredBoundary.getDisplayString()}.', - element: field, - ); - } - schemas.add(await _schemaForType(argument, field)); - } - final arguments = [ - for (final argument in type.typeArguments) - _typeArgumentRef(argument, field).render(ackPrefix: ackPrefix), - ]; - final name = _visibleTypeName(type, field); - expression = arguments.isEmpty - ? '$name.schema(${schemas.join(', ')})' - : '$name.schema<${arguments.join(', ')}>(${schemas.join(', ')})'; - returnType = instantiated.returnType; - } else { - return null; - } - if (_containsInvalidType(returnType)) { - if (target is ClassElement && _classFirstFacadeName(target) != null) { - return expression; - } - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, whose type does not resolve ' - 'yet. Declare the type of ${target.name}.schema explicitly.', - element: field, - ); - } - final runtime = _ackSchemaRuntimeType(returnType); - if (runtime == null || !_sameTypeIgnoringNullability(runtime, fieldType)) { - final generic = type.typeArguments.isEmpty - ? '' - : ' A generic type declares static AckSchema<..., ' - '${target.name}<${target.typeParameters.map((p) => p.name).join(', ')}>> ' - 'schema<${target.typeParameters.map((p) => p.name).join(', ')}>() ' - 'instead.'; - throw InvalidGenerationSource( - '$path resolves to ${target.name}.schema, which produces ' - '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' - 'type is $display.$generic', - element: field, - ); - } - return expression; - } - void _rejectNullableCollectionElement(FieldElement field, DartType itemType) { - if (!_isNullable(itemType)) return; + if (!isNullableType(itemType)) return; _nullableCollectionElementError(field, itemType.getDisplayString()); } @@ -1873,82 +867,12 @@ final class ClassModelGraphBuilder { ); } - bool _isClassFirstFacadeVisible( - ClassElement target, - String facadeName, { - required String? prefix, - }) { - if (target.library == library.element) return true; - final modelName = target.name!; - var modelVisible = false; - var facadeVisible = false; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importPrefix = import.prefix?.element.name; - if (importPrefix != prefix) continue; - final candidate = prefix == null - ? import.namespace.get2(modelName) - : import.namespace.getPrefixed2(prefix, modelName); - if (candidate?.baseElement == target.baseElement) { - modelVisible = true; - } - if (importExposesGeneratedCompanion( - import, - definingLibrary: target.library, - generatedName: facadeName, - )) { - facadeVisible = true; - } - } - return modelVisible && facadeVisible; - } - - void _validateClassFirstFacadeImport( - ClassElement target, - String facadeName, { - required String? prefix, - required FieldElement field, - }) { - if (target.library == library.element) return; - final modelName = target.name!; - var modelVisible = false; - var facadeVisible = false; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importPrefix = import.prefix?.element.name; - if (importPrefix != prefix) continue; - final candidate = prefix == null - ? import.namespace.get2(modelName) - : import.namespace.getPrefixed2(prefix, modelName); - if (candidate?.baseElement == target.baseElement) { - modelVisible = true; - } - if (importExposesGeneratedCompanion( - import, - definingLibrary: target.library, - generatedName: facadeName, - )) { - facadeVisible = true; - } - } - if (modelVisible && facadeVisible) return; - if (modelVisible) { - throw InvalidGenerationSource( - 'Generated class-first facade "$facadeName" is hidden by an import ' - 'combinator or an upstream export combinator for $modelName. Expose ' - 'both through the full import route (for example, show $modelName, ' - '$facadeName).', - element: field, - ); - } - } - String _applySugar( String schema, FieldElement field, FormalParameterElement? parameter, ) { - final effectiveType = _unwrapJsonMaybeType(field.type); + final effectiveType = unwrapJsonMaybeType(field.type); if (field.isOriginDeclaringFormalParameter) { final target = field.metadata.annotations.isNotEmpty ? field @@ -1967,9 +891,13 @@ final class ClassModelGraphBuilder { FieldElement field, FormalParameterElement? parameter, ) { - final target = _ackFieldChecker.hasAnnotationOfExact(field) + final target = + ModelGraphBuilderBase.ackFieldChecker.hasAnnotationOfExact(field) ? field - : (parameter != null && _ackFieldChecker.hasAnnotationOfExact(parameter) + : (parameter != null && + ModelGraphBuilderBase.ackFieldChecker.hasAnnotationOfExact( + parameter, + ) ? parameter : field); return _inference.applyDescription( @@ -2026,8 +954,7 @@ final class ClassModelGraphBuilder { type.typeArguments.length != 2) { return; } - final key = type.typeArguments.first; - if (key is InterfaceType && _isCore(key, 'String')) return; + if (isSupportedMapKey(type)) return; throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} must use Map; ' 'received ${type.getDisplayString()}.', @@ -2035,109 +962,52 @@ final class ClassModelGraphBuilder { ); } - bool _isJsonMaybe(DartType type) => - type is InterfaceType && - type.typeArguments.length == 1 && - _jsonMaybeChecker.isExactlyType(type); - - DartType _unwrapJsonMaybeType(DartType type) => - _isJsonMaybe(type) ? (type as InterfaceType).typeArguments.single : type; - /// A type argument of an application type, where `void` is also valid, /// as in `Trigger`. It is never a field or collection item type. AckInferRef _typeArgumentRef(DartType type, FieldElement field) => - type is VoidType - ? const AckExternalTypeRef(name: 'void') - : _typeRef(type, field, allowJsonMaybe: false); + inferTypeArgumentRef( + type, + path: '${field.enclosingElement.name}.${field.name}', + context: field, + onUnsupportedType: (t) => _unsupportedFieldType(field, t), + validateMapKey: (mapType) => _validateMapKey(field, mapType), + resolveModelType: (element) => _resolveModelTypeRef(element, field), + ); AckInferRef _typeRef( DartType type, FieldElement field, { bool allowJsonMaybe = true, - }) { - if (type is DynamicType || type is TypeParameterType) { - _unsupportedFieldType(field, type); - } - if (type is! InterfaceType) _unsupportedFieldType(field, type); - final interfaceType = type; - final nullable = _isNullable(interfaceType); - if (_isJsonMaybe(interfaceType)) { - if (!allowJsonMaybe) { - _unsupportedFieldType(field, type); - } - if (nullable) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} uses nullable ' - '${interfaceType.getDisplayString()}; use non-nullable JsonMaybe<...> ' - 'instead, which already models both absent and null values.', - element: field, - ); - } - return AckJsonMaybeTypeRef( - _typeRef( - interfaceType.typeArguments.single, - field, - allowJsonMaybe: false, - ), - ); - } - late final AckInferRef result; - if (interfaceType.isDartCoreList && - interfaceType.typeArguments.length == 1) { - result = AckListTypeRef( - _typeRef( - interfaceType.typeArguments.single, - field, - allowJsonMaybe: false, - ), - ); - } else if (interfaceType.isDartCoreSet && - interfaceType.typeArguments.length == 1) { - result = AckSetTypeRef( - _typeRef( - interfaceType.typeArguments.single, - field, - allowJsonMaybe: false, - ), - ); - } else if (interfaceType.isDartCoreMap && - interfaceType.typeArguments.length == 2) { - _validateMapKey(field, interfaceType); - result = AckMapTypeRef( - _typeRef(interfaceType.typeArguments[1], field, allowJsonMaybe: false), - ); - } else if (interfaceType.element.library.uri.toString() == 'dart:core' && - const { - 'String', - 'int', - 'double', - 'num', - 'bool', - 'Object', - }.contains(interfaceType.element.name)) { - result = AckScalarTypeRef(interfaceType.element.name!); - } else if (_schemaFirstRuntimeRef(interfaceType.element, field) - case final runtimeRef?) { - result = AckModelTypeRef( - schemaId: AckSchemaId( - libraryUri: interfaceType.element.library.uri, - declarationName: interfaceType.element.name!, - ), - className: interfaceType.element.name!, - runtimeRef: runtimeRef, - importPrefix: _visiblePrefix(interfaceType.element, field), - ); - } else { - result = AckExternalTypeRef( - name: interfaceType.element.name!, - importPrefix: _visiblePrefix(interfaceType.element, field), - typeArguments: [ - for (final argument in interfaceType.typeArguments) - _typeArgumentRef(argument, field), - ], - ); - } - return nullable ? AckNullableTypeRef(result) : result; + }) => inferTypeRef( + type, + path: '${field.enclosingElement.name}.${field.name}', + context: field, + allowJsonMaybe: allowJsonMaybe, + onUnsupportedType: (t) => _unsupportedFieldType(field, t), + validateMapKey: (mapType) => _validateMapKey(field, mapType), + resolveModelType: (element) => _resolveModelTypeRef(element, field), + ); + + AckInferRef? _resolveModelTypeRef( + InterfaceElement element, + FieldElement field, + ) { + final runtimeRef = _customSchemas.schemaFirstRuntimeRef( + element, + field, + typeRef: (t, f) => _typeRef(t, f, allowJsonMaybe: false), + ); + if (runtimeRef == null) return null; + final path = '${field.enclosingElement.name}.${field.name}'; + return AckModelTypeRef( + schemaId: AckSchemaId( + libraryUri: element.library.uri, + declarationName: element.name!, + ), + className: element.name!, + runtimeRef: runtimeRef, + importPrefix: visiblePrefixFor(element, path: path, context: field), + ); } Map _instanceFields(ClassElement element) { @@ -2163,7 +1033,7 @@ final class ClassModelGraphBuilder { if (element.isAbstract || element.isSealed || element.isFinal) return; throw InvalidGenerationSource( '${element.name} must be declared as a final class because ' - '${_annotationName(element)} generates immutable value semantics.', + '${annotationName(element)} generates immutable value semantics.', element: element, todo: 'Add the final class modifier.', ); @@ -2208,7 +1078,11 @@ final class ClassModelGraphBuilder { ); } - final annotation = _firstAnnotation(_ackFieldChecker, field, parameter); + final annotation = _firstAnnotation( + ModelGraphBuilderBase.ackFieldChecker, + field, + parameter, + ); if (annotation != null) { final reader = ConstantReader(annotation); if (reader.read('name').isNull && @@ -2241,7 +1115,7 @@ final class ClassModelGraphBuilder { final canBeOptional = isDiscriminator || (parameter != null && - (!parameter.isRequired || _isNullable(parameter.type))); + (!parameter.isRequired || isNullableType(parameter.type))); if (!canBeOptional) { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} cannot be optional ' @@ -2263,9 +1137,9 @@ final class ClassModelGraphBuilder { final key = type.typeArguments[0]; final value = type.typeArguments[1]; return key is InterfaceType && - _isCore(key, 'String') && + isCoreType(key, 'String') && value is InterfaceType && - _isCore(value, 'Object') && + isCoreType(value, 'Object') && value.nullabilitySuffix == NullabilitySuffix.question; } @@ -2274,8 +1148,8 @@ final class ClassModelGraphBuilder { if (getter == null) return; final type = getter.returnType; if (type is InterfaceType && - _isCore(type, 'String') && - !_isNullable(type)) { + isCoreType(type, 'String') && + !isNullableType(type)) { return; } throw InvalidGenerationSource( @@ -2293,14 +1167,17 @@ final class ClassModelGraphBuilder { if (getter == null) return; final type = getter.returnType; if (type is! InterfaceType || - !_isCore(type, 'String') || - _isNullable(type)) { + !isCoreType(type, 'String') || + isNullableType(type)) { throw InvalidGenerationSource( '${branch.name}.$key discriminator member must be String.', element: getter, ); } - final expression = _memberExpression(getter); + final resolved = inputResolved; + final expression = resolved == null + ? null + : declarationExpression(resolved, propertyDeclaration(getter)); if (expression is SimpleStringLiteral && expression.value == expected) { return; } @@ -2310,88 +1187,6 @@ final class ClassModelGraphBuilder { ); } - Expression? _memberExpression(GetterElement getter) { - Element target = getter; - if (getter.isOriginVariable) target = getter.variable; - final resolved = _inputResolved; - if (resolved == null) return null; - final node = resolved.getFragmentDeclaration(target.firstFragment)?.node; - if (node is VariableDeclaration) return node.initializer; - if (node is FunctionDeclaration) { - return _functionBodyExpression(node.functionExpression.body); - } - if (node is MethodDeclaration) { - return _functionBodyExpression(node.body); - } - return null; - } - - Expression? _functionBodyExpression(FunctionBody body) { - if (body is ExpressionFunctionBody) return body.expression; - if (body is BlockFunctionBody && body.block.statements.length == 1) { - final statement = body.block.statements.single; - if (statement is ReturnStatement) return statement.expression; - } - return null; - } - - Future _oneWayTransformSource( - Expression expression, { - Set visited = const {}, - }) async { - Expression? current = expression; - while (current is ParenthesizedExpression) { - current = current.expression; - } - Element? referenced; - while (current is MethodInvocation) { - if (_oneWayTransformMethods.contains(current.methodName.name)) { - return current.methodName.name; - } - final method = current.methodName.element; - if (method is TopLevelFunctionElement) referenced = method; - current = current.target; - } - referenced ??= switch (current) { - SimpleIdentifier() => current.element, - PrefixedIdentifier() => current.identifier.element, - PropertyAccess() => current.propertyName.element, - _ => null, - }; - if (referenced == null) return null; - - Element declaration = referenced.baseElement; - if (declaration is GetterElement && declaration.isOriginVariable) { - declaration = declaration.variable.baseElement; - } - if (declaration is! TopLevelVariableElement && - declaration is! GetterElement && - declaration is! TopLevelFunctionElement) { - return null; - } - final canonical = declaration.baseElement; - if (visited.contains(canonical) || visited.length >= 16) return null; - final owningLibrary = declaration.library; - if (owningLibrary == null) return null; - final resolved = await _resolvedLibraryFor(owningLibrary); - final node = resolved - .getFragmentDeclaration(declaration.firstFragment) - ?.node; - final referencedExpression = switch (node) { - VariableDeclaration() => node.initializer, - FunctionDeclaration() => _functionBodyExpression( - node.functionExpression.body, - ), - _ => null, - }; - if (referencedExpression == null) return null; - final nested = await _oneWayTransformSource( - referencedExpression, - visited: {...visited, canonical}, - ); - return nested == null ? null : '${declaration.name} → $nested'; - } - void _requireMixin(ClassElement element) { final mixinName = ackClassMixinName(element.name!); if (_declaresMixin(element, mixinName)) return; @@ -2406,9 +1201,9 @@ final class ClassModelGraphBuilder { if (element.mixins.any((type) => type.element.name == mixinName)) { return true; } - final resolved = _inputResolved; + final resolved = inputResolved; if (resolved == null) return false; - final node = resolved.getFragmentDeclaration(element.firstFragment)?.node; + final node = declarationNode(resolved, element); if (node is! ClassDeclaration) return false; final withClause = node.withClause; if (withClause == null) return false; @@ -2455,7 +1250,7 @@ final class ClassModelGraphBuilder { if (!RegExp(r'^[A-Z][A-Za-z0-9_$]*$').hasMatch(facadeName) || isDartKeyword(facadeName)) { throw InvalidGenerationSource( - 'Invalid ${_annotationName(element)} schema facade name "$facadeName" on ' + 'Invalid ${annotationName(element)} schema facade name "$facadeName" on ' '${element.name}; schemaName must be a public UpperCamel identifier.', element: element, ); @@ -2471,7 +1266,7 @@ final class ClassModelGraphBuilder { } _schemaNameOwners[name] = element; } - _graph.setClassMetadata( + graph.setClassMetadata( id, AckClassModelMetadata( facadeName: facadeName, @@ -2482,7 +1277,7 @@ final class ClassModelGraphBuilder { // Implicit sealed-union branches also receive value members. if (parent.library == element.library && !parent.isSealed && - _classFirstFacadeName(parent) != null) + classFirstFacadeName(parent) != null) ackCopyWithInterfaceName(parent.name!), }.toList(), ), @@ -2490,7 +1285,8 @@ final class ClassModelGraphBuilder { } _ModelOptions? _options(ClassElement element) { - final annotation = _schemableChecker.firstAnnotationOfExact(element); + final annotation = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(element); if (annotation == null) return null; final reader = ConstantReader(annotation); final caseStyle = reader.read('caseStyle').objectValue; @@ -2512,15 +1308,12 @@ final class ClassModelGraphBuilder { ); } - /// The annotation spelling that marks [element] or its sealed base. - String _annotationName(Element element) => '@Schemable'; - String? _classDescription(ClassElement element, _ModelOptions options) { final tag = _descriptionTag(element); final explicit = options.description; if (explicit != null && explicit.trim().isEmpty) { throw InvalidGenerationSource( - '${element.name} ${_annotationName(element)}.description must not be ' + '${element.name} ${annotationName(element)}.description must not be ' 'blank.', element: element, ); @@ -2547,27 +1340,16 @@ final class ClassModelGraphBuilder { String _facadeName(ClassElement element, _ModelOptions options) => ackClassSchemaFacadeName(element.name!, override: options.schemaName); - String? _classFirstFacadeName(ClassElement element) { - final direct = _options(element); - if (direct != null) return _facadeName(element, direct); - final isImplicitUnionBranch = element.allSupertypes.any((supertype) { - final base = supertype.element; - return base is ClassElement && - base.library == element.library && - base.isSealed && - _schemableChecker.hasAnnotationOfExact(base); - }); - return isImplicitUnionBranch - ? ackClassSchemaFacadeName(element.name!) - : null; - } - String? _jsonKey(FieldElement field, [FormalParameterElement? parameter]) { _rejectJsonKey(field.enclosingElement as ClassElement, field); if (parameter != null) { _rejectJsonKey(field.enclosingElement as ClassElement, parameter); } - final ackField = _firstAnnotation(_ackFieldChecker, field, parameter); + final ackField = _firstAnnotation( + ModelGraphBuilderBase.ackFieldChecker, + field, + parameter, + ); if (ackField == null) return null; final nameReader = ConstantReader(ackField).read('name'); if (nameReader.isNull) return null; @@ -2586,7 +1368,7 @@ final class ClassModelGraphBuilder { if (!_jsonKeyChecker.hasAnnotationOfExact(element)) return; throw InvalidGenerationSource( '${owner.name}.${element.name} uses @JsonKey, which is not supported on ' - '${_annotationName(owner)} classes in Ack 2.0. Use @AckField(name: ...) ' + '${annotationName(owner)} classes in Ack 2.0. Use @AckField(name: ...) ' 'instead.', element: element, ); @@ -2630,63 +1412,12 @@ final class ClassModelGraphBuilder { }; } - bool _isCore(InterfaceType type, String name) => - type.element.library.uri.toString() == 'dart:core' && - type.element.name == name; - - bool _isNullable(DartType type) => - type.nullabilitySuffix == NullabilitySuffix.question; - String _visibleTypeName(InterfaceType type, FieldElement field) => - qualifySymbol(_visiblePrefix(type.element, field), type.element.name!); - - /// The import prefix that names [target] in this library, or null when an - /// unprefixed name reaches it. - /// - /// Throws when [target] is not visible, because a part cannot add imports. - String? _visiblePrefix(Element target, FieldElement field) { - if (target.library == library.element) return null; - final name = target.name; - if (name == null) return null; - String? prefixed; - var unprefixed = false; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final prefix = import.prefix?.element.name; - final candidate = prefix == null - ? import.namespace.get2(name) - : import.namespace.getPrefixed2(prefix, name); - if (candidate?.baseElement != target.baseElement) continue; - if (prefix != null && prefix.isNotEmpty) { - prefixed ??= prefix; - } else { - unprefixed = true; - } - } - if (prefixed != null || unprefixed) return prefixed; - final inScope = library.element.firstFragment.scope.lookup(name).getter; - if (inScope?.baseElement == target.baseElement) return null; - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} uses $name, which is not ' - 'visible in this library. Import ${target.library?.uri}.', - element: field, - ); - } - - Future _resolvedLibraryFor( - LibraryElement element, - ) async { - final cached = _resolvedByUri[element.uri]; - if (cached != null) return cached; - final result = await element.session.getResolvedLibraryByElement(element); - if (result is! ResolvedLibraryResult) { - throw InvalidGenerationSource( - 'Could not resolve ${element.uri} for Ack model generation.', + visibleTypeNameFor( + type, + path: '${field.enclosingElement.name}.${field.name}', + context: field, ); - } - _resolvedByUri[element.uri] = result; - return result; - } void _rejectInvalidMemberName( String name, @@ -2703,14 +1434,8 @@ final class ClassModelGraphBuilder { } } - String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); - AckSchemaId _id(ClassElement element) => AckSchemaId( libraryUri: library.element.uri, declarationName: element.name!, ); - - static const AckInferRef _jsonMapRef = AckMapTypeRef( - AckNullableTypeRef(AckScalarTypeRef('Object')), - ); } diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart new file mode 100644 index 00000000..48f2a158 --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -0,0 +1,1032 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; + +import '../models/schema_model_graph.dart'; +import '../utils/string_literal.dart'; +import 'generated_companion_visibility.dart'; +import 'model_graph_builder_base.dart'; + +typedef RegisteredSchema = ({ + Element declaration, + DartType boundary, + DartType runtime, + bool isFunction, +}); + +typedef FutureGeneratedType = ({ + String schemaExpression, + AckInferRef runtimeRef, + String? setListSchema, +}); + +/// Resolves custom `@Schemable(schemas: ...)`, `@AckField(schema: ...)`, +/// auto-detected top-level schemas, type-owned static `.schema` declarations, +/// and future-generated schema-first types for [ClassModelGraphBuilder]. +final class CustomSchemaResolver { + CustomSchemaResolver(this._base); + + final ModelGraphBuilderBase _base; + + final List _librarySchemas = []; + final List _importedLibrarySchemas = []; + final Map> _classSchemas = {}; + final Map unionBaseByBranch = {}; + final List _libraryTopLevelSchemaCandidates = []; + + LibraryReader get _library => _base.library; + String? get _ackPrefix => _base.ackPrefix; + + Future initRegisteredAndLibrarySchemas() async { + final libraryAnnotation = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(_library.element); + if (libraryAnnotation != null) { + _librarySchemas.addAll( + await _parseSchemasList( + _library.element, + ConstantReader(libraryAnnotation), + ), + ); + } + + final seenImportedLibraries = {}; + for (final import in _library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final importedLibrary = import.importedLibrary; + if (importedLibrary == null || + importedLibrary == _library.element || + !seenImportedLibraries.add(importedLibrary)) { + continue; + } + final importedAnnotation = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(importedLibrary); + if (importedAnnotation == null) continue; + _importedLibrarySchemas.addAll( + await _parseSchemasList( + importedLibrary, + ConstantReader(importedAnnotation), + ), + ); + } + + for (final classElement in _library.classes) { + final annotation = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(classElement); + if (annotation == null) continue; + final parsed = await _parseSchemasList( + classElement, + ConstantReader(annotation), + ); + if (parsed.isNotEmpty) { + _classSchemas[classElement] = parsed; + } + } + + for (final variable in _library.element.topLevelVariables) { + if (!variable.isOriginDeclaration || + ModelGraphBuilderBase.schemableChecker.hasAnnotationOfExact( + variable, + )) { + continue; + } + _addLibrarySchemaCandidate( + declaration: variable, + schemaType: variable.type, + isFunction: false, + ); + } + for (final getter in _library.element.getters) { + if (!getter.isOriginDeclaration || + ModelGraphBuilderBase.schemableChecker.hasAnnotationOfExact(getter)) { + continue; + } + _addLibrarySchemaCandidate( + declaration: getter, + schemaType: getter.returnType, + isFunction: false, + ); + } + for (final function in _library.element.topLevelFunctions) { + if (!function.isOriginDeclaration || + function.typeParameters.isNotEmpty || + function.formalParameters.isNotEmpty) { + continue; + } + _addLibrarySchemaCandidate( + declaration: function, + schemaType: function.returnType, + isFunction: true, + ); + } + } + + void _addLibrarySchemaCandidate({ + required Element declaration, + required DartType schemaType, + required bool isFunction, + }) { + if (_base.containsInvalidType(schemaType) || + !ModelGraphBuilderBase.ackSchemaChecker.isAssignableFromType( + schemaType, + )) { + return; + } + final boundary = _base.ackSchemaBoundaryType(schemaType); + final runtime = _base.ackSchemaRuntimeType(schemaType); + if (boundary == null || + runtime == null || + _base.containsInvalidType(boundary) || + _base.containsInvalidType(runtime) || + runtime is DynamicType || + (runtime is InterfaceType && _base.isCoreType(runtime, 'Object'))) { + return; + } + _libraryTopLevelSchemaCandidates.add(( + declaration: declaration, + boundary: boundary, + runtime: _library.element.typeSystem.promoteToNonNull(runtime), + isFunction: isFunction, + )); + } + + Future> _parseSchemasList( + Element owner, + ConstantReader reader, + ) async { + final schemasReader = reader.read('schemas'); + if (schemasReader.isNull) return const []; + final ownerLabel = owner is LibraryElement + ? 'library' + : (owner.name ?? '@Schemable'); + final list = schemasReader.listValue; + if (list.isEmpty) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) must not be empty.', + element: owner, + ); + } + final result = []; + for (final item in list) { + final function = item.toFunctionValue(); + if (function is! TopLevelFunctionElement) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) entries must be const ' + 'tear-offs of top-level functions.', + element: owner, + ); + } + if (function.typeParameters.isNotEmpty) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} is ' + 'generic. Use a non-generic top-level function, or declare a static ' + 'schema method on the type.', + element: owner, + ); + } + if (function.formalParameters.isNotEmpty || + !ModelGraphBuilderBase.ackSchemaChecker.isAssignableFromType( + function.returnType, + )) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' + 'have type AckSchema Function().', + element: owner, + ); + } + final boundary = _base.ackSchemaBoundaryType(function.returnType); + final runtime = _base.ackSchemaRuntimeType(function.returnType); + if (boundary == null || + runtime == null || + runtime is DynamicType || + (runtime is InterfaceType && _base.isCoreType(runtime, 'Object'))) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' + 'return a typed AckSchema for a specific runtime ' + 'type.', + element: owner, + ); + } + if (runtime is InterfaceType && isNonOverridableCoreType(runtime)) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) cannot override core type ' + '${runtime.getDisplayString()}; use @AckField(schema: ' + '${function.name}) on the specific field instead.', + element: owner, + ); + } + final resolved = await _base.resolvedLibraryFor(function.library); + final bodyExpression = _base.declarationExpression(resolved, function); + if (bodyExpression == null) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' + 'have a statically resolvable expression or single return.', + element: owner, + ); + } + final oneWaySource = await _base.oneWayTransformSource(bodyExpression); + if (oneWaySource != null) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) function ${function.name} ' + 'reaches one-way schema $oneWaySource. Migrate this .transform() ' + 'path to .codec() with an encoder.', + element: owner, + ); + } + final nonNullRuntime = _library.element.typeSystem.promoteToNonNull( + runtime, + ); + for (final prior in result) { + if (_base.sameTypeIgnoringNullability(prior.runtime, nonNullRuntime)) { + throw InvalidGenerationSource( + '$ownerLabel @Schemable(schemas: ...) registers multiple schemas ' + 'for ${nonNullRuntime.getDisplayString()}: ' + '${prior.declaration.name} and ${function.name}.', + element: owner, + ); + } + } + result.add(( + declaration: function, + boundary: boundary, + runtime: nonNullRuntime, + isFunction: true, + )); + } + return result; + } + + RegisteredSchema? registeredSchemaForType( + InterfaceType interfaceType, + FieldElement field, { + ClassElement? activeClass, + }) { + final fieldOwner = field.enclosingElement; + for (final scope in [ + if (activeClass != null) ...[ + if (_classSchemas[activeClass] case final classList?) classList, + if (unionBaseByBranch[activeClass] case final base?) + if (_classSchemas[base] case final baseList?) baseList, + ], + if (fieldOwner is ClassElement && fieldOwner != activeClass) + if (_classSchemas[fieldOwner] case final ownerList?) ownerList, + _librarySchemas, + ]) { + for (final entry in scope) { + if (_base.sameTypeIgnoringNullability(entry.runtime, interfaceType)) { + return entry; + } + } + } + final importedMatches = {}; + for (final entry in _importedLibrarySchemas) { + if (_base.sameTypeIgnoringNullability(entry.runtime, interfaceType)) { + importedMatches[entry.declaration.baseElement] = entry; + } + } + if (importedMatches.length > 1) { + final path = _fieldPath(field); + final display = _library.element.typeSystem + .promoteToNonNull(interfaceType) + .getDisplayString(); + final names = importedMatches.values + .map((m) => m.declaration.name) + .join(', '); + throw InvalidGenerationSource( + '$path has multiple imported library schemas for $display ($names). ' + 'Disambiguate with @Schemable(schemas: [...]) or ' + '@AckField(schema: ...).', + element: field, + ); + } + if (importedMatches.length == 1) return importedMatches.values.single; + return null; + } + + RegisteredSchema? uniqueLibrarySchemaCandidate(InterfaceType interfaceType) { + if (isNonOverridableCoreType(interfaceType) || + isOverridableBuiltInScalar(interfaceType) || + interfaceType.element is EnumElement) { + return null; + } + final matches = [ + for (final candidate in _libraryTopLevelSchemaCandidates) + if (_base.sameTypeIgnoringNullability(candidate.runtime, interfaceType)) + candidate, + ]; + return matches.length == 1 ? matches.single : null; + } + + Future autoDetectedLibrarySchema( + InterfaceType interfaceType, + FieldElement field, + ) async { + if (isNonOverridableCoreType(interfaceType) || + isOverridableBuiltInScalar(interfaceType) || + interfaceType.element is EnumElement) { + return null; + } + final matches = [ + for (final candidate in _libraryTopLevelSchemaCandidates) + if (_base.sameTypeIgnoringNullability(candidate.runtime, interfaceType)) + candidate, + ]; + if (matches.isEmpty) return null; + final path = _fieldPath(field); + final display = _library.element.typeSystem + .promoteToNonNull(interfaceType) + .getDisplayString(); + if (matches.length > 1) { + final names = matches.map((m) => m.declaration.name).join(', '); + throw InvalidGenerationSource( + '$path has multiple top-level schemas for $display in this library ' + '($names). Disambiguate with @Schemable(schemas: [...]) or ' + '@AckField(schema: ...).', + element: field, + ); + } + final candidate = matches.single; + final resolved = await _base.resolvedLibraryFor( + candidate.declaration.library!, + ); + final bodyExpression = _base.declarationExpression( + resolved, + candidate.declaration, + ); + if (bodyExpression == null) { + throw InvalidGenerationSource( + '$path resolves to top-level schema ${candidate.declaration.name}, ' + 'which must have a statically resolvable expression or single return.', + element: field, + ); + } + final oneWaySource = await _base.oneWayTransformSource(bodyExpression); + if (oneWaySource != null) { + throw InvalidGenerationSource( + '$path resolves to top-level schema ${candidate.declaration.name}, ' + 'which reaches one-way schema $oneWaySource. Migrate this ' + '.transform() path to .codec() with an encoder.', + element: field, + ); + } + return renderRegisteredSchema(candidate, field); + } + + String renderRegisteredSchema(RegisteredSchema schema, FieldElement field) { + final prefix = _base.visiblePrefixFor( + schema.declaration, + path: _fieldPath(field), + context: field, + ); + final qualified = + '${prefix == null ? '' : '$prefix.'}${schema.declaration.name}'; + return schema.isFunction ? '$qualified()' : qualified; + } + + bool isOverridableBuiltInScalar(InterfaceType type) => + _base.isCoreType(type, 'DateTime') || + _base.isCoreType(type, 'Uri') || + _base.isCoreType(type, 'Duration'); + + bool isNonOverridableCoreType(InterfaceType type) => + _base.isCoreType(type, 'String') || + _base.isCoreType(type, 'int') || + _base.isCoreType(type, 'double') || + _base.isCoreType(type, 'num') || + _base.isCoreType(type, 'bool') || + _base.isCoreType(type, 'Object') || + type.isDartCoreList || + type.isDartCoreSet || + type.isDartCoreMap || + _base.isJsonMaybeType(type); + + Future escapeHatchExpression( + FieldElement field, + ConstantReader annotation, + ) async { + final function = annotation.read('schema').objectValue.toFunctionValue(); + final path = _fieldPath(field); + if (function is! TopLevelFunctionElement) { + throw InvalidGenerationSource( + '$path @AckField schema must be a const tear-off of a top-level ' + 'function.', + element: field, + ); + } + if (function.typeParameters.isNotEmpty) { + throw InvalidGenerationSource( + '$path @AckField schema function ${function.name} is generic, and the ' + 'generated call cannot keep its type arguments. Use a non-generic ' + 'function, or declare a static schema method on the field type.', + element: field, + ); + } + if (function.formalParameters.isNotEmpty || + !ModelGraphBuilderBase.ackSchemaChecker.isAssignableFromType( + function.returnType, + )) { + throw InvalidGenerationSource( + '$path @AckField top-level function must have type AckSchema ' + 'Function().', + element: field, + ); + } + final runtime = _base.ackSchemaRuntimeType(function.returnType); + final expectedFieldType = _base.unwrapJsonMaybeType(field.type); + if (runtime == null || + !_base.sameTypeIgnoringNullability(runtime, expectedFieldType)) { + throw InvalidGenerationSource( + '$path @AckField schema function ${function.name} produces ' + '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' + 'type is ${expectedFieldType.getDisplayString()}.', + element: field, + todo: 'Return AckSchema<..., ${expectedFieldType.getDisplayString()}>.', + ); + } + final resolved = await _base.resolvedLibraryFor(function.library); + final bodyExpression = _base.declarationExpression(resolved, function); + if (bodyExpression == null) { + throw InvalidGenerationSource( + '$path @AckField function must have a statically resolvable ' + 'expression or single return.', + element: field, + ); + } + final oneWaySource = await _base.oneWayTransformSource(bodyExpression); + if (oneWaySource != null) { + throw InvalidGenerationSource( + '$path @AckField schema function ${function.name} reaches one-way ' + 'schema $oneWaySource. Migrate this .transform() path to .codec() ' + 'with an encoder.', + element: field, + ); + } + final prefix = _base.visiblePrefixFor(function, path: path, context: field); + return '${prefix == null ? '' : '$prefix.'}${function.name}()'; + } + + DartType? inferredBoundaryType( + DartType type, + FieldElement field, { + ClassElement? activeClass, + }) { + if (type is! InterfaceType) return null; + final registered = registeredSchemaForType( + type, + field, + activeClass: activeClass, + ); + if (registered != null) return registered.boundary; + final provider = _library.element.typeProvider; + if (_base.isCoreType(type, 'int') || + _base.isCoreType(type, 'double') || + _base.isCoreType(type, 'num')) { + return provider.numType; + } + if (_base.isCoreType(type, 'String') || + _base.isCoreType(type, 'DateTime') || + _base.isCoreType(type, 'Uri')) { + return provider.stringType; + } + if (_base.isCoreType(type, 'bool')) return provider.boolType; + if (_base.isCoreType(type, 'Duration')) return provider.intType; + if (_base.isCoreType(type, 'Object')) return provider.objectType; + if (type.isDartCoreList || type.isDartCoreSet) { + return provider.listType(provider.objectQuestionType); + } + if (type.isDartCoreMap) { + return provider.mapType(provider.stringType, provider.objectQuestionType); + } + final target = type.element; + if (target is ClassElement && _base.classFirstFacadeName(target) != null) { + return provider.mapType(provider.stringType, provider.objectQuestionType); + } + final ackGetter = target.getGetter(r'$ack'); + final ackReturnType = ackGetter?.returnType; + if (ackGetter != null && + ackGetter.isStatic && + ackReturnType is InterfaceType && + ModelGraphBuilderBase.ackModelAdapterChecker.isExactlyType( + ackReturnType, + ) && + ackReturnType.typeArguments.length == 3) { + return ackReturnType.typeArguments[0]; + } + final schemaGetter = target.getGetter('schema'); + if (schemaGetter != null && schemaGetter.isStatic) { + return _base.ackSchemaBoundaryType(schemaGetter.returnType); + } + final schemaMethod = target.getMethod('schema'); + if (schemaMethod != null && + schemaMethod.isStatic && + schemaMethod.typeParameters.length == type.typeArguments.length) { + return _base.ackSchemaBoundaryType( + schemaMethod.type.instantiate(type.typeArguments).returnType, + ); + } + if (target is EnumElement) return provider.stringType; + final autoDetected = uniqueLibrarySchemaCandidate(type); + if (autoDetected != null) return autoDetected.boundary; + return null; + } + + AckInferRef? schemaFirstRuntimeRef( + InterfaceElement target, + FieldElement field, { + required AckInferRef Function(DartType type, FieldElement field) typeRef, + }) { + final getter = target.getGetter(r'$ack'); + final returnType = getter?.returnType; + if (getter == null || + !getter.isStatic || + returnType is! InterfaceType || + !ModelGraphBuilderBase.ackModelAdapterChecker.isExactlyType( + returnType, + )) { + return null; + } + final runtimeType = returnType.typeArguments.length == 3 + ? returnType.typeArguments[1] + : null; + return runtimeType == null || _base.containsInvalidType(runtimeType) + ? ModelGraphBuilderBase.jsonMapRef + : typeRef(runtimeType, field); + } + + Future namedSchemaForType( + InterfaceType interfaceType, + FieldElement field, { + ClassElement? activeClass, + required Future Function(DartType type, FieldElement field) + schemaForType, + required AckInferRef Function(DartType type, FieldElement field) typeRef, + required AckInferRef Function(DartType type, FieldElement field) + typeArgumentRef, + }) async { + final registered = registeredSchemaForType( + interfaceType, + field, + activeClass: activeClass, + ); + if (registered != null) { + return renderRegisteredSchema(registered, field); + } + if (isOverridableBuiltInScalar(interfaceType)) { + return null; + } + final target = interfaceType.element; + final path = _fieldPath(field); + if (target is ClassElement) { + final facadeName = _base.classFirstFacadeName(target); + if (facadeName != null) { + final prefix = _base.visiblePrefixFor( + target, + path: path, + context: field, + ); + if (target.library != _library.element && + !_isClassFirstFacadeVisible(target, facadeName, prefix: prefix)) { + final typeOwned = await typeOwnedSchema( + interfaceType, + field, + activeClass: activeClass, + schemaForType: schemaForType, + typeArgumentRef: typeArgumentRef, + ); + if (typeOwned != null) return typeOwned; + } + _validateClassFirstFacadeImport( + target, + facadeName, + prefix: prefix, + field: field, + ); + return '${prefix == null ? '' : '$prefix.'}$facadeName.schema'; + } + } + if (schemaFirstRuntimeRef(target, field, typeRef: typeRef) != null) { + final visibleName = _base.visibleTypeNameFor( + interfaceType, + path: path, + context: field, + ); + return '$visibleName.\$ack.schema'; + } + final typeOwned = await typeOwnedSchema( + interfaceType, + field, + activeClass: activeClass, + schemaForType: schemaForType, + typeArgumentRef: typeArgumentRef, + ); + if (typeOwned != null) return typeOwned; + if (target is EnumElement) return null; + return await autoDetectedLibrarySchema(interfaceType, field); + } + + /// Resolves [type] through a static `schema` that the type declares. + Future typeOwnedSchema( + InterfaceType type, + FieldElement field, { + ClassElement? activeClass, + required Future Function(DartType type, FieldElement field) + schemaForType, + required AckInferRef Function(DartType type, FieldElement field) + typeArgumentRef, + }) async { + final target = type.element; + final getter = target.getGetter('schema'); + final method = target.getMethod('schema'); + final path = _fieldPath(field); + final fieldType = _library.element.typeSystem.promoteToNonNull(type); + final display = fieldType.getDisplayString(); + final String expression; + final DartType returnType; + if (getter != null && getter.isStatic) { + expression = + '${_base.visibleTypeNameFor(type, path: path, context: field)}.schema'; + returnType = getter.returnType; + } else if (method != null && method.isStatic) { + if (method.typeParameters.length != type.typeArguments.length) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, which must declare one ' + 'type parameter per type argument of $display.', + element: field, + ); + } + for (var i = 0; i < method.typeParameters.length; i++) { + final typeParam = method.typeParameters[i]; + final rawBound = typeParam.bound; + if (rawBound == null) continue; + final bound = + rawBound is TypeParameterType && + method.typeParameters.contains(rawBound.element) + ? type.typeArguments[method.typeParameters.indexOf( + rawBound.element, + )] + : rawBound; + final argument = type.typeArguments[i]; + if (!_library.element.typeSystem.isSubtypeOf(argument, bound)) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, whose type parameter ' + '${typeParam.name} requires a subtype of ' + '${bound.getDisplayString()}; received ' + '${argument.getDisplayString()}.', + element: field, + ); + } + } + final parameters = method.formalParameters; + if (parameters.isNotEmpty && + (parameters.length != type.typeArguments.length || + parameters.any((parameter) => !parameter.isRequiredPositional))) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, which must take no ' + 'parameters or one positional AckSchema parameter per type ' + 'parameter; it takes ${parameters.length} for ' + '${type.typeArguments.length} type parameters.', + element: field, + ); + } + final instantiated = method.type.instantiate(type.typeArguments); + final schemas = []; + for (var i = 0; i < parameters.length; i++) { + final argument = type.typeArguments[i]; + final parameterType = instantiated.formalParameters[i].type; + final expected = _base.ackSchemaRuntimeType(parameterType); + if (expected == null || + !_base.sameTypeIgnoringNullability(expected, argument)) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, whose parameter ' + '${parameters[i].name} must be an AckSchema of ' + '${argument.getDisplayString()}; it is ' + '${parameterType.getDisplayString()}.', + element: field, + ); + } + final expectedBoundary = _base.ackSchemaBoundaryType(parameterType); + final inferredBoundary = inferredBoundaryType( + argument, + field, + activeClass: activeClass, + ); + if (expectedBoundary != null && + inferredBoundary != null && + !_library.element.typeSystem.isSubtypeOf( + inferredBoundary, + expectedBoundary, + )) { + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, whose parameter ' + '${parameters[i].name} expects boundary type ' + '${expectedBoundary.getDisplayString()}, but the schema inferred ' + 'for ${argument.getDisplayString()} has boundary type ' + '${inferredBoundary.getDisplayString()}.', + element: field, + ); + } + schemas.add(await schemaForType(argument, field)); + } + final arguments = [ + for (final argument in type.typeArguments) + typeArgumentRef(argument, field).render(ackPrefix: _ackPrefix), + ]; + final name = _base.visibleTypeNameFor(type, path: path, context: field); + expression = arguments.isEmpty + ? '$name.schema(${schemas.join(', ')})' + : '$name.schema<${arguments.join(', ')}>(${schemas.join(', ')})'; + returnType = instantiated.returnType; + } else { + return null; + } + if (_base.containsInvalidType(returnType)) { + if (target is ClassElement && + _base.classFirstFacadeName(target) != null) { + return expression; + } + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, whose type does not resolve ' + 'yet. Declare the type of ${target.name}.schema explicitly.', + element: field, + ); + } + final runtime = _base.ackSchemaRuntimeType(returnType); + if (runtime == null || + !_base.sameTypeIgnoringNullability(runtime, fieldType)) { + final generic = type.typeArguments.isEmpty + ? '' + : ' A generic type declares static AckSchema<..., ' + '${target.name}<${target.typeParameters.map((p) => p.name).join(', ')}>> ' + 'schema<${target.typeParameters.map((p) => p.name).join(', ')}>() ' + 'instead.'; + throw InvalidGenerationSource( + '$path resolves to ${target.name}.schema, which produces ' + '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' + 'type is $display.$generic', + element: field, + ); + } + return expression; + } + + Future futureGeneratedType( + FieldElement field, { + required String Function(String listSchema, AckInferRef runtimeRef) + setCodec, + }) async { + final resolved = await _base.resolvedLibraryFor(field.library); + AstNode? node = _base.declarationNode(resolved, field); + while (node != null && + node is! FieldDeclaration && + node is! NormalFormalParameter) { + node = node.parent; + } + final annotation = switch (node) { + FieldDeclaration() => node.fields.type, + SimpleFormalParameter() => node.type, + FieldFormalParameter() => node.type, + SuperFormalParameter() => node.type, + _ => null, + }; + final futureType = annotation == null + ? null + : _futureGeneratedTypeForAnnotation( + annotation, + field, + setCodec: setCodec, + ); + if (futureType != null) return futureType; + final path = _fieldPath(field); + final arguments = annotation is NamedType + ? annotation.typeArguments?.arguments ?? const [] + : const []; + if (annotation is NamedType && + annotation.importPrefix == null && + annotation.name.lexeme == 'Map' && + arguments.length == 2 && + _futureGeneratedTypeForAnnotation( + arguments.last, + field, + setCodec: setCodec, + ) != + null) { + throw InvalidGenerationSource( + '$path uses ${annotation.toSource()}. A model generated from a ' + '@Schemable schema in this build can be a field type directly or a ' + 'List or Set item, but not a Map value.', + element: field, + ); + } + throw InvalidGenerationSource( + '$path uses ${annotation?.toSource() ?? 'a type'}, which does not ' + 'resolve. Check that it is imported.', + element: field, + ); + } + + FutureGeneratedType? _futureGeneratedTypeForAnnotation( + TypeAnnotation annotation, + FieldElement field, { + required String Function(String listSchema, AckInferRef runtimeRef) + setCodec, + bool allowJsonMaybe = true, + }) { + if (annotation is! NamedType) return null; + final name = annotation.name.lexeme; + final prefix = annotation.importPrefix?.name.lexeme; + final arguments = annotation.typeArguments?.arguments ?? const []; + FutureGeneratedType? result; + if (allowJsonMaybe && + name == 'JsonMaybe' && + (prefix == null || prefix == _ackPrefix) && + arguments.length == 1) { + final item = _futureGeneratedTypeForAnnotation( + arguments.single, + field, + setCodec: setCodec, + allowJsonMaybe: false, + ); + if (item == null) return null; + result = ( + schemaExpression: item.schemaExpression, + runtimeRef: AckJsonMaybeTypeRef(item.runtimeRef), + setListSchema: item.setListSchema, + ); + } else if (prefix == null && name == 'List' && arguments.length == 1) { + final item = _futureGeneratedTypeForAnnotation( + arguments.single, + field, + setCodec: setCodec, + allowJsonMaybe: false, + ); + if (item == null) return null; + result = ( + schemaExpression: '${_base.ack('Ack')}.list(${item.schemaExpression})', + runtimeRef: AckListTypeRef(item.runtimeRef), + setListSchema: null, + ); + } else if (prefix == null && name == 'Set' && arguments.length == 1) { + final item = _futureGeneratedTypeForAnnotation( + arguments.single, + field, + setCodec: setCodec, + allowJsonMaybe: false, + ); + if (item == null) return null; + final runtimeRef = AckSetTypeRef(item.runtimeRef); + final listSchema = '${_base.ack('Ack')}.list(${item.schemaExpression})'; + result = ( + schemaExpression: setCodec(listSchema, runtimeRef), + runtimeRef: runtimeRef, + setListSchema: listSchema, + ); + } else { + final target = _futureGeneratedTarget( + className: name, + prefix: prefix, + field: field, + ); + if (target == null) return null; + final visibleName = qualifySymbol(prefix, name); + result = ( + schemaExpression: '$visibleName.\$ack.schema', + runtimeRef: AckModelTypeRef( + schemaId: AckSchemaId( + libraryUri: target.library!.uri, + declarationName: target.name!, + ), + className: name, + runtimeRef: ModelGraphBuilderBase.jsonMapRef, + importPrefix: prefix, + ), + setListSchema: null, + ); + } + if (annotation.question == null) return result; + return ( + schemaExpression: result.schemaExpression, + runtimeRef: AckNullableTypeRef(result.runtimeRef), + setListSchema: result.setListSchema, + ); + } + + Element? _futureGeneratedTarget({ + required String className, + required String? prefix, + required FieldElement field, + }) { + final matches = {}; + final candidates = {}; + final imports = []; + String? hiddenDeclaration; + + Element? candidateFor(Element element) { + final declaration = _base.schemableDeclaration(element); + if (declaration == null || + _base.generatedSchemaClassName(declaration) != className) { + return null; + } + return declaration.baseElement; + } + + if (prefix == null) { + for (final element in _library.allElements) { + final candidate = candidateFor(element); + if (candidate != null) matches.add(candidate); + } + } + for (final import in _library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final importPrefix = import.prefix?.element.name; + if (importPrefix != prefix) continue; + imports.add(import); + final importedLibrary = import.importedLibrary; + final elements = { + if (importedLibrary != null) + ...LibraryReader(importedLibrary).allElements, + ...import.namespace.definedNames2.values, + }; + for (final element in elements) { + final candidate = candidateFor(element); + if (candidate != null) candidates.add(candidate); + } + } + for (final candidate in candidates) { + final definingLibrary = candidate.library; + final generatedClassVisible = + definingLibrary != null && + imports.any( + (import) => importExposesGeneratedCompanion( + import, + definingLibrary: definingLibrary, + generatedName: className, + ), + ); + if (generatedClassVisible) { + matches.add(candidate); + } else { + hiddenDeclaration = candidate.name; + } + } + if (matches.isEmpty) { + if (hiddenDeclaration != null) { + final fieldPath = _fieldPath(field); + throw InvalidGenerationSource( + '$fieldPath resolves to generated schema-first type "$className", ' + 'which is hidden by an import combinator or an upstream export ' + 'combinator. Expose $className through the full import route.', + element: field, + ); + } + return null; + } + if (matches.length > 1) { + throw InvalidGenerationSource( + '${_fieldPath(field)} resolves future generated type "$className" ' + 'ambiguously.', + element: field, + ); + } + return matches.single; + } + + bool _isClassFirstFacadeVisible( + ClassElement target, + String facadeName, { + required String? prefix, + }) { + final visibility = _base.checkCompanionVisibility( + target, + facadeName, + prefix: prefix, + ); + return visibility.declarationVisible && visibility.companionVisible; + } + + void _validateClassFirstFacadeImport( + ClassElement target, + String facadeName, { + required String? prefix, + required FieldElement field, + }) { + final visibility = _base.checkCompanionVisibility( + target, + facadeName, + prefix: prefix, + ); + if (visibility.declarationVisible && visibility.companionVisible) return; + if (visibility.declarationVisible) { + final modelName = target.name!; + throw InvalidGenerationSource( + 'Generated class-first facade "$facadeName" is hidden by an import ' + 'combinator or an upstream export combinator for $modelName. Expose ' + 'both through the full import route (for example, show $modelName, ' + '$facadeName).', + element: field, + ); + } + } + + String _fieldPath(FieldElement field) => + '${field.enclosingElement.name}.${field.name}'; +} diff --git a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart new file mode 100644 index 00000000..6de85ef1 --- /dev/null +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -0,0 +1,893 @@ +import 'package:ack/ack.dart' show AckModelAdapter, AckSchema, JsonMaybe; +import 'package:ack/annotations.dart' as annotations; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; + +import '../json/helper_names.dart'; +import '../models/schema_model_graph.dart'; +import '../utils/string_literal.dart'; +import 'generated_companion_visibility.dart'; + +/// Parsed method-invocation chain for an `Ack.*` or referenced schema AST. +final class SchemaChain { + const SchemaChain({ + required this.base, + required this.reference, + required this.optional, + required this.nullable, + required this.defaulted, + required this.hasTransform, + required this.hasCodec, + }); + + final MethodInvocation? base; + final Expression? reference; + final bool optional; + final bool nullable; + final bool defaulted; + final bool hasTransform; + final bool hasCodec; +} + +/// Shared analyzer infrastructure for [ClassModelGraphBuilder] and +/// [SchemaModelGraphBuilder]. +/// +/// Centralizes resolved-library AST caching, import prefix and companion +/// visibility checks, `AckSchema` type inspection, +/// structural [AckInferRef] construction, and `Ack.*` AST chain inspection. +abstract base class ModelGraphBuilderBase { + ModelGraphBuilderBase(this.library, {this.ackPrefix}); + + static const oneWayTransformMethods = {'transform'}; + + static const maxReferenceDepth = 16; + + static const AckInferRef jsonMapRef = AckMapTypeRef( + AckNullableTypeRef(AckScalarTypeRef('Object')), + ); + + static const coreScalarNames = { + 'String', + 'int', + 'double', + 'num', + 'bool', + 'Object', + }; + + static const schemableChecker = TypeChecker.typeNamed( + annotations.Schemable, + inPackage: 'ack', + ); + static const ackFieldChecker = TypeChecker.typeNamed( + annotations.AckField, + inPackage: 'ack', + ); + static const ackSchemaChecker = TypeChecker.typeNamed( + AckSchema, + inPackage: 'ack', + ); + static const ackModelAdapterChecker = TypeChecker.typeNamed( + AckModelAdapter, + inPackage: 'ack', + ); + static const jsonMaybeChecker = TypeChecker.typeNamed( + JsonMaybe, + inPackage: 'ack', + ); + + final LibraryReader library; + final String? ackPrefix; + final AckModelGraph graph = AckModelGraph(); + ResolvedLibraryResult? inputResolved; + final Map _resolvedByUri = {}; + + /// Resolves and caches the input [library] before graph analysis begins. + Future initInputLibrary() async { + final libraryElement = library.element; + final resolved = await libraryElement.session.getResolvedLibraryByElement( + libraryElement, + ); + if (resolved is! ResolvedLibraryResult) { + throw InvalidGenerationSource( + 'Could not resolve ${libraryElement.uri} for Ack model generation.', + ); + } + inputResolved = resolved; + _resolvedByUri[libraryElement.uri] = resolved; + return resolved; + } + + /// Resolves and caches [element]'s library for cross-library AST inspection. + Future resolvedLibraryFor( + LibraryElement element, + ) async { + if (identical(element, library.element) && inputResolved != null) { + return inputResolved!; + } + final cached = _resolvedByUri[element.uri]; + if (cached != null) return cached; + final result = await element.session.getResolvedLibraryByElement(element); + if (result is! ResolvedLibraryResult) { + throw InvalidGenerationSource( + 'Could not resolve ${element.uri} for Ack model generation.', + ); + } + _resolvedByUri[element.uri] = result; + return result; + } + + /// Looks up the AST declaration node for [element] in [result]. + AstNode? declarationNode(ResolvedLibraryResult result, Element element) => + result.getFragmentDeclaration(element.firstFragment)?.node; + + /// Extracts the initializer or single-expression body of [element]. + Expression? declarationExpression( + ResolvedLibraryResult result, + Element element, + ) { + final node = declarationNode(result, element); + if (node is VariableDeclaration) return node.initializer; + if (node is FunctionDeclaration) { + return functionBodyExpression(node.functionExpression.body); + } + if (node is MethodDeclaration) { + return functionBodyExpression(node.body); + } + return null; + } + + /// Extracts the returned expression from an arrow or single-return [body]. + Expression? functionBodyExpression(FunctionBody body) { + if (body is ExpressionFunctionBody) return body.expression; + if (body is BlockFunctionBody && body.block.statements.length == 1) { + final statement = body.block.statements.single; + if (statement is ReturnStatement) return statement.expression; + } + return null; + } + + /// Normalizes synthetic variable getters back to their backing variable. + Element propertyDeclaration(Element element) { + if (element is GetterElement && element.isOriginVariable) { + return element.variable.baseElement; + } + return element.baseElement; + } + + /// Returns the `@Schemable` top-level declaration behind [element], if any. + Element? schemableDeclaration(Element element) { + final declaration = switch (element) { + GetterElement(isOriginVariable: true) => element.variable.baseElement, + TopLevelVariableElement() => element.baseElement, + _ => null, + }; + return declaration != null && + schemableChecker.hasAnnotationOfExact(declaration) + ? declaration + : null; + } + + bool hasSchemable(Element element) => + schemableChecker.hasAnnotationOfExact(propertyDeclaration(element)); + + /// The annotation spelling that marks [element] or its sealed base. + String annotationName(Element element) => '@Schemable'; + + /// Reads the custom `name` override from `@Schemable(name: ...)` on [element]. + String? schemableCustomName(Element element) { + final declaration = propertyDeclaration(element); + final annotation = schemableChecker.firstAnnotationOfExact(declaration); + final field = annotation == null + ? null + : ConstantReader(annotation).peek('name'); + return field == null || field.isNull ? null : field.stringValue; + } + + /// Derives the model class name generated for a schema-first [declaration]. + String generatedSchemaClassName(Element declaration) => + ackInferModelClassName( + declaration.name!, + override: schemableCustomName(declaration), + ); + + /// Returns the generated facade name for a class-first `@Schemable` class or + /// implicit sealed-union branch, or `null` if [element] is not class-first. + String? classFirstFacadeName(ClassElement element) { + final annotation = schemableChecker.firstAnnotationOfExact(element); + if (annotation != null) { + final value = ConstantReader(annotation).read('schemaName'); + return ackClassSchemaFacadeName( + element.name!, + override: value.isNull ? null : value.stringValue, + ); + } + final isImplicitUnionBranch = element.allSupertypes.any((supertype) { + final base = supertype.element; + return base is ClassElement && + base.library == element.library && + base.isSealed && + schemableChecker.hasAnnotationOfExact(base); + }); + return isImplicitUnionBranch + ? ackClassSchemaFacadeName(element.name!) + : null; + } + + /// The boundary and runtime type arguments of [type] as an + /// `AckSchema`. + ({DartType boundary, DartType runtime})? ackSchemaTypeArguments( + DartType type, + ) { + if (type is! InterfaceType) return null; + for (final candidate in [type, ...type.allSupertypes]) { + if (ackSchemaChecker.isExactlyType(candidate) && + candidate.typeArguments.length == 2) { + return ( + boundary: candidate.typeArguments[0], + runtime: candidate.typeArguments[1], + ); + } + } + return null; + } + + /// The boundary type argument of [type] as an `AckSchema`. + DartType? ackSchemaBoundaryType(DartType type) => + ackSchemaTypeArguments(type)?.boundary; + + /// The runtime type argument of [type] as an `AckSchema`. + DartType? ackSchemaRuntimeType(DartType type) => + ackSchemaTypeArguments(type)?.runtime; + + /// Whether a schema producing [left] can back a field of type [right]. + /// + /// The types must match apart from nullability: collection schemas such as + /// `Ack.map` type their values as nullable. + bool sameTypeIgnoringNullability(DartType left, DartType right) { + final typeSystem = library.element.typeSystem; + final a = typeSystem.promoteToNonNull(left); + final b = typeSystem.promoteToNonNull(right); + if (a is InterfaceType && b is InterfaceType) { + if (a.element.baseElement != b.element.baseElement || + a.typeArguments.length != b.typeArguments.length) { + return false; + } + for (var i = 0; i < a.typeArguments.length; i++) { + if (!sameTypeIgnoringNullability( + a.typeArguments[i], + b.typeArguments[i], + )) { + return false; + } + } + return true; + } + return typeSystem.isSubtypeOf(a, b) && typeSystem.isSubtypeOf(b, a); + } + + bool containsInvalidType(DartType type) => + type is InvalidType || + (type is InterfaceType && type.typeArguments.any(containsInvalidType)); + + bool isCoreType(InterfaceType type, String name) => + type.element.library.uri.toString() == 'dart:core' && + type.element.name == name; + + bool isCoreScalar(InterfaceType type) => + type.element.library.uri.toString() == 'dart:core' && + coreScalarNames.contains(type.element.name); + + bool isNullableType(DartType type) => + type.nullabilitySuffix == NullabilitySuffix.question; + + bool isJsonMaybeType(DartType type) => + type is InterfaceType && + type.typeArguments.length == 1 && + jsonMaybeChecker.isExactlyType(type); + + DartType unwrapJsonMaybeType(DartType type) => isJsonMaybeType(type) + ? (type as InterfaceType).typeArguments.single + : type; + + bool isSupportedMapKey(InterfaceType mapType) { + if (!mapType.isDartCoreMap || mapType.typeArguments.length != 2) { + return true; + } + final key = mapType.typeArguments.first; + return key is InterfaceType && isCoreType(key, 'String'); + } + + /// A type argument of an application type, where `void` is also valid, + /// as in `Trigger`. It is never a field or collection item type. + AckInferRef inferTypeArgumentRef( + DartType type, { + required String path, + required Element context, + AckInferRef Function(DartType type)? onUnsupportedType, + void Function(InterfaceType mapType)? validateMapKey, + AckInferRef? Function(InterfaceElement element)? resolveModelType, + }) => type is VoidType + ? const AckExternalTypeRef(name: 'void') + : inferTypeRef( + type, + path: path, + context: context, + allowJsonMaybe: false, + onUnsupportedType: onUnsupportedType, + validateMapKey: validateMapKey, + resolveModelType: resolveModelType, + ); + + /// Converts a resolved [DartType] into a normalized [AckInferRef]. + AckInferRef inferTypeRef( + DartType type, { + required String path, + required Element context, + bool allowJsonMaybe = false, + AckInferRef Function(DartType type)? onUnsupportedType, + void Function(InterfaceType mapType)? validateMapKey, + AckInferRef? Function(InterfaceElement element)? resolveModelType, + }) { + if (type is DynamicType) { + if (onUnsupportedType != null) return onUnsupportedType(type); + return const AckNullableTypeRef(AckScalarTypeRef('Object')); + } + if (type is TypeParameterType) { + if (onUnsupportedType != null) return onUnsupportedType(type); + return AckExternalTypeRef(name: type.element.name ?? 'Object'); + } + if (type is! InterfaceType) { + if (onUnsupportedType != null) return onUnsupportedType(type); + throw InvalidGenerationSource( + 'Unsupported runtime type ${type.getDisplayString()}.', + element: context, + ); + } + final nullable = isNullableType(type); + if (onUnsupportedType != null && isJsonMaybeType(type)) { + if (!allowJsonMaybe) { + return onUnsupportedType(type); + } + if (nullable) { + throw InvalidGenerationSource( + '$path uses nullable ${type.getDisplayString()}; use non-nullable ' + 'JsonMaybe<...> instead, which already models both absent and null ' + 'values.', + element: context, + ); + } + return AckJsonMaybeTypeRef( + inferTypeRef( + type.typeArguments.single, + path: path, + context: context, + onUnsupportedType: onUnsupportedType, + validateMapKey: validateMapKey, + resolveModelType: resolveModelType, + ), + ); + } + final name = type.element.name ?? type.getDisplayString(); + late final AckInferRef result; + if (type.isDartCoreList && type.typeArguments.length == 1) { + result = AckListTypeRef( + inferTypeRef( + type.typeArguments.single, + path: path, + context: context, + onUnsupportedType: onUnsupportedType, + validateMapKey: validateMapKey, + resolveModelType: resolveModelType, + ), + ); + } else if (type.isDartCoreSet && type.typeArguments.length == 1) { + result = AckSetTypeRef( + inferTypeRef( + type.typeArguments.single, + path: path, + context: context, + onUnsupportedType: onUnsupportedType, + validateMapKey: validateMapKey, + resolveModelType: resolveModelType, + ), + ); + } else if (type.isDartCoreMap && type.typeArguments.length == 2) { + if (validateMapKey != null) { + validateMapKey(type); + } else if (!isSupportedMapKey(type)) { + throw InvalidGenerationSource( + 'Generated Ack models support only Map runtime types; ' + 'received ${type.getDisplayString()}.', + element: context, + todo: + 'Use a string-keyed map or codec the value to a supported ' + 'runtime type before generating the model.', + ); + } + result = AckMapTypeRef( + inferTypeRef( + type.typeArguments[1], + path: path, + context: context, + onUnsupportedType: onUnsupportedType, + validateMapKey: validateMapKey, + resolveModelType: resolveModelType, + ), + ); + } else if (isCoreScalar(type)) { + result = AckScalarTypeRef(name); + } else if (resolveModelType?.call(type.element) case final modelRef?) { + result = modelRef; + } else { + result = AckExternalTypeRef( + name: name, + importPrefix: visiblePrefixFor( + type.element, + path: path, + context: context, + ), + typeArguments: [ + for (final argument in type.typeArguments) + inferTypeArgumentRef( + argument, + path: path, + context: context, + onUnsupportedType: onUnsupportedType, + validateMapKey: validateMapKey, + resolveModelType: resolveModelType, + ), + ], + ); + } + return nullable ? AckNullableTypeRef(result) : result; + } + + /// The import prefix that names [target] in this library, or null when an + /// unprefixed name reaches it. + /// + /// Throws when [target] is not visible, because a part cannot add imports. + String? visiblePrefixFor( + Element target, { + required String path, + required Element context, + }) { + if (target.library == library.element) return null; + final name = target.name; + if (name == null) return null; + String? prefixed; + var unprefixed = false; + for (final import in library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final prefix = import.prefix?.element.name; + final candidate = prefix == null + ? import.namespace.get2(name) + : import.namespace.getPrefixed2(prefix, name); + if (candidate?.baseElement != target.baseElement) continue; + if (prefix != null && prefix.isNotEmpty) { + prefixed ??= prefix; + } else { + unprefixed = true; + } + } + if (prefixed != null || unprefixed) return prefixed; + final inScope = library.element.firstFragment.scope.lookup(name).getter; + if (inScope?.baseElement == target.baseElement) return null; + throw InvalidGenerationSource( + '$path uses $name, which is not visible in this library. Import ' + '${target.library?.uri}.', + element: context, + ); + } + + String visibleTypeNameFor( + InterfaceType type, { + required String path, + required Element context, + }) => qualifySymbol( + visiblePrefixFor(type.element, path: path, context: context), + type.element.name!, + ); + + /// Checks whether both [declaration] and its generated [companionName] are + /// exposed through imports with [prefix]. + ({bool declarationVisible, bool companionVisible}) checkCompanionVisibility( + Element declaration, + String companionName, { + required String? prefix, + }) { + final definingLibrary = declaration.library; + final declarationName = declaration.name; + if (definingLibrary == null || declarationName == null) { + return (declarationVisible: false, companionVisible: false); + } + if (definingLibrary == library.element) { + return (declarationVisible: true, companionVisible: true); + } + final canonical = propertyDeclaration(declaration).baseElement; + var declarationVisible = false; + var companionVisible = false; + for (final import in library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final importPrefix = import.prefix?.element.name; + if (importPrefix != prefix) continue; + final candidate = prefix == null + ? import.namespace.get2(declarationName) + : import.namespace.getPrefixed2(prefix, declarationName); + if (candidate != null && + propertyDeclaration(candidate).baseElement == canonical) { + declarationVisible = true; + } + if (importExposesGeneratedCompanion( + import, + definingLibrary: definingLibrary, + generatedName: companionName, + )) { + companionVisible = true; + } + } + return ( + declarationVisible: declarationVisible, + companionVisible: companionVisible, + ); + } + + /// Yields all classes in [root] and its transitive export closure. + Iterable classesInExportClosure(LibraryElement root) sync* { + final pending = [root]; + final visited = {}; + while (pending.isNotEmpty) { + final current = pending.removeLast(); + if (!visited.add(current.uri)) continue; + yield* current.classes; + for (final export in current.firstFragment.libraryExports) { + if (export.exportedLibrary case final exported?) { + pending.add(exported); + } + } + } + } + + /// Resolves a `?.schema` reference to the underlying + /// class-first `@Schemable` model class, if any. + AckExternalTypeRef? resolveClassFirstModelReference( + Expression expression, + Element context, + ) { + final match = RegExp( + r'^(?:([A-Za-z$][A-Za-z0-9_$]*)\.)?' + r'([A-Z][A-Za-z0-9_$]*)\.schema$', + ).firstMatch(expression.toSource()); + if (match == null) return null; + final prefix = match.group(1); + final facadeName = match.group(2)!; + final matches = {}; + final candidates = {}; + final visibleModels = {}; + String? hiddenModelName; + + void consider(ClassElement element) { + final generatedFacade = classFirstFacadeName(element); + if (generatedFacade != facadeName) return; + candidates.add(element); + } + + if (prefix == null) { + for (final element in library.element.classes) { + if (classFirstFacadeName(element) == facadeName) { + matches.add(element); + } + } + } + final imports = []; + for (final import in library.element.firstFragment.libraryImports) { + if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; + final importPrefix = import.prefix?.element.name; + if (importPrefix != prefix) continue; + imports.add(import); + final importedLibrary = import.importedLibrary; + if (importedLibrary == null) continue; + for (final element in classesInExportClosure(importedLibrary)) { + consider(element); + final modelName = element.name; + if (modelName == null) continue; + final visible = prefix == null + ? import.namespace.get2(modelName) + : import.namespace.getPrefixed2(prefix, modelName); + if (visible?.baseElement == element.baseElement) { + visibleModels.add(element); + } + } + } + for (final target in candidates) { + final facadeVisible = imports.any( + (import) => importExposesGeneratedCompanion( + import, + definingLibrary: target.library, + generatedName: facadeName, + ), + ); + if (visibleModels.contains(target) && facadeVisible) { + matches.add(target); + } else { + hiddenModelName = target.name; + } + } + if (matches.isEmpty) { + if (hiddenModelName != null) { + throw InvalidGenerationSource( + 'Generated facade "$facadeName" is hidden by an import combinator ' + 'or an upstream export combinator. Expose both $hiddenModelName and ' + '$facadeName through the full import route.', + element: context, + ); + } + return null; + } + if (matches.length > 1) { + throw InvalidGenerationSource( + 'Generated facade reference ${expression.toSource()} is ambiguous.', + element: context, + ); + } + final target = matches.single; + return AckExternalTypeRef(name: target.name!, importPrefix: prefix); + } + + /// Names of all top-level declarations in the current [library]. + Set localDeclarationNames() => { + for (final element in library.allElements) + if (element.name case final name?) name, + }; + + /// Validates that generated `copyWith` helper types for [className] do not + /// collide with [localNames]. + void validateCopyWithHelperNames({ + required String className, + required bool needsCopyWithSentinel, + required Element element, + required Set localNames, + }) { + final sentinelType = ackCopyWithUnsetTypeName(className); + if (needsCopyWithSentinel && localNames.contains(sentinelType)) { + throw InvalidGenerationSource( + 'Generated helper "$sentinelType" conflicts with a local declaration.', + element: element, + ); + } + for (final copyWithType in [ + ackCopyWithInterfaceName(className), + ackCopyWithImplementationName(className), + ]) { + if (!localNames.contains(copyWithType)) continue; + throw InvalidGenerationSource( + 'Generated helper "$copyWithType" conflicts with a local declaration.', + element: element, + ); + } + } + + /// Parses an `Ack.*` or referenced schema method chain from [expression]. + SchemaChain parseSchemaChain(Expression expression) { + if (expression is! MethodInvocation) { + return SchemaChain( + base: null, + reference: expression, + optional: false, + nullable: false, + defaulted: false, + hasTransform: false, + hasCodec: false, + ); + } + MethodInvocation? current = expression; + MethodInvocation? base; + Expression? reference; + var optional = false; + var nullable = false; + var defaulted = false; + var transform = false; + var codec = false; + while (current != null) { + final name = current.methodName.name; + optional |= name == 'optional'; + nullable |= name == 'nullable'; + defaulted |= name == 'withDefault'; + transform |= oneWayTransformMethods.contains(name); + codec |= name == 'codec'; + if (current.methodName.element is TopLevelFunctionElement || + isTypeOwnedSchema(current.methodName.element)) { + reference = current; + break; + } + final target = current.target; + if (isAckTarget(target)) { + base = current; + break; + } + if (target is MethodInvocation) { + current = target; + } else { + reference = target; + break; + } + } + return SchemaChain( + base: base, + reference: reference, + optional: optional, + nullable: nullable, + defaulted: defaulted, + hasTransform: transform, + hasCodec: codec, + ); + } + + bool isAckTarget(Expression? expression) { + if (expression is SimpleIdentifier) return expression.name == 'Ack'; + if (expression is PrefixedIdentifier) { + return expression.identifier.name == 'Ack'; + } + return false; + } + + /// Whether [element] is a static `schema` member that a type declares. + bool isTypeOwnedSchema(Element? element) => + (element is GetterElement || element is MethodElement) && + element is ExecutableElement && + element.isStatic && + element.name == 'schema' && + element.enclosingElement is InterfaceElement; + + Element? memberElement(Expression expression) => switch (expression) { + PrefixedIdentifier() => expression.identifier.element, + PropertyAccess() => expression.propertyName.element, + MethodInvocation() => expression.methodName.element, + _ => null, + }; + + /// Resolves the top-level declaration referenced by [expression], if any. + Element? referencedElement(Expression expression) { + if (expression is ParenthesizedExpression) { + return referencedElement(expression.expression); + } + if (expression is SimpleIdentifier) { + final element = expression.element; + return element == null ? null : propertyDeclaration(element); + } + if (expression is PrefixedIdentifier) { + final element = expression.identifier.element; + return element == null ? null : propertyDeclaration(element); + } + if (expression is MethodInvocation) { + final method = expression.methodName.element; + if (method is TopLevelFunctionElement) { + return propertyDeclaration(method); + } + final chain = parseSchemaChain(expression); + final reference = chain.reference; + return reference == null ? null : referencedElement(reference); + } + return null; + } + + String? expressionPrefix(Expression expression) { + if (expression is PrefixedIdentifier) return expression.prefix.name; + if (expression is MethodInvocation && expression.target != null) { + return expressionPrefix(expression.target!); + } + return null; + } + + /// Resolves the initializer or body expression of a top-level variable, + /// getter, or function [referenced] by another schema expression. + Future<({Element declaration, Expression expression})?> + resolveReferencedTopLevelExpression( + Element? referenced, { + required Set visited, + }) async { + if (referenced == null) return null; + final declaration = propertyDeclaration(referenced); + if (declaration is! TopLevelVariableElement && + declaration is! GetterElement && + declaration is! TopLevelFunctionElement) { + return null; + } + final canonical = declaration.baseElement; + if (visited.contains(canonical) || visited.length >= maxReferenceDepth) { + return null; + } + final owningLibrary = declaration.library; + if (owningLibrary == null) return null; + final resolved = await resolvedLibraryFor(owningLibrary); + final expression = declarationExpression(resolved, declaration); + if (expression == null) return null; + return (declaration: declaration, expression: expression); + } + + /// Returns a human-readable trace (`a → b → transform`) if [expression] + /// invokes or transitively reaches a one-way `.transform()` call. + Future oneWayTransformSource( + Expression expression, { + Set visited = const {}, + }) async { + Expression? current = expression; + while (current is ParenthesizedExpression) { + current = current.expression; + } + Element? referenced; + while (current is MethodInvocation) { + if (oneWayTransformMethods.contains(current.methodName.name)) { + return current.methodName.name; + } + final method = current.methodName.element; + if (method is TopLevelFunctionElement) referenced = method; + current = current.target; + } + referenced ??= switch (current) { + SimpleIdentifier() => current.element, + PrefixedIdentifier() => current.identifier.element, + PropertyAccess() => current.propertyName.element, + _ => null, + }; + final target = await resolveReferencedTopLevelExpression( + referenced, + visited: visited, + ); + if (target == null) return null; + final nested = await oneWayTransformSource( + target.expression, + visited: {...visited, target.declaration.baseElement}, + ); + return nested == null ? null : '${target.declaration.name} → $nested'; + } + + /// Normalizes Analyzer argument nodes into plain expressions. + List argumentExpressions(ArgumentList argumentList) => + argumentList.arguments.map(argumentExpression).toList(growable: false); + + Expression argumentExpression(Expression argument) => + argument is NamedExpression ? argument.expression : argument; + + ({String name, Expression expression})? namedArgument(Expression argument) => + argument is NamedExpression + ? (name: argument.name.label.name, expression: argument.expression) + : null; + + bool? constantBool(Expression expression) { + while (expression is ParenthesizedExpression) { + expression = expression.expression; + } + if (expression is BooleanLiteral) return expression.value; + + final element = switch (expression) { + SimpleIdentifier() => expression.element, + PrefixedIdentifier() => expression.identifier.element, + PropertyAccess() => expression.propertyName.element, + _ => null, + }; + final declaration = element == null ? null : propertyDeclaration(element); + return declaration is VariableElement + ? declaration.computeConstantValue()?.toBoolValue() + : null; + } + + String? descriptionFromExpression(Expression expression) { + Expression? current = expression; + while (current is MethodInvocation) { + final arguments = argumentExpressions(current.argumentList); + if (current.methodName.name == 'describe' && + arguments.firstOrNull is SimpleStringLiteral) { + return (arguments.first as SimpleStringLiteral).value; + } + current = current.target; + } + return null; + } + + String ack(String symbol) => qualifySymbol(ackPrefix, symbol); +} diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index 6b4c4fe3..22339390 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -1,17 +1,14 @@ import 'package:ack/ack.dart' - show AckSchema, AnyOfSchema, AnySchema, InstanceSchema, MapSchema; -import 'package:ack/annotations.dart' hide AckUnknownPropertyPolicy; -import 'package:analyzer/dart/analysis/results.dart'; + show AnyOfSchema, AnySchema, InstanceSchema, MapSchema; import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; import '../json/helper_names.dart'; import '../models/schema_model_graph.dart'; import '../utils/string_literal.dart'; -import 'generated_companion_visibility.dart'; +import 'model_graph_builder_base.dart'; import 'schema_first_names.dart'; final class _Declaration { @@ -28,46 +25,14 @@ final class _Declaration { final String className; } -final class _SchemaChain { - const _SchemaChain({ - required this.base, - required this.reference, - required this.optional, - required this.nullable, - required this.defaulted, - required this.hasTransform, - required this.hasCodec, - }); - - final MethodInvocation? base; - final Expression? reference; - final bool optional; - final bool nullable; - final bool defaulted; - final bool hasTransform; - final bool hasCodec; -} - typedef _SchemaTypes = ({AckInferRef boundary, AckInferRef runtime}); /// Builds the single normalized graph consumed by Ack model emission. -final class SchemaModelGraphBuilder { - SchemaModelGraphBuilder(this.library); +final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { + SchemaModelGraphBuilder(super.library); static const _reservedMembers = ackSchemaFirstReservedMembers; - static const _oneWayTransformMethods = {'transform'}; - - static const _maxReferenceDepth = 16; - - static const _schemableChecker = TypeChecker.typeNamed( - Schemable, - inPackage: 'ack', - ); - static const _ackSchemaChecker = TypeChecker.typeNamed( - AckSchema, - inPackage: 'ack', - ); static const _anySchemaChecker = TypeChecker.typeNamed( AnySchema, inPackage: 'ack', @@ -85,30 +50,17 @@ final class SchemaModelGraphBuilder { inPackage: 'ack', ); - final LibraryReader library; - final AckModelGraph _graph = AckModelGraph(); final Map _declarationsByElement = {}; final Map _declarationsById = {}; final Map _unionOwnerByBranch = {}; - ResolvedLibraryResult? _inputResolved; - final Map _resolvedByUri = {}; Future build(List annotatedElements) async { + final resolved = await initInputLibrary(); final libraryElement = library.element; - final resolved = await libraryElement.session.getResolvedLibraryByElement( - libraryElement, - ); - if (resolved is! ResolvedLibraryResult) { - throw InvalidGenerationSource( - 'Could not resolve ${libraryElement.uri} for Ack model generation.', - ); - } - _inputResolved = resolved; - _resolvedByUri[libraryElement.uri] = resolved; for (final element in annotatedElements) { _rejectClassOnlyOptions(element); - final expression = _declarationExpression(resolved, element); + final expression = declarationExpression(resolved, element); final declarationName = element.name; if (declarationName == null || expression == null) { throw InvalidGenerationSource( @@ -126,7 +78,7 @@ final class SchemaModelGraphBuilder { id: id, className: _className( declarationName, - _annotationName(element), + schemableCustomName(element), element, ), ); @@ -143,13 +95,12 @@ final class SchemaModelGraphBuilder { ); } _validateDelegatedHelperNames(); - return _graph; + return graph; } void _rejectClassOnlyOptions(Element element) { - final annotation = _schemableChecker.firstAnnotationOfExact( - _propertyDeclaration(element), - ); + final annotation = ModelGraphBuilderBase.schemableChecker + .firstAnnotationOfExact(propertyDeclaration(element)); if (annotation == null) return; final reader = ConstantReader(annotation); int index(String option) => @@ -187,54 +138,12 @@ final class SchemaModelGraphBuilder { } } - Expression? _declarationExpression( - ResolvedLibraryResult result, - Element element, - ) { - final declaration = result.getFragmentDeclaration(element.firstFragment); - final node = declaration?.node; - if (node is VariableDeclaration) return node.initializer; - if (node is FunctionDeclaration) { - return _functionBodyExpression(node.functionExpression.body); - } - return null; - } - - Expression? _functionBodyExpression(FunctionBody body) { - if (body is ExpressionFunctionBody) return body.expression; - if (body is BlockFunctionBody && body.block.statements.length == 1) { - final statement = body.block.statements.single; - if (statement is ReturnStatement) return statement.expression; - } - return null; - } - - Future _resolvedLibraryFor( - LibraryElement libraryElement, - ) async { - if (identical(libraryElement, library.element) && _inputResolved != null) { - return _inputResolved!; - } - final cached = _resolvedByUri[libraryElement.uri]; - if (cached != null) return cached; - final resolved = await libraryElement.session.getResolvedLibraryByElement( - libraryElement, - ); - if (resolved is! ResolvedLibraryResult) { - throw InvalidGenerationSource( - 'Could not resolve ${libraryElement.uri} for Ack model generation.', - ); - } - _resolvedByUri[libraryElement.uri] = resolved; - return resolved; - } - Future _resolve( _Declaration declaration, { required bool throughLazy, required String path, }) async { - switch (_graph.stateOf(declaration.id)) { + switch (graph.stateOf(declaration.id)) { case AckResolutionState.resolved: return; case AckResolutionState.visiting: @@ -248,8 +157,8 @@ final class SchemaModelGraphBuilder { break; } - _graph.begin(declaration.id); - final chain = _chain(declaration.expression); + graph.begin(declaration.id); + final chain = parseSchemaChain(declaration.expression); _rejectTransform(chain, path, declaration.element); if (chain.nullable) { throw InvalidGenerationSource( @@ -274,7 +183,7 @@ final class SchemaModelGraphBuilder { _isCrossLibrarySchemable(chain.reference!)) { throw InvalidGenerationSource( '$path aliases a cross-library ' - '${_annotationLabel(_referencedElement(chain.reference!)!)} schema. ' + '${annotationName(referencedElement(chain.reference!)!)} schema. ' 'Use the original model directly.', element: declaration.element, ); @@ -317,7 +226,7 @@ final class SchemaModelGraphBuilder { } node = await _valueNode(declaration, path); } - _graph.complete(node); + graph.complete(node); } Future _valueNode( @@ -336,7 +245,7 @@ final class SchemaModelGraphBuilder { context: declaration.element, throughLazy: false, ), - description: _description(expression), + description: descriptionFromExpression(expression), ); } @@ -357,7 +266,7 @@ final class SchemaModelGraphBuilder { throughLazy: false, path: '$path -> ${target.id.declarationName}', ); - final source = _graph.nodeFor(target.id)!; + final source = graph.nodeFor(target.id)!; if (source is AckObjectModelNode) { return AckObjectModelNode( id: declaration.id, @@ -388,11 +297,11 @@ final class SchemaModelGraphBuilder { Future _objectNode( _Declaration declaration, - _SchemaChain chain, + SchemaChain chain, String path, ) async { final invocation = chain.base!; - final arguments = _argumentExpressions(invocation.argumentList); + final arguments = argumentExpressions(invocation.argumentList); if (arguments.isEmpty || arguments.first is! SetOrMapLiteral) { throw InvalidGenerationSource( '$path must use a map literal in Ack.object(...).', @@ -425,7 +334,7 @@ final class SchemaModelGraphBuilder { ); } final fieldPath = '$path.$jsonKey'; - final fieldChain = _chain(entry.value); + final fieldChain = parseSchemaChain(entry.value); _rejectTransform(fieldChain, fieldPath, declaration.element); if (fieldChain.base?.methodName.name == 'object') { throw InvalidGenerationSource( @@ -446,7 +355,7 @@ final class SchemaModelGraphBuilder { context: declaration.element, throughLazy: false, ), - description: _description(entry.value), + description: descriptionFromExpression(entry.value), ), ); } @@ -462,19 +371,19 @@ final class SchemaModelGraphBuilder { ? AckUnknownPropertyPolicy.capture : AckUnknownPropertyPolicy.reject, captureFieldName: additionalProperties ? 'additionalProperties' : null, - description: _description(declaration.expression), + description: descriptionFromExpression(declaration.expression), ); } Future _unionNode( _Declaration declaration, - _SchemaChain chain, + SchemaChain chain, String path, ) async { String? discriminatorKey; SetOrMapLiteral? branchesLiteral; for (final argumentNode in chain.base!.argumentList.arguments) { - final argument = _namedArgument(argumentNode); + final argument = namedArgument(argumentNode); if (argument == null) continue; switch (argument.name) { case 'discriminatorKey': @@ -519,11 +428,11 @@ final class SchemaModelGraphBuilder { } _validateUnionBranchDiscriminator(target, discriminatorKey, value); await _resolve(target, throughLazy: false, path: '$path.$value'); - final branch = _graph.nodeFor(target.id); + final branch = graph.nodeFor(target.id); if (branch is! AckObjectModelNode) { throw InvalidGenerationSource( '$path.$value must reference an ' - '${_annotationLabel(declaration.element)} object schema.', + '${annotationName(declaration.element)} object schema.', element: declaration.element, ); } @@ -535,7 +444,7 @@ final class SchemaModelGraphBuilder { ); } _unionOwnerByBranch[target.id] = declaration.id; - _graph.replace( + graph.replace( AckObjectModelNode( id: branch.id, className: branch.className, @@ -562,7 +471,7 @@ final class SchemaModelGraphBuilder { className: declaration.className, discriminatorKey: discriminatorKey, branches: branches, - description: _description(declaration.expression), + description: descriptionFromExpression(declaration.expression), ); } @@ -576,7 +485,7 @@ final class SchemaModelGraphBuilder { String? followedName, bool collectionElement = false, }) async { - final chain = _chain(expression); + final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); if (collectionElement && chain.nullable) { throw InvalidGenerationSource( @@ -617,7 +526,7 @@ final class SchemaModelGraphBuilder { case 'instance': _rejectUnsupportedRoot(baseName, path, context); case 'map': - final arguments = _argumentExpressions(chain.base!.argumentList); + final arguments = argumentExpressions(chain.base!.argumentList); if (arguments.isEmpty) { throw InvalidGenerationSource( '$path has an empty Ack.map().', @@ -640,7 +549,7 @@ final class SchemaModelGraphBuilder { : valueType, ); case 'list': - final arguments = _argumentExpressions(chain.base!.argumentList); + final arguments = argumentExpressions(chain.base!.argumentList); if (arguments.isEmpty) { throw InvalidGenerationSource( '$path has an empty Ack.list().', @@ -659,7 +568,7 @@ final class SchemaModelGraphBuilder { ), ); case 'enumValues': - final arguments = _argumentExpressions(chain.base!.argumentList); + final arguments = argumentExpressions(chain.base!.argumentList); final valuesType = arguments.firstOrNull?.staticType; if (valuesType is InterfaceType && valuesType.isDartCoreList && @@ -688,17 +597,17 @@ final class SchemaModelGraphBuilder { final reference = chain.reference; if (reference != null) { - final classFirst = _classFirstModelReference(reference, context); + final classFirst = resolveClassFirstModelReference(reference, context); if (classFirst != null) return classFirst; - final member = _memberElement(reference); - if (_isTypeOwnedSchema(member)) { + final member = memberElement(reference); + if (isTypeOwnedSchema(member)) { final owner = member!.enclosingElement! as InterfaceElement; // A class-first model's own schema may point at a facade generated // in this build, so its type is the model rather than the static's. - if (owner is ClassElement && _classFirstFacadeName(owner) != null) { + if (owner is ClassElement && classFirstFacadeName(owner) != null) { return AckExternalTypeRef( name: owner.name!, - importPrefix: _visiblePrefix(owner, path, context), + importPrefix: visiblePrefixFor(owner, path: path, context: context), ); } return _schemaTypes(reference, path, context).runtime; @@ -740,15 +649,16 @@ final class SchemaModelGraphBuilder { required int depth, required bool collectionElement, }) async { - final element = _referencedElement(reference); + final element = referencedElement(reference); if (element == null) return null; if (element is! TopLevelVariableElement && element is! GetterElement) { return null; } - if (_hasSchemable(element)) return null; - if (depth >= _maxReferenceDepth) { + if (hasSchemable(element)) return null; + if (depth >= ModelGraphBuilderBase.maxReferenceDepth) { throw InvalidGenerationSource( - '$path exceeds schema reference depth $_maxReferenceDepth.', + '$path exceeds schema reference depth ' + '${ModelGraphBuilderBase.maxReferenceDepth}.', element: context, ); } @@ -759,11 +669,11 @@ final class SchemaModelGraphBuilder { element: context, ); } - final declaration = _propertyDeclaration(element); + final declaration = propertyDeclaration(element); final owningLibrary = declaration.library; if (owningLibrary == null) return null; - final resolved = await _resolvedLibraryFor(owningLibrary); - final initializer = _declarationExpression(resolved, declaration); + final resolved = await resolvedLibraryFor(owningLibrary); + final initializer = declarationExpression(resolved, declaration); if (initializer == null) { throw InvalidGenerationSource( "$path references '${element.name}', which has no statically " @@ -786,24 +696,24 @@ final class SchemaModelGraphBuilder { /// Whether [expression] marks its schema `.nullable()`, following /// unannotated variable references such as `Ack.map(nullableLabel)`. Future _isNullableSchema(Expression expression, {int depth = 0}) async { - final chain = _chain(expression); + final chain = parseSchemaChain(expression); if (chain.nullable) return true; final reference = chain.reference; if (chain.base != null || reference == null || - depth >= _maxReferenceDepth) { + depth >= ModelGraphBuilderBase.maxReferenceDepth) { return false; } - final element = _referencedElement(reference); + final element = referencedElement(reference); if (element == null || (element is! TopLevelVariableElement && element is! GetterElement)) { return false; } - final declaration = _propertyDeclaration(element); + final declaration = propertyDeclaration(element); final owningLibrary = declaration.library; if (owningLibrary == null) return false; - final initializer = _declarationExpression( - await _resolvedLibraryFor(owningLibrary), + final initializer = declarationExpression( + await resolvedLibraryFor(owningLibrary), declaration, ); return initializer != null && @@ -815,7 +725,7 @@ final class SchemaModelGraphBuilder { String path, Element context, ) async { - final arguments = _argumentExpressions(invocation.argumentList); + final arguments = argumentExpressions(invocation.argumentList); if (arguments.length < 2 || arguments.first is! SimpleStringLiteral) { throw InvalidGenerationSource( '$path must use named Ack.lazy(name, () => schema) recursion.', @@ -829,13 +739,7 @@ final class SchemaModelGraphBuilder { element: context, ); } - final body = callback.body; - Expression? target; - if (body is ExpressionFunctionBody) target = body.expression; - if (body is BlockFunctionBody && body.block.statements.length == 1) { - final statement = body.block.statements.single; - if (statement is ReturnStatement) target = statement.expression; - } + final target = functionBodyExpression(callback.body); if (target == null) { throw InvalidGenerationSource( '$path Ack.lazy builder is not statically resolvable.', @@ -850,7 +754,7 @@ final class SchemaModelGraphBuilder { ); if (model == null) { throw InvalidGenerationSource( - '$path Ack.lazy must resolve to a named ${_annotationLabel(context)} ' + '$path Ack.lazy must resolve to a named ${annotationName(context)} ' 'schema.', element: context, ); @@ -864,7 +768,7 @@ final class SchemaModelGraphBuilder { required Element context, required bool throughLazy, }) async { - final element = _referencedElement(expression); + final element = referencedElement(expression); if (element == null) return null; final local = _declarationsByElement[element.baseElement]; if (local != null) { @@ -880,17 +784,17 @@ final class SchemaModelGraphBuilder { runtimeRef: runtime, ); } - if (!_hasSchemable(element)) return null; - final declaration = _propertyDeclaration(element); + if (!hasSchemable(element)) return null; + final declaration = propertyDeclaration(element); final name = declaration.name; final owningLibrary = declaration.library; if (name == null || owningLibrary == null) return null; final className = _className( name, - _annotationName(declaration), + schemableCustomName(declaration), declaration, ); - final prefix = _expressionPrefix(expression); + final prefix = expressionPrefix(expression); _validateGeneratedModelImport( declaration, className, @@ -916,33 +820,15 @@ final class SchemaModelGraphBuilder { required String path, required Element context, }) { - if (declaration.library == library.element) return; final declarationName = declaration.name; if (declarationName == null) return; - var declarationVisible = false; - var generatedModelVisible = false; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importPrefix = import.prefix?.element.name; - if (importPrefix != prefix) continue; - final candidate = prefix == null - ? import.namespace.get2(declarationName) - : import.namespace.getPrefixed2(prefix, declarationName); - if (candidate != null && - _propertyDeclaration(candidate).baseElement == - declaration.baseElement) { - declarationVisible = true; - } - if (importExposesGeneratedCompanion( - import, - definingLibrary: declaration.library!, - generatedName: className, - )) { - generatedModelVisible = true; - } - } - if (declarationVisible && generatedModelVisible) return; - if (declarationVisible) { + final visibility = checkCompanionVisibility( + declaration, + className, + prefix: prefix, + ); + if (visibility.declarationVisible && visibility.companionVisible) return; + if (visibility.declarationVisible) { throw InvalidGenerationSource( '$path resolves to generated model "$className", which is hidden by ' 'an import combinator or an upstream export combinator. Expose both ' @@ -952,126 +838,6 @@ final class SchemaModelGraphBuilder { } } - AckExternalTypeRef? _classFirstModelReference( - Expression expression, - Element context, - ) { - final match = RegExp( - r'^(?:([A-Za-z$][A-Za-z0-9_$]*)\.)?' - r'([A-Z][A-Za-z0-9_$]*)\.schema$', - ).firstMatch(expression.toSource()); - if (match == null) return null; - final prefix = match.group(1); - final facadeName = match.group(2)!; - final matches = {}; - final candidates = {}; - final visibleModels = {}; - String? hiddenModelName; - - void consider(ClassElement element) { - final generatedFacade = _classFirstFacadeName(element); - if (generatedFacade != facadeName) return; - candidates.add(element); - } - - if (prefix == null) { - for (final element in library.element.classes) { - if (_classFirstFacadeName(element) == facadeName) { - matches.add(element); - } - } - } - final imports = []; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) continue; - final importPrefix = import.prefix?.element.name; - if (importPrefix != prefix) continue; - imports.add(import); - final importedLibrary = import.importedLibrary; - if (importedLibrary == null) continue; - for (final element in _classesInExportClosure(importedLibrary)) { - consider(element); - final modelName = element.name; - if (modelName == null) continue; - final visible = prefix == null - ? import.namespace.get2(modelName) - : import.namespace.getPrefixed2(prefix, modelName); - if (visible?.baseElement == element.baseElement) { - visibleModels.add(element); - } - } - } - for (final target in candidates) { - final facadeVisible = imports.any( - (import) => importExposesGeneratedCompanion( - import, - definingLibrary: target.library, - generatedName: facadeName, - ), - ); - if (visibleModels.contains(target) && facadeVisible) { - matches.add(target); - } else { - hiddenModelName = target.name; - } - } - if (matches.isEmpty) { - if (hiddenModelName != null) { - throw InvalidGenerationSource( - 'Generated facade "$facadeName" is hidden by an import combinator ' - 'or an upstream export combinator. Expose both $hiddenModelName and ' - '$facadeName through the full import route.', - element: context, - ); - } - return null; - } - if (matches.length > 1) { - throw InvalidGenerationSource( - 'Generated facade reference ${expression.toSource()} is ambiguous.', - element: context, - ); - } - final target = matches.single; - return AckExternalTypeRef(name: target.name!, importPrefix: prefix); - } - - Iterable _classesInExportClosure(LibraryElement root) sync* { - final pending = [root]; - final visited = {}; - while (pending.isNotEmpty) { - final current = pending.removeLast(); - if (!visited.add(current.uri)) continue; - yield* current.classes; - for (final export in current.firstFragment.libraryExports) { - if (export.exportedLibrary case final exported?) { - pending.add(exported); - } - } - } - } - - String? _classFirstFacadeName(ClassElement element) { - final annotation = _schemableChecker.firstAnnotationOfExact(element); - if (annotation != null) { - final value = ConstantReader(annotation).read('schemaName'); - return ackClassSchemaFacadeName( - element.name!, - override: value.isNull ? null : value.stringValue, - ); - } - final isImplicitUnionBranch = element.allSupertypes.any((supertype) { - final base = supertype.element; - return base is ClassElement && - base.library == element.library && - base.isSealed && - _schemableChecker.hasAnnotationOfExact(base); - }); - return isImplicitUnionBranch - ? ackClassSchemaFacadeName(element.name!) - : null; - } - _SchemaTypes _schemaTypes( Expression expression, String path, @@ -1084,275 +850,32 @@ final class SchemaModelGraphBuilder { element: context, ); } - final InterfaceElement? ackElement = _isAckSchema(type) - ? type.element - : type.element.allSupertypes.where(_isAckSchema).firstOrNull?.element; - final ackInfer = ackElement == null ? null : type.asInstanceOf(ackElement); - if (ackInfer == null || ackInfer.typeArguments.length != 2) { + final ackInfer = ackSchemaTypeArguments(type); + if (ackInfer == null) { throw InvalidGenerationSource( '$path does not resolve to AckSchema.', element: context, ); } return ( - boundary: _typeRef(ackInfer.typeArguments[0], path, context), - runtime: _typeRef(ackInfer.typeArguments[1], path, context), - ); - } - - bool _isAckSchema(InterfaceType type) { - return _ackSchemaChecker.isExactlyType(type); - } - - AckInferRef _typeArgumentRef(DartType type, String path, Element context) => - type is VoidType - ? const AckExternalTypeRef(name: 'void') - : _typeRef(type, path, context); - - AckInferRef _typeRef(DartType type, String path, Element context) { - if (type is DynamicType) { - return const AckNullableTypeRef(AckScalarTypeRef('Object')); - } - if (type is TypeParameterType) { - return AckExternalTypeRef(name: type.element.name ?? 'Object'); - } - if (type is! InterfaceType) { - throw InvalidGenerationSource( - 'Unsupported runtime type ${type.getDisplayString()}.', - element: context, - ); - } - final nullable = type.nullabilitySuffix == NullabilitySuffix.question; - final name = type.element.name ?? type.getDisplayString(); - AckInferRef result; - if (type.isDartCoreList && type.typeArguments.length == 1) { - result = AckListTypeRef( - _typeRef(type.typeArguments.single, path, context), - ); - } else if (type.isDartCoreSet && type.typeArguments.length == 1) { - result = AckSetTypeRef( - _typeRef(type.typeArguments.single, path, context), - ); - } else if (type.isDartCoreMap && type.typeArguments.length == 2) { - final keyType = type.typeArguments.first; - if (keyType is! InterfaceType || !keyType.isDartCoreString) { - throw InvalidGenerationSource( - 'Generated Ack models support only Map runtime types; ' - 'received ${type.getDisplayString()}.', - element: context, - todo: - 'Use a string-keyed map or codec the value to a supported ' - 'runtime type before generating the model.', - ); - } - result = AckMapTypeRef(_typeRef(type.typeArguments[1], path, context)); - } else if (type.element.library.uri.toString() == 'dart:core' && - const { - 'String', - 'int', - 'double', - 'num', - 'bool', - 'Object', - }.contains(name)) { - result = AckScalarTypeRef(name); - } else { - result = AckExternalTypeRef( - name: name, - importPrefix: _visiblePrefix(type.element, path, context), - typeArguments: [ - for (final argument in type.typeArguments) - _typeArgumentRef(argument, path, context), - ], - ); - } - return nullable ? AckNullableTypeRef(result) : result; - } - - /// The import prefix that names [target] in this library, or null when an - /// unprefixed name reaches it. - /// - /// Throws when [target] is not visible, because a part cannot add imports. - String? _visiblePrefix( - InterfaceElement target, - String path, - Element context, - ) { - if (target.library == library.element) return null; - final name = target.name; - if (name == null) return null; - String? prefixed; - var unprefixed = false; - for (final import in library.element.firstFragment.libraryImports) { - if (import.isSynthetic || (import.prefix?.isDeferred ?? false)) { - continue; - } - final prefix = import.prefix?.element.name; - final candidate = prefix == null - ? import.namespace.get2(name) - : import.namespace.getPrefixed2(prefix, name); - if (candidate != target) continue; - if (prefix != null && prefix.isNotEmpty) { - prefixed ??= prefix; - } else { - unprefixed = true; - } - } - if (prefixed != null || unprefixed) return prefixed; - if (library.element.firstFragment.scope.lookup(name).getter == target) { - return null; - } - throw InvalidGenerationSource( - '$path uses $name, which is not visible in this library. Import ' - '${target.library.uri}.', - element: context, + boundary: _typeRef(ackInfer.boundary, path, context), + runtime: _typeRef(ackInfer.runtime, path, context), ); } - _SchemaChain _chain(Expression expression) { - if (expression is! MethodInvocation) { - return _SchemaChain( - base: null, - reference: expression, - optional: false, - nullable: false, - defaulted: false, - hasTransform: false, - hasCodec: false, - ); - } - MethodInvocation? current = expression; - MethodInvocation? base; - Expression? reference; - var optional = false; - var nullable = false; - var defaulted = false; - var transform = false; - var codec = false; - while (current != null) { - final name = current.methodName.name; - optional |= name == 'optional'; - nullable |= name == 'nullable'; - defaulted |= name == 'withDefault'; - transform |= _oneWayTransformMethods.contains(name); - codec |= name == 'codec'; - if (current.methodName.element is TopLevelFunctionElement || - _isTypeOwnedSchema(current.methodName.element)) { - reference = current; - break; - } - final target = current.target; - if (_isAckTarget(target)) { - base = current; - break; - } - if (target is MethodInvocation) { - current = target; - } else { - reference = target; - break; - } - } - return _SchemaChain( - base: base, - reference: reference, - optional: optional, - nullable: nullable, - defaulted: defaulted, - hasTransform: transform, - hasCodec: codec, - ); - } - - bool _isAckTarget(Expression? expression) { - if (expression is SimpleIdentifier) return expression.name == 'Ack'; - if (expression is PrefixedIdentifier) { - return expression.identifier.name == 'Ack'; - } - return false; - } - - Element? _referencedElement(Expression expression) { - if (expression is ParenthesizedExpression) { - return _referencedElement(expression.expression); - } - if (expression is SimpleIdentifier) { - final element = expression.element; - return element == null ? null : _propertyDeclaration(element); - } - if (expression is PrefixedIdentifier) { - final element = expression.identifier.element; - return element == null ? null : _propertyDeclaration(element); - } - if (expression is MethodInvocation) { - final method = expression.methodName.element; - if (method is TopLevelFunctionElement) { - return _propertyDeclaration(method); - } - final chain = _chain(expression); - final reference = chain.reference; - return reference == null ? null : _referencedElement(reference); - } - return null; - } - - /// Whether [element] is a static `schema` member that a type declares. - bool _isTypeOwnedSchema(Element? element) => - (element is GetterElement || element is MethodElement) && - element is ExecutableElement && - element.isStatic && - element.name == 'schema' && - element.enclosingElement is InterfaceElement; - - Element? _memberElement(Expression expression) => switch (expression) { - PrefixedIdentifier() => expression.identifier.element, - PropertyAccess() => expression.propertyName.element, - MethodInvocation() => expression.methodName.element, - _ => null, - }; - - Element _propertyDeclaration(Element element) { - if (element is GetterElement && element.isOriginVariable) { - return element.variable.baseElement; - } - return element.baseElement; - } + AckInferRef _typeRef(DartType type, String path, Element context) => + inferTypeRef(type, path: path, context: context); _Declaration? _localDeclaration(Expression expression) { - final element = _referencedElement(expression); + final element = referencedElement(expression); return element == null ? null : _declarationsByElement[element.baseElement]; } bool _isCrossLibrarySchemable(Expression expression) { - final element = _referencedElement(expression); + final element = referencedElement(expression); if (element == null) return false; if (_declarationsByElement[element.baseElement] != null) return false; - return _hasSchemable(element); - } - - String? _expressionPrefix(Expression expression) { - if (expression is PrefixedIdentifier) return expression.prefix.name; - if (expression is MethodInvocation && expression.target != null) { - return _expressionPrefix(expression.target!); - } - return null; - } - - bool _hasSchemable(Element element) { - final declaration = _propertyDeclaration(element); - return _schemableChecker.hasAnnotationOfExact(declaration); - } - - /// The annotation spelling that marks the declaration behind [element]. - String _annotationLabel(Element element) => '@Schemable'; - - String? _annotationName(Element element) { - final declaration = _propertyDeclaration(element); - final annotation = _schemableChecker.firstAnnotationOfExact(declaration); - final field = annotation == null - ? null - : ConstantReader(annotation).peek('name'); - return field == null || field.isNull ? null : field.stringValue; + return hasSchemable(element); } List _schemaFirstConstructorParameters( @@ -1381,7 +904,7 @@ final class SchemaModelGraphBuilder { ]; } - AckSchemaFieldPresence _fieldPresence(_SchemaChain chain) { + AckSchemaFieldPresence _fieldPresence(SchemaChain chain) { if (chain.defaulted) return AckSchemaFieldPresence.defaulted; if (chain.optional) return AckSchemaFieldPresence.optional; return AckSchemaFieldPresence.required; @@ -1396,7 +919,7 @@ final class SchemaModelGraphBuilder { if (customName.trim() != customName || !RegExp(r'^[A-Z][A-Za-z0-9]*$').hasMatch(customName)) { throw InvalidGenerationSource( - 'Invalid ${_annotationLabel(element)} name "$customName". Names must ' + 'Invalid ${annotationName(element)} name "$customName". Names must ' 'be unchanged UpperCamelCase identifiers.', element: element, ); @@ -1418,15 +941,12 @@ final class SchemaModelGraphBuilder { void _validateClassNames() { final generated = {}; - final localNames = { - for (final element in library.allElements) - if (element.name case final name?) name, - }; + final localNames = localDeclarationNames(); for (final declaration in _declarationsById.values) { final name = declaration.className; if (!generated.add(name)) { throw InvalidGenerationSource( - 'Multiple ${_annotationLabel(declaration.element)} declarations ' + 'Multiple ${annotationName(declaration.element)} declarations ' 'generate "$name".', element: declaration.element, ); @@ -1448,8 +968,8 @@ final class SchemaModelGraphBuilder { } void _validateDelegatedHelperNames() { - final localNames = _localDeclarationNames(); - for (final node in _graph.nodes) { + final localNames = localDeclarationNames(); + for (final node in graph.nodes) { final declaration = _declarationsById[node.id]; if (declaration == null) continue; switch (node) { @@ -1457,7 +977,7 @@ final class SchemaModelGraphBuilder { case AckValueModelNode(): continue; case AckObjectModelNode(): - _validateClassHelperNames( + validateCopyWithHelperNames( className: node.className, needsCopyWithSentinel: node.fields.any( (field) => @@ -1471,45 +991,15 @@ final class SchemaModelGraphBuilder { } } - void _validateClassHelperNames({ - required String className, - required bool needsCopyWithSentinel, - required Element element, - required Set localNames, - }) { - final sentinelType = ackCopyWithUnsetTypeName(className); - if (needsCopyWithSentinel && localNames.contains(sentinelType)) { - throw InvalidGenerationSource( - 'Generated helper "$sentinelType" conflicts with a local declaration.', - element: element, - ); - } - for (final copyWithType in [ - ackCopyWithInterfaceName(className), - ackCopyWithImplementationName(className), - ]) { - if (!localNames.contains(copyWithType)) continue; - throw InvalidGenerationSource( - 'Generated helper "$copyWithType" conflicts with a local declaration.', - element: element, - ); - } - } - - Set _localDeclarationNames() => { - for (final element in library.allElements) - if (element.name case final name?) name, - }; - void _validateUnionBranchDiscriminator( _Declaration branch, String discriminatorKey, String discriminatorValue, ) { - final chain = _chain(branch.expression); + final chain = parseSchemaChain(branch.expression); final object = chain.base; if (object?.methodName.name != 'object') return; - final arguments = _argumentExpressions(object!.argumentList); + final arguments = argumentExpressions(object!.argumentList); if (arguments.firstOrNull case SetOrMapLiteral(:final elements)) { for (final entry in elements.whereType()) { final key = entry.key; @@ -1527,10 +1017,10 @@ final class SchemaModelGraphBuilder { } bool _matchesDiscriminator(Expression expression, String expected) { - final chain = _chain(expression); + final chain = parseSchemaChain(expression); final base = chain.base; if (base == null || !identical(base, expression)) return false; - final arguments = _argumentExpressions(base.argumentList); + final arguments = argumentExpressions(base.argumentList); return switch (base.methodName.name) { 'literal' => arguments.firstOrNull is SimpleStringLiteral && @@ -1576,7 +1066,7 @@ final class SchemaModelGraphBuilder { } } - void _rejectTransform(_SchemaChain chain, String path, Element element) { + void _rejectTransform(SchemaChain chain, String path, Element element) { if (!chain.hasTransform) return; throw InvalidGenerationSource( '$path uses one-way .transform(). Migrate this path to .codec() with an encoder.', @@ -1595,37 +1085,26 @@ final class SchemaModelGraphBuilder { while (directExpression is ParenthesizedExpression) { directExpression = directExpression.expression; } - final directChain = _chain(directExpression); + final directChain = parseSchemaChain(directExpression); _rejectTransform(directChain, path, context); - final referenced = _referencedElement( + final referenced = referencedElement( directChain.reference ?? directExpression, ); - if (referenced == null) return; - final declaration = _propertyDeclaration(referenced); - if (declaration is! TopLevelVariableElement && - declaration is! GetterElement && - declaration is! TopLevelFunctionElement) { - return; - } - final canonical = declaration.baseElement; - if (visited.contains(canonical) || visited.length >= _maxReferenceDepth) { - return; - } - final owningLibrary = declaration.library; - if (owningLibrary == null) return; - final resolved = await _resolvedLibraryFor(owningLibrary); - final expression = _declarationExpression(resolved, declaration); - if (expression == null) return; + final target = await resolveReferencedTopLevelExpression( + referenced, + visited: visited, + ); + if (target == null) return; - final referencePath = '$path(→ ${declaration.name})'; - final chain = _chain(expression); + final referencePath = '$path(→ ${target.declaration.name})'; + final chain = parseSchemaChain(target.expression); _rejectTransform(chain, referencePath, context); await _rejectReferencedTransform( chain.reference, path: referencePath, context: context, - visited: {...visited, canonical}, + visited: {...visited, target.declaration.baseElement}, ); } @@ -1653,9 +1132,9 @@ final class SchemaModelGraphBuilder { if (current.methodName.name == 'passthrough') return true; if (current.methodName.name == 'object') { for (final argumentNode in current.argumentList.arguments) { - final argument = _namedArgument(argumentNode); + final argument = namedArgument(argumentNode); if (argument != null && argument.name == 'additionalProperties') { - final value = _constantBool(argument.expression); + final value = constantBool(argument.expression); if (value != null) return value; throw InvalidGenerationSource( '$path additionalProperties must be a bool literal or const ' @@ -1669,50 +1148,4 @@ final class SchemaModelGraphBuilder { } return false; } - - bool? _constantBool(Expression expression) { - while (expression is ParenthesizedExpression) { - expression = expression.expression; - } - if (expression is BooleanLiteral) return expression.value; - - final element = switch (expression) { - SimpleIdentifier() => expression.element, - PrefixedIdentifier() => expression.identifier.element, - PropertyAccess() => expression.propertyName.element, - _ => null, - }; - final declaration = element == null ? null : _propertyDeclaration(element); - return declaration is VariableElement - ? declaration.computeConstantValue()?.toBoolValue() - : null; - } - - String? _description(Expression expression) { - Expression? current = expression; - while (current is MethodInvocation) { - final arguments = _argumentExpressions(current.argumentList); - if (current.methodName.name == 'describe' && - arguments.firstOrNull is SimpleStringLiteral) { - return (arguments.first as SimpleStringLiteral).value; - } - current = current.target; - } - return null; - } - - /// Normalizes Analyzer 10 argument nodes into the expression API used by the - /// graph. - List _argumentExpressions(ArgumentList argumentList) => - argumentList.arguments - .map((argument) => _argumentExpression(argument)) - .toList(growable: false); - - Expression _argumentExpression(Expression argument) => - argument is NamedExpression ? argument.expression : argument; - - ({String name, Expression expression})? _namedArgument(Expression argument) => - argument is NamedExpression - ? (name: argument.name.label.name, expression: argument.expression) - : null; } diff --git a/packages/ack_generator/lib/src/builders/class_model_emitter.dart b/packages/ack_generator/lib/src/builders/class_model_emitter.dart index e39c3227..3f00fcb4 100644 --- a/packages/ack_generator/lib/src/builders/class_model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/class_model_emitter.dart @@ -308,7 +308,7 @@ ${node.className} $function(Map value) => final declared = _declaredKeys(node); return ''' ${node.className} $function(Map value) { - const declared = ${_keySet(declared)}; + const declared = ${_dataClasses.declaredKeysLiteral(declared)}; return ${node.className}( ${arguments.join(',\n ')}, ); @@ -323,7 +323,7 @@ ${node.className} $function(Map value) { final keyLiteral = dartStringLiteral(field.jsonKey); final raw = 'value[$keyLiteral]'; final innerType = _type(valueType); - final baseValueType = _nonNullable(valueType); + final baseValueType = _dataClasses.nonNullable(valueType); final absentExpr = parameter.defaultExpression != null && parameter.defaultExpression != 'null' @@ -354,7 +354,7 @@ ${node.className} $function(Map value) { parameter.defaultExpression != 'null' ? parameter.defaultExpression : null; - final baseType = _nonNullable(parameter.typeRef); + final baseType = _dataClasses.nonNullable(parameter.typeRef); final isNullableOrDefaulted = parameter.typeRef is AckNullableTypeRef || defaultExpr != null; if (!isNullableOrDefaulted) { @@ -398,7 +398,7 @@ Map $function(${node.className} model) => ]; return ''' Map $function(${node.className} model) { - const declared = ${_keySet(_declaredKeys(node))}; + const declared = ${_dataClasses.declaredKeysLiteral(_declaredKeys(node))}; return { ${entries.join(',\n ')}, }; @@ -407,12 +407,12 @@ Map $function(${node.className} model) { String _fieldRuntimeMapEntry(AckFieldNode field) { if (field.runtimeRef case AckJsonMaybeTypeRef(:final valueType)) { - final baseValueType = _nonNullable(valueType); + final baseValueType = _dataClasses.nonNullable(valueType); final encodedValue = baseValueType is AckScalarTypeRef || baseValueType is AckExternalTypeRef ? 'value' - : _toRuntime(AckNullableTypeRef(baseValueType), 'value'); + : _dataClasses.toRuntime(AckNullableTypeRef(baseValueType), 'value'); return 'if (model.${field.dartName} case ${_ack('JsonValue')}(:final value)) ' '${dartStringLiteral(field.jsonKey)}: $encodedValue'; } @@ -423,83 +423,14 @@ Map $function(${node.className} model) { bool _omitIfNull(AckFieldNode field) => field.nullable && !(field.isRequired && field.acceptsNull); - String _encodeField(AckFieldNode field) { - final baseType = _nonNullable(field.runtimeRef); - final access = 'model.${field.dartName}'; - if (!field.nullable) { - return _toRuntime(baseType, access); - } - if (baseType is AckScalarTypeRef || baseType is AckExternalTypeRef) { - return access; - } - if (baseType is AckListTypeRef || - baseType is AckSetTypeRef || - baseType is AckMapTypeRef) { - return _toRuntime(AckNullableTypeRef(baseType), access); - } - return 'switch ($access) {' - ' null => null,' - ' final fieldValue => ${_toRuntime(baseType, 'fieldValue')},' - ' }'; - } - - AckInferRef _nonNullable(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => inner, - _ => type, - }; - - String _fromRuntime(AckInferRef type, String expression) => switch (type) { - // Any input is already assignable to Object?; a cast would be redundant. - AckNullableTypeRef(inner: AckScalarTypeRef(dartType: 'Object')) => - expression, - AckNullableTypeRef(:final inner) - when inner is AckScalarTypeRef || inner is AckExternalTypeRef => - '$expression as ${_type(type)}', - AckNullableTypeRef(:final inner) => - '$expression == null ? null : ' - '${_fromRuntime(inner, expression)}', - AckModelTypeRef(:final runtimeRef, :final visibleName) => - '$visibleName.\$ack.fromRuntime(' - '$expression as ${_type(runtimeRef)})', - AckListTypeRef(:final elementType) => - 'List<${_type(elementType)}>.unmodifiable((' - '$expression as List).map((item) => ' - '${_fromRuntime(elementType, 'item')}))', - AckSetTypeRef(:final elementType) => - 'Set<${_type(elementType)}>.unmodifiable((' - '$expression as Set).map((item) => ' - '${_fromRuntime(elementType, 'item')}))', - AckMapTypeRef(:final valueType) => - 'Map.unmodifiable((' - '$expression as Map).map((key, item) => MapEntry(' - 'key as String, ${_fromRuntime(valueType, 'item')})))', - _ => '$expression as ${_type(type)}', - }; + String _encodeField(AckFieldNode field) => _dataClasses.encodeField( + field.runtimeRef, + 'model.${field.dartName}', + nullable: field.nullable, + ); - String _toRuntime(AckInferRef type, String expression) => switch (type) { - AckNullableTypeRef(:final inner) - when inner is AckScalarTypeRef || inner is AckExternalTypeRef => - expression, - // `value?.map(...)` satisfies prefer_null_aware_operators. - AckNullableTypeRef(:final inner) - when inner is AckListTypeRef || - inner is AckSetTypeRef || - inner is AckMapTypeRef => - _toRuntime(inner, '$expression?'), - AckNullableTypeRef(:final inner) => - '$expression == null ? null : ${_toRuntime(inner, expression)}', - AckModelTypeRef(:final visibleName) => - '$visibleName.\$ack.toRuntime($expression)', - AckListTypeRef(:final elementType) => - '$expression.map((item) => ${_toRuntime(elementType, 'item')})' - '.toList(growable: false)', - AckSetTypeRef(:final elementType) => - '$expression.map((item) => ${_toRuntime(elementType, 'item')}).toSet()', - AckMapTypeRef(:final valueType) => - '$expression.map((key, item) => ' - 'MapEntry(key, ${_toRuntime(valueType, 'item')}))', - _ => expression, - }; + String _fromRuntime(AckInferRef type, String expression) => _dataClasses + .fromRuntime(type, expression, wrapImmutableCollections: true); String _type(AckInferRef type) => type.render(ackPrefix: ackPrefix); @@ -508,9 +439,6 @@ Map $function(${node.className} model) { if (node.discriminatorKey case final discriminator?) discriminator, }; - String _keySet(Set keys) => - '{${keys.map(dartStringLiteral).join(', ')}}'; - AckClassModelMetadata _metadata(AckModelGraph graph, AckModelNode node) { final metadata = graph.classMetadataFor(node.id); if (metadata == null) { diff --git a/packages/ack_generator/lib/src/builders/data_class_emitter.dart b/packages/ack_generator/lib/src/builders/data_class_emitter.dart index 9df1f088..05955e9c 100644 --- a/packages/ack_generator/lib/src/builders/data_class_emitter.dart +++ b/packages/ack_generator/lib/src/builders/data_class_emitter.dart @@ -360,6 +360,181 @@ ${_ack('SchemaResult')}> safeToJson() => String _hash(String expression) => '${_ack('deepHashCode')}($expression)'; + AckInferRef nonNullable(AckInferRef type) => switch (type) { + AckNullableTypeRef(:final inner) => inner, + _ => type, + }; + + bool requiresImmutableCopy(AckInferRef type) => switch (type) { + AckNullableTypeRef(:final inner) => requiresImmutableCopy(inner), + AckListTypeRef() || AckSetTypeRef() || AckMapTypeRef() => true, + _ => false, + }; + + bool requiresRuntimeConversion(AckInferRef type) => switch (type) { + AckNullableTypeRef(:final inner) => requiresRuntimeConversion(inner), + AckModelTypeRef() || + AckListTypeRef() || + AckSetTypeRef() || + AckMapTypeRef() => true, + _ => false, + }; + + String immutableCopy(AckInferRef type, String expression) { + return switch (type) { + AckNullableTypeRef(:final inner) when !requiresImmutableCopy(inner) => + expression, + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${immutableCopy(inner, expression)}', + AckListTypeRef(:final elementType) => + 'List<${_type(elementType)}>.unmodifiable($expression.map((item) => ${immutableCopy(elementType, 'item')}))', + AckSetTypeRef(:final elementType) => + 'Set<${_type(elementType)}>.unmodifiable($expression.map((item) => ${immutableCopy(elementType, 'item')}))', + AckMapTypeRef(:final valueType) when !requiresImmutableCopy(valueType) => + 'Map.unmodifiable($expression)', + AckMapTypeRef(:final valueType) => + 'Map.unmodifiable($expression.map((key, item) => MapEntry(key, ${immutableCopy(valueType, 'item')})))', + _ => expression, + }; + } + + String fromRuntime( + AckInferRef type, + String expression, { + bool wrapImmutableCollections = false, + }) { + return switch (type) { + // Any input is already assignable to Object?; a cast would be redundant. + AckNullableTypeRef(inner: AckScalarTypeRef(dartType: 'Object')) => + expression, + AckNullableTypeRef(:final inner) + when wrapImmutableCollections && + (inner is AckScalarTypeRef || inner is AckExternalTypeRef) => + '$expression as ${_type(type)}', + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${fromRuntime(inner, wrapImmutableCollections ? expression : '$expression!', wrapImmutableCollections: wrapImmutableCollections)}', + AckModelTypeRef(:final runtimeRef, :final visibleName) => + '$visibleName.\$ack.fromRuntime($expression as ${_type(runtimeRef)})', + AckListTypeRef(:final elementType) => _listFromRuntime( + elementType, + expression, + wrapImmutableCollections: wrapImmutableCollections, + ), + AckSetTypeRef(:final elementType) => _setFromRuntime( + elementType, + expression, + wrapImmutableCollections: wrapImmutableCollections, + ), + AckMapTypeRef(:final valueType) => _mapFromRuntime( + valueType, + expression, + wrapImmutableCollections: wrapImmutableCollections, + ), + _ => '$expression as ${_type(type)}', + }; + } + + String _listFromRuntime( + AckInferRef elementType, + String expression, { + required bool wrapImmutableCollections, + }) { + final elementTypeName = _type(elementType); + if (wrapImmutableCollections) { + return 'List<$elementTypeName>.unmodifiable(($expression as List).map(' + '(item) => ${fromRuntime(elementType, 'item', wrapImmutableCollections: true)}))'; + } + if (!requiresRuntimeConversion(elementType)) { + return '($expression as List).cast<$elementTypeName>()'; + } + return '($expression as List).map(' + '(item) => ${fromRuntime(elementType, 'item')}).toList()'; + } + + String _setFromRuntime( + AckInferRef elementType, + String expression, { + required bool wrapImmutableCollections, + }) { + final elementTypeName = _type(elementType); + if (wrapImmutableCollections) { + return 'Set<$elementTypeName>.unmodifiable(($expression as Set).map(' + '(item) => ${fromRuntime(elementType, 'item', wrapImmutableCollections: true)}))'; + } + if (!requiresRuntimeConversion(elementType)) { + return '($expression as Set).cast<$elementTypeName>()'; + } + return '($expression as Set).map(' + '(item) => ${fromRuntime(elementType, 'item')}).toSet()'; + } + + String _mapFromRuntime( + AckInferRef valueType, + String expression, { + required bool wrapImmutableCollections, + }) { + final valueTypeName = _type(valueType); + if (wrapImmutableCollections) { + return 'Map.unmodifiable(($expression as Map).map(' + '(key, item) => MapEntry(key as String, ' + '${fromRuntime(valueType, 'item', wrapImmutableCollections: true)})))'; + } + if (!requiresRuntimeConversion(valueType)) { + return '($expression as Map).cast()'; + } + return '($expression as Map).map((key, item) => MapEntry(' + 'key as String, ${fromRuntime(valueType, 'item')}))'; + } + + String toRuntime(AckInferRef type, String expression) { + return switch (type) { + AckNullableTypeRef(:final inner) + when inner is AckScalarTypeRef || inner is AckExternalTypeRef => + expression, + // `value?.map(...)` satisfies prefer_null_aware_operators. + AckNullableTypeRef(:final inner) + when inner is AckListTypeRef || + inner is AckSetTypeRef || + inner is AckMapTypeRef => + toRuntime(inner, '$expression?'), + // Expressions are promotable locals, so the null check needs no `!`. + AckNullableTypeRef(:final inner) => + '$expression == null ? null : ${toRuntime(inner, expression)}', + AckModelTypeRef(:final visibleName) => + '$visibleName.\$ack.toRuntime($expression)', + AckListTypeRef(:final elementType) => + '$expression.map((item) => ${toRuntime(elementType, 'item')}).toList(growable: false)', + AckSetTypeRef(:final elementType) => + '$expression.map((item) => ${toRuntime(elementType, 'item')}).toSet()', + AckMapTypeRef(:final valueType) => + '$expression.map((key, item) => MapEntry(key, ${toRuntime(valueType, 'item')}))', + _ => expression, + }; + } + + String encodeField( + AckInferRef runtimeRef, + String access, { + required bool nullable, + }) { + final baseType = nonNullable(runtimeRef); + if (!nullable || !requiresRuntimeConversion(baseType)) { + return toRuntime(baseType, access); + } + if (baseType is AckListTypeRef || + baseType is AckSetTypeRef || + baseType is AckMapTypeRef) { + return toRuntime(AckNullableTypeRef(baseType), access); + } + return 'switch ($access) {' + ' null => null,' + ' final fieldValue => ${toRuntime(baseType, 'fieldValue')},' + ' }'; + } + + String declaredKeysLiteral(Iterable keys) => + '{${keys.map(dartStringLiteral).join(', ')}}'; + String _type(AckInferRef type) => type.render(ackPrefix: ackPrefix); String _ack(String symbol) => qualifySymbol(ackPrefix, symbol); diff --git a/packages/ack_generator/lib/src/builders/model_emitter.dart b/packages/ack_generator/lib/src/builders/model_emitter.dart index 0ba354a3..bdab8d51 100644 --- a/packages/ack_generator/lib/src/builders/model_emitter.dart +++ b/packages/ack_generator/lib/src/builders/model_emitter.dart @@ -150,7 +150,7 @@ final class AckModelEmitter { ..name = '_toAckRuntime' ..returns = refer(schemaRuntimeRef) ..lambda = true - ..body = Code(_toRuntime(node.runtimeRef, 'value')), + ..body = Code(_dataClasses.toRuntime(node.runtimeRef, 'value')), ), ]), ); @@ -360,8 +360,8 @@ ${_ack('AckModelAdapter')}( '/// the model while encoding it.', ]); for (final field in fields) { - final copyType = _nonNullable(field.runtimeRef); - final needsCopy = _requiresImmutableCopy(copyType); + final copyType = _dataClasses.nonNullable(field.runtimeRef); + final needsCopy = _dataClasses.requiresImmutableCopy(copyType); c.optionalParameters.add( Parameter( (p) => p @@ -375,13 +375,13 @@ ${_ack('AckModelAdapter')}( if (!needsCopy) continue; final value = field.dartName; - final copy = _immutableCopy(copyType, value); + final copy = _dataClasses.immutableCopy(copyType, value); var initializer = copy; if (!field.isRequired || field.nullable) { initializer = 'switch ($value) {' ' null => null,' - ' final fieldValue => ${_immutableCopy(copyType, 'fieldValue')},' + ' final fieldValue => ${_dataClasses.immutableCopy(copyType, 'fieldValue')},' ' }'; } c.initializers.add(Code('${field.dartName} = $initializer')); @@ -406,7 +406,7 @@ ${_ack('AckModelAdapter')}( }); Constructor _valueConstructor(AckValueModelNode node, String runtimeType) { - final needsCopy = _requiresImmutableCopy(node.runtimeRef); + final needsCopy = _dataClasses.requiresImmutableCopy(node.runtimeRef); return Constructor((c) { c.docs.addAll([ '/// Creates a model without validating it.', @@ -424,7 +424,9 @@ ${_ack('AckModelAdapter')}( ); if (needsCopy) { c.initializers.add( - Code('value = ${_immutableCopy(node.runtimeRef, 'value')}'), + Code( + 'value = ${_dataClasses.immutableCopy(node.runtimeRef, 'value')}', + ), ); } }); @@ -654,7 +656,7 @@ ${_ack('AckModelAdapter')}( ), ) ..body = Code(''' -const declared = ${_declaredKeysLiteral(keys)}; +const declared = ${_dataClasses.declaredKeysLiteral(keys)}; return ${node.className}( ${allArguments.join(',\n ')}, );'''), @@ -688,7 +690,7 @@ return ${node.className}( return; } - final declaredLiteral = _declaredKeysLiteral( + final declaredLiteral = _dataClasses.declaredKeysLiteral( _declaredJsonKeys(effectiveFields, additionalKeys: leadingEntries.keys), ); final allEntries = [ @@ -705,111 +707,27 @@ return $_runtimeMapLiteral{ } String _decodeField(AckFieldNode field, String rawExpression) { - final runtimeRef = _nonNullable(field.runtimeRef); + final runtimeRef = _dataClasses.nonNullable(field.runtimeRef); final needsNullGuard = !field.isRequired || field.nullable; if (!needsNullGuard) { - return _fromRuntime(runtimeRef, rawExpression); + return _dataClasses.fromRuntime(runtimeRef, rawExpression); } - if (!_requiresRuntimeConversion(runtimeRef)) { + if (!_dataClasses.requiresRuntimeConversion(runtimeRef)) { final type = '${_type(runtimeRef)}?'; // The runtime map entry is already Object?. return type == 'Object?' ? rawExpression : '$rawExpression as $type'; } return 'switch ($rawExpression) {' ' null => null,' - ' final fieldValue => ${_fromRuntime(runtimeRef, 'fieldValue')},' + ' final fieldValue => ${_dataClasses.fromRuntime(runtimeRef, 'fieldValue')},' ' }'; } - String _encodeField(AckFieldNode field) { - final runtimeRef = _nonNullable(field.runtimeRef); - final name = field.dartName; - final needsNullGuard = !field.isRequired || field.nullable; - if (!needsNullGuard || !_requiresRuntimeConversion(runtimeRef)) { - return _toRuntime(runtimeRef, name); - } - if (runtimeRef is AckListTypeRef || - runtimeRef is AckSetTypeRef || - runtimeRef is AckMapTypeRef) { - return _toRuntime(AckNullableTypeRef(runtimeRef), name); - } - return 'switch ($name) {' - ' null => null,' - ' final fieldValue => ${_toRuntime(runtimeRef, 'fieldValue')},' - ' }'; - } - - String _fromRuntime(AckInferRef type, String expression) { - return switch (type) { - // Any input is already assignable to Object?; a cast would be redundant. - AckNullableTypeRef(inner: AckScalarTypeRef(dartType: 'Object')) => - expression, - AckNullableTypeRef(:final inner) => - '$expression == null ? null : ${_fromRuntime(inner, '$expression!')}', - AckModelTypeRef(:final runtimeRef, :final visibleName) => - '$visibleName.\$ack.fromRuntime($expression as ${_type(runtimeRef)})', - AckListTypeRef(:final elementType) - when !_requiresRuntimeConversion(elementType) => - '($expression as List).cast<${_type(elementType)}>()', - AckListTypeRef(:final elementType) => - '($expression as List).map((item) => ${_fromRuntime(elementType, 'item')}).toList()', - AckSetTypeRef(:final elementType) - when !_requiresRuntimeConversion(elementType) => - '($expression as Set).cast<${_type(elementType)}>()', - AckSetTypeRef(:final elementType) => - '($expression as Set).map((item) => ${_fromRuntime(elementType, 'item')}).toSet()', - AckMapTypeRef(:final valueType) - when !_requiresRuntimeConversion(valueType) => - '($expression as Map).cast()', - AckMapTypeRef(:final valueType) => - '($expression as Map).map((key, item) => MapEntry(key as String, ${_fromRuntime(valueType, 'item')}))', - _ => '$expression as ${_type(type)}', - }; - } - - String _toRuntime(AckInferRef type, String expression) { - return switch (type) { - AckNullableTypeRef(:final inner) - when inner is AckScalarTypeRef || inner is AckExternalTypeRef => - expression, - // `value?.map(...)` satisfies prefer_null_aware_operators. - AckNullableTypeRef(:final inner) - when inner is AckListTypeRef || - inner is AckSetTypeRef || - inner is AckMapTypeRef => - _toRuntime(inner, '$expression?'), - // Expressions are promotable locals, so the null check needs no `!`. - AckNullableTypeRef(:final inner) => - '$expression == null ? null : ${_toRuntime(inner, expression)}', - AckModelTypeRef(:final visibleName) => - '$visibleName.\$ack.toRuntime($expression)', - AckListTypeRef(:final elementType) => - '$expression.map((item) => ${_toRuntime(elementType, 'item')}).toList(growable: false)', - AckSetTypeRef(:final elementType) => - '$expression.map((item) => ${_toRuntime(elementType, 'item')}).toSet()', - AckMapTypeRef(:final valueType) => - '$expression.map((key, item) => MapEntry(key, ${_toRuntime(valueType, 'item')}))', - _ => expression, - }; - } - - String _immutableCopy(AckInferRef type, String expression) { - return switch (type) { - AckNullableTypeRef(:final inner) when !_requiresImmutableCopy(inner) => - expression, - AckNullableTypeRef(:final inner) => - '$expression == null ? null : ${_immutableCopy(inner, expression)}', - AckListTypeRef(:final elementType) => - 'List<${_type(elementType)}>.unmodifiable($expression.map((item) => ${_immutableCopy(elementType, 'item')}))', - AckSetTypeRef(:final elementType) => - 'Set<${_type(elementType)}>.unmodifiable($expression.map((item) => ${_immutableCopy(elementType, 'item')}))', - AckMapTypeRef(:final valueType) when !_requiresImmutableCopy(valueType) => - 'Map.unmodifiable($expression)', - AckMapTypeRef(:final valueType) => - 'Map.unmodifiable($expression.map((key, item) => MapEntry(key, ${_immutableCopy(valueType, 'item')})))', - _ => expression, - }; - } + String _encodeField(AckFieldNode field) => _dataClasses.encodeField( + field.runtimeRef, + field.dartName, + nullable: !field.isRequired || field.nullable, + ); String _fieldType(AckFieldNode field) { final base = _type(field.runtimeRef); @@ -831,29 +749,6 @@ return $_runtimeMapLiteral{ Iterable additionalKeys = const [], }) => {...additionalKeys, for (final field in fields) field.jsonKey}; - String _declaredKeysLiteral(Set keys) => - '{${keys.map(dartStringLiteral).join(', ')}}'; - - AckInferRef _nonNullable(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => inner, - _ => type, - }; - - bool _requiresImmutableCopy(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => _requiresImmutableCopy(inner), - AckListTypeRef() || AckSetTypeRef() || AckMapTypeRef() => true, - _ => false, - }; - - bool _requiresRuntimeConversion(AckInferRef type) => switch (type) { - AckNullableTypeRef(:final inner) => _requiresRuntimeConversion(inner), - AckModelTypeRef() || - AckListTypeRef() || - AckSetTypeRef() || - AckMapTypeRef() => true, - _ => false, - }; - String _valueToJsonBody(AckInferRef boundaryType) { return switch (boundaryType) { AckListTypeRef(:final elementType) => From 4f1c54dce684cbf48864c47f91189db083cf4701 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 11 Oct 2026 20:14:53 -0400 Subject: [PATCH 19/29] fix(ack_generator): read AST doc comments on primary constructor declaring parameters --- .../analyzer/class_model_graph_builder.dart | 26 ++++++++++- .../integration/class_model_emitter_test.dart | 46 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index cb133ce0..274f2164 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -903,10 +903,34 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { return _inference.applyDescription( schema, target, - sourceComment: field.documentationComment, + sourceComment: _fieldDocumentationComment(field, parameter), ); } + String? _fieldDocumentationComment( + FieldElement field, + FormalParameterElement? parameter, + ) { + final direct = + field.documentationComment ?? parameter?.documentationComment; + if (direct != null) return direct; + final resolved = inputResolved; + if (resolved == null) return null; + for (final element in [field, if (parameter != null) parameter]) { + var node = declarationNode(resolved, element); + if (node is DefaultFormalParameter) { + node = node.parameter; + } + if (node is NormalFormalParameter) { + final comment = node.documentationComment; + if (comment != null && comment.tokens.isNotEmpty) { + return comment.tokens.map((token) => token.lexeme).join('\n'); + } + } + } + return null; + } + DartObject? _firstAnnotation( TypeChecker checker, FieldElement field, [ diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 5b7ab6f2..26867557 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -1,5 +1,6 @@ import 'package:ack_generator/src/builder.dart'; import 'package:build/build.dart'; +import 'package:build/experiments.dart'; import 'package:build_test/build_test.dart'; import 'package:test/test.dart'; @@ -169,6 +170,51 @@ final class Documented with _\$DocumentedAck { ); }); + test( + 'primary constructor declaring parameters support @description and @Validate', + () async { + await withEnabledExperiments( + () => _build( + { + 'primary_doc.dart': + ''' +// @dart=3.11 +$_imports +part 'primary_doc.g.dart'; + +/// @description A task item. +@Schemable() +final class const PrimaryTask({ + /// @description The item title. + @Validate.minLength(1) + required final String title, + + /// @description The category id. + @Validate.minLength(1) + final String category = 'general', +}) with _\$PrimaryTaskAck { + static final schema = _primaryTaskSchema; +} +''', + }, + outputs: { + 'test_pkg|lib/primary_doc.g.dart': decodedMatches( + allOf( + _containsCode( + "'title': Ack.string().minLength(1).describe('The item title.')", + ), + _containsCode( + "'category': Ack.string().minLength(1).describe('The category id.').withDefault('general')", + ), + ), + ), + }, + ), + const ['primary-constructors'], + ); + }, + ); + test('description tags describe the object and its codec', () async { const task = r"'A task the person\'s \$team can complete.'"; await _build( From 38af885de0cc17b82a10f10a1268c18e0770b90d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 11 Oct 2026 20:19:15 -0400 Subject: [PATCH 20/29] chore(ack_generator): widen analyzer constraint to >=10.0.0 <15.0.0 for Dart 3.13 --- packages/ack_generator/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 1c7a7aad..6c8a9771 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -15,7 +15,7 @@ environment: dependencies: # Core code generation dependencies - analyzer: ">=10.0.0 <11.0.0" + analyzer: ">=10.0.0 <15.0.0" build: ^4.0.0 source_gen: ^4.2.4 code_builder: ^4.10.0 From cabd6cd3b0fb9fa87fc0f83eac5153b555e90cd4 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sun, 11 Oct 2026 20:32:29 -0400 Subject: [PATCH 21/29] chore(ack_generator): migrate AST helpers to analyzer 13 --- .../src/analyzer/class_model_graph_builder.dart | 7 ++----- .../lib/src/analyzer/custom_schema_resolver.dart | 4 ++-- .../src/analyzer/model_graph_builder_base.dart | 15 ++++++++------- packages/ack_generator/pubspec.yaml | 2 +- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 274f2164..46f87a09 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -917,11 +917,8 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { final resolved = inputResolved; if (resolved == null) return null; for (final element in [field, if (parameter != null) parameter]) { - var node = declarationNode(resolved, element); - if (node is DefaultFormalParameter) { - node = node.parameter; - } - if (node is NormalFormalParameter) { + final node = declarationNode(resolved, element); + if (node is FormalParameter) { final comment = node.documentationComment; if (comment != null && comment.tokens.isNotEmpty) { return comment.tokens.map((token) => token.lexeme).join('\n'); diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart index 48f2a158..e732b575 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -778,12 +778,12 @@ final class CustomSchemaResolver { AstNode? node = _base.declarationNode(resolved, field); while (node != null && node is! FieldDeclaration && - node is! NormalFormalParameter) { + node is! FormalParameter) { node = node.parent; } final annotation = switch (node) { FieldDeclaration() => node.fields.type, - SimpleFormalParameter() => node.type, + RegularFormalParameter() => node.type, FieldFormalParameter() => node.type, SuperFormalParameter() => node.type, _ => null, diff --git a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart index 6de85ef1..d44cc845 100644 --- a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -847,15 +847,16 @@ abstract base class ModelGraphBuilderBase { } /// Normalizes Analyzer argument nodes into plain expressions. - List argumentExpressions(ArgumentList argumentList) => - argumentList.arguments.map(argumentExpression).toList(growable: false); + List argumentExpressions(ArgumentList argumentList) => [ + for (final argument in argumentList.arguments) argument.argumentExpression, + ]; - Expression argumentExpression(Expression argument) => - argument is NamedExpression ? argument.expression : argument; + Expression argumentExpression(Argument argument) => + argument.argumentExpression; - ({String name, Expression expression})? namedArgument(Expression argument) => - argument is NamedExpression - ? (name: argument.name.label.name, expression: argument.expression) + ({String name, Expression expression})? namedArgument(Argument argument) => + argument is NamedArgument + ? (name: argument.name.lexeme, expression: argument.argumentExpression) : null; bool? constantBool(Expression expression) { diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 6c8a9771..332376cf 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -15,7 +15,7 @@ environment: dependencies: # Core code generation dependencies - analyzer: ">=10.0.0 <15.0.0" + analyzer: ">=13.0.0 <15.0.0" build: ^4.0.0 source_gen: ^4.2.4 code_builder: ^4.10.0 From d616763af3bf98e29ff4aa28f4f6ceef31a01d7c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 12 Oct 2026 10:09:37 -0400 Subject: [PATCH 22/29] fix(ack,ack_generator): harden JSON Schema export composition, Draft-7 lowering, and model codegen --- CHANGELOG.md | 13 ++ packages/ack/CHANGELOG.md | 10 + .../src/json_schema/json_schema_compiler.dart | 23 ++ .../ack_schema_model_builder.dart | 206 +++++++++++++++++- .../lib/src/schemas/imported_json_schema.dart | 80 +++++-- .../json_schema_draft7_lowering_test.dart | 123 +++++++++++ .../json_schema_import_composition_test.dart | 176 +++++++++++++++ .../json_schema_resource_export_test.dart | 107 +++++++++ packages/ack_generator/CHANGELOG.md | 6 + packages/ack_generator/README.md | 84 +++++++ .../src/json_schema/json_schema_library.dart | 60 +++-- packages/ack_generator/pubspec.yaml | 2 +- .../json_schema_runtime_build_test.dart | 81 +++++++ .../json_schema/fixtures/mapping.schema.dart | 123 +++++++---- .../fixtures/mapping_preserve.schema.dart | 125 +++++++---- .../json_schema/json_schema_library_test.dart | 42 +++- 16 files changed, 1137 insertions(+), 124 deletions(-) create mode 100644 packages/ack/test/json_schema_draft7_lowering_test.dart create mode 100644 packages/ack/test/json_schema_import_composition_test.dart create mode 100644 packages/ack/test/json_schema_resource_export_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc962b5..192572ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,19 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline `.schema.g.dart` typed or value-model libraries (and `generateAckSchemaLibrary` is available for programmatic generation). +### Fixed + +* Explicit Draft-7 export (`toJsonSchemaDraft7()` / `toSchemaModelDraft7()`) + rejects unsupported Draft 2020-12 assertions rather than emitting keywords + that Draft-7 validators ignore. +* Imported schema exports preserve JSON Pointer targets through retrieval URI + aliases and opt-in format assertions in nested resources. +* Composed imports share identical identified resources, retain occurrence-level + nullability and constraints, and reject conflicting resource definitions. +* Preserve integral decimal length and item bounds in JSON Schema generation, and + use validated value models for Unicode regex patterns and named scalar unions + that the typed model generator cannot represent faithfully. + ## 1.7.0-beta.7 * Strict JSON Schema import (`Ack.fromJsonSchema()`) now supports `multipleOf` diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 8a5b6a38..3d7b375d 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -50,6 +50,16 @@ `maxContains`, bundled offline 2020-12 meta-schemas, and the full Draft 2020-12 format suite (`#171`, `#186`). +### Fixed + +* Explicit Draft-7 export (`toJsonSchemaDraft7()` / `toSchemaModelDraft7()`) + rejects unsupported Draft 2020-12 assertions rather than emitting keywords + that Draft-7 validators ignore. +* Imported schema exports preserve JSON Pointer targets through retrieval URI + aliases and opt-in format assertions in nested resources. +* Composed imports share identical identified resources, retain occurrence-level + nullability and constraints, and reject conflicting resource definitions. + ## 1.7.0-beta.7 ### Added diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index 13c93aa4..3e50009b 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -103,6 +103,29 @@ final class _ImportedNode { /// The 1.x Draft-7 representation of an imported node. Newer 2020-12 /// assertions that cannot be lowered fail when `toJsonSchemaDraft7()` is used. Map renderLegacy(String Function(_ImportedNode) refOf) { + // Draft-7 silently ignores these keywords (and applies `items` to the + // entire array instead of only the suffix after `prefixItems`). Until a + // semantics-preserving lowering exists, fail instead of changing which + // instances the exported schema accepts. + const unsupportedKeywords = { + 'prefixItems', + 'dependentRequired', + 'dependentSchemas', + 'unevaluatedProperties', + 'unevaluatedItems', + 'minContains', + 'maxContains', + }; + if (source case final Map document) { + for (final keyword in unsupportedKeywords) { + if (document.containsKey(keyword)) { + throw UnsupportedError( + 'Cannot lower "$keyword" at ${_keywordPointer(this, keyword)} ' + 'with toJsonSchemaDraft7(); use toJsonSchema() for 2020-12 semantics.', + ); + } + } + } if (dynamicReference != null || dynamicAnchorName != null || (source is Map && diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 88468eef..141d033f 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:convert'; import 'package:collection/collection.dart'; @@ -7,6 +8,7 @@ import '../constraints/datetime_constraint.dart'; import '../context.dart'; import '../json_schema/json_schema_utils.dart'; import '../schemas/schema.dart'; +import '../utils/json_utils.dart'; import 'ack_schema_model.dart'; import 'ack_schema_model_warning.dart'; @@ -44,6 +46,9 @@ final class _SchemaModelBuilder { // Lazy-target identity is tracked separately from emitted definitions. final _lazyTargets = {}; var _importCount = 0; + var _composingImports = false; + final _importedResources = >{}; + final _resourceLocations = {}; Map _mergeRootDefinitions(Object? existingDefinitions) { final schemaDefinitions = {}; @@ -172,6 +177,27 @@ final class _SchemaModelBuilder { AckSchemaModel _imported(ImportedJsonSchema schema) { final importIndex = _importCount++; if (preserveImportedDialect) { + if (_composingImports) { + final indexedSchemas = HashSet>.identity(); + final document = schema.export2020Document( + importIndex: importIndex, + includeFluentNullability: false, + onSchema: indexedSchemas.add, + ); + return AckAllOfSchemaModel( + schemas: [ + AckRawSchemaModel( + schema: _shareImportedResources(document, indexedSchemas), + ), + ], + description: schema.description, + nullable: schema.isNullable, + extensions: { + if (!schema.isNullable && schema.sourceAllowsNull) + 'not': const {'type': 'null'}, + }, + ); + } return AckRawSchemaModel( schema: schema.export2020Document(importIndex: importIndex), description: schema.description, @@ -202,6 +228,106 @@ final class _SchemaModelBuilder { ); } + // Resource identity belongs to the imported document, not an occurrence's + // fluent constraints. Keep one copy and reference it from later occurrences. + Map _shareImportedResources( + Map document, + Set> indexedSchemas, + ) { + final locations = {}; + Uri location(Uri base, List path) => path.isEmpty + ? base.removeFragment() + : base.replace( + fragment: '/${path.map(escapeJsonPointerToken).join('/')}', + ); + + Object? index(Object? value, Uri base, List<(Uri, List)> scopes) { + final id = value is Map ? value[r'$id'] : null; + if (id is String) { + base = base.resolve(id); + final address = scopes.isEmpty + ? base + : location(scopes.first.$1, scopes.first.$2); + _resourceLocations.putIfAbsent(base, () => address); + scopes = [...scopes, (base, [])]; + } + for (final (uri, path) in scopes) { + final (resource, relativePath) = scopes.last; + locations[location(uri, path)] = location(resource, relativePath); + } + if (value is! Map) return value; + return _mapImportedChildren( + value, + (child, path) => index(child, base, [ + for (final (uri, prefix) in scopes) (uri, [...prefix, ...path]), + ]), + indexedSchemas, + ); + } + + final base = Uri.parse(document[r'$id']! as String); + index(document, base, []); + Object? normalize(Object? value, Uri base) { + if (value is! Map) return value; + final id = value[r'$id']; + if (id is String) base = base.resolve(id); + final result = _mapImportedChildren( + value, + (child, _) => normalize(child, base), + indexedSchemas, + ); + if (indexedSchemas.contains(value)) indexedSchemas.add(result); + if (id is String) result[r'$id'] = base.toString(); + for (final keyword in [r'$ref', r'$dynamicRef']) { + final ref = result[keyword]; + if (ref is String) { + Uri resolved; + try { + resolved = base.resolve(ref); + } on FormatException { + // Dormant malformed references are not evaluated by the importer. + continue; + } + final canonical = locations[resolved] ?? resolved; + final address = _resourceLocations[canonical.removeFragment()]; + if (address != null && + (canonical.fragment.isEmpty || + canonical.fragment.startsWith('/'))) { + result[keyword] = address + .replace(fragment: '${address.fragment}${canonical.fragment}') + .toString(); + } else { + result[keyword] = canonical.toString(); + } + } + } + return result; + } + + Object? share(Object? value) { + if (value is! Map) return value; + final id = value[r'$id']; + if (id is String) { + final uri = Uri.parse(id); + final existing = _importedResources[uri]; + if (existing != null) { + if (!_deepEquality.equals(existing, value)) { + throw ArgumentError('Conflicting imported schema resource "$uri".'); + } + return {r'$ref': _resourceLocations[uri].toString()}; + } + _importedResources[uri] = value; + } + return _mapImportedChildren( + value, + (child, _) => share(child), + indexedSchemas, + ); + } + + return share(normalize(document, base))! as Map; + } + AckSchemaModel _string(StringSchema schema) { return AckStringSchemaModel( description: schema.description, @@ -426,12 +552,25 @@ final class _SchemaModelBuilder { } AckSchemaModel build(AckSchema schema) { + var unwrapped = schema; + while (unwrapped is WrapperSchema) { + unwrapped = unwrapped.inner; + } + _composingImports = unwrapped is! ImportedJsonSchema; final root = _build(schema); - if (_definitions.isEmpty) return root; + final needs2020Dialect = + preserveImportedDialect && + _importCount > 0 && + root is! AckRawSchemaModel && + !root.extensions.containsKey(r'$schema'); + if (_definitions.isEmpty && !needs2020Dialect) return root; return root.withExtensions({ + if (needs2020Dialect) + r'$schema': 'https://json-schema.org/draft/2020-12/schema', ...root.extensions, - 'definitions': _mergeRootDefinitions(root.extensions['definitions']), + if (_definitions.isNotEmpty) + 'definitions': _mergeRootDefinitions(root.extensions['definitions']), }); } } @@ -587,3 +726,66 @@ Object? _jsonRoundTripOrNull(Object? value) { return null; } } + +// Only schema-bearing keywords contain resource identities. Values of const, +// default, examples, and unknown annotations remain ordinary instance data. +Map _mapImportedChildren( + Map schema, + Object? Function(Object?, List) visit, + Set> indexedSchemas, +) { + Object? visitIndexed(Object? value, List path) { + if (value is Map) { + if (indexedSchemas.contains(value)) return visit(value, path); + return { + for (final entry in value.entries) + entry.key: visitIndexed(entry.value, [...path, entry.key]), + }; + } + if (value is List) { + return [ + for (final (index, child) in value.indexed) + visitIndexed(child, [...path, '$index']), + ]; + } + return value; + } + + const maps = { + r'$defs', + 'definitions', + 'properties', + 'patternProperties', + 'dependentSchemas', + }; + const children = { + 'items', + 'additionalProperties', + 'not', + 'contains', + 'propertyNames', + 'if', + 'then', + 'else', + 'unevaluatedProperties', + 'unevaluatedItems', + }; + const lists = {'allOf', 'anyOf', 'oneOf', 'prefixItems'}; + return { + for (final entry in schema.entries) + entry.key: maps.contains(entry.key) && entry.value is Map + ? { + for (final child + in (entry.value! as Map).entries) + child.key: visit(child.value, [entry.key, child.key]), + } + : children.contains(entry.key) + ? visit(entry.value, [entry.key]) + : lists.contains(entry.key) && entry.value is List + ? [ + for (final (index, child) in (entry.value! as List).indexed) + visit(child, [entry.key, '$index']), + ] + : visitIndexed(entry.value, [entry.key]), + }; +} diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index 92472d8a..d963ff2d 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -37,6 +37,7 @@ final class ImportedJsonSchema extends AckSchema return ImportedJsonSchema._( root, externalRoots: externalRoots, + indexedNodes: compiler.locations.values.toList(growable: false), assertFormats: assertFormats, ); } @@ -44,6 +45,7 @@ final class ImportedJsonSchema extends AckSchema ImportedJsonSchema._( this._root, { required List<_ImportedNode> externalRoots, + required List<_ImportedNode> indexedNodes, required this.assertFormats, bool? isNullable, super.isOptional, @@ -51,6 +53,7 @@ final class ImportedJsonSchema extends AckSchema super.constraints, super.refinements, }) : _externalRoots = externalRoots, + _indexedNodes = indexedNodes, super( isNullable: isNullable ?? _checkImportedNode(_root, null) == null, description: description ?? _root.keywords['description'] as String?, @@ -58,6 +61,7 @@ final class ImportedJsonSchema extends AckSchema final _ImportedNode _root; final List<_ImportedNode> _externalRoots; + final List<_ImportedNode> _indexedNodes; final bool assertFormats; Object get rawSchema => _root.source; @@ -91,9 +95,68 @@ final class ImportedJsonSchema extends AckSchema /// Exports the original resources with 2020-12 identity and reference /// semantics intact. Supplied documents are embedded as distinct resources. @internal - Map export2020Document({required int importIndex}) { + Map export2020Document({ + required int importIndex, + bool includeFluentNullability = true, + void Function(Map)? onSchema, + }) { + Object? copyJson(Object? value) => switch (value) { + final Map map => map.map( + (key, value) => MapEntry(key, copyJson(value)), + ), + final List list => list.map(copyJson).toList(), + _ => value, + }; + Map resource(_ImportedNode node, {Uri? syntheticId}) { - final source = cloneDefault(node.source); + final source = copyJson(node.source); + // Edit only indexed schema locations, never annotation payloads. This + // includes explicitly referenced schemas in nonstandard containers. + for (final indexed in _indexedNodes) { + if (indexed.documentUri != node.documentUri || source is! Map) continue; + Object? exported = source; + for (final token in indexed.pointer.substring(1).split('/').skip(1)) { + final key = token.replaceAll('~1', '/').replaceAll('~0', '~'); + exported = switch (exported) { + final Map map => map[key], + final List list => list[int.parse(key)], + _ => null, + }; + } + if (exported is! Map) continue; + onSchema?.call(exported); + if (assertFormats && + indexed.dialectUri == _JsonSchemaCompiler.standardDialect && + (identical(indexed, node) || exported.containsKey(r'$schema'))) { + exported[r'$schema'] = _assertionDialect.toString(); + } + // A retrieval URI is an address for the original document, not a + // resource that can be replaced by a $ref-only alias. Resolve it to + // its canonical ID while retaining the encoded fragment. + for (final keyword in [r'$ref', r'$dynamicRef', r'$schema']) { + final value = exported[keyword]; + if (value is! String) continue; + final Uri resolved; + try { + resolved = indexed.baseUri.resolve(value); + } on FormatException { + // Unreachable references remain unchanged in the source; the + // compiler already rejects malformed references that are evaluated. + continue; + } + for (final external in _externalRoots) { + if (external.documentUri != external.baseUri && + resolved.removeFragment() == external.documentUri) { + exported[keyword] = external.baseUri + .replace( + fragment: resolved.hasFragment ? resolved.fragment : null, + ) + .toString(); + break; + } + } + } + } return { if (source is Map) ...source, r'$id': (syntheticId ?? node.baseUri).toString(), @@ -120,22 +183,12 @@ final class ImportedJsonSchema extends AckSchema ...?root[r'$defs'] as Map?, }; var index = 0; - var aliasIndex = 0; for (final external in _externalRoots) { while (definitions.containsKey('_ack_document_$index')) { index++; } final name = '_ack_document_${index++}'; definitions[name] = resource(external); - if (external.documentUri != external.baseUri) { - while (definitions.containsKey('_ack_retrieval_$aliasIndex')) { - aliasIndex++; - } - definitions['_ack_retrieval_${aliasIndex++}'] = { - r'$id': external.documentUri.toString(), - r'$ref': external.baseUri.toString(), - }; - } } if (assertFormats) { var name = '_ack_format_assertion_dialect'; @@ -166,7 +219,7 @@ final class ImportedJsonSchema extends AckSchema } root[r'$defs'] = definitions; } - if (!isNullable && sourceAllowsNull) { + if (includeFluentNullability && !isNullable && sourceAllowsNull) { root['allOf'] = [ ...?root['allOf'] as List?, const { @@ -263,6 +316,7 @@ final class ImportedJsonSchema extends AckSchema }) => ImportedJsonSchema._( _root, externalRoots: _externalRoots, + indexedNodes: _indexedNodes, assertFormats: assertFormats, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, diff --git a/packages/ack/test/json_schema_draft7_lowering_test.dart b/packages/ack/test/json_schema_draft7_lowering_test.dart new file mode 100644 index 00000000..fa9af00a --- /dev/null +++ b/packages/ack/test/json_schema_draft7_lowering_test.dart @@ -0,0 +1,123 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + final cases = + < + ({ + String keyword, + Map document, + Object valid, + Object invalid, + }) + >[ + ( + keyword: 'prefixItems', + document: { + 'prefixItems': [ + {'type': 'string'}, + ], + 'items': false, + }, + valid: ['name'], + invalid: [1], + ), + ( + keyword: 'dependentRequired', + document: { + 'dependentRequired': { + 'a': ['b'], + }, + }, + valid: {'a': 1, 'b': 2}, + invalid: {'a': 1}, + ), + ( + keyword: 'dependentSchemas', + document: { + 'dependentSchemas': { + 'a': { + 'required': ['b'], + }, + }, + }, + valid: {'a': 1, 'b': 2}, + invalid: {'a': 1}, + ), + ( + keyword: 'unevaluatedProperties', + document: {'unevaluatedProperties': false}, + valid: {}, + invalid: {'extra': 1}, + ), + ( + keyword: 'unevaluatedItems', + document: {'unevaluatedItems': false}, + valid: [], + invalid: [1], + ), + ( + keyword: 'minContains', + document: { + 'contains': {'type': 'integer'}, + 'minContains': 2, + }, + valid: [1, 2], + invalid: [1], + ), + ( + keyword: 'maxContains', + document: { + 'contains': {'type': 'integer'}, + 'maxContains': 1, + }, + valid: [1], + invalid: [1, 2], + ), + ]; + + for (final testCase in cases) { + for (final referenced in [false, true]) { + test('${testCase.keyword} rejects lossy Draft-7 export ' + '${referenced ? 'through a reference' : 'at the root'}', () { + final schema = Ack.fromJsonSchema( + referenced + ? { + r'$defs': {'constraint': testCase.document}, + r'$ref': r'#/$defs/constraint', + } + : testCase.document, + ); + expect(schema.safeParse(testCase.valid).isOk, isTrue); + expect(schema.safeParse(testCase.invalid).isFail, isTrue); + expect( + schema.toJsonSchemaDraft7, + throwsA( + isA().having( + (error) => error.message, + 'message', + contains(testCase.keyword), + ), + ), + ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + expect(restored.safeParse(testCase.valid).isOk, isTrue); + expect(restored.safeParse(testCase.invalid).isFail, isTrue); + }); + } + } + + test('Draft-7 compatible array constraints still export', () { + final schema = Ack.fromJsonSchema({ + 'type': 'array', + 'items': {'type': 'integer'}, + 'contains': {'minimum': 2}, + 'minItems': 1, + }); + final exported = schema.toJsonSchemaDraft7(); + final definitions = exported['definitions']! as Map; + expect(definitions.values, contains(containsPair('minItems', 1))); + expect(definitions.values, contains(containsPair('type', 'integer'))); + expect(definitions.values, contains(containsPair('minimum', 2))); + }); +} diff --git a/packages/ack/test/json_schema_import_composition_test.dart b/packages/ack/test/json_schema_import_composition_test.dart new file mode 100644 index 00000000..1a3a1bd3 --- /dev/null +++ b/packages/ack/test/json_schema_import_composition_test.dart @@ -0,0 +1,176 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + test('repeated identified imports preserve occurrence overrides', () { + final shared = Ack.fromJsonSchema({ + r'$id': 'https://example.test/shared', + 'type': 'string', + }); + final composed = Ack.object({ + 'optionalNull': shared.nullable().describe('Nullable occurrence'), + 'requiredString': shared.describe('String occurrence'), + }); + final restored = Ack.fromJsonSchema(composed.toJsonSchema()); + expect( + restored.safeParse({'optionalNull': null, 'requiredString': 'ok'}).isOk, + isTrue, + ); + expect( + restored.safeParse({'optionalNull': 'ok', 'requiredString': null}).isFail, + isTrue, + ); + }); + + test('distinct imports share external resources and assertion dialects', () { + final documents = { + Uri.parse('https://example.test/email'): { + r'$id': 'https://example.test/email', + 'type': 'string', + 'format': 'email', + }, + }; + final composed = Ack.object({ + for (final key in ['first', 'second']) + key: Ack.fromJsonSchema( + { + r'$id': 'https://example.test/$key', + r'$ref': 'https://example.test/email', + }, + documents: documents, + assertFormats: true, + ), + }); + final restored = Ack.fromJsonSchema(composed.toJsonSchema()); + expect( + restored.safeParse({ + 'first': 'a@example.test', + 'second': 'b@example.test', + }).isOk, + isTrue, + ); + expect( + restored.safeParse({'first': 'a@example.test', 'second': 'bad'}).isFail, + isTrue, + ); + }); + + test('nested resource pointers survive resource deduplication', () { + final nested = { + r'$id': 'https://example.test/nested', + r'$defs': { + 'value': {'type': 'string'}, + 'never': false, + }, + }; + for (final target in ['value', 'never']) { + final composed = Ack.object({ + for (final key in ['first', 'second']) + key: Ack.fromJsonSchema({ + r'$id': 'https://example.test/$key', + r'$defs': {'shared': nested}, + r'$ref': '#/\$defs/shared/\$defs/$target', + }), + }); + final restored = Ack.fromJsonSchema(composed.toJsonSchema()); + expect( + restored.safeParse({'first': 'ok', 'second': 'ok'}).isOk, + target == 'value', + ); + expect(restored.safeParse({'first': 'ok', 'second': 1}).isFail, isTrue); + } + }); + + test('referenced resources in nonstandard containers can be composed', () { + final composed = Ack.object({ + for (final key in ['first', 'second']) + key: Ack.fromJsonSchema({ + r'$id': 'https://example.test/$key', + 'catalog': { + 'shared': {r'$id': 'https://example.test/shared', 'type': 'string'}, + }, + r'$ref': '#/catalog/shared', + }), + }); + final restored = Ack.fromJsonSchema(composed.toJsonSchema()); + expect(restored.safeParse({'first': 'ok', 'second': 'ok'}).isOk, isTrue); + expect(restored.safeParse({'first': 'ok', 'second': 1}).isFail, isTrue); + }); + + test('constraints and non-null overrides stay local to each occurrence', () { + final shared = Ack.fromJsonSchema({r'$id': 'urn:anything'}); + final composed = Ack.object({ + 'string': shared.constrain(const _OnlyStrings()), + 'any': shared, + 'nonNull': shared.nullable(value: false), + }); + final restored = Ack.fromJsonSchema(composed.toJsonSchema()); + for (final value in [ + {'string': 'ok', 'any': 1, 'nonNull': false}, + {'string': null, 'any': null, 'nonNull': 'ok'}, + {'string': 1, 'any': 'ok', 'nonNull': 'ok'}, + {'string': 'ok', 'any': 'ok', 'nonNull': null}, + ]) { + expect(restored.safeParse(value).isOk, composed.safeParse(value).isOk); + } + }); + + test('shared recursive resources retain dynamic reference scope', () { + final tree = Ack.fromJsonSchema({ + r'$id': 'https://example.test/tree', + r'$dynamicAnchor': 'node', + 'type': 'object', + 'properties': { + 'value': {'type': 'string'}, + 'child': {r'$dynamicRef': '#node'}, + }, + 'required': ['value'], + }); + final schema = Ack.object({'first': tree, 'second': tree}); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + for (final child in [ + {'value': 'ok'}, + {'value': 42}, + null, + ]) { + final data = { + 'first': {'value': 'ok'}, + 'second': {'value': 'ok', 'child': child}, + }; + expect(restored.safeParse(data).isOk, schema.safeParse(data).isOk); + } + }); + + test('conflicting resource identifiers fail explicitly during export', () { + final composed = Ack.object({ + 'first': Ack.fromJsonSchema({r'$id': 'urn:shared', 'type': 'string'}), + 'second': Ack.fromJsonSchema({r'$id': 'urn:shared', 'type': 'number'}), + }); + expect(() => composed.toJsonSchema(), throwsArgumentError); + }); + + test('identifier-shaped instance data is not deduplicated', () { + final value = {r'$id': 'urn:instance', 'type': 'string'}; + final composed = Ack.object({ + 'first': Ack.fromJsonSchema({'const': value}), + 'second': Ack.fromJsonSchema({'const': value}), + }); + final restored = Ack.fromJsonSchema(composed.toJsonSchema()); + expect(restored.safeParse({'first': value, 'second': value}).isOk, isTrue); + }); +} + +final class _OnlyStrings extends Constraint + with Validator, JsonSchemaSpec { + const _OnlyStrings() + : super(constraintKey: 'only_strings', description: 'Expected a string.'); + + @override + bool isValid(Object value) => value is String; + + @override + String buildMessage(Object value) => description; + + @override + Map toJsonSchema() => {'type': 'string'}; +} diff --git a/packages/ack/test/json_schema_resource_export_test.dart b/packages/ack/test/json_schema_resource_export_test.dart new file mode 100644 index 00000000..053219c3 --- /dev/null +++ b/packages/ack/test/json_schema_resource_export_test.dart @@ -0,0 +1,107 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + for (final keyword in [r'$ref', r'$dynamicRef']) { + test('$keyword preserves pointers through retrieval aliases', () { + final schema = Ack.fromJsonSchema( + {keyword: r'https://example.test/download.json#/$defs/a~1b'}, + documents: { + Uri.parse('https://example.test/download.json'): { + r'$id': 'https://example.test/canonical.json', + r'$defs': { + 'a/b': {'type': 'string'}, + }, + }, + }, + ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + expect(restored.safeParse('value').isOk, isTrue); + expect(restored.safeParse(42).isFail, isTrue); + }); + } + + test('nested resource retains opt-in format assertion after export', () { + final schema = Ack.fromJsonSchema({ + r'$id': 'https://example.test/root', + r'$defs': { + 'email': { + r'$id': 'email', + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'string', + 'format': 'email', + }, + }, + r'$ref': 'email', + 'default': {r'$schema': 'https://json-schema.org/draft/2020-12/schema'}, + }, assertFormats: true); + final exported = schema.toJsonSchema(); + final restored = Ack.fromJsonSchema(exported); + expect(schema.safeParse('not-an-email').isFail, isTrue); + expect(restored.safeParse('not-an-email').isFail, isTrue); + expect(restored.safeParse('user@example.test').isOk, isTrue); + expect(exported['default'], { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + }); + }); + + test('retrieval aliases preserve encoded pointers and annotation data', () { + final source = { + r'$ref': 'https://example.test/download.json#/components/a%25b', + 'default': {r'$ref': 'https://example.test/download.json'}, + }; + final schema = Ack.fromJsonSchema( + source, + documents: { + Uri.parse('https://example.test/download.json'): { + r'$id': 'https://example.test/canonical.json', + 'components': { + 'a%b': {'type': 'integer'}, + }, + }, + }, + ); + final exported = schema.toJsonSchema(); + expect(exported['default'], source['default']); + final restored = Ack.fromJsonSchema(exported); + expect(restored.safeParse(1).isOk, isTrue); + expect(restored.safeParse('1').isFail, isTrue); + }); + + test('nested formats in supplied documents retain assertion behavior', () { + final schema = Ack.fromJsonSchema( + {r'$ref': r'https://example.test/download.json#/$defs/email'}, + assertFormats: true, + documents: { + Uri.parse('https://example.test/download.json'): { + r'$id': 'https://example.test/canonical.json', + r'$defs': { + 'email': { + r'$id': 'email', + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + 'type': 'string', + 'format': 'email', + }, + }, + }, + }, + ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + expect(restored.safeParse('not-an-email').isFail, isTrue); + expect(restored.safeParse('user@example.test').isOk, isTrue); + }); + + test( + 'export retains dormant malformed references without evaluating them', + () { + final schema = Ack.fromJsonSchema({ + 'type': 'string', + r'$defs': { + 'unused': {r'$ref': 'http://['}, + }, + }); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + expect(restored.safeParse('ok').isOk, isTrue); + }, + ); +} diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 26d15146..710ce806 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -37,6 +37,12 @@ `ack_generator:ack_generator` compiles into `.schema.g.dart` typed models or validated value-model libraries (`#167`). +### Fixed + +* Preserve integral decimal length and item bounds in JSON Schema generation. + Use validated value models for Unicode regex patterns and named scalar unions + that the typed model generator cannot represent faithfully. + ## 1.7.0-beta.7 ### Changed diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index b73b87d5..702b5226 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -6,6 +6,90 @@ codec schema from a hand-written class. `@Schemable()`, the model annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the `@Validate.*` constraint namespace are all exported from `package:ack/ack.dart`. +## Generate models from JSON Schema + +The opt-in `ack_json_schema` builder turns a draft 2020-12 JSON Schema into +models. It emits typed `@Schemable` models for shapes it can represent as Ack +fields. For other valid 2020-12 shapes, it emits validated value models backed +by `Ack.fromJsonSchema`, preserving every supported assertion. Name the source +`lib/contract.schema.json`; named `$defs` entries produce models, and a schema +with root assertions also produces `Root` (`DocumentRoot` when `$defs/Root` +already owns that name): + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "User": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"], + "additionalProperties": false + } + } +} +``` + +Enable the builder in the consuming package's `build.yaml`: + +```yaml +targets: + $default: + builders: + ack_generator:ack_json_schema: + enabled: true + options: + unknown_properties: schema + assert_formats: false + model_mode: auto # use validated for exact source-schema parity + # Optional offline resources: retrieval URI -> package asset path. + # documents: + # "https://example.test/common.json": "lib/common.json" +``` + +Run `dart run build_runner build`. Import `contract.schema.dart` to use the +generated models. A typed model also writes `contract.schema.g.dart` and offers +typed fields and `copyWith`. A validated value model needs no parts: +`User.parse(value)` returns a wrapper whose immutable `value` is the validated +JSON value; `User.encode(model)`, `User.safeEncode(model)`, and `model.toJson()` +revalidate on output. The `userSchema` declaration and `User.schema` expose the +exact source validator. Do not hand-edit generated files. + +The default `unknown_properties: schema` follows each object's +`additionalProperties`. Choose `preserve` for a forward-compatible client: it +widens `additionalProperties: false` to `true` at every object, records every +widened JSON pointer in the generated header, and captures unknown fields in +`additionalProperties`. The source JSON Schema is never rewritten. This is a +deliberate change to the effective validation contract. + +Typed output covers named non-null objects, references to named definitions, +inline objects, maps, arrays with non-null items, common primitives and bounds, +string enums, and simple unions in fields. Other valid shapes—including named +scalar unions, regex patterns, recursive and dynamic references, composition, +nullable roots and items, `multipleOf`, unevaluated keywords, and format +assertions—use validated value models. Patterns use the source validator to +preserve Unicode regex semantics, including astral characters. Integral decimal +length and item bounds are normalized to integers; bounds outside the Dart +integer range use validated value models. `assert_formats: true` opts into +format assertions under the standard dialect; unknown asserted formats and +unresolved or malformed schemas fail the build with a JSON-pointer diagnostic. +External resources are read only from the explicit `documents` mapping; the +builder never fetches URLs. The complete required 2020-12 corpus generates +offline with its supplied resources. Custom vocabulary support is limited to +what `Ack.fromJsonSchema` implements. + +Definitions with non-Dart names receive stable UpperCamelCase wrapper names. +If the entire document uses fallback, all its models are value wrappers so +references and dynamic scope stay bound to the same effective source document. +Validated wrappers do not expose inferred fields or typed `copyWith`. + +An integral JSON number beyond Dart's `int` range is a known edge case: +`Ack.fromJsonSchema` can accept it as an integral `double`, while a typed +`Ack.integer()` model cannot represent it as an `int`. String length bounds also +differ for astral Unicode characters (code points in the importer versus UTF-16 +units in the fluent schema). Set `model_mode: validated` when these edge cases +matter; typed output retains the original v1 contract for compatibility. + ## Schema-first usage ```dart diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart index 085a00c1..f39fa101 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -567,7 +567,7 @@ final class _Translator { _fail(pointer, 'A multi-type schema cannot also have constraints.'); return null; } - final union = _scalarUnion(nonNull, '$pointer/type'); + final union = _scalarUnion(nonNull, '$pointer/type', root: root); return union == null ? null : _Expr(describe(union), nullable: nullable); } final single = nonNull.single! as String; @@ -692,11 +692,19 @@ final class _Translator { } types.add(branch['type']); } - final union = _scalarUnion(types, pointer); + final union = _scalarUnion(types, pointer, root: root); return union == null ? null : _Expr(describe(union), nullable: nullable); } - String? _scalarUnion(List types, String pointer) { + String? _scalarUnion( + List types, + String pointer, { + required bool root, + }) { + if (root) { + _fail(pointer, 'Named scalar unions require a validated value model.'); + return null; + } const checks = {'string': 'String', 'number': 'num', 'boolean': 'bool'}; final dartTypes = []; for (final type in types) { @@ -791,20 +799,34 @@ final class _Translator { return _Expr(describe('ack.Ack.literal(${dartStringLiteral(value)})')); } + int? _sizeBound(Map node, String keyword, String pointer) { + final value = node[keyword]; + if (value == null) return null; + // The importer gate has already checked that the bound is integral. + // Guard conversion as toInt() clamps values outside the integer range. + if (value is num && value.isFinite && value.toInt() == value) { + return value.toInt(); + } + _fail( + '$pointer/$keyword', + 'Bound cannot be represented as a Dart integer.', + ); + return null; + } + String? _string(Map node, String pointer) { final buffer = StringBuffer('ack.Ack.string()'); - if (node['minLength'] case final int n) buffer.write('.minLength($n)'); - if (node['maxLength'] case final int n) buffer.write('.maxLength($n)'); - if (node['pattern'] case final String pattern) { - if (RegExp(r'\\[pPu]\{').hasMatch(pattern)) { - _fail( - '$pointer/pattern', - r'Unicode-mode patterns (\p{…}, \P{…}, ' - r'\u{…}) are not supported.', - ); - return null; - } - buffer.write('.matches(${dartStringLiteral(pattern)})'); + if (_sizeBound(node, 'minLength', pointer) case final int n) { + buffer.write('.minLength($n)'); + } + if (_sizeBound(node, 'maxLength', pointer) case final int n) { + buffer.write('.maxLength($n)'); + } + if (node.containsKey('pattern')) { + // matches() uses non-Unicode RegExp semantics, unlike the importer. + // Even patterns without Unicode escapes (for example ^.$) can differ. + _fail('$pointer/pattern', 'Patterns require Unicode-mode validation.'); + return null; } return buffer.toString(); } @@ -871,8 +893,12 @@ final class _Translator { return null; } final buffer = StringBuffer('ack.Ack.list(${item.code})'); - if (node['minItems'] case final int n) buffer.write('.minItems($n)'); - if (node['maxItems'] case final int n) buffer.write('.maxItems($n)'); + if (_sizeBound(node, 'minItems', pointer) case final n?) { + buffer.write('.minItems($n)'); + } + if (_sizeBound(node, 'maxItems', pointer) case final n?) { + buffer.write('.maxItems($n)'); + } if (node['uniqueItems'] == true) buffer.write('.unique()'); return buffer.toString(); } diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 332376cf..43e3c829 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -15,7 +15,7 @@ environment: dependencies: # Core code generation dependencies - analyzer: ">=13.0.0 <15.0.0" + analyzer: ">=13.0.0 <14.0.0" build: ^4.0.0 source_gen: ^4.2.4 code_builder: ^4.10.0 diff --git a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart index ed1ace65..6f2174b7 100644 --- a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -1,3 +1,7 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; @@ -173,6 +177,83 @@ void main() { expect(Root.encode(Root.parse('a@example.test')), 'a@example.test'); }); } +'''); + + final regressions = >{ + 'bounds': { + 'Bounded': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string', 'minLength': 2.0, 'maxLength': 3e0}, + 'items': { + 'type': 'array', + 'items': {'type': 'string'}, + 'minItems': 1.0, + 'maxItems': 2e0, + }, + }, + 'required': ['name', 'items'], + }, + }, + 'patterns': { + 'Glyph': {'type': 'string', 'pattern': r'^.$'}, + 'Pair': {'type': 'string', 'pattern': r'^[😀]{2}$'}, + }, + 'types': { + 'Choice': { + 'type': ['string', 'number'], + }, + }, + 'anyof': { + 'Choice': { + 'anyOf': [ + {'type': 'string'}, + {'type': 'number'}, + ], + }, + }, + }; + for (final entry in regressions.entries) { + File( + p.join(project.path, 'lib', '${entry.key}.schema.json'), + ).writeAsStringSync(jsonEncode({r'$defs': entry.value})); + } + File( + p.join(project.path, 'test', 'regressions_test.dart'), + ).writeAsStringSync(r''' +import 'package:ack_json_schema_runtime/bounds.schema.dart'; +import 'package:ack_json_schema_runtime/patterns.schema.dart'; +import 'package:ack_json_schema_runtime/types.schema.dart' as types; +import 'package:ack_json_schema_runtime/anyof.schema.dart' as anyof; +import 'package:test/test.dart'; + +void main() { + test('integral decimal bounds preserve validation', () { + expect(Bounded.safeParse({'name': 'ab', 'items': ['x']}).isOk, isTrue); + for (final name in ['a', 'abcd']) { + expect(Bounded.safeParse({'name': name, 'items': ['x']}).isFail, isTrue); + } + for (final items in [[], ['x', 'y', 'z']]) { + expect(Bounded.safeParse({'name': 'ab', 'items': items}).isFail, isTrue); + } + }); + test('patterns preserve Unicode code point semantics', () { + expect(Glyph.safeParse('😀').isOk, isTrue); + expect(Glyph.safeParse('ab').isFail, isTrue); + expect(Pair.safeParse('😀😀').isOk, isTrue); + expect(Pair.safeParse('😀').isFail, isTrue); + }); + test('named scalar unions build and validate', () { + for (final value in ['ok', 1, 1.5]) { + expect(types.Choice.safeParse(value).isOk, isTrue); + expect(anyof.Choice.safeParse(value).isOk, isTrue); + } + for (final value in [null, true, []]) { + expect(types.Choice.safeParse(value).isFail, isTrue); + expect(anyof.Choice.safeParse(value).isFail, isTrue); + } + }); +} '''); _succeeds( diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart index fdee1ba5..e9f63e28 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -3,49 +3,86 @@ // ignore_for_file: type=lint // coverage:ignore-file // -// Generated from mapping.schema.json by ack_generator:ack_json_schema. +// Draft 2020-12 value models retain source-schema validation. +// Complex shapes use Object? values, not inferred typed fields. + +import 'dart:convert'; import 'package:ack/ack.dart' as ack; -@ack.Schemable(name: 'ListingAddress') -final listingAddressSchema = ack.Ack.object({'city': ack.Ack.string()}); - -@ack.Schemable(name: 'Listing') -final listingSchema = ack.Ack.object({ - 'key': ack.Ack.string() - .minLength(2) - .maxLength(8) - .describe('The key, with \'quotes\', \$dollars, and \\backslashes\\.'), - 'code': ack.Ack.string().matches('^[A-Z]{2}-\\d+\$').optional(), - 'rooms': ack.Ack.integer().min(1).max(9), - 'floors': ack.Ack.integer().greaterThan(0).max(4).optional(), - 'price': ack.Ack.number().min(0).lessThan(1000000.5).optional(), - 'active': ack.Ack.boolean(), - 'status': ack.Ack.enumString(['Active', 'Closed']).nullable().optional(), - 'kind': ack.Ack.literal('residential').optional(), - 'tags': ack.Ack.list( - ack.Ack.string(), - ).minItems(1).maxItems(3).unique().optional(), - 'notes': ack.Ack.string().nullable().optional(), - 'nickname': ack.Ack.string().nullable().optional(), - 'streetNumber': ack.Ack.any() - .refine( - (value) => value is String || value is num, - message: 'Expected a string or a number.', - ) - .nullable() - .optional(), - 'extra': ack.Ack.any().nullable().optional(), - 'labels': ack.Ack.map(ack.Ack.integer()).optional(), - 'meta': ack.Ack.map(ack.Ack.any().nullable()).optional(), - 'address': listingAddressSchema.optional(), - 'photo': photoSchema.optional(), - 'cover': photoSchema.nullable().optional(), - 'photos': ack.Ack.list(photoSchema).optional(), -}).describe('A listing.\u000aWith a second line.'); - -@ack.Schemable(name: 'Photo') -final photoSchema = ack.Ack.object({ - 'url': ack.Ack.string(), - 'width': ack.Ack.integer().optional(), -}, additionalProperties: true); +final Object _jsonSchemaDocument = jsonDecode( + '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":false},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', +) as Object; +final Map _jsonSchemaDocuments = { + Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): + _jsonSchemaDocument, +}; + +final listingSchema = ack.Ack.fromJsonSchema( + { + r'$ref': + 'package:ack_generator_fixtures/mapping.schema.json#/\$defs/Listing', + }, + baseUri: Uri.parse( + 'package:ack_generator_fixtures/__ack_json_schema_generated__.json', + ), + documents: _jsonSchemaDocuments, +); + +/// A validated Draft 2020-12 value model. +final class Listing { + const Listing._(this.value); + + /// The parsed, immutable JSON value. + final Object? value; + + /// The exact source-schema validator for this model. + static ack.AckSchema get schema => listingSchema; + + static Listing parse(Object? value) => safeParse(value).getOrThrow()!; + + static ack.SchemaResult safeParse(Object? value) => + schema.safeParse(value).map((parsed) => Listing._(parsed)); + + static Object? encode(Listing model) => safeEncode(model).getOrThrow(); + + static ack.SchemaResult safeEncode(Listing model) => + schema.safeEncode(model.value); + + Object? toJson() => encode(this); + + static Map toJsonSchema() => schema.toJsonSchema(); +} + +final photoSchema = ack.Ack.fromJsonSchema( + {r'$ref': 'package:ack_generator_fixtures/mapping.schema.json#/\$defs/Photo'}, + baseUri: Uri.parse( + 'package:ack_generator_fixtures/__ack_json_schema_generated__.json', + ), + documents: _jsonSchemaDocuments, +); + +/// A validated Draft 2020-12 value model. +final class Photo { + const Photo._(this.value); + + /// The parsed, immutable JSON value. + final Object? value; + + /// The exact source-schema validator for this model. + static ack.AckSchema get schema => photoSchema; + + static Photo parse(Object? value) => safeParse(value).getOrThrow()!; + + static ack.SchemaResult safeParse(Object? value) => + schema.safeParse(value).map((parsed) => Photo._(parsed)); + + static Object? encode(Photo model) => safeEncode(model).getOrThrow(); + + static ack.SchemaResult safeEncode(Photo model) => + schema.safeEncode(model.value); + + Object? toJson() => encode(this); + + static Map toJsonSchema() => schema.toJsonSchema(); +} diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart index 9acf764f..0f6803b0 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -3,54 +3,89 @@ // ignore_for_file: type=lint // coverage:ignore-file // -// Generated from mapping.schema.json by ack_generator:ack_json_schema. +// Draft 2020-12 value models retain source-schema validation. +// Complex shapes use Object? values, not inferred typed fields. // unknown_properties: preserve widened these objects: // - #/$defs/Listing // - #/$defs/Listing/properties/address +import 'dart:convert'; + import 'package:ack/ack.dart' as ack; -@ack.Schemable(name: 'ListingAddress') -final listingAddressSchema = ack.Ack.object({ - 'city': ack.Ack.string(), -}, additionalProperties: true); - -@ack.Schemable(name: 'Listing') -final listingSchema = ack.Ack.object({ - 'key': ack.Ack.string() - .minLength(2) - .maxLength(8) - .describe('The key, with \'quotes\', \$dollars, and \\backslashes\\.'), - 'code': ack.Ack.string().matches('^[A-Z]{2}-\\d+\$').optional(), - 'rooms': ack.Ack.integer().min(1).max(9), - 'floors': ack.Ack.integer().greaterThan(0).max(4).optional(), - 'price': ack.Ack.number().min(0).lessThan(1000000.5).optional(), - 'active': ack.Ack.boolean(), - 'status': ack.Ack.enumString(['Active', 'Closed']).nullable().optional(), - 'kind': ack.Ack.literal('residential').optional(), - 'tags': ack.Ack.list( - ack.Ack.string(), - ).minItems(1).maxItems(3).unique().optional(), - 'notes': ack.Ack.string().nullable().optional(), - 'nickname': ack.Ack.string().nullable().optional(), - 'streetNumber': ack.Ack.any() - .refine( - (value) => value is String || value is num, - message: 'Expected a string or a number.', - ) - .nullable() - .optional(), - 'extra': ack.Ack.any().nullable().optional(), - 'labels': ack.Ack.map(ack.Ack.integer()).optional(), - 'meta': ack.Ack.map(ack.Ack.any().nullable()).optional(), - 'address': listingAddressSchema.optional(), - 'photo': photoSchema.optional(), - 'cover': photoSchema.nullable().optional(), - 'photos': ack.Ack.list(photoSchema).optional(), -}, additionalProperties: true).describe('A listing.\u000aWith a second line.'); - -@ack.Schemable(name: 'Photo') -final photoSchema = ack.Ack.object({ - 'url': ack.Ack.string(), - 'width': ack.Ack.integer().optional(), -}, additionalProperties: true); +final Object _jsonSchemaDocument = jsonDecode( + '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":true},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":true},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', +) as Object; +final Map _jsonSchemaDocuments = { + Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): + _jsonSchemaDocument, +}; + +final listingSchema = ack.Ack.fromJsonSchema( + { + r'$ref': + 'package:ack_generator_fixtures/mapping.schema.json#/\$defs/Listing', + }, + baseUri: Uri.parse( + 'package:ack_generator_fixtures/__ack_json_schema_generated__.json', + ), + documents: _jsonSchemaDocuments, +); + +/// A validated Draft 2020-12 value model. +final class Listing { + const Listing._(this.value); + + /// The parsed, immutable JSON value. + final Object? value; + + /// The exact source-schema validator for this model. + static ack.AckSchema get schema => listingSchema; + + static Listing parse(Object? value) => safeParse(value).getOrThrow()!; + + static ack.SchemaResult safeParse(Object? value) => + schema.safeParse(value).map((parsed) => Listing._(parsed)); + + static Object? encode(Listing model) => safeEncode(model).getOrThrow(); + + static ack.SchemaResult safeEncode(Listing model) => + schema.safeEncode(model.value); + + Object? toJson() => encode(this); + + static Map toJsonSchema() => schema.toJsonSchema(); +} + +final photoSchema = ack.Ack.fromJsonSchema( + {r'$ref': 'package:ack_generator_fixtures/mapping.schema.json#/\$defs/Photo'}, + baseUri: Uri.parse( + 'package:ack_generator_fixtures/__ack_json_schema_generated__.json', + ), + documents: _jsonSchemaDocuments, +); + +/// A validated Draft 2020-12 value model. +final class Photo { + const Photo._(this.value); + + /// The parsed, immutable JSON value. + final Object? value; + + /// The exact source-schema validator for this model. + static ack.AckSchema get schema => photoSchema; + + static Photo parse(Object? value) => safeParse(value).getOrThrow()!; + + static ack.SchemaResult safeParse(Object? value) => + schema.safeParse(value).map((parsed) => Photo._(parsed)); + + static Object? encode(Photo model) => safeEncode(model).getOrThrow(); + + static ack.SchemaResult safeEncode(Photo model) => + schema.safeEncode(model.value); + + Object? toJson() => encode(this); + + static Map toJsonSchema() => schema.toJsonSchema(); +} diff --git a/packages/ack_generator/test/json_schema/json_schema_library_test.dart b/packages/ack_generator/test/json_schema/json_schema_library_test.dart index 2e7ea132..38eca476 100644 --- a/packages/ack_generator/test/json_schema/json_schema_library_test.dart +++ b/packages/ack_generator/test/json_schema/json_schema_library_test.dart @@ -66,9 +66,9 @@ void main() { {'type': 'string', 'minLength': 2, 'maxLength': 8}, 'ack.Ack.string().minLength(2).maxLength(8).optional()', ), - 'an escaped pattern': ( - {'type': 'string', 'pattern': r'^[A-Z]{2}-\d+$'}, - r"ack.Ack.string().matches('^[A-Z]{2}-\\d+\$')", + 'integral decimal string length bounds': ( + {'type': 'string', 'minLength': 2.0, 'maxLength': 3e0}, + 'ack.Ack.string().minLength(2).maxLength(3).optional()', ), 'integer bounds': ( {'type': 'integer', 'minimum': 1, 'maximum': 9}, @@ -111,6 +111,15 @@ void main() { }, 'ack.Ack.list(ack.Ack.string()).minItems(1).maxItems(3).unique()', ), + 'integral decimal array bounds': ( + { + 'type': 'array', + 'items': {'type': 'string'}, + 'minItems': 1.0, + 'maxItems': 2e0, + }, + 'ack.Ack.list(ack.Ack.string()).minItems(1).maxItems(2)', + ), 'anyOf with null': ( { 'anyOf': [ @@ -312,6 +321,12 @@ void main() { group('Draft 2020-12 validated fallback', () { final examples = { + 'Unicode-sensitive dot pattern': {'type': 'string', 'pattern': r'^.$'}, + 'Unicode-sensitive character class': { + 'type': 'string', + 'pattern': r'^[😀]{2}$', + }, + 'unrepresentable size bound': {'type': 'string', 'minLength': 1e30}, 'composition': { 'allOf': [ {'type': 'string'}, @@ -358,6 +373,27 @@ void main() { }); } + for (final schema in [ + { + 'type': ['string', 'number'], + }, + { + 'anyOf': [ + {'type': 'string'}, + {'type': 'number'}, + ], + }, + ]) { + test('named scalar union falls back: $schema', () { + final library = _generate({ + r'$defs': {'Choice': schema}, + }); + expect(library.source, contains('final class Choice')); + expect(library.source, contains('Ack.fromJsonSchema')); + expect(library.source, isNot(contains('@ack.Schemable'))); + }); + } + test(r'generates a root model without $defs', () { final library = _generate({ 'type': 'array', From 0f508da3246c86921c34a0b7ccaa319741e3d1b5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 12 Oct 2026 10:19:28 -0400 Subject: [PATCH 23/29] fix(ack): preserve source allOf when layering conflicting constraints --- .../src/schema_model/ack_schema_model.dart | 19 +++++-- .../ack_schema_model_builder.dart | 1 + .../json_schema_import_composition_test.dart | 50 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 876f6316..71cb6153 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -238,7 +238,11 @@ sealed class AckSchemaModel { String keyword, List schemas, ) { - final branches = schemas.map((schema) => schema.toJsonSchema()).toList(); + final branches = [ + ...schemas.map((schema) => schema.toJsonSchema()), + if (keyword == 'allOf') + if (extensions['allOf'] case final List extra) ...extra, + ]; if (nullable && !schemas.any((schema) => schema is AckNullSchemaModel)) { // Match Zod v4's Draft-7 nullable-union shape: keep the composition as // one branch, then add null as the other branch. Flattening would validate @@ -311,7 +315,13 @@ final class AckRawSchemaModel extends AckSchemaModel { @override Map toJsonSchema() { - final body = {...schema, ..._common.toJson()}; + final body = { + ...schema, + ..._common.toJson(), + if (schema['allOf'] case final List sourceAllOf) + if (extensions['allOf'] case final List extraAllOf) + 'allOf': [...sourceAllOf, ...extraAllOf], + }; if (!nullable) return body; const resourceKeywords = {r'$schema', r'$id', r'$defs', 'definitions'}; final relocated = _relocateRootPointers( @@ -441,7 +451,10 @@ final class AckRefSchemaModel extends AckSchemaModel { return { ...commonJson, - 'allOf': [refJson], + 'allOf': [ + refJson, + if (commonJson['allOf'] case final List extra) ...extra, + ], }; } diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 141d033f..a43abc0e 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -620,6 +620,7 @@ AckSchemaModel _applyConstraints( next = next.withJsonSchemaKeywords(newKeywords); } if (conflicts.isNotEmpty) { + appliedKeywordValues.putIfAbsent('allOf', () => []); final existingAllOf = switch (next.extensions['allOf']) { final List value => value, _ => const [], diff --git a/packages/ack/test/json_schema_import_composition_test.dart b/packages/ack/test/json_schema_import_composition_test.dart index 1a3a1bd3..358ad7d6 100644 --- a/packages/ack/test/json_schema_import_composition_test.dart +++ b/packages/ack/test/json_schema_import_composition_test.dart @@ -158,6 +158,39 @@ void main() { final restored = Ack.fromJsonSchema(composed.toJsonSchema()); expect(restored.safeParse({'first': value, 'second': value}).isOk, isTrue); }); + + test( + 'conflicting constraints preserve source allOf and non-null branches', + () { + final schema = Ack.fromJsonSchema({ + 'minLength': 2, + 'allOf': [ + {'maxLength': 10}, + ], + }).nullable(value: false).constrain(const _MinLength(3)); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + for (final value in [null, 'ab', 'abc', 'abcdefghijk']) { + expect( + restored.safeParse(value).isOk, + schema.safeParse(value).isOk, + reason: '$value', + ); + } + + final composed = Ack.object({ + 'value': schema.constrain(const _MinLength(4)), + }); + final restoredComposed = Ack.fromJsonSchema(composed.toJsonSchema()); + for (final value in [null, 'abc', 'abcd', 'abcdefghijk']) { + final input = {'value': value}; + expect( + restoredComposed.safeParse(input).isOk, + composed.safeParse(input).isOk, + reason: '$input', + ); + } + }, + ); } final class _OnlyStrings extends Constraint @@ -174,3 +207,20 @@ final class _OnlyStrings extends Constraint @override Map toJsonSchema() => {'type': 'string'}; } + +final class _MinLength extends Constraint + with Validator, JsonSchemaSpec { + const _MinLength(this.min) + : super(constraintKey: 'min_length', description: 'Too short.'); + + final int min; + + @override + bool isValid(Object value) => value is! String || value.length >= min; + + @override + String buildMessage(Object value) => description; + + @override + Map toJsonSchema() => {'minLength': min}; +} From 2c2baa99f49ff68b5d0a3a879a1d8ee8a17aced7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 12 Oct 2026 12:19:18 -0400 Subject: [PATCH 24/29] chore(ack): pin Flutter 3.47.5 in .fvmrc and update ack_generator test constraints --- .fvmrc | 2 +- packages/ack_generator/test/analyzer_constraint_test.dart | 4 ++-- .../test/integration/class_model_emitter_test.dart | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.fvmrc b/.fvmrc index 391983cd..827b5564 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.41.2" + "flutter": "3.47.5" } diff --git a/packages/ack_generator/test/analyzer_constraint_test.dart b/packages/ack_generator/test/analyzer_constraint_test.dart index 79548e12..843b521b 100644 --- a/packages/ack_generator/test/analyzer_constraint_test.dart +++ b/packages/ack_generator/test/analyzer_constraint_test.dart @@ -4,7 +4,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { - test('ack_generator pins Analyzer to the supported 10.x line', () { + test('ack_generator pins Analyzer to the supported 13.x line', () { final pubspec = File( p.join( Directory.current.path.endsWith('ack_generator') @@ -14,7 +14,7 @@ void main() { ), ).readAsStringSync(); - expect(pubspec, contains('analyzer: ">=10.0.0 <11.0.0"')); + expect(pubspec, contains('analyzer: ">=13.0.0 <14.0.0"')); }); test('schema analysis uses only the supported Analyzer 10 AST shape', () { diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 26867557..dda31c76 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -178,7 +178,7 @@ final class Documented with _\$DocumentedAck { { 'primary_doc.dart': ''' -// @dart=3.11 +// @dart=3.13 $_imports part 'primary_doc.g.dart'; From 5608c4fb2cfeaa422f288bef360f7e9cb78445b5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 12 Oct 2026 15:37:34 -0400 Subject: [PATCH 25/29] chore(ci): add Flutter 3.47.5 to flutter-releases.json and format with Dart 3.13 --- .github/flutter-releases.json | 4 + ...firebase_ai_response_json_schema_test.dart | 24 ++-- .../example_folder_build_test.dart | 116 ++++++++-------- .../type_owned_schema_build_test.dart | 92 ++++++------- .../json_schema/fixtures/mapping.schema.dart | 8 +- .../fixtures/mapping_preserve.schema.dart | 8 +- test/scripts/api_check_test.dart | 126 ++++++++---------- 7 files changed, 180 insertions(+), 198 deletions(-) diff --git a/.github/flutter-releases.json b/.github/flutter-releases.json index 78083a78..cd1fb1a7 100644 --- a/.github/flutter-releases.json +++ b/.github/flutter-releases.json @@ -8,6 +8,10 @@ "3.41.2": { "dart": "3.11.0", "sha256": "4a04f8a6152986d14fc137ffaf98106ca743c0f9ab66f1bc2f20ee84eb573e5c" + }, + "3.47.5": { + "dart": "3.13.4", + "sha256": "2132e990f236f8d22e7c6314b29a191a95b10d7cbcfec9b4e2e303d996652cbb" } } } diff --git a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart index f4b761c3..6a270a83 100644 --- a/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart +++ b/packages/ack_firebase_ai/test/live_firebase_ai_response_json_schema_test.dart @@ -64,20 +64,16 @@ void main() { }); for (final schemaCase in _liveFirebaseAiSchemaCases()) { - test( - 'generates valid JSON for ${schemaCase.name}', - () async { - final generatedJson = await _generateLiveFirebaseJson( - app: app, - config: liveConfig!, - schemaCase: schemaCase, - ); - - schemaCase.expectGeneratedJson(generatedJson); - _expectAckValid(schemaCase.schema, generatedJson); - }, - timeout: const Timeout(Duration(minutes: 2)), - ); + test('generates valid JSON for ${schemaCase.name}', () async { + final generatedJson = await _generateLiveFirebaseJson( + app: app, + config: liveConfig!, + schemaCase: schemaCase, + ); + + schemaCase.expectGeneratedJson(generatedJson); + _expectAckValid(schemaCase.schema, generatedJson); + }, timeout: const Timeout(Duration(minutes: 2))); } }, ); diff --git a/packages/ack_generator/test/integration/example_folder_build_test.dart b/packages/ack_generator/test/integration/example_folder_build_test.dart index 7b371270..9659ab62 100644 --- a/packages/ack_generator/test/integration/example_folder_build_test.dart +++ b/packages/ack_generator/test/integration/example_folder_build_test.dart @@ -157,34 +157,31 @@ dependency_overrides: timeout: const Timeout(Duration(minutes: 3)), ); - test( - 'rebuilds when generated outputs are already present', - () async { - var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { - projectRoot = projectRoot.parent; - } - final sourceExample = Directory(p.join(projectRoot.path, 'example')); - final temporaryRoot = await Directory.systemTemp.createTemp( - 'ack_generator_example_present_', - ); - final temporaryExample = Directory( - p.join(temporaryRoot.path, 'ack_example'), - ); - - try { - _copyDirectory(sourceExample, temporaryExample, keepGenerated: true); - File( - p.join(temporaryExample.path, 'analysis_options.yaml'), - ).writeAsStringSync(''' + test('rebuilds when generated outputs are already present', () async { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + final sourceExample = Directory(p.join(projectRoot.path, 'example')); + final temporaryRoot = await Directory.systemTemp.createTemp( + 'ack_generator_example_present_', + ); + final temporaryExample = Directory( + p.join(temporaryRoot.path, 'ack_example'), + ); + + try { + _copyDirectory(sourceExample, temporaryExample, keepGenerated: true); + File( + p.join(temporaryExample.path, 'analysis_options.yaml'), + ).writeAsStringSync(''' analyzer: language: strict-casts: true '''); - File(p.join(temporaryExample.path, 'pubspec.yaml')).writeAsStringSync( - ''' + File(p.join(temporaryExample.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_example publish_to: none environment: @@ -200,45 +197,42 @@ dev_dependencies: dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} -''', - ); +'''); - _expectSuccess( - await _run(temporaryExample, ['pub', 'get', '--offline']), - 'dart pub get --offline', - ); + _expectSuccess( + await _run(temporaryExample, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); - final schemaFile = File( - p.join(temporaryExample.path, 'lib', 'schema_types_simple.dart'), - ); - schemaFile.writeAsStringSync( - schemaFile.readAsStringSync().replaceFirst( - "'name': Ack.string(),", - "'name': Ack.string(),\n 'nickname': Ack.string().optional(),", - ), - ); - final generatedFile = File( - p.join(temporaryExample.path, 'lib', 'schema_types_simple.g.dart'), - ); - final before = generatedFile.readAsStringSync(); + final schemaFile = File( + p.join(temporaryExample.path, 'lib', 'schema_types_simple.dart'), + ); + schemaFile.writeAsStringSync( + schemaFile.readAsStringSync().replaceFirst( + "'name': Ack.string(),", + "'name': Ack.string(),\n 'nickname': Ack.string().optional(),", + ), + ); + final generatedFile = File( + p.join(temporaryExample.path, 'lib', 'schema_types_simple.g.dart'), + ); + final before = generatedFile.readAsStringSync(); - _expectSuccess( - await _run(temporaryExample, ['run', 'build_runner', 'build']), - 'outputs-present build_runner build', - ); + _expectSuccess( + await _run(temporaryExample, ['run', 'build_runner', 'build']), + 'outputs-present build_runner build', + ); - final after = generatedFile.readAsStringSync(); - expect(after, isNot(equals(before))); - expect(after, contains('nickname')); - _expectSuccess( - await _run(temporaryExample, ['analyze', '--fatal-infos']), - 'dart analyze --fatal-infos', - ); - _expectSuccess(await _run(temporaryExample, ['test']), 'dart test'); - } finally { - temporaryRoot.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); + final after = generatedFile.readAsStringSync(); + expect(after, isNot(equals(before))); + expect(after, contains('nickname')); + _expectSuccess( + await _run(temporaryExample, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + _expectSuccess(await _run(temporaryExample, ['test']), 'dart test'); + } finally { + temporaryRoot.deleteSync(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart index 61c68842..6707ef07 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_build_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_build_test.dart @@ -19,22 +19,18 @@ void _expectSuccess(ProcessResult result, String command) { } void main() { - test( - 'type-owned schemas build, analyze, and round-trip', - () async { - var projectRoot = Directory.current; - while (!Directory( - p.join(projectRoot.path, 'packages', 'ack_generator'), - ).existsSync()) { - projectRoot = projectRoot.parent; - } - final temporary = await Directory.systemTemp.createTemp( - 'ack_type_owned_', - ); - try { - Directory(p.join(temporary.path, 'lib')).createSync(); - Directory(p.join(temporary.path, 'test')).createSync(); - File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' + test('type-owned schemas build, analyze, and round-trip', () async { + var projectRoot = Directory.current; + while (!Directory( + p.join(projectRoot.path, 'packages', 'ack_generator'), + ).existsSync()) { + projectRoot = projectRoot.parent; + } + final temporary = await Directory.systemTemp.createTemp('ack_type_owned_'); + try { + Directory(p.join(temporary.path, 'lib')).createSync(); + Directory(p.join(temporary.path, 'test')).createSync(); + File(p.join(temporary.path, 'pubspec.yaml')).writeAsStringSync(''' name: ack_type_owned publish_to: none environment: @@ -51,7 +47,7 @@ dependency_overrides: ack: path: ${p.join(projectRoot.path, 'packages', 'ack')} '''); - File(p.join(temporary.path, 'lib', 'types.dart')).writeAsStringSync(r''' + File(p.join(temporary.path, 'lib', 'types.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; final class Slot { @@ -143,8 +139,7 @@ AckSchema importedMoneySchema() => Ack.integer().codec( encode: (money) => money.cents, ); '''); - File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync( - r''' + File(p.join(temporary.path, 'lib', 'models.dart')).writeAsStringSync(r''' import 'package:ack/ack.dart'; import 'types.dart'; @@ -236,11 +231,10 @@ final panelSchema = Ack.object({ 'openAll': Trigger.schema(), 'habit': Habit.schema.optional(), }); -''', - ); - File( - p.join(temporary.path, 'test', 'type_owned_test.dart'), - ).writeAsStringSync(r''' +'''); + File( + p.join(temporary.path, 'test', 'type_owned_test.dart'), + ).writeAsStringSync(r''' import 'package:ack_type_owned/models.dart'; import 'package:ack_type_owned/types.dart'; import 'package:test/test.dart'; @@ -348,30 +342,28 @@ void main() { } '''); - _expectSuccess( - await _run(temporary, ['pub', 'get', '--offline']), - 'dart pub get --offline', - ); - _expectSuccess( - await _run(temporary, ['run', 'build_runner', 'build']), - 'build_runner build', - ); - final generated = File( - p.join(temporary.path, 'lib', 'models.g.dart'), - ).readAsStringSync(); - expect(generated, contains('Command.schema()')); - expect(generated, contains('Trigger.schema()')); - expect(generated, contains('Box.schema(HabitSchema.schema)')); - expect(generated, contains('Ack.map(Slot.schema)')); - _expectSuccess( - await _run(temporary, ['analyze', '--fatal-infos']), - 'dart analyze --fatal-infos', - ); - _expectSuccess(await _run(temporary, ['test']), 'dart test'); - } finally { - temporary.deleteSync(recursive: true); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); + _expectSuccess( + await _run(temporary, ['pub', 'get', '--offline']), + 'dart pub get --offline', + ); + _expectSuccess( + await _run(temporary, ['run', 'build_runner', 'build']), + 'build_runner build', + ); + final generated = File( + p.join(temporary.path, 'lib', 'models.g.dart'), + ).readAsStringSync(); + expect(generated, contains('Command.schema()')); + expect(generated, contains('Trigger.schema()')); + expect(generated, contains('Box.schema(HabitSchema.schema)')); + expect(generated, contains('Ack.map(Slot.schema)')); + _expectSuccess( + await _run(temporary, ['analyze', '--fatal-infos']), + 'dart analyze --fatal-infos', + ); + _expectSuccess(await _run(temporary, ['test']), 'dart test'); + } finally { + temporary.deleteSync(recursive: true); + } + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart index e9f63e28..691df3d6 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -10,9 +10,11 @@ import 'dart:convert'; import 'package:ack/ack.dart' as ack; -final Object _jsonSchemaDocument = jsonDecode( - '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":false},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', -) as Object; +final Object _jsonSchemaDocument = + jsonDecode( + '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":false},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', + ) + as Object; final Map _jsonSchemaDocuments = { Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): _jsonSchemaDocument, diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart index 0f6803b0..52bb94e3 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -13,9 +13,11 @@ import 'dart:convert'; import 'package:ack/ack.dart' as ack; -final Object _jsonSchemaDocument = jsonDecode( - '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":true},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":true},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', -) as Object; +final Object _jsonSchemaDocument = + jsonDecode( + '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":true},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":true},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', + ) + as Object; final Map _jsonSchemaDocuments = { Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): _jsonSchemaDocument, diff --git a/test/scripts/api_check_test.dart b/test/scripts/api_check_test.dart index 22aca6f9..557303cc 100644 --- a/test/scripts/api_check_test.dart +++ b/test/scripts/api_check_test.dart @@ -3,50 +3,46 @@ import 'dart:io'; import 'package:test/test.dart'; void main() { - test( - 'additive mode changes only the API version check mode', - () async { - final fakeBin = Directory.systemTemp.createTempSync('ack-api-mode-'); - final workingDirectory = Directory.systemTemp.createTempSync( - 'ack-api-mode-report-', - ); - addTearDown(() => fakeBin.deleteSync(recursive: true)); - addTearDown(() => workingDirectory.deleteSync(recursive: true)); - final arguments = File('${workingDirectory.path}/arguments.txt'); - _writeExecutable(fakeBin, 'dart', '''#!/bin/sh + test('additive mode changes only the API version check mode', () async { + final fakeBin = Directory.systemTemp.createTempSync('ack-api-mode-'); + final workingDirectory = Directory.systemTemp.createTempSync( + 'ack-api-mode-report-', + ); + addTearDown(() => fakeBin.deleteSync(recursive: true)); + addTearDown(() => workingDirectory.deleteSync(recursive: true)); + final arguments = File('${workingDirectory.path}/arguments.txt'); + _writeExecutable(fakeBin, 'dart', '''#!/bin/sh if [ "\$3" = "activate" ]; then exit 0; fi printf '%s\\n' "\$@" > '${arguments.path}' exit 23 '''); - final scriptPath = File('scripts/api_check.dart').absolute.path; - final environment = { - ...Platform.environment, - 'PATH': '${fakeBin.path}:/usr/bin:/bin', - }; + final scriptPath = File('scripts/api_check.dart').absolute.path; + final environment = { + ...Platform.environment, + 'PATH': '${fakeBin.path}:/usr/bin:/bin', + }; - final additive = await Process.run( - Platform.resolvedExecutable, - [scriptPath, 'ack', '1.6.2', '--allow-additive'], - workingDirectory: workingDirectory.path, - environment: environment, - ); - expect(additive.exitCode, 1); - expect(arguments.readAsStringSync(), contains('onlyBreakingChanges')); + final additive = await Process.run( + Platform.resolvedExecutable, + [scriptPath, 'ack', '1.6.2', '--allow-additive'], + workingDirectory: workingDirectory.path, + environment: environment, + ); + expect(additive.exitCode, 1); + expect(arguments.readAsStringSync(), contains('onlyBreakingChanges')); - final strict = await Process.run( - Platform.resolvedExecutable, - [scriptPath, 'ack', '1.6.2'], - workingDirectory: workingDirectory.path, - environment: environment, - ); - expect(strict.exitCode, 1); - expect( - arguments.readAsStringSync(), - isNot(contains('onlyBreakingChanges')), - ); - }, - skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, - ); + final strict = await Process.run( + Platform.resolvedExecutable, + [scriptPath, 'ack', '1.6.2'], + workingDirectory: workingDirectory.path, + environment: environment, + ); + expect(strict.exitCode, 1); + expect( + arguments.readAsStringSync(), + isNot(contains('onlyBreakingChanges')), + ); + }, skip: Platform.isWindows ? 'Uses POSIX test executables.' : false); for (final baseline in ['1.1.0', '1.2.0', '2.0.0-beta.1']) { test( @@ -105,38 +101,34 @@ exit 23 skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, ); - test( - 'stale reports do not satisfy the current API check', - () async { - final fakeBin = Directory.systemTemp.createTempSync('ack-api-check-'); - final workingDirectory = Directory.systemTemp.createTempSync( - 'ack-api-report-', - ); - addTearDown(() => fakeBin.deleteSync(recursive: true)); - addTearDown(() => workingDirectory.deleteSync(recursive: true)); + test('stale reports do not satisfy the current API check', () async { + final fakeBin = Directory.systemTemp.createTempSync('ack-api-check-'); + final workingDirectory = Directory.systemTemp.createTempSync( + 'ack-api-report-', + ); + addTearDown(() => fakeBin.deleteSync(recursive: true)); + addTearDown(() => workingDirectory.deleteSync(recursive: true)); - _writeExecutable(fakeBin, 'dart', '#!/bin/sh\nexit 0\n'); - final staleReport = File( - '${workingDirectory.path}/api-compat-ack-vs-1.0.0.md', - )..writeAsStringSync('stale report'); - final scriptPath = File('scripts/api_check.dart').absolute.path; + _writeExecutable(fakeBin, 'dart', '#!/bin/sh\nexit 0\n'); + final staleReport = File( + '${workingDirectory.path}/api-compat-ack-vs-1.0.0.md', + )..writeAsStringSync('stale report'); + final scriptPath = File('scripts/api_check.dart').absolute.path; - final result = await Process.run( - Platform.resolvedExecutable, - [scriptPath, 'ack', '1.0.0'], - workingDirectory: workingDirectory.path, - environment: { - ...Platform.environment, - 'PATH': '${fakeBin.path}:/usr/bin:/bin', - }, - ); + final result = await Process.run( + Platform.resolvedExecutable, + [scriptPath, 'ack', '1.0.0'], + workingDirectory: workingDirectory.path, + environment: { + ...Platform.environment, + 'PATH': '${fakeBin.path}:/usr/bin:/bin', + }, + ); - expect(result.exitCode, 1); - expect(result.stderr, contains('did not create')); - expect(staleReport.existsSync(), isFalse); - }, - skip: Platform.isWindows ? 'Uses POSIX test executables.' : false, - ); + expect(result.exitCode, 1); + expect(result.stderr, contains('did not create')); + expect(staleReport.existsSync(), isFalse); + }, skip: Platform.isWindows ? 'Uses POSIX test executables.' : false); } void _writeExecutable(Directory directory, String name, String contents) { From b5045ff22b35b2bd6a38871ba112afe3c17de01c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 12 Oct 2026 16:09:05 -0400 Subject: [PATCH 26/29] refactor(ack)!: unify @Validate.optional, @Validate.required, and @Validate.notNull and align SDK floor --- .github/workflows/preflight.yml | 5 ++- CHANGELOG.md | 14 ++++--- docs/api-reference/index.mdx | 12 +++--- docs/core-concepts/typesafe-schemas.mdx | 38 +++++++++-------- docs/getting-started/migration-guide.mdx | 24 ++++++----- example/pubspec.yaml | 3 +- llms.txt | 24 +++++------ packages/ack/CHANGELOG.md | 12 +++--- .../ack/lib/src/annotations/ack_field.dart | 34 --------------- .../ack/lib/src/annotations/constraints.dart | 31 ++++++++++---- packages/ack/pubspec.yaml | 3 +- .../ack/test/annotations/ack_model_test.dart | 19 +++------ .../ack/test/annotations/exports_test.dart | 6 +-- packages/ack_firebase_ai/pubspec.yaml | 5 ++- packages/ack_generator/CHANGELOG.md | 13 +++--- packages/ack_generator/README.md | 20 +++++---- .../analyzer/class_model_graph_builder.dart | 42 +++++++++++-------- .../lib/src/inference/schema_inference.dart | 20 +++++---- .../lib/src/models/schema_model_graph.dart | 10 ++--- packages/ack_generator/pubspec.yaml | 3 +- .../ack_infer_data_class_test.dart | 5 ++- .../class_first_runtime_build_test.dart | 32 +++++++------- .../integration/class_model_emitter_test.dart | 32 +++++++------- .../integration/class_model_graph_test.dart | 36 +++++++++------- .../test/src/ack_exports_test.dart | 3 +- packages/ack_json_schema_builder/pubspec.yaml | 3 +- packages/ack_mcp_dart/pubspec.yaml | 3 +- pubspec.yaml | 5 ++- 28 files changed, 240 insertions(+), 217 deletions(-) diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index b3678964..ec7c2aab 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -25,8 +25,9 @@ env: # against the change severity, so a breaking change requires a major bump. API_BASELINE_VERSION: '1.6.2' # The lowest SDK versions that packages/*/pubspec.yaml accept. - MINIMUM_DART_VERSION: '3.9.0' - MINIMUM_FLUTTER_VERSION: '3.35.0' + MINIMUM_DART_VERSION: '3.11.0' + MINIMUM_FLUTTER_VERSION: '3.41.2' + jobs: minimum-dart-sdk: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7879236d..1aff6ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline * **Substitute:** Replace `ack_generator|ack_models` and `ack_generator|ack_model_json` in `build.yaml` with `ack_generator|ack_generator`. -* **Unified `@Validate.*` constraint namespace (`#191`)**: Removed the 18 - `@Deprecated` 1.x top-level constraint classes (`@Min`, `@Max`, `@Email`, etc.) +* **Unified `@Validate.*` presence & constraint namespace (`#191`)**: Removed the + top-level presence classes (`@Optional`, `@Required`, `@NotNull`), the 18 + `@Deprecated` 1.x top-level constraint classes (`@Min`, `@Max`, `@Email`, etc.), and `package:ack/format_annotations.dart`. * **Substitute:** Use `@Validate.(...)` from `package:ack/ack.dart` - (`@Validate.min(...)`, `@Validate.max(...)`, `@Validate.multipleOf(...)`, + (`@Validate.optional()`, `@Validate.required()`, `@Validate.notNull()`, + `@Validate.min(...)`, `@Validate.max(...)`, `@Validate.multipleOf(...)`, `@Validate.positive()`, `@Validate.negative()`, `@Validate.minLength(...)`, `@Validate.maxLength(...)`, `@Validate.matches(...)`, `@Validate.notEmpty()`, `@Validate.email()`, `@Validate.url()`, `@Validate.uuid()`, `@Validate.date()`, @@ -31,9 +33,9 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline Removed `@AckType`, `@AckInfer`, `@AckModel`, `JsonKeyCaseStyle`, `AckTypeDef`, `AckFieldPresence`, `*Type` extension-type models, and the `@JsonKey(name: ...)` fallback. - * **Substitute:** Use `@Schemable()` on schemas and classes, `@Optional()` / - `@Required()` / `@NotNull()` for field presence, and `@AckField(name: '...')` - for wire-name overrides. + * **Substitute:** Use `@Schemable()` on schemas and classes, + `@Validate.optional()` / `@Validate.required()` / `@Validate.notNull()` for + field presence, and `@AckField(name: '...')` for wire-name overrides. * **Removed redundant helpers & `StandardSchemaV1` (`#129`)**: * **Substitute:** `Ack.enumCodec(E.values)` $\rightarrow$ `Ack.enumValues(E.values)`. * **Substitute:** `registerAckModelTool(...)` $\rightarrow$ diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index 7997e446..b6a3a190 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -316,8 +316,8 @@ Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builders in either direction with one annotation, `@Schemable()`: on a top-level schema it generates a model, on a class it generates a schema, and on a `library;` directive it registers shared custom schemas via `@Schemable(schemas: [...])`. -`package:ack/ack.dart` exports `@Schemable`, the model annotations (`@AckField`, -`@Optional`, `@Required`, `@NotNull`), and the `@Validate.*` constraint namespace. +`package:ack/ack.dart` exports `@Schemable`, `@AckField`, and the `@Validate.*` +validation namespace. `AckSchema` is the runtime schema type; there is no `@AckSchema()` annotation. After adding a `part '.g.dart';` directive, run: @@ -400,12 +400,14 @@ compile-time error. **Inferred field types:** strings, booleans, numeric types, `DateTime`, `Uri`, `Duration`, enums, `JsonMaybe`, nested lists, sets, and string-keyed maps -**Constraint annotations (`@Validate.*` from `package:ack/ack.dart`):** +**Validation annotations (`@Validate.*` from `package:ack/ack.dart`):** +- presence & nullability: `@Validate.required`, `@Validate.optional`, `@Validate.notNull` - numeric: `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative` - strings: `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.email`, `@Validate.notEmpty` -- string formats: `@Validate.url`, `@Validate.uri`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime` (`@Validate.datetime`), `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6` -- collections: `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` (`@Validate.unique`) +- string formats: `@Validate.url`, `@Validate.uri`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime`, `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6` +- collections: `@Validate.notEmpty`, `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` + **Custom schemas:** a field of custom type `T` resolves through: 1. `@AckField(schema: ...)` on the field or constructor parameter; diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index ef94da9d..bb2536a4 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -11,9 +11,8 @@ from a Dart class, or start from a `.schema.json` document. | A Dart class or sealed union (`final class Account`) | `@Schemable()` | `_accountSchema` codec, `AccountSchema` facade, and `_$AccountAck` mixin in `.g.dart` | | A JSON Schema asset (`lib/**/*.schema.json`) | `ack_generator:ack_json_schema` in `build.yaml` | `.schema.dart` + `.schema.g.dart` with typed `@Schemable()` models or validated value models | -`@Schemable()`, the model annotations (`@AckField`, `@Optional`, -`@Required`, `@NotNull`), and the `@Validate.*` constraint namespace all come -from `package:ack/ack.dart` (and `package:ack/annotations.dart`). +`@Schemable()`, `@AckField`, and the `@Validate.*` constraint and presence +namespace all come from `package:ack/ack.dart` (and `package:ack/annotations.dart`). There is no `@AckSchema()` annotation; `AckSchema` is the runtime type returned by factories such as `Ack.string()` and `Ack.object()`. @@ -292,24 +291,25 @@ Nullable defaults keep their source-level behavior. With null. With `this.label = null`, missing input and JSON `null` parse as null and the encoded object keeps the key with a null value. -Use `@Optional()` or `@Required()` to override inferred key presence. These -control whether the JSON key must exist; they do not change Dart nullability. -Unannotated fields keep constructor inference. +Use `@Validate.optional()` or `@Validate.required()` to override inferred key +presence. These control whether the JSON key must exist; they do not change Dart +nullability. Unannotated fields keep constructor inference. -Use `@NotNull()` when the Dart type is nullable (`String?`) but a present JSON -value must not be `null`. Do not add it on non-nullable fields such as -`Map` — those already reject JSON `null`. `@Optional()` alone on -`String?` still accepts JSON `null`, matching legacy `presence: optional` -behavior. `@Optional()` is redundant when the constructor already treats the -field as optional; `@NotNull()` is the annotation that changes null acceptance: +Use `@Validate.notNull()` when the Dart type is nullable (`String?`) or +`JsonMaybe` but a present JSON value must not be `null`. Do not add it on +non-nullable fields such as `Map` — those already reject JSON `null`. +`@Validate.optional()` alone on `String?` still accepts JSON `null`, matching +legacy `presence: optional` behavior. `@Validate.optional()` is redundant when +the constructor already treats the field as optional; `@Validate.notNull()` is +the annotation that changes null acceptance: ```dart @Schemable() final class Example with _$ExampleAck { const Example({this.label}); - @Optional() - @NotNull() + @Validate.optional() + @Validate.notNull() final String? label; } ``` @@ -360,12 +360,14 @@ Map values may be `null` only when the value type is nullable. `List` stays rejected because `Ack.list` has no nullable-item contract. Parsed `Object` values are recursively unmodifiable, like other stored collections. -Constraint annotations use `@Validate.*` and follow the field type: +Validation annotations use `@Validate.*` and follow the field type: +- presence & nullability: `@Validate.required`, `@Validate.optional`, `@Validate.notNull`; - numeric: `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative`; - strings: `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.email`, `@Validate.notEmpty`; -- string formats: `@Validate.url`, `@Validate.uri`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime` (`@Validate.datetime`), `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6`; -- lists and sets: `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` (`@Validate.unique`). +- string formats: `@Validate.url`, `@Validate.uri`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime`, `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6`; +- lists and sets: `@Validate.notEmpty`, `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems`. + `@Validate.matches` is the annotation form of `Ack.string().matches(...)`. Numeric constraint values must be finite. On a `Set>` field the collection @@ -681,7 +683,7 @@ targets: - Remove `ack_annotations`, `json_serializable`, and `json_annotation` from `pubspec.yaml` (unless used by non-Ack classes), and import `package:ack/ack.dart`. - Replace `part '.ack.dart';` + `part '.ack.g.dart';` with a single `part '.g.dart';`. - Replace `@AckType()`, `@AckInfer()`, and `@AckModel()` with `@Schemable()`. -- Replace top-level constraint annotations (`@Min`, `@Max`, `@MinLength`, `@Matches`, `@Email`, `@DateTime`, etc.) with `@Validate.*` (`@Validate.min`, `@Validate.max`, `@Validate.minLength`, `@Validate.matches`, `@Validate.email`, `@Validate.dateTime`, etc.). +- Replace top-level presence and constraint annotations (`@Optional`, `@Required`, `@NotNull`, `@Min`, `@Max`, `@MinLength`, `@Matches`, `@Email`, `@DateTime`, etc.) with `@Validate.*` (`@Validate.optional`, `@Validate.required`, `@Validate.notNull`, `@Validate.min`, `@Validate.max`, `@Validate.minLength`, `@Validate.matches`, `@Validate.email`, `@Validate.dateTime`, etc.). - Replace `@JsonKey(name: '...')` on `@Schemable` fields with `@AckField(name: '...')`. See the [What's New & Migration Guide](../getting-started/migration-guide.mdx) for the complete substitution tables and side-by-side before/after examples. diff --git a/docs/getting-started/migration-guide.mdx b/docs/getting-started/migration-guide.mdx index 4a9a3520..01e7b306 100644 --- a/docs/getting-started/migration-guide.mdx +++ b/docs/getting-started/migration-guide.mdx @@ -12,7 +12,7 @@ Ack is organized into five coordinated packages that share one version number an | Package | Role | Key Capabilities | | --- | --- | --- | -| **`ack`** | Core runtime & annotations | • Fluent schema builders: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.number()`, `Ack.boolean()`, `Ack.list()`, `Ack.map()`, `Ack.object()`, `Ack.any()`, `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()`, `Ack.anyOf()`, `Ack.discriminated()`, `Ack.lazy()`
• Bidirectional `CodecSchema`: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, `.codec()`, `.transform()`
• Two-way string normalizers: `.trim()`, `.toLowerCase()`, `.toUpperCase()`
• `JsonMaybe` (`JsonAbsent` / `JsonValue`) for tri-state `PATCH` payloads
• Full JSON Schema Draft 2020-12 runtime (`Ack.fromJsonSchema()`) and exporter (`toJsonSchema()`, `toSchemaModel()`, `toJsonSchemaDraft7()`)
• Sealed `SchemaResult` (`Ok` / `Fail`) and sealed `SchemaError` hierarchy
• Unified codegen annotations in `package:ack/ack.dart`: `@Schemable()`, `@AckField()`, `@Optional()`, `@Required()`, `@NotNull()`, and `@Validate.*` | +| **`ack`** | Core runtime & annotations | • Fluent schema builders: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.number()`, `Ack.boolean()`, `Ack.list()`, `Ack.map()`, `Ack.object()`, `Ack.any()`, `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()`, `Ack.anyOf()`, `Ack.discriminated()`, `Ack.lazy()`
• Bidirectional `CodecSchema`: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, `.codec()`, `.transform()`
• Two-way string normalizers: `.trim()`, `.toLowerCase()`, `.toUpperCase()`
• `JsonMaybe` (`JsonAbsent` / `JsonValue`) for tri-state `PATCH` payloads
• Full JSON Schema Draft 2020-12 runtime (`Ack.fromJsonSchema()`) and exporter (`toJsonSchema()`, `toSchemaModel()`, `toJsonSchemaDraft7()`)
• Sealed `SchemaResult` (`Ok` / `Fail`) and sealed `SchemaError` hierarchy
• Unified codegen annotations in `package:ack/ack.dart`: `@Schemable()`, `@AckField()`, and `@Validate.*` | | **`ack_generator`** | Two-way code generator | • **Schema-first (`@Schemable()` on a top-level schema)**: Generates an immutable model class, `Model.schema`, `parse`/`safeParse`, `fromJson`/`toJson`/`safeToJson`, typed `copyWith`, and deep `==`/`hashCode` in `.g.dart`
• **Class-first (`@Schemable()` on a `final class` or `sealed class`)**: Supports standard and Dart 3.12+ primary constructors; generates `_Schema`, `Schema`, and `_$Ack` in `.g.dart`
• **`.schema.json` codegen (`ack_generator:ack_json_schema`)**: Compiles `lib/**/*.schema.json` assets into `.schema.dart` + `.schema.g.dart` | | **`ack_mcp_dart`** | MCP tool adapter | • `server.registerAckTool()`, `registerAckInputTool()`, `registerAckOutputTool()`, and `result.decodeAckOutput()` for `mcp_dart` | | **`ack_firebase_ai`** | Firebase AI (Gemini) adapter | • `schema.toFirebaseAiSchema()` for Gemini structured output and tool declarations | @@ -24,9 +24,9 @@ Ack is organized into five coordinated packages that share one version number an | Change | Introduced | What Changed | Why It Changed | | --- | --- | --- | --- | -| **Annotations merged into `package:ack`** | `1.7.0` | `ack_annotations` was retired. `@Schemable()`, `@AckField()`, `@Optional()`, `@Required()`, `@NotNull()`, and `@Validate.*` ship directly in `package:ack/ack.dart`. | Eliminated version skew between `ack` and a separate annotations package and reduced model files to a single `import 'package:ack/ack.dart';`. | +| **Annotations merged into `package:ack`** | `1.7.0` | `ack_annotations` was retired. `@Schemable()`, `@AckField()`, and `@Validate.*` ship directly in `package:ack/ack.dart`. | Eliminated version skew between `ack` and a separate annotations package and reduced model files to a single `import 'package:ack/ack.dart';`. | | **Unified `@Schemable()` annotation** | `1.7.0` | `@Schemable()` drives both schema-first and class-first generation, replacing `@AckInfer()`, `@AckModel()`, and `@AckType()`. | One annotation and one mental model whether you start from an `AckSchema` or a hand-written Dart `class`. | -| **Unified `@Validate.*` constraint namespace** | `2.0.0` | All field and parameter constraint annotations live under `@Validate.*` (`@Validate.min(0)`, `@Validate.email()`, `@Validate.matches(...)`, `@Validate.dateTime()`, `@Validate.uri()`, etc.) in `package:ack/ack.dart`. | Top-level 1.x constraint classes (`@Email`, `@Uuid`, `@Date`, `@Uri`, `@DateTime`, `@Pattern`) collided with `dart:core` (`Uri`, `DateTime`, `Pattern`) and common packages (`package:uuid`), forcing import prefixes (`as ack`). `@Validate.*` has zero symbol collisions. | +| **Unified `@Validate.*` validation namespace** | `2.0.0` | All field and parameter validation annotations—including presence (`@Validate.required()`, `@Validate.optional()`), nullability (`@Validate.notNull()`), bounds (`@Validate.min(0)`), formats (`@Validate.email()`, `@Validate.dateTime()`, `@Validate.uri()`), and collection rules (`@Validate.notEmpty()`, `@Validate.uniqueItems()`)—live under `@Validate.*` in `package:ack/ack.dart`. | Top-level 1.x classes (`@Required`, `@Email`, `@Uuid`, `@Date`, `@Uri`, `@DateTime`, `@Pattern`) collided with `package:meta` / Flutter (`Required`), `dart:core` (`Uri`, `DateTime`, `Pattern`), and `package:uuid`, forcing import prefixes (`as ack`). `@Validate.*` has zero symbol collisions and one canonical name per validation rule. | | **Single `.g.dart` part & direct JSON emitter** | `2.0.0` | `ack_generator:ack_generator` is a `SharedPartBuilder` that emits `.g.dart` directly with no `json_serializable` or `json_annotation` dependency. | In 1.x, every model required two `part` directives (`.ack.dart` + `.ack.g.dart`) and a second `json_serializable` pass. Direct emission cuts build steps in half, uses standard `part '.g.dart';`, and coexists with Freezed or Riverpod in the same file. | | **Layered custom schema resolution (`T.schema`, `schemas: [...]`)** | `1.7.0` / `2.0.0` | Custom field types resolve via `@AckField(schema: ...)`, `@Schemable(schemas: [...])` (on classes, unions, or `library;` directives), type-owned `T.schema` / `T.schema()` factories, or automatic detection of a unique top-level `AckSchema` in the current library. | Removed repetitive `@AckField(schema: ...)` boilerplate on every field that uses a domain value object, extension type, enum, or generic wrapper. | | **Typed `$ClassCopyWith` getter** | `1.7.0` | `model.copyWith` returns a typed callable interface instead of widening parameters to `Object?`. | Passing `model.copyWith(age: 'wrong')` previously compiled and failed only at runtime (`TypeError`). Now mistyped arguments fail at compile time while `model.copyWith(age: null)` still clears nullable fields. | @@ -58,26 +58,28 @@ Ack is organized into five coordinated packages that share one version number an | `@AckInfer(...)` | Deprecated in `1.7.0`, Removed in `2.0.0` | `@Schemable(...)` on the top-level schema | | `@AckModel(...)` | Deprecated in `1.7.0`, Removed in `2.0.0` | `@Schemable(...)` on the class | | `JsonKeyCaseStyle` | Removed in `2.0.0` | `AckCaseStyle` (`none`, `snake`, `kebab`, `pascal`, `screamingSnake`) | -| `@AckField(presence: AckFieldPresence.optional)` | Removed in `2.0.0` | `@Optional()` (add `@NotNull()` if present JSON value must not be `null`) | -| `@AckField(presence: AckFieldPresence.requiredNull)` | Removed in `2.0.0` | `@Required()` on a nullable field (`final T? field;`) | -| `@AckField(presence: AckFieldPresence.requiredNonNull)` | Removed in `2.0.0` | `@Required()` + `@NotNull()` | +| `@Optional()` / `@AckField(presence: AckFieldPresence.optional)` | Removed in `2.0.0` | `@Validate.optional()` (add `@Validate.notNull()` if present JSON value must not be `null`) | +| `@Required()` / `@AckField(presence: AckFieldPresence.requiredNull)` | Removed in `2.0.0` | `@Validate.required()` on a nullable field (`final T? field;`) | +| `@NotNull()` / `@AckField(presence: AckFieldPresence.requiredNonNull)` | Removed in `2.0.0` | `@Validate.required()` + `@Validate.notNull()` | | `@JsonKey(name: 'wire_name')` on `@Schemable` fields | Removed in `2.0.0` | `@AckField(name: 'wire_name')` | | `@JsonConverter()` on `@Schemable` classes/fields | Rejected in `1.7.0+` | Declare `static final schema = ...` on the type, `@Schemable(schemas: [...])`, or `@AckField(schema: ...)` | -### Constraint Annotations (`1.x` Top-Level Classes $\rightarrow$ `2.0` `@Validate.*`) +### Validation Annotations (`1.x` Top-Level Classes $\rightarrow$ `2.0` `@Validate.*`) -All 18 top-level constraint classes were removed in `2.0.0`. Replace them with `@Validate.*` from `package:ack/ack.dart`: +All top-level validation and presence classes were removed in `2.0.0`. Replace them with `@Validate.*` from `package:ack/ack.dart`: | Removed `1.x` Annotation | Exact `2.0` Substitution | | --- | --- | +| `@Required()` / `@Optional()` / `@NotNull()` | `@Validate.required()` / `@Validate.optional()` / `@Validate.notNull()` | | `@Min(value)` / `@Max(value)` / `@MultipleOf(value)` | `@Validate.min(value)` / `@Validate.max(value)` / `@Validate.multipleOf(value)` | | `@Positive()` / `@Negative()` | `@Validate.positive()` / `@Validate.negative()` | -| `@MinLength(n)` / `@MaxLength(n)` / `@NotEmpty()` | `@Validate.minLength(n)` / `@Validate.maxLength(n)` / `@Validate.notEmpty()` | +| `@MinLength(n)` / `@MaxLength(n)` / `@NotEmpty()` | `@Validate.minLength(n)` / `@Validate.maxLength(n)` / `@Validate.notEmpty()` (works on `String`, `List`, and `Set`) | | `@Pattern(r'...')` / `@Matches(r'...')` | `@Validate.matches(r'...')` | | `@Email()` / `@Url()` / `@Uuid()` / `@Date()` | `@Validate.email()` / `@Validate.url()` / `@Validate.uuid()` / `@Validate.date()` | -| `@DateTime()` / `@Uri()` | `@Validate.dateTime()` (or `@Validate.datetime()`) / `@Validate.uri()` | +| `@DateTime()` / `@Uri()` | `@Validate.dateTime()` / `@Validate.uri()` | | `@Ip()` / `@Ipv4()` / `@Ipv6()` | `@Validate.ip()` / `@Validate.ipv4()` / `@Validate.ipv6()` | -| `@MinItems(n)` / `@MaxItems(n)` / `@UniqueItems()` | `@Validate.minItems(n)` / `@Validate.maxItems(n)` / `@Validate.uniqueItems()` (or `@Validate.unique()`) | +| `@MinItems(n)` / `@MaxItems(n)` / `@UniqueItems()` | `@Validate.minItems(n)` / `@Validate.maxItems(n)` / `@Validate.uniqueItems()` | + ### Generated Files, Builders & Runtime APIs diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6930ba85..689e6b53 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,7 +6,8 @@ publish_to: none resolution: workspace environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: ack: ^2.0.0-beta.1 diff --git a/llms.txt b/llms.txt index bd99f503..a9b3e657 100644 --- a/llms.txt +++ b/llms.txt @@ -5,10 +5,9 @@ Ack validates untrusted boundary data with schemas built through the `Ack` factory. Annotate a top-level schema with `@Schemable()` to generate a model, or annotate a hand-written class with `@Schemable()` to generate its schema and -JSON helpers. `package:ack/ack.dart` exports `@Schemable`, the model -annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the -`@Validate.*` constraint namespace. There is no `@AckSchema()` annotation; -`AckSchema` is the runtime schema type. +JSON helpers. `package:ack/ack.dart` exports `@Schemable`, `@AckField`, and the +`@Validate.*` presence and constraint namespace. There is no `@AckSchema()` +annotation; `AckSchema` is the runtime schema type. ## Packages @@ -123,11 +122,11 @@ equality. The facade exposes `schema`, `wireSchema`, `parse`, `safeParse`, lower-camel alias is emitted. Constructor parameters determine presence and defaults. Field types and `@Validate.*` constraint annotations determine the schema. Sealed classes use `@Schemable(discriminatorKey: ...)`; same-library concrete branches -are included automatically. Use `@Optional()` or `@Required()` to override -inferred key presence, `@NotNull()` to reject an explicit JSON `null` without -requiring the key, `JsonMaybe` to distinguish omitted keys from explicit -`null` on both parse and encode, and `@AckField(name: 'wire_name')` for a single -field's JSON key override. +are included automatically. Use `@Validate.optional()` or `@Validate.required()` +to override inferred key presence, `@Validate.notNull()` to reject an explicit +JSON `null` without requiring the key, `JsonMaybe` to distinguish omitted +keys from explicit `null` on both parse and encode, and `@AckField(name: 'wire_name')` +for a single field's JSON key override. Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export schema descriptions. A single-line `@description` doc tag is a fallback. Untagged prose does not export. A field description applies at its property @@ -226,7 +225,8 @@ Class-first generation supports object models, inferred scalar and enum fields, JSON-safe `Object` fields, nested lists, sets through list codecs, string-keyed maps, `JsonMaybe`, custom schemas (`@AckField(schema: ...)`, `@Schemable(schemas: [...])`, `T.schema`, or auto-detected top-level library -schemas), `@Optional()`, `@Required()`, `@NotNull()`, `@Validate.*` constraints, +schemas), `@Validate.*` presence and constraint annotations (`@Validate.optional()`, +`@Validate.required()`, `@Validate.notNull()`, `@Validate.min(...)`, etc.), constructor defaults, case styles, `AckUnknownPropertyPolicy`, and same-library sealed discriminated unions. It rejects `dynamic`, nullable list or set elements, non-String map keys, recursive class-first graphs, class-first value @@ -301,9 +301,9 @@ validates through the union's effective branch. | `part 'file.ack.dart';` + `part 'file.ack.g.dart';` | Single `part 'file.g.dart';` | | `ack_generator\|ack_models`, `ack_generator\|ack_model_json` | `ack_generator\|ack_generator` (plus optional `ack_generator\|ack_json_schema`) | | `@AckType()`, `@AckInfer()`, `@AckModel()` | `@Schemable()` | -| `@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, `@Pattern`, `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems` | `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative`, `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.notEmpty`, `@Validate.email`, `@Validate.url`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime`, `@Validate.uri`, `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6`, `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` | +| `@Optional()`, `@Required()`, `@NotNull()`, `@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, `@Pattern`, `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems` | `@Validate.optional()`, `@Validate.required()`, `@Validate.notNull()`, `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative`, `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.notEmpty`, `@Validate.email`, `@Validate.url`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime`, `@Validate.uri`, `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6`, `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` | | `@JsonKey(name: 'wire')` on `@Schemable` fields | `@AckField(name: 'wire')` | -| `@AckField(presence: ...)` | `@Optional()`, `@Required()`, `@NotNull()`, or `JsonMaybe` | +| `@AckField(presence: ...)` | `@Validate.optional()`, `@Validate.required()`, `@Validate.notNull()`, or `JsonMaybe` | | `Ack.enumCodec(MyEnum.values)` | `Ack.enumValues(MyEnum.values)` | | `registerAckModelTool(...)` (`ack_mcp_dart`) | `server.registerAckTool(name, inputSchema: Model.schema, ...)` | | `convertAckSchemaModelToBuilder(model)` (`ack_json_schema_builder`) | `schema.toJsonSchemaBuilder()` | diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 7c436b65..0a78d0d1 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -5,10 +5,11 @@ * Remove legacy 1.x generator annotations (`@AckType`, `@AckInfer`, `@AckModel`, `JsonKeyCaseStyle`, `AckFieldPresence`, and `@Schemable.generatedJson`). Use `@Schemable()` on both top-level schemas and hand-written classes/unions (`#188`, `#189`). -* Remove the 18 `@Deprecated` 1.x top-level constraint classes (`@Min`, `@Max`, +* Remove top-level presence annotations (`@Optional`, `@Required`, `@NotNull`), + the 18 `@Deprecated` 1.x top-level constraint classes (`@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, - `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems`) and + `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems`), and `package:ack/format_annotations.dart` in favor of `@Validate.*` (`#191`). * Remove `Ack.enumCodec` in favor of `Ack.enumValues` and remove unused `StandardSchemaV1` (`#129`). @@ -28,9 +29,10 @@ ### Added -* Add unified `@Validate.*` constraint annotation namespace - (`final class Validate`) exported from both `package:ack/ack.dart` and - `package:ack/annotations.dart` (`#191`). +* Add unified `@Validate.*` presence and constraint annotation namespace + (`final class Validate`, including `@Validate.optional()`, + `@Validate.required()`, and `@Validate.notNull()`) exported from both + `package:ack/ack.dart` and `package:ack/annotations.dart` (`#191`). * Add `sealed class JsonMaybe` (`JsonAbsent`, `JsonValue`) with `JsonMaybe.absent()`, `JsonMaybe.value(T? value)`, and `JsonMaybe.nullValue()` for PATCH tri-state presence (`#148`). diff --git a/packages/ack/lib/src/annotations/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 3f4d1d8e..5e5b86f9 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -1,39 +1,5 @@ import 'package:meta/meta_meta.dart'; -/// Marks a class-first field as optional on the wire. -/// -/// The JSON key may be omitted. This does not change whether a present JSON -/// value may be `null`: a nullable Dart type still accepts JSON `null`, and -/// `@NotNull()` rejects it. Valid only when the constructor can accept a -/// missing value, with a discriminator-specific exception for union branches. -@Target({TargetKind.field, TargetKind.parameter}) -final class Optional { - /// Creates an optional-presence annotation. - const Optional(); -} - -/// Marks a class-first field as required on the wire. -/// -/// The JSON key must be present. This does not change Dart nullability. -@Target({TargetKind.field, TargetKind.parameter}) -final class Required { - /// Creates a required-presence annotation. - const Required(); -} - -/// Rejects an explicit JSON `null` without requiring the key to exist. -/// -/// Needed only when the Dart type is nullable but a present JSON value must not -/// be `null`. Non-nullable Dart fields already reject JSON `null`; do not add -/// this annotation there. Dart nullability is unchanged: an omitted optional key -/// still becomes `null` on a nullable field, and encoding omits a null Dart -/// value. -@Target({TargetKind.field, TargetKind.parameter}) -final class NotNull { - /// Creates a JSON null-rejection annotation. - const NotNull(); -} - /// Overrides the JSON key, inferred schema, and/or description for a /// class-first field. /// diff --git a/packages/ack/lib/src/annotations/constraints.dart b/packages/ack/lib/src/annotations/constraints.dart index 04214127..8893d76e 100644 --- a/packages/ack/lib/src/annotations/constraints.dart +++ b/packages/ack/lib/src/annotations/constraints.dart @@ -22,6 +22,29 @@ final class Validate { /// Optional constraint argument (`num`, `int`, or `String`). final Object? value; + /// Marks a class-first field as optional on the JSON wire. + /// + /// The JSON key may be omitted. This does not change whether a present JSON + /// value may be `null`: a nullable Dart type still accepts JSON `null`, and + /// [Validate.notNull] rejects it. Valid only when the constructor can accept + /// a missing value, with a discriminator-specific exception for union + /// branches. + const Validate.optional() : kind = 'optional', value = null; + + /// Marks a class-first field as required on the JSON wire. + /// + /// The JSON key must be present. This does not change Dart nullability. + const Validate.required() : kind = 'required', value = null; + + /// Rejects an explicit JSON `null` without requiring the key to exist. + /// + /// Needed only when the Dart type is nullable (`T?`) or `JsonMaybe` but a + /// present JSON value must not be `null`. Non-nullable Dart fields already + /// reject JSON `null`. Dart nullability is unchanged: an omitted optional key + /// still becomes `null` on a nullable field, and encoding omits a null Dart + /// value. + const Validate.notNull() : kind = 'notNull', value = null; + /// Adds `.min(value)` to an inferred numeric schema. const Validate.min(num this.value) : kind = 'min'; @@ -46,7 +69,7 @@ final class Validate { /// Adds `.matches(pattern)` to an inferred string schema. const Validate.matches(String pattern) : kind = 'matches', value = pattern; - /// Adds `.notEmpty()` to an inferred string schema. + /// Adds `.notEmpty()` to an inferred string or collection (`List` / `Set`) schema. const Validate.notEmpty() : kind = 'notEmpty', value = null; /// Adds `.email()` to an inferred string schema. @@ -67,9 +90,6 @@ final class Validate { /// Adds `.datetime()` to an inferred string schema. const Validate.dateTime() : kind = 'dateTime', value = null; - /// Adds `.datetime()` to an inferred string schema. - const Validate.datetime() : kind = 'datetime', value = null; - /// Adds `.ip()` to an inferred string schema. const Validate.ip({int? version}) : kind = 'ip', value = version; @@ -87,7 +107,4 @@ final class Validate { /// Adds `.unique()` to an inferred collection schema. const Validate.uniqueItems() : kind = 'uniqueItems', value = null; - - /// Adds `.unique()` to an inferred collection schema. - const Validate.unique() : kind = 'unique', value = null; } diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index cb891ba8..897b3847 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -14,7 +14,8 @@ topics: - serialization environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: meta: ^1.15.0 collection: ^1.18.0 diff --git a/packages/ack/test/annotations/ack_model_test.dart b/packages/ack/test/annotations/ack_model_test.dart index 1aea498f..e854e3a1 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -63,19 +63,13 @@ void main() { expect(defaults.description, isNull); }); - test('presence and null annotations are const', () { - const optional = Optional(); - const required = Required(); - const notNull = NotNull(); - expect(optional, isA()); - expect(required, isA()); - expect(notNull, isA()); - }); - test( 'Validate.* constraint annotations are const and expose kind + value', () { const checks = [ + Validate.optional(), + Validate.required(), + Validate.notNull(), Validate.min(1), Validate.max(9.5), Validate.multipleOf(2), @@ -91,7 +85,6 @@ void main() { Validate.uuid(), Validate.date(), Validate.dateTime(), - Validate.datetime(), Validate.ip(), Validate.ip(version: 4), Validate.ipv4(), @@ -99,12 +92,14 @@ void main() { Validate.minItems(1), Validate.maxItems(10), Validate.uniqueItems(), - Validate.unique(), ]; expect( checks.map((c) => (c.kind, c.value)).toList(), const <(String, Object?)>[ + ('optional', null), + ('required', null), + ('notNull', null), ('min', 1), ('max', 9.5), ('multipleOf', 2), @@ -120,7 +115,6 @@ void main() { ('uuid', null), ('date', null), ('dateTime', null), - ('datetime', null), ('ip', null), ('ip', 4), ('ipv4', null), @@ -128,7 +122,6 @@ void main() { ('minItems', 1), ('maxItems', 10), ('uniqueItems', null), - ('unique', null), ], ); }, diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index 9699f9f4..7c0c3207 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -30,9 +30,9 @@ void main() { const values = [ Schemable(caseStyle: AckCaseStyle.snake), AckField(description: 'id'), - Required(), - Optional(), - NotNull(), + Validate.required(), + Validate.optional(), + Validate.notNull(), Validate.email(), Validate.minLength(2), Validate.matches(r'^\d+$'), diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index fc426a7f..fd8f2739 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -12,8 +12,9 @@ topics: - validation environment: - sdk: '>=3.9.0 <4.0.0' - flutter: '>=3.35.0' + sdk: '>=3.11.0 <4.0.0' + flutter: '>=3.41.2' + dependencies: ack: ^2.0.0-beta.1 diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 19b2ef04..3f4ae631 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -10,14 +10,17 @@ `part '.ack.dart';` and `part '.ack.g.dart';` with a single `part '.g.dart';`. * Remove legacy `@AckType`, `@AckInfer`, `@AckModel`, `*Type` extension-type - model generation, and the legacy `@JsonKey(name: ...)` fallback in favor of - `@AckField(name: ...)` (`#188`, `#189`). + model generation, top-level `@Optional` / `@Required` / `@NotNull` / constraint + classes, and the legacy `@JsonKey(name: ...)` fallback in favor of + `@Schemable()`, `@Validate.*`, and `@AckField(name: ...)` (`#188`, `#189`, `#191`). ### Added -* Support `@Validate.*` constraint annotations from `package:ack/ack.dart` - across field declarations and primary/generative constructor parameters, as - well as `static final schema = _Schema;` on class-first models (`#191`). +* Support `@Validate.*` presence and constraint annotations (`@Validate.optional()`, + `@Validate.required()`, `@Validate.notNull()`, `@Validate.notEmpty()` on + `String`/`List`/`Set`, etc.) from `package:ack/ack.dart` across field + declarations and primary/generative constructor parameters, as well as + `static final schema = _Schema;` on class-first models (`#191`). * Support `JsonMaybe` fields in `@Schemable()` class-first models for PATCH tri-state presence (`#148`). * Support `@AckField(name: '...')` directly without `package:json_annotation` diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 702b5226..c7b1b914 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -2,9 +2,9 @@ `ack_generator` supports two directions through `@Schemable()`: it turns a top-level Ack schema into an immutable model and derives an Ack -codec schema from a hand-written class. `@Schemable()`, the model annotations -(`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the `@Validate.*` -constraint namespace are all exported from `package:ack/ack.dart`. +codec schema from a hand-written class. `@Schemable()`, `@AckField()`, and the +`@Validate.*` validation namespace are all exported from `package:ack/ack.dart`. + ## Generate models from JSON Schema @@ -277,16 +277,18 @@ shared by all `@Schemable` classes in that library and any library that imports On a class, `@Schemable()` annotates public, constructable `final class` declarations whose stored fields are final. Annotated sealed union bases remain supported, and -their concrete branches must also be final. Use `@Optional()` or `@Required()` -to override inferred key presence, `@NotNull()` to reject JSON `null` without -requiring the key, `JsonMaybe` to distinguish omitted keys from explicit -`null` on both parse and encode, and `@AckField(name: 'wire_name')` to override -a single field's JSON key. Constraint annotations use `@Validate.*` (such as -`@Validate.matches(pattern)` for `Ack.string().matches(pattern)`). See the +their concrete branches must also be final. Use `@Validate.optional()` or +`@Validate.required()` to override inferred key presence, `@Validate.notNull()` +to reject JSON `null` without requiring the key, `JsonMaybe` to distinguish +omitted keys from explicit `null` on both parse and encode, and +`@AckField(name: 'wire_name')` to override a single field's JSON key. Validation +annotations use `@Validate.*` (such as `@Validate.matches(pattern)` for +`Ack.string().matches(pattern)`). See the [Model Code Generation guide](https://concepta.dev/documentation/ack/advanced/typesafe-schemas) for both directions, field inference, sealed unions, passthrough properties, and build configuration. + Open JSON and presence-aware fields are inferred from their Dart types. `Object` means a JSON-safe value, not an arbitrary Dart instance: diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 46f87a09..336cf99d 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -69,16 +69,8 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { inPackage: 'json_annotation', ); - static const _optionalChecker = TypeChecker.typeNamed( - annotations.Optional, - inPackage: 'ack', - ); - static const _requiredChecker = TypeChecker.typeNamed( - annotations.Required, - inPackage: 'ack', - ); - static const _notNullChecker = TypeChecker.typeNamed( - annotations.NotNull, + static const _validateChecker = TypeChecker.typeNamed( + annotations.Validate, inPackage: 'ack', ); @@ -607,7 +599,8 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { final dartNullable = futureType?.runtimeRef is AckNullableTypeRef || isNullableType(field.type); - final rejectNull = _hasAnnotation(_notNullChecker, field, parameter); + final rejectNull = _hasValidateKind('notNull', field, parameter); + final acceptsNull = (dartNullable || isJsonMaybe) && !rejectNull; final presence = _effectivePresence( field, @@ -936,13 +929,26 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { checker.firstAnnotationOfExact(field) ?? (parameter == null ? null : checker.firstAnnotationOfExact(parameter)); - bool _hasAnnotation( - TypeChecker checker, + bool _hasValidateKind( + String kind, FieldElement field, [ FormalParameterElement? parameter, ]) => - checker.hasAnnotationOfExact(field) || - (parameter != null && checker.hasAnnotationOfExact(parameter)); + _elementHasValidateKind(field, kind) || + (parameter != null && _elementHasValidateKind(parameter, kind)); + + bool _elementHasValidateKind(Element element, String kind) { + for (final metadata in element.metadata.annotations) { + final value = metadata.computeConstantValue(); + final valueType = value?.type; + if (value == null || valueType == null) continue; + if (_validateChecker.isExactlyType(valueType) && + value.getField('kind')?.toStringValue() == kind) { + return true; + } + } + return false; + } void _rejectUnsupportedStaticType(FieldElement field, DartType type) { if (type is DynamicType || type is TypeParameterType) { @@ -1089,12 +1095,12 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { bool isJsonMaybe = false, }) { final inferred = _fieldPresence(parameter); - final hasOptional = _hasAnnotation(_optionalChecker, field, parameter); - final hasRequired = _hasAnnotation(_requiredChecker, field, parameter); + final hasOptional = _hasValidateKind('optional', field, parameter); + final hasRequired = _hasValidateKind('required', field, parameter); if (hasOptional && hasRequired) { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} cannot combine ' - '@Optional() and @Required().', + '@Validate.optional() and @Validate.required().', element: field, ); } diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 0be2ef8e..1b71150b 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -120,6 +120,8 @@ final class AckSchemaInference { if (_validate.isExactlyType(valueType)) { final kind = value.getField('kind')!.toStringValue()!; switch (kind) { + case 'optional' || 'required' || 'notNull': + break; case 'min': _require( declaration, @@ -203,10 +205,14 @@ final class AckSchemaInference { declaration, type, '@Validate.notEmpty', - isString, - 'String field', + isString || isCollection, + 'String, List, or Set field', ); - output = '$output.notEmpty()'; + if (isCollection) { + addCollectionConstraint('.notEmpty()'); + } else { + output = '$output.notEmpty()'; + } case 'email': _require( declaration, @@ -252,11 +258,11 @@ final class AckSchemaInference { 'String field', ); output = '$output.date()'; - case 'dateTime' || 'datetime': + case 'dateTime': _require( declaration, type, - '@Validate.$kind', + '@Validate.dateTime', isString, 'String field', ); @@ -322,11 +328,11 @@ final class AckSchemaInference { addCollectionConstraint( '.maxItems(${value.getField('value')!.toIntValue()})', ); - case 'uniqueItems' || 'unique': + case 'uniqueItems': _require( declaration, type, - '@Validate.$kind', + '@Validate.uniqueItems', isCollection, 'List or Set field', ); diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index 462413d1..5c954cd6 100644 --- a/packages/ack_generator/lib/src/models/schema_model_graph.dart +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -23,10 +23,9 @@ final class AckSchemaId { /// Input-presence semantics for an object field in the normalized graph. /// -/// This is distinct from the public `@Optional()` / `@Required()` annotations -/// and the deprecated `AckFieldPresence` override. Presence and nullability -/// stay separate: a field can be required and nullable, optional and -/// non-nullable, or defaulted by the schema. +/// This is distinct from the public `@Validate.optional()` / `@Validate.required()` +/// annotations. Presence and nullability stay separate: a field can be required +/// and nullable, optional and non-nullable, or defaulted by the schema. enum AckSchemaFieldPresence { required, optional, defaulted } /// How an object model treats undeclared properties. @@ -175,8 +174,9 @@ final class AckFieldNode { /// Whether a present JSON value may be `null`. /// /// This is independent of [nullable] Dart storage and of key [presence]. - /// `@NotNull()` sets it to false while leaving the Dart type unchanged. + /// `@Validate.notNull()` sets it to false while leaving the Dart type unchanged. final bool acceptsNull; + final AckInferRef runtimeRef; final String? description; diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 43e3c829..5a0e7a31 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -11,7 +11,8 @@ topics: - schema environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: # Core code generation dependencies diff --git a/packages/ack_generator/test/integration/ack_infer_data_class_test.dart b/packages/ack_generator/test/integration/ack_infer_data_class_test.dart index 3566e9a5..3f81fa77 100644 --- a/packages/ack_generator/test/integration/ack_infer_data_class_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_data_class_test.dart @@ -152,7 +152,7 @@ $_imports @Schemable() final class User with _\$UserAck { const User({this.nickname}); - @Required() + @Validate.required() final String? nickname; } @@ -192,9 +192,10 @@ $_imports final class Normalized with _\$NormalizedAck { const Normalized(String? value) : value = value ?? ''; - @Optional() + @Validate.optional() final String value; } + ''', outputs: { 'test_pkg|lib/model.g.dart': decodedMatches( diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index ca0eb4ae..5acfa9e1 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -253,7 +253,7 @@ final class Loose with _$LooseAck { final class Normalized with _$NormalizedAck { const Normalized(String? value) : value = value ?? ''; - @Optional() + @Validate.optional() final String value; } @@ -299,12 +299,12 @@ final class ImmutableCollections with _$ImmutableCollectionsAck { final class Example with _$ExampleAck { const Example({this.label, this.title}); - @Optional() - @NotNull() + @Validate.optional() + @Validate.notNull() final String? label; - @Optional() - @NotNull() + @Validate.optional() + @Validate.notNull() @Validate.notEmpty() final String? title; @@ -322,7 +322,7 @@ final class ExampleHolder with _$ExampleHolderAck { final class OptionalNullable with _$OptionalNullableAck { const OptionalNullable({this.note}); - @Optional() + @Validate.optional() final String? note; } @@ -330,7 +330,7 @@ final class OptionalNullable with _$OptionalNullableAck { final class InferredNotNull with _$InferredNotNullAck { const InferredNotNull({this.label}); - @NotNull() + @Validate.notNull() final String? label; } @@ -340,7 +340,7 @@ AckSchema nullableNameSchema() => Ack.string().nullable(); final class OverrideNotNull with _$OverrideNotNullAck { const OverrideNotNull({this.name}); - @NotNull() + @Validate.notNull() @AckField(schema: nullableNameSchema) final String? name; } @@ -349,8 +349,8 @@ final class OverrideNotNull with _$OverrideNotNullAck { final class RequiredNotNull with _$RequiredNotNullAck { const RequiredNotNull({this.value}); - @Required() - @NotNull() + @Validate.required() + @Validate.notNull() final String? value; } @@ -380,7 +380,7 @@ final class CapabilityBinding with _$CapabilityBindingAck { final String name; - @Optional() + @Validate.optional() @AckField(schema: parameterMapSchema) final Map parameters; @@ -404,11 +404,12 @@ final class Envelope with _$EnvelopeAck { }); final Object kind; - @Optional() + @Validate.optional() final Object? payload; - @Optional() - @NotNull() + @Validate.optional() + @Validate.notNull() final Object? strict; + final List items; final Map values; final Map metadata; @@ -559,10 +560,11 @@ final class UserPatch with _$UserPatchAck { final JsonMaybe nickname; - @NotNull() + @Validate.notNull() @Validate.notEmpty() final JsonMaybe title; + final JsonMaybe> tags; } diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index dda31c76..6faf7615 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -487,8 +487,9 @@ final class Envelope with _\$EnvelopeAck { }); final Object kind; - @Optional() + @Validate.optional() final Object? payload; + final List items; final Set tags; final Map values; @@ -898,15 +899,15 @@ part 'fields.g.dart'; final class Example with _\$ExampleAck { const Example({this.label, this.nickname, this.title}); - @Optional() - @NotNull() + @Validate.optional() + @Validate.notNull() final String? label; - @Optional() + @Validate.optional() final String? nickname; - @Optional() - @NotNull() + @Validate.optional() + @Validate.notNull() @Validate.notEmpty() final String? title; } @@ -915,7 +916,7 @@ final class Example with _\$ExampleAck { final class InferredNotNull with _\$InferredNotNullAck { const InferredNotNull({this.label}); - @NotNull() + @Validate.notNull() final String? label; } @@ -925,7 +926,7 @@ AckSchema nullableNameSchema() => Ack.string().nullable(); final class OverrideNotNull with _\$OverrideNotNullAck { const OverrideNotNull({this.name}); - @NotNull() + @Validate.notNull() @AckField(schema: nullableNameSchema) final String? name; } @@ -934,8 +935,8 @@ final class OverrideNotNull with _\$OverrideNotNullAck { final class RequiredNotNull with _\$RequiredNotNullAck { const RequiredNotNull({this.value}); - @Required() - @NotNull() + @Validate.required() + @Validate.notNull() final String? value; } @@ -943,7 +944,7 @@ final class RequiredNotNull with _\$RequiredNotNullAck { final class Forced with _\$ForcedAck { const Forced({this.summary}); - @Required() + @Validate.required() final String? summary; } @@ -974,10 +975,11 @@ final class CapabilityBinding with _\$CapabilityBindingAck { final String name; - @Optional() + @Validate.optional() @AckField(schema: parameterMapSchema) final Map parameters; } + ''', }, outputs: { @@ -1126,6 +1128,7 @@ final class CheckedRecord with _\$CheckedRecordAck { @Validate.negative() final double debt; + @Validate.notEmpty() @Validate.minItems(1) @Validate.maxItems(5) @Validate.uniqueItems() @@ -1155,7 +1158,7 @@ final class CheckedRecord with _\$CheckedRecordAck { ), _containsCode("'debt': Ack.double().negative()"), _containsCode( - "'tags': Ack.list(Ack.string()).minItems(1).maxItems(5).unique()", + "'tags': Ack.list(Ack.string()).notEmpty().minItems(1).maxItems(5).unique()", ), _containsCode( "'handle': Ack.string().minLength(2).maxLength(20)", @@ -1187,10 +1190,11 @@ final class UserPatch with _$UserPatchAck { final JsonMaybe nickname; - @NotNull() + @Validate.notNull() @Validate.notEmpty() final JsonMaybe title; + final JsonMaybe> tags; } ''', diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index b997d031..8645aab7 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -1286,36 +1286,42 @@ final class User with _\$UserAck { }, ); - test('rejects @Optional() on a required constructor parameter', () async { - await _expectFailure( - ''' + test( + 'rejects @Validate.optional() on a required constructor parameter', + () async { + await _expectFailure( + ''' @Schemable() final class User with _\$UserAck { const User({required this.name}); - @Optional() + @Validate.optional() final String name; } ''', - ['User.name', 'optional', 'constructor'], - ); - }); + ['User.name', 'optional', 'constructor'], + ); + }, + ); - test('rejects combining @Optional() and @Required()', () async { - await _expectFailure( - ''' + test( + 'rejects combining @Validate.optional() and @Validate.required()', + () async { + await _expectFailure( + ''' @Schemable() final class User with _\$UserAck { const User({this.name}); - @Optional() - @Required() + @Validate.optional() + @Validate.required() final String? name; } ''', - ['User.name', '@Optional()', '@Required()'], - ); - }); + ['User.name', '@Validate.optional()', '@Validate.required()'], + ); + }, + ); test('rejects an unmapped constructor parameter', () async { await _expectFailure( diff --git a/packages/ack_generator/test/src/ack_exports_test.dart b/packages/ack_generator/test/src/ack_exports_test.dart index 3a33862d..e134c95f 100644 --- a/packages/ack_generator/test/src/ack_exports_test.dart +++ b/packages/ack_generator/test/src/ack_exports_test.dart @@ -40,8 +40,7 @@ void main() { readerWriter: readerWriter, outputs: { 'test_pkg|lib/probe.exports': decodedMatches( - 'AckCaseStyle,AckField,AckUnknownPropertyPolicy,' - 'NotNull,Optional,Required,Schemable,Validate', + 'AckCaseStyle,AckField,AckUnknownPropertyPolicy,Schemable,Validate', ), }, ); diff --git a/packages/ack_json_schema_builder/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index 14a47059..f2ca3c57 100644 --- a/packages/ack_json_schema_builder/pubspec.yaml +++ b/packages/ack_json_schema_builder/pubspec.yaml @@ -10,7 +10,8 @@ topics: - validation environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: ack: ^2.0.0-beta.1 diff --git a/packages/ack_mcp_dart/pubspec.yaml b/packages/ack_mcp_dart/pubspec.yaml index c7ce363a..772b4fc2 100644 --- a/packages/ack_mcp_dart/pubspec.yaml +++ b/packages/ack_mcp_dart/pubspec.yaml @@ -10,7 +10,8 @@ topics: - validation environment: - sdk: '>=3.9.0 <4.0.0' + sdk: '>=3.11.0 <4.0.0' + dependencies: ack: ^2.0.0-beta.1 diff --git a/pubspec.yaml b/pubspec.yaml index c22db492..38c4bbf9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,8 +4,9 @@ publish_to: none repository: https://github.com/btwld/ack environment: - sdk: '>=3.9.0 <4.0.0' - flutter: '>=3.35.0' + sdk: '>=3.11.0 <4.0.0' + flutter: '>=3.41.2' + workspace: - packages/ack From b4c4f053c83b3d9828a7eccac4649a1d9672f8b8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 14 Oct 2026 14:20:39 -0400 Subject: [PATCH 27/29] feat(ack)!: prepare 2.0.0-alpha.1 on release/2.0 with @AckKey and review fixes --- .github/workflows/ci.yml | 2 +- .github/workflows/preflight.yml | 2 +- .github/workflows/release.yml | 25 +- CHANGELOG.md | 19 +- PUBLISHING.md | 21 +- README.md | 11 +- docs.json | 2 +- docs/api-reference/index.mdx | 11 +- docs/core-concepts/typesafe-schemas.mdx | 37 +- docs/getting-started/installation.mdx | 8 +- docs/getting-started/migration-guide.mdx | 14 +- .../creating-schema-converter-packages.mdx | 4 +- docs/guides/ecosystem-integrations.mdx | 2 +- docs/guides/schema-converter-quickstart.mdx | 4 +- example/lib/class_first_models.dart | 2 +- example/pubspec.yaml | 4 +- llms.txt | 30 +- packages/ack/CHANGELOG.md | 11 +- packages/ack/lib/src/ack.dart | 28 +- .../ack/lib/src/annotations/ack_field.dart | 35 +- .../ack/lib/src/annotations/schemable.dart | 8 +- .../src/json_schema/json_schema_compiler.dart | 39 +- .../src/json_schema/json_schema_formats.dart | 60 ++- .../ack/lib/src/models/ack_model_adapter.dart | 6 +- packages/ack/lib/src/models/json_maybe.dart | 9 +- .../src/schema_model/ack_schema_model.dart | 67 +++- .../ack_schema_model_builder.dart | 75 +++- .../ack/lib/src/schemas/boundary_schema.dart | 3 +- .../ack/lib/src/schemas/default_schema.dart | 8 +- .../schemas/discriminated_object_schema.dart | 6 +- .../lib/src/schemas/imported_json_schema.dart | 367 ++++++++++-------- packages/ack/lib/src/schemas/lazy_schema.dart | 4 +- packages/ack/lib/src/schemas/schema.dart | 44 ++- .../ack/lib/src/schemas/string_schema.dart | 29 +- packages/ack/pubspec.yaml | 2 +- .../ack/test/annotations/ack_model_test.dart | 19 +- .../annotations/dart_core_names_test.dart | 2 +- .../ack/test/annotations/exports_test.dart | 15 +- .../ack/test/json_schema_import_api_test.dart | 19 +- .../ack/test/json_schema_import_test.dart | 230 ++++++++++- .../extensions/string_transformers_test.dart | 37 +- packages/ack_firebase_ai/CHANGELOG.md | 4 +- packages/ack_firebase_ai/README.md | 4 +- packages/ack_firebase_ai/pubspec.yaml | 4 +- packages/ack_generator/CHANGELOG.md | 22 +- packages/ack_generator/README.md | 29 +- .../analyzer/class_model_graph_builder.dart | 66 +--- .../src/analyzer/custom_schema_resolver.dart | 80 +--- .../analyzer/model_graph_builder_base.dart | 4 +- .../lib/src/inference/schema_inference.dart | 29 +- .../json_schema_generation_exception.dart | 2 +- .../src/json_schema/json_schema_library.dart | 95 ++++- packages/ack_generator/pubspec.yaml | 4 +- .../class_first_runtime_build_test.dart | 74 ++-- .../integration/class_model_emitter_test.dart | 143 +++---- .../integration/class_model_graph_test.dart | 183 +-------- .../integration/inference_library_test.dart | 27 -- .../json_schema_runtime_build_test.dart | 2 +- .../integration/type_owned_schema_test.dart | 8 +- .../json_schema/fixtures/mapping.schema.dart | 10 +- .../fixtures/mapping_preserve.schema.dart | 10 +- .../json_schema/json_schema_library_test.dart | 72 ++++ .../test/src/ack_exports_test.dart | 2 +- packages/ack_json_schema_builder/CHANGELOG.md | 4 +- packages/ack_json_schema_builder/README.md | 6 +- packages/ack_json_schema_builder/pubspec.yaml | 4 +- packages/ack_mcp_dart/CHANGELOG.md | 4 +- packages/ack_mcp_dart/README.md | 6 +- packages/ack_mcp_dart/pubspec.yaml | 4 +- scripts/src/workspace_packages.dart | 4 +- test/scripts/api_check_test.dart | 6 +- 71 files changed, 1257 insertions(+), 976 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44c475f1..28e87c82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, 'release/**'] # No `branches:` filter. A stacked pull request targets its parent # branch, not main, and would otherwise run no checks at all. pull_request: diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index ec7c2aab..90d097e3 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -5,7 +5,7 @@ name: Release preflight on: push: - branches: [main] + branches: [main, 'release/**'] # No `branches:` filter. A stacked pull request targets its parent # branch, not main, and would otherwise run no checks at all. pull_request: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0301333..466edf00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,13 +26,15 @@ jobs: fetch-depth: 0 persist-credentials: false - # A full checkout already carries origin/main. The refresh keeps the - # check current when the tag lands moments after a merge, and the - # verification step reports a missing ref on its own. + # A full checkout already carries origin/main and origin/release/*. The + # refresh keeps the check current when the tag lands moments after a + # merge, and the verification step reports a missing ref on its own. - name: Refresh the release branch run: | - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main \ - || echo "Could not refresh origin/main; using the checked-out refs." + git fetch --no-tags origin \ + +refs/heads/main:refs/remotes/origin/main \ + '+refs/heads/release/*:refs/remotes/origin/release/*' \ + || echo "Could not refresh origin refs; using the checked-out refs." - name: Set up the pinned Flutter SDK uses: ./.github/actions/setup-flutter @@ -42,10 +44,21 @@ jobs: # A tag is the only trigger of this workflow, so the tag must prove that # it points at reviewed code and that every package agrees on the version. + # Pre-release tags may also publish from a matching `release/.` + # branch. - name: Require a releasable tag run: | + set -euo pipefail + main_ref="origin/main" + if [[ "$GITHUB_REF_NAME" =~ ^v([0-9]+\.[0-9]+)\.[0-9]+- ]]; then + release_ref="origin/release/${BASH_REMATCH[1]}" + if git rev-parse --verify "${release_ref}^{commit}" >/dev/null 2>&1 \ + && git merge-base --is-ancestor "$GITHUB_SHA" "$release_ref"; then + main_ref="$release_ref" + fi + fi dart scripts/verify_release_tag.dart "$GITHUB_REF_NAME" \ - --commit="$GITHUB_SHA" --main-ref=origin/main + --commit="$GITHUB_SHA" --main-ref="$main_ref" preflight: name: Preflight diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aff6ddf..77f25aa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -## 2.0.0-beta.1 +## 2.0.0-alpha.1 ### Breaking & Substitutions @@ -30,12 +30,13 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline `@Validate.ipv6()`, `@Validate.minItems(...)`, `@Validate.maxItems(...)`, `@Validate.uniqueItems()`). * **Removed legacy 1.x generator annotations & shims (`#188`, `#189`)**: - Removed `@AckType`, `@AckInfer`, `@AckModel`, `JsonKeyCaseStyle`, `AckTypeDef`, - `AckFieldPresence`, `*Type` extension-type models, and the `@JsonKey(name: ...)` - fallback. + Removed `@AckType`, `@AckInfer`, `@AckModel`, `@AckField`, `JsonKeyCaseStyle`, + `AckTypeDef`, `AckFieldPresence`, `*Type` extension-type models, and the + `@JsonKey(name: ...)` fallback. * **Substitute:** Use `@Schemable()` on schemas and classes, `@Validate.optional()` / `@Validate.required()` / `@Validate.notNull()` for - field presence, and `@AckField(name: '...')` for wire-name overrides. + field presence, `/// @description ...` for field descriptions, and + `@AckKey('...')` for wire-name overrides. * **Removed redundant helpers & `StandardSchemaV1` (`#129`)**: * **Substitute:** `Ack.enumCodec(E.values)` $\rightarrow$ `Ack.enumValues(E.values)`. * **Substitute:** `registerAckModelTool(...)` $\rightarrow$ @@ -64,10 +65,10 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline explicit `null` (`JsonValue(null)`), and present values (`JsonValue(v)`) in `@Schemable()` class-first models for `PATCH` payloads. * **Layered custom schema resolution (`#185`)**: Class-first `@Schemable()` - fields resolve custom types via `@AckField(schema: ...)`, - `@Schemable(schemas: [...])` (on classes, unions, or `library;` directives), - type-owned `T.schema` / `T.schema<...>()` factories, or automatic detection of - a unique top-level `AckSchema` in the current library. + fields resolve custom types via `@Schemable(schemas: [...])` (on classes, + unions, or `library;` directives), type-owned `T.schema` / `T.schema<...>()` + factories, or automatic detection of a unique top-level + `AckSchema` in the current library. * **Two-way `StringSchema` normalizers**: `.trim()`, `.toLowerCase()`, and `.toUpperCase()` execute in declaration order during both `parse` and `encode`, preserve `StringSchema` chaining, work in `@Schemable()` models, and export as diff --git a/PUBLISHING.md b/PUBLISHING.md index ae8a006b..925b73e2 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -12,7 +12,7 @@ new compatible features/packages, and major when any stable public API breaks. For 1.7.0, the imported-pattern Unicode behavior change is a deliberately accepted exception to this major-version policy. -The next candidate is **2.0.0-beta.1**. It introduces single-pass `.g.dart` +The next candidate is **2.0.0-alpha.1**. It introduces single-pass `.g.dart` code generation without a runtime `json_serializable` delegation step, the unified `@Validate.*` constraint annotation namespace, custom schema resolution via `@Schemable(schemas: [...])` and automatic same-library detection, @@ -57,7 +57,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 3. Preview/prepare a coordinated version without creating commits or tags: ```sh - dart run melos version --manual-version=ack:2.0.0-beta.1 --yes --no-git-commit-version + dart run melos version --manual-version=ack:2.0.0-alpha.1 --yes --no-git-commit-version ``` Use the named flag, not a positional package argument: the positional form @@ -72,7 +72,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), Update installation snippets to match each package's version. 5. Compare against the most recent coordinated stable release in `API_BASELINE_VERSION` in `.github/workflows/preflight.yml` (currently - `1.6.2`). Keep that stable baseline for prereleases, so a beta cannot hide a + `1.6.2`). Keep that stable baseline for prereleases, so a prerelease cannot hide a breaking change since the last stable version. Raise it after the next stable release. Record a new package's actual first release in `ackPackageFirstReleases` in @@ -84,7 +84,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 7. Validate the complete release from that clean commit: ```sh - dart scripts/verify_release_tag.dart v2.0.0-beta.1 --skip-ancestry + dart scripts/verify_release_tag.dart v2.0.0-alpha.1 --skip-ancestry dart run melos run ci dart scripts/api_check.dart 1.6.2 dart scripts/publish_dry_run.dart @@ -92,7 +92,8 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), ``` `--skip-ancestry` is only for preparing a version before the tag exists. - The actual release workflow always enforces ancestry on `origin/main`. + The actual release workflow always enforces ancestry on `origin/main` (or the + matching `origin/release/.` branch for pre-release tags). CI also runs minimum Dart/Flutter SDK checks, deterministic generation, and staged hosted-dependency analysis; all must pass on the release merge commit. 8. Review and merge the release PR before creating the tag. @@ -138,9 +139,9 @@ After the release commit's CI/preflight succeeds: ```sh git fetch origin main --tags # Use the exact reviewed release merge commit, not an arbitrary later main head. -git tag -a v2.0.0-beta.1 -m 'Ack 2.0.0 beta 1' -dart scripts/verify_release_tag.dart v2.0.0-beta.1 -git push origin v2.0.0-beta.1 +git tag -a v2.0.0-alpha.1 -m 'Ack 2.0.0 alpha 1' +dart scripts/verify_release_tag.dart v2.0.0-alpha.1 +git push origin v2.0.0-alpha.1 ``` Pushing the tag triggers `.github/workflows/release.yml`; there is no Melos @@ -168,6 +169,6 @@ or republish different contents under an existing version. After all five exact versions are visible, create the GitHub Release from the existing tag using the prepared release notes and mark it as a prerelease for -beta versions. Before the first subsequent code +alpha/beta versions. Before the first subsequent code change, begin a new unreleased changelog section instead of editing the -2.0.0-beta.1 notes. +2.0.0-alpha.1 notes. diff --git a/README.md b/README.md index 64cd45e5..8c152b00 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,9 @@ if (result.isOk) { Generate immutable models for hand-written schemas with `@Schemable()`. Add `ack` to `dependencies` and `ack_generator` + `build_runner` to -`dev_dependencies`, then annotate a top-level schema. `@Schemable()`, the model -annotations (`@AckField`, `@Optional`, `@Required`, `@NotNull`), and the -`@Validate.*` constraint namespace are all exported from `package:ack/ack.dart`: +`dev_dependencies`, then annotate a top-level schema. `@Schemable()`, `@AckKey()`, +and the `@Validate.*` constraint and presence namespace are all exported from +`package:ack/ack.dart`: ```dart import 'package:ack/ack.dart'; @@ -187,9 +187,8 @@ and constructs the model, while `account.toJson()` validates and encodes it. Custom field types resolve automatically through a static `schema` on the type (`Slot.schema`, `Ack.list(Slot.schema)`, `Command.schema()`), a unique -top-level `AckSchema` in the same library, `@Schemable(schemas: [...])` -on the class, union, or `library;` directive, or `@AckField(schema: ...)` on a -specific field. See the +top-level `AckSchema` in the same library, or `@Schemable(schemas: [...])` +on the class, union, or `library;` directive. See the [Model Code Generation guide](docs/core-concepts/typesafe-schemas.mdx). ## Codecs diff --git a/docs.json b/docs.json index 1f1d83ec..73d03822 100644 --- a/docs.json +++ b/docs.json @@ -97,7 +97,7 @@ ], "variables": { "versions": { - "default": "2.0.0-beta.1", + "default": "2.0.0-alpha.1", "isPrerelease": true } }, diff --git a/docs/api-reference/index.mdx b/docs/api-reference/index.mdx index b6a3a190..48ec9316 100644 --- a/docs/api-reference/index.mdx +++ b/docs/api-reference/index.mdx @@ -316,7 +316,7 @@ Use the [`ack_generator`](https://pub.dev/packages/ack_generator) builders in either direction with one annotation, `@Schemable()`: on a top-level schema it generates a model, on a class it generates a schema, and on a `library;` directive it registers shared custom schemas via `@Schemable(schemas: [...])`. -`package:ack/ack.dart` exports `@Schemable`, `@AckField`, and the `@Validate.*` +`package:ack/ack.dart` exports `@Schemable`, `@AckKey`, and the `@Validate.*` validation namespace. `AckSchema` is the runtime schema type; there is no `@AckSchema()` annotation. After adding a `part '.g.dart';` directive, run: @@ -390,7 +390,7 @@ for `toJson`, `safeToJson`, `copyWith`, and deep collection-aware `==` / Ack infers schema fields from constructor-backed fields (including Dart 3.12+ primary constructors). Required parameters, nullable types, optional parameters, `JsonMaybe`, and constructor defaults determine field presence. Use -`caseStyle` for model-wide JSON naming and `@AckField(name: ...)` on the field +`caseStyle` for model-wide JSON naming and `@AckKey(...)` on the field or primary constructor parameter for a wire-name override. Instantiable models and implicit union branches must apply the generated mixin. In `copyWith`, omission keeps the current value and explicit `null` clears a nullable field. @@ -410,10 +410,9 @@ compile-time error. **Custom schemas:** a field of custom type `T` resolves through: -1. `@AckField(schema: ...)` on the field or constructor parameter; -2. `@Schemable(schemas: [...])` on the class, union base, `library;` directive, or an imported `library;` directive; -3. a static `T.schema` / `T.schema(...)` on the type; -4. a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. +1. `@Schemable(schemas: [...])` on the class, union base, `library;` directive, or an imported `library;` directive; +2. a static `T.schema` / `T.schema(...)` on the type; +3. a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. A sealed base annotated with `@Schemable(discriminatorKey: ...)` generates a discriminated union from its same-library concrete branches. Unknown properties use diff --git a/docs/core-concepts/typesafe-schemas.mdx b/docs/core-concepts/typesafe-schemas.mdx index bb2536a4..1aa65379 100644 --- a/docs/core-concepts/typesafe-schemas.mdx +++ b/docs/core-concepts/typesafe-schemas.mdx @@ -11,7 +11,7 @@ from a Dart class, or start from a `.schema.json` document. | A Dart class or sealed union (`final class Account`) | `@Schemable()` | `_accountSchema` codec, `AccountSchema` facade, and `_$AccountAck` mixin in `.g.dart` | | A JSON Schema asset (`lib/**/*.schema.json`) | `ack_generator:ack_json_schema` in `build.yaml` | `.schema.dart` + `.schema.g.dart` with typed `@Schemable()` models or validated value models | -`@Schemable()`, `@AckField`, and the `@Validate.*` constraint and presence +`@Schemable()`, `@AckKey`, and the `@Validate.*` constraint and presence namespace all come from `package:ack/ack.dart` (and `package:ack/annotations.dart`). There is no `@AckSchema()` annotation; `AckSchema` is the runtime type returned by factories such as `Ack.string()` and `Ack.object()`. @@ -245,12 +245,13 @@ static final Profile Function(Map) fromJson = ProfileSchema.fromJson; ``` -Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export -schema descriptions. A single-line `@description` tag in a `///` or `/** */` -doc comment is a fallback. Only the text after the tag, on the same line, is exported. Untagged -prose does not become schema data. Explicit annotation text takes precedence. -Blank or duplicate tags fail generation. `@description` is an Ack convention, -not a Dart annotation. +Use `@Schemable(description: ...)` and a single-line `@description` tag in a +`///` or `/** */` doc comment on classes, fields, or constructor parameters to +export schema descriptions. Only the text after the tag, on the same line, is +exported. Untagged prose does not become schema data. Explicit annotation text +on `@Schemable(description: ...)` takes precedence over a class doc tag. Blank +or duplicate tags fail generation. `@description` is an Ack convention, not a +Dart annotation. A class description applies to its object schema and model codec. This includes sealed union bases and branches. A field description applies at its property. @@ -375,7 +376,7 @@ constraints apply to the outer set. Use `caseStyle` for model-wide JSON names. Supported values are `none`, `snake`, `kebab`, `pascal`, and `screamingSnake`. Use -`@AckField(name: 'wire_name')` on the field or primary constructor parameter for +`@AckKey('wire_name')` on the field or primary constructor parameter for a single wire-name override. Ack resolves each wire key once and uses it for both validation and JSON mapping. `JsonConverter` annotations are rejected so the Ack schema and generated JSON mapping cannot diverge. @@ -386,11 +387,10 @@ When a class-first field has a custom type `T` (including inside `List`, `Set`, `Map`, `JsonMaybe`, or generic `Box`), Ack resolves its schema in the following priority order: -1. **`@AckField(schema: ...)`** on the field or constructor parameter; -2. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current +1. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current `library;` directive, or an imported `library;` directive; -3. **Type-owned schema (`T.schema` / `T.schema(...)`)** on the type itself; -4. **Automatic current-library detection**: a unique top-level +2. **Type-owned schema (`T.schema` / `T.schema(...)`)** on the type itself; +3. **Automatic current-library detection**: a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. @@ -487,7 +487,7 @@ offers a static field instead of a `schema()` method. Schema-first objects can reference the same members, such as `'header': Slot.schema` or `'open': Command.schema()`. -### External types, library registries, and field overrides +### External types and library registries For a third-party type you cannot edit: @@ -496,8 +496,6 @@ For a third-party type you cannot edit: - **Across a class, union, or library**: pass top-level schema function tear-offs in `@Schemable(schemas: [colorSchema])` on the class, union base, or `library;` directive (including an imported shared `library;` directive). -- **On a single field**: use `@AckField(schema: colorSchema)` when two fields of - the same type need different schemas. ```dart @Schemable(schemas: [colorSchema]) @@ -521,15 +519,10 @@ final class Theme with _$ThemeAck { } ``` -`@AckField(schema: ...)` also replaces an inferred `Object` or `Map` -schema when a single field needs a tighter contract, such as a function -returning `Ack.map(Ack.integer().min(0))` for a `Map` field. Non-String map keys and `dynamic` are rejected because they do not provide a static schema; use `Object?` for an open JSON value. Automatic `List` and `Set` inference also requires non-nullable element -types because `Ack.list` has no nullable-item contract. An explicit -`@AckField(schema: ...)` codec remains the escape hatch when a field deliberately -uses a different collection representation. +types because `Ack.list` has no nullable-item contract. ### Class-first unions @@ -684,6 +677,6 @@ targets: - Replace `part '.ack.dart';` + `part '.ack.g.dart';` with a single `part '.g.dart';`. - Replace `@AckType()`, `@AckInfer()`, and `@AckModel()` with `@Schemable()`. - Replace top-level presence and constraint annotations (`@Optional`, `@Required`, `@NotNull`, `@Min`, `@Max`, `@MinLength`, `@Matches`, `@Email`, `@DateTime`, etc.) with `@Validate.*` (`@Validate.optional`, `@Validate.required`, `@Validate.notNull`, `@Validate.min`, `@Validate.max`, `@Validate.minLength`, `@Validate.matches`, `@Validate.email`, `@Validate.dateTime`, etc.). -- Replace `@JsonKey(name: '...')` on `@Schemable` fields with `@AckField(name: '...')`. +- Replace `@JsonKey(name: '...')` and `@AckField(name: '...')` on `@Schemable` fields with `@AckKey('...')`. See the [What's New & Migration Guide](../getting-started/migration-guide.mdx) for the complete substitution tables and side-by-side before/after examples. diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 5e361b56..102a32ce 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -17,11 +17,11 @@ flutter pub add ack ``` The unversioned commands select the latest stable release. To select the -2.0.0-beta.1 prerelease, add it to your `pubspec.yaml`: +2.0.0-alpha.1 prerelease, add it to your `pubspec.yaml`: ```yaml dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 ``` ## Optional code generator @@ -38,10 +38,10 @@ Or add them to your `pubspec.yaml`: ```yaml dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 dev_dependencies: - ack_generator: ^2.0.0-beta.1 + ack_generator: ^2.0.0-alpha.1 build_runner: ^2.4.0 ``` diff --git a/docs/getting-started/migration-guide.mdx b/docs/getting-started/migration-guide.mdx index 01e7b306..f0c092f2 100644 --- a/docs/getting-started/migration-guide.mdx +++ b/docs/getting-started/migration-guide.mdx @@ -12,7 +12,7 @@ Ack is organized into five coordinated packages that share one version number an | Package | Role | Key Capabilities | | --- | --- | --- | -| **`ack`** | Core runtime & annotations | • Fluent schema builders: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.number()`, `Ack.boolean()`, `Ack.list()`, `Ack.map()`, `Ack.object()`, `Ack.any()`, `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()`, `Ack.anyOf()`, `Ack.discriminated()`, `Ack.lazy()`
• Bidirectional `CodecSchema`: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, `.codec()`, `.transform()`
• Two-way string normalizers: `.trim()`, `.toLowerCase()`, `.toUpperCase()`
• `JsonMaybe` (`JsonAbsent` / `JsonValue`) for tri-state `PATCH` payloads
• Full JSON Schema Draft 2020-12 runtime (`Ack.fromJsonSchema()`) and exporter (`toJsonSchema()`, `toSchemaModel()`, `toJsonSchemaDraft7()`)
• Sealed `SchemaResult` (`Ok` / `Fail`) and sealed `SchemaError` hierarchy
• Unified codegen annotations in `package:ack/ack.dart`: `@Schemable()`, `@AckField()`, and `@Validate.*` | +| **`ack`** | Core runtime & annotations | • Fluent schema builders: `Ack.string()`, `Ack.integer()`, `Ack.double()`, `Ack.number()`, `Ack.boolean()`, `Ack.list()`, `Ack.map()`, `Ack.object()`, `Ack.any()`, `Ack.literal()`, `Ack.enumString()`, `Ack.enumValues()`, `Ack.anyOf()`, `Ack.discriminated()`, `Ack.lazy()`
• Bidirectional `CodecSchema`: `Ack.date()`, `Ack.datetime()`, `Ack.uri()`, `Ack.duration()`, `.codec()`, `.transform()`
• Two-way string normalizers: `.trim()`, `.toLowerCase()`, `.toUpperCase()`
• `JsonMaybe` (`JsonAbsent` / `JsonValue`) for tri-state `PATCH` payloads
• Full JSON Schema Draft 2020-12 runtime (`Ack.fromJsonSchema()`) and exporter (`toJsonSchema()`, `toSchemaModel()`, `toJsonSchemaDraft7()`)
• Sealed `SchemaResult` (`Ok` / `Fail`) and sealed `SchemaError` hierarchy
• Unified codegen annotations in `package:ack/ack.dart`: `@Schemable()`, `@AckKey()`, and `@Validate.*` | | **`ack_generator`** | Two-way code generator | • **Schema-first (`@Schemable()` on a top-level schema)**: Generates an immutable model class, `Model.schema`, `parse`/`safeParse`, `fromJson`/`toJson`/`safeToJson`, typed `copyWith`, and deep `==`/`hashCode` in `.g.dart`
• **Class-first (`@Schemable()` on a `final class` or `sealed class`)**: Supports standard and Dart 3.12+ primary constructors; generates `_Schema`, `Schema`, and `_$Ack` in `.g.dart`
• **`.schema.json` codegen (`ack_generator:ack_json_schema`)**: Compiles `lib/**/*.schema.json` assets into `.schema.dart` + `.schema.g.dart` | | **`ack_mcp_dart`** | MCP tool adapter | • `server.registerAckTool()`, `registerAckInputTool()`, `registerAckOutputTool()`, and `result.decodeAckOutput()` for `mcp_dart` | | **`ack_firebase_ai`** | Firebase AI (Gemini) adapter | • `schema.toFirebaseAiSchema()` for Gemini structured output and tool declarations | @@ -24,11 +24,11 @@ Ack is organized into five coordinated packages that share one version number an | Change | Introduced | What Changed | Why It Changed | | --- | --- | --- | --- | -| **Annotations merged into `package:ack`** | `1.7.0` | `ack_annotations` was retired. `@Schemable()`, `@AckField()`, and `@Validate.*` ship directly in `package:ack/ack.dart`. | Eliminated version skew between `ack` and a separate annotations package and reduced model files to a single `import 'package:ack/ack.dart';`. | +| **Annotations merged into `package:ack`** | `1.7.0` | `ack_annotations` was retired. `@Schemable()`, `@AckKey()`, and `@Validate.*` ship directly in `package:ack/ack.dart`. | Eliminated version skew between `ack` and a separate annotations package and reduced model files to a single `import 'package:ack/ack.dart';`. | | **Unified `@Schemable()` annotation** | `1.7.0` | `@Schemable()` drives both schema-first and class-first generation, replacing `@AckInfer()`, `@AckModel()`, and `@AckType()`. | One annotation and one mental model whether you start from an `AckSchema` or a hand-written Dart `class`. | | **Unified `@Validate.*` validation namespace** | `2.0.0` | All field and parameter validation annotations—including presence (`@Validate.required()`, `@Validate.optional()`), nullability (`@Validate.notNull()`), bounds (`@Validate.min(0)`), formats (`@Validate.email()`, `@Validate.dateTime()`, `@Validate.uri()`), and collection rules (`@Validate.notEmpty()`, `@Validate.uniqueItems()`)—live under `@Validate.*` in `package:ack/ack.dart`. | Top-level 1.x classes (`@Required`, `@Email`, `@Uuid`, `@Date`, `@Uri`, `@DateTime`, `@Pattern`) collided with `package:meta` / Flutter (`Required`), `dart:core` (`Uri`, `DateTime`, `Pattern`), and `package:uuid`, forcing import prefixes (`as ack`). `@Validate.*` has zero symbol collisions and one canonical name per validation rule. | | **Single `.g.dart` part & direct JSON emitter** | `2.0.0` | `ack_generator:ack_generator` is a `SharedPartBuilder` that emits `.g.dart` directly with no `json_serializable` or `json_annotation` dependency. | In 1.x, every model required two `part` directives (`.ack.dart` + `.ack.g.dart`) and a second `json_serializable` pass. Direct emission cuts build steps in half, uses standard `part '.g.dart';`, and coexists with Freezed or Riverpod in the same file. | -| **Layered custom schema resolution (`T.schema`, `schemas: [...]`)** | `1.7.0` / `2.0.0` | Custom field types resolve via `@AckField(schema: ...)`, `@Schemable(schemas: [...])` (on classes, unions, or `library;` directives), type-owned `T.schema` / `T.schema
()` factories, or automatic detection of a unique top-level `AckSchema` in the current library. | Removed repetitive `@AckField(schema: ...)` boilerplate on every field that uses a domain value object, extension type, enum, or generic wrapper. | +| **Layered custom schema resolution (`T.schema`, `schemas: [...]`)** | `1.7.0` / `2.0.0` | Custom field types resolve via `@Schemable(schemas: [...])` (on classes, unions, or `library;` directives), type-owned `T.schema` / `T.schema()` factories, or automatic detection of a unique top-level `AckSchema` in the current library. | Removed per-field `@AckField(schema: ...)` boilerplate on every field that uses a domain value object, extension type, enum, or generic wrapper. | | **Typed `$ClassCopyWith` getter** | `1.7.0` | `model.copyWith` returns a typed callable interface instead of widening parameters to `Object?`. | Passing `model.copyWith(age: 'wrong')` previously compiled and failed only at runtime (`TypeError`). Now mistyped arguments fail at compile time while `model.copyWith(age: null)` still clears nullable fields. | | **Two-way string normalizers (`.trim()`, `.toLowerCase()`, `.toUpperCase()`)** | `2.0.0` | Normalizers live directly on `StringSchema` and run in declaration order alongside constraints during both `parse` and `encode`. | In 1.x, `.trim()` used a one-way `.transform()`, which failed on `encode()`, was rejected by `@Schemable()`, and emitted `'x-transformed': true` in JSON Schema. | | **`JsonMaybe` (`JsonAbsent` / `JsonValue`)** | `2.0.0` | Tri-state field container for class-first `@Schemable()` models. | A plain `String?` field in Dart cannot distinguish `{"bio": null}` (clear field in a `PATCH` request) from `{}` (leave field unchanged) when encoding back to JSON. | @@ -61,8 +61,10 @@ Ack is organized into five coordinated packages that share one version number an | `@Optional()` / `@AckField(presence: AckFieldPresence.optional)` | Removed in `2.0.0` | `@Validate.optional()` (add `@Validate.notNull()` if present JSON value must not be `null`) | | `@Required()` / `@AckField(presence: AckFieldPresence.requiredNull)` | Removed in `2.0.0` | `@Validate.required()` on a nullable field (`final T? field;`) | | `@NotNull()` / `@AckField(presence: AckFieldPresence.requiredNonNull)` | Removed in `2.0.0` | `@Validate.required()` + `@Validate.notNull()` | -| `@JsonKey(name: 'wire_name')` on `@Schemable` fields | Removed in `2.0.0` | `@AckField(name: 'wire_name')` | -| `@JsonConverter()` on `@Schemable` classes/fields | Rejected in `1.7.0+` | Declare `static final schema = ...` on the type, `@Schemable(schemas: [...])`, or `@AckField(schema: ...)` | +| `@JsonKey(name: 'wire_name')` / `@AckField(name: 'wire_name')` | Removed in `2.0.0` | `@AckKey('wire_name')` | +| `@AckField(schema: ...)` | Removed in `2.0.0` | `@Schemable(schemas: [...])`, `T.schema`, or top-level library schema | +| `@AckField(description: ...)` | Removed in `2.0.0` | `/// @description ...` doc tag | +| `@JsonConverter()` on `@Schemable` classes/fields | Rejected in `1.7.0+` | Declare `static final schema = ...` on the type or register one in `@Schemable(schemas: [...])` | ### Validation Annotations (`1.x` Top-Level Classes $\rightarrow$ `2.0` `@Validate.*`) @@ -136,7 +138,7 @@ part 'account.g.dart'; final class Account with _$AccountAck { const Account({required this.userName, required this.createdAt}); - @AckField(name: 'username') + @AckKey('username') @Validate.minLength(3) @Validate.matches(r'^[a-z0-9_]+$') final String userName; diff --git a/docs/guides/creating-schema-converter-packages.mdx b/docs/guides/creating-schema-converter-packages.mdx index 79438cf2..26b67119 100644 --- a/docs/guides/creating-schema-converter-packages.mdx +++ b/docs/guides/creating-schema-converter-packages.mdx @@ -146,7 +146,7 @@ environment: # flutter: '>=3.35.0' dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 # Add target SDK dependency if needed # : ^x.y.z meta: ^1.15.0 @@ -1023,7 +1023,7 @@ Converts Ack schemas to format for [use case]. Assumes familiarity with \`\`\`yaml dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 ack_: ^1.0.0 : ^x.y.z # Required peer dependency \`\`\` diff --git a/docs/guides/ecosystem-integrations.mdx b/docs/guides/ecosystem-integrations.mdx index 868b9775..eb14b660 100644 --- a/docs/guides/ecosystem-integrations.mdx +++ b/docs/guides/ecosystem-integrations.mdx @@ -23,7 +23,7 @@ part 'weather_models.g.dart'; final class WeatherQuery with _$WeatherQueryAck { const WeatherQuery({required this.city, this.units = 'celsius'}); - @AckField(description: 'City and country, e.g. "Tokyo, JP".') + /// @description City and country, e.g. "Tokyo, JP". @Validate.minLength(2) final String city; diff --git a/docs/guides/schema-converter-quickstart.mdx b/docs/guides/schema-converter-quickstart.mdx index 3ccbbd2f..cc8bbddd 100644 --- a/docs/guides/schema-converter-quickstart.mdx +++ b/docs/guides/schema-converter-quickstart.mdx @@ -41,7 +41,7 @@ environment: # flutter: '>=3.35.0' # Uncomment if needed dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 # : ^x.y.z # Add if needed meta: ^1.15.0 @@ -325,7 +325,7 @@ void main() { \`\`\`yaml dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 ack_: ^1.0.0 \`\`\` diff --git a/example/lib/class_first_models.dart b/example/lib/class_first_models.dart index 5bcb3d9a..ce014f05 100644 --- a/example/lib/class_first_models.dart +++ b/example/lib/class_first_models.dart @@ -15,8 +15,8 @@ final class Account with _$AccountAck { }); /// The name shown to other users. + /// @description The name shown to other users. @Validate.minLength(2) - @AckField(description: 'The name shown to other users.') final String displayName; final Uri? website; final String role; diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 689e6b53..0d0c188b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,10 +10,10 @@ environment: dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 dev_dependencies: - ack_generator: ^2.0.0-beta.1 + ack_generator: ^2.0.0-alpha.1 build_runner: ^2.4.0 dart_code_metrics_presets: ^2.22.0 lints: ^5.0.0 diff --git a/llms.txt b/llms.txt index a9b3e657..f79f7a60 100644 --- a/llms.txt +++ b/llms.txt @@ -5,7 +5,7 @@ Ack validates untrusted boundary data with schemas built through the `Ack` factory. Annotate a top-level schema with `@Schemable()` to generate a model, or annotate a hand-written class with `@Schemable()` to generate its schema and -JSON helpers. `package:ack/ack.dart` exports `@Schemable`, `@AckField`, and the +JSON helpers. `package:ack/ack.dart` exports `@Schemable`, `@AckKey`, and the `@Validate.*` presence and constraint namespace. There is no `@AckSchema()` annotation; `AckSchema` is the runtime schema type. @@ -125,10 +125,10 @@ classes use `@Schemable(discriminatorKey: ...)`; same-library concrete branches are included automatically. Use `@Validate.optional()` or `@Validate.required()` to override inferred key presence, `@Validate.notNull()` to reject an explicit JSON `null` without requiring the key, `JsonMaybe` to distinguish omitted -keys from explicit `null` on both parse and encode, and `@AckField(name: 'wire_name')` +keys from explicit `null` on both parse and encode, and `@AckKey('wire_name')` for a single field's JSON key override. -Use `@Schemable(description: ...)` and `@AckField(description: ...)` to export -schema descriptions. A single-line `@description` doc tag is a fallback. +Use `@Schemable(description: ...)` and a single-line `@description` doc tag on +classes, fields, or constructor parameters to export schema descriptions. Untagged prose does not export. A field description applies at its property without changing a nested model's own description. Unknown properties use `unknownProperties: AckUnknownPropertyPolicy.` @@ -142,11 +142,10 @@ validation preserves extras; the class-first policy is a later projection. A field of custom type `T` (including inside `List`, `Set`, `Map`, `JsonMaybe`, or generic type-owned schemas `Box`) resolves in priority order: -1. **`@AckField(schema: ...)`** on the field or constructor parameter; -2. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current +1. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current `library;` directive, or an imported `library;` directive; -3. **Type-owned schema**: a static `T.schema` or `T.schema(...)` on the type; -4. **Automatic current-library detection**: a unique top-level +2. **Type-owned schema**: a static `T.schema` or `T.schema(...)` on the type; +3. **Automatic current-library detection**: a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. @@ -223,15 +222,14 @@ collisions, and cross-library discriminated branches. Class-first generation supports object models, inferred scalar and enum fields, JSON-safe `Object` fields, nested lists, sets through list codecs, -string-keyed maps, `JsonMaybe`, custom schemas (`@AckField(schema: ...)`, -`@Schemable(schemas: [...])`, `T.schema`, or auto-detected top-level library -schemas), `@Validate.*` presence and constraint annotations (`@Validate.optional()`, -`@Validate.required()`, `@Validate.notNull()`, `@Validate.min(...)`, etc.), +string-keyed maps, `JsonMaybe`, custom schemas (`@Schemable(schemas: [...])`, +`T.schema`, or auto-detected top-level library schemas), `@Validate.*` presence +and constraint annotations (`@Validate.optional()`, `@Validate.required()`, +`@Validate.notNull()`, `@Validate.min(...)`, etc.), `@AckKey('wire_name')`, constructor defaults, case styles, `AckUnknownPropertyPolicy`, and same-library sealed discriminated unions. It rejects `dynamic`, nullable list or set elements, non-String map keys, recursive class-first graphs, class-first value -roots, undiscriminated `anyOf` models, missing `_$ClassAck` mixins, and no-op -`@AckField()`. +roots, undiscriminated `anyOf` models, and missing `_$ClassAck` mixins. Open JSON and presence-aware fields are inferred from their Dart types. `Object` means a JSON-safe value (finite numbers, strings, booleans, lists, and @@ -302,7 +300,9 @@ validates through the union's effective branch. | `ack_generator\|ack_models`, `ack_generator\|ack_model_json` | `ack_generator\|ack_generator` (plus optional `ack_generator\|ack_json_schema`) | | `@AckType()`, `@AckInfer()`, `@AckModel()` | `@Schemable()` | | `@Optional()`, `@Required()`, `@NotNull()`, `@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, `@Pattern`, `@Matches`, `@NotEmpty`, `@Email`, `@Url`, `@Uuid`, `@Date`, `@DateTime`, `@Uri`, `@Ip`, `@Ipv4`, `@Ipv6`, `@MinItems`, `@MaxItems`, `@UniqueItems` | `@Validate.optional()`, `@Validate.required()`, `@Validate.notNull()`, `@Validate.min`, `@Validate.max`, `@Validate.multipleOf`, `@Validate.positive`, `@Validate.negative`, `@Validate.minLength`, `@Validate.maxLength`, `@Validate.matches`, `@Validate.notEmpty`, `@Validate.email`, `@Validate.url`, `@Validate.uuid`, `@Validate.date`, `@Validate.dateTime`, `@Validate.uri`, `@Validate.ip`, `@Validate.ipv4`, `@Validate.ipv6`, `@Validate.minItems`, `@Validate.maxItems`, `@Validate.uniqueItems` | -| `@JsonKey(name: 'wire')` on `@Schemable` fields | `@AckField(name: 'wire')` | +| `@JsonKey(name: 'wire')` / `@AckField(name: 'wire')` on `@Schemable` fields | `@AckKey('wire')` | +| `@AckField(schema: ...)` | `@Schemable(schemas: [...])`, `T.schema`, or top-level library schema | +| `@AckField(description: ...)` | `/// @description ...` doc tag | | `@AckField(presence: ...)` | `@Validate.optional()`, `@Validate.required()`, `@Validate.notNull()`, or `JsonMaybe` | | `Ack.enumCodec(MyEnum.values)` | `Ack.enumValues(MyEnum.values)` | | `registerAckModelTool(...)` (`ack_mcp_dart`) | `server.registerAckTool(name, inputSchema: Model.schema, ...)` | diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 0a78d0d1..6a5b33c2 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,10 +1,11 @@ -## 2.0.0-beta.1 +## 2.0.0-alpha.1 ### Breaking * Remove legacy 1.x generator annotations (`@AckType`, `@AckInfer`, `@AckModel`, - `JsonKeyCaseStyle`, `AckFieldPresence`, and `@Schemable.generatedJson`). Use - `@Schemable()` on both top-level schemas and hand-written classes/unions (`#188`, `#189`). + `@AckField`, `JsonKeyCaseStyle`, `AckFieldPresence`, and + `@Schemable.generatedJson`). Use `@Schemable()` on both top-level schemas and + hand-written classes/unions (`#188`, `#189`). * Remove top-level presence annotations (`@Optional`, `@Required`, `@NotNull`), the 18 `@Deprecated` 1.x top-level constraint classes (`@Min`, `@Max`, `@MultipleOf`, `@Positive`, `@Negative`, `@MinLength`, `@MaxLength`, @@ -37,8 +38,8 @@ `JsonMaybe.absent()`, `JsonMaybe.value(T? value)`, and `JsonMaybe.nullValue()` for PATCH tri-state presence (`#148`). * Add `@Schemable(schemas: [...])` (supported on classes, sealed unions, and - `library;` directives) and `@AckField(name: ..., schema: ...)` for custom - schema registration and field wire-name overrides (`#185`, `#189`). + `library;` directives) and `@AckKey('...')` for custom schema registration and + field wire-name overrides (`#185`, `#189`). * Make `.trim()`, `.toLowerCase()`, and `.toUpperCase()` two-way normalizers directly on `StringSchema` that execute in declaration order alongside constraints during both `parse` and `encode`, preserve `StringSchema` diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index e42fcdfe..1f0fc756 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -26,23 +26,23 @@ final class Ack { assertFormats: assertFormats, ); - /// Creates a string schema. Boundary and runtime are both `String`. + /// Creates a string schema where both boundary and runtime types are `String`. static StringSchema string() => const StringSchema(); /// Creates a literal string schema that only accepts the exact [value]. static StringSchema literal(String value) => string().withConstraint(StringLiteralConstraint(value)); - /// Creates an integer schema. Boundary and runtime are both `int`. + /// Creates an integer schema where both boundary and runtime types are `int`. static IntegerSchema integer() => const IntegerSchema(); - /// Creates a double schema. Boundary and runtime are both `double`. + /// Creates a double schema where both boundary and runtime types are `double`. static DoubleSchema double() => const DoubleSchema(); - /// Creates a number schema. Boundary and runtime are both `num`. + /// Creates a number schema where both boundary and runtime types are `num`. static NumberSchema number() => const NumberSchema(); - /// Creates a boolean schema. Boundary and runtime are both `bool`. + /// Creates a boolean schema where both boundary and runtime types are `bool`. static BooleanSchema boolean() => const BooleanSchema(); /// Creates an object schema with the given properties. @@ -149,8 +149,9 @@ final class Ack { static InstanceSchema instance() => InstanceSchema(); /// Creates a universal codec from an [input] schema, with a [decode] - /// function and an [encode] function. The optional [output] schema - /// applies runtime-side invariants. + /// function and an [encode] function. + /// + /// The optional [output] schema applies runtime-side invariants. static CodecSchema codec< Boundary extends Object, InputRuntime extends Object, @@ -170,8 +171,8 @@ final class Ack { ); } - /// Bidirectional date codec: ISO 8601 `YYYY-MM-DD` strings ↔ local - /// midnight `DateTime` runtime values. + /// Creates a bidirectional date codec between ISO 8601 `YYYY-MM-DD` strings + /// and local midnight `DateTime` runtime values. /// /// Runtime invariant: the encoded `DateTime` must be local midnight /// (year/month/day only). Values with non-zero time-of-day fail @@ -188,8 +189,8 @@ final class Ack { ); } - /// Bidirectional datetime codec: ISO 8601 datetime strings ↔ UTC - /// `DateTime` runtime values. + /// Creates a bidirectional datetime codec between ISO 8601 datetime strings + /// and UTC `DateTime` runtime values. /// /// Announced RFC 3339 leap seconds are valid for `Ack.string().datetime()`, /// but this codec rejects them because Dart normalizes `:60` to the following @@ -212,7 +213,7 @@ final class Ack { ); } - /// Bidirectional URI codec. + /// Creates a bidirectional URI codec between strings and `Uri` values. /// /// Runtime invariant: the `Uri` must have both a scheme and a host /// (matching the parse-side predicate). @@ -228,7 +229,8 @@ final class Ack { ); } - /// Bidirectional duration codec: milliseconds ↔ `Duration`. + /// Creates a bidirectional duration codec between integer milliseconds and + /// `Duration` values. /// /// Runtime invariant: the `Duration` must be a whole number of /// milliseconds (sub-millisecond precision is rejected to avoid silent diff --git a/packages/ack/lib/src/annotations/ack_field.dart b/packages/ack/lib/src/annotations/ack_field.dart index 5e5b86f9..991f8854 100644 --- a/packages/ack/lib/src/annotations/ack_field.dart +++ b/packages/ack/lib/src/annotations/ack_field.dart @@ -1,25 +1,22 @@ import 'package:meta/meta_meta.dart'; -/// Overrides the JSON key, inferred schema, and/or description for a -/// class-first field. +/// Annotation that overrides the wire JSON key for a class-first field or +/// constructor parameter. /// -/// [name] overrides the wire JSON key for this field. -/// [schema] must be a const tear-off of a top-level function returning an Ack -/// schema. The generator validates the declaration and follows its expression. -/// [description] describes this property, not a nested model's schema. -/// It takes precedence over an `@description` doc tag. A no-op `@AckField()` -/// is rejected. +/// ```dart +/// @Schemable() +/// final class Account with _$AccountAck { +/// const Account({required this.imageUrl}); +/// +/// @AckKey('avatar') +/// final Uri imageUrl; +/// } +/// ``` @Target({TargetKind.field, TargetKind.parameter}) -final class AckField { - /// Creates a field annotation. - const AckField({this.name, this.schema, this.description}); - - /// Explicit JSON key for this property. - final String? name; - - /// Top-level schema-function tear-off followed by `ack_generator`. - final Object Function()? schema; +final class AckKey { + /// Creates a JSON key override annotation for a field or parameter. + const AckKey(this.name); - /// Schema description for this property. - final String? description; + /// Explicit wire JSON key for this property. + final String name; } diff --git a/packages/ack/lib/src/annotations/schemable.dart b/packages/ack/lib/src/annotations/schemable.dart index f6d69bd1..5a4d2676 100644 --- a/packages/ack/lib/src/annotations/schemable.dart +++ b/packages/ack/lib/src/annotations/schemable.dart @@ -17,7 +17,7 @@ enum AckCaseStyle { none, snake, kebab, pascal, screamingSnake } /// round-trip unknown properties use [capture]. enum AckUnknownPropertyPolicy { reject, discard, capture } -/// Marks an Ack schema declaration. +/// Annotation that marks an Ack schema declaration. /// /// Use this annotation on a top-level schema variable or getter to generate a /// schema-first model. Use it on a class to generate a class-first schema. @@ -52,8 +52,10 @@ final class Schemable { /// Exact generated schema facade name for a class declaration. final String? schemaName; - /// Description for a class schema. This takes precedence over an - /// `@description` doc tag. A top-level schema uses `.describe(...)` instead. + /// Description for a class schema. + /// + /// This takes precedence over an `@description` doc tag. A top-level schema + /// uses `.describe(...)` instead. final String? description; /// JSON field-name style for a class declaration. diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index 3e50009b..622a7374 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -23,6 +23,8 @@ final class _ImportedNode { final String pointer; final Uri baseUri; final Uri dialectUri; + bool applicatorVocabulary = true; + bool unevaluatedVocabulary = true; bool validationVocabulary = true; bool formatAssertion = false; final keywords = {}; @@ -90,14 +92,16 @@ final class _ImportedNode { List<_ImportedNode> get inPlaceDependencies => [ ?reference, ?dynamicReference, - ?not, - ?ifNode, - ?thenNode, - ?elseNode, - ...?anyOf, - ...?allOf, - ...?oneOf, - ...?dependentSchemas?.values, + if (applicatorVocabulary) ...[ + ?not, + ?ifNode, + ?thenNode, + ?elseNode, + ...?anyOf, + ...?allOf, + ...?oneOf, + ...?dependentSchemas?.values, + ], ]; /// The 1.x Draft-7 representation of an imported node. Newer 2020-12 @@ -161,7 +165,15 @@ final class _JsonSchemaCompiler { static final standardDialect = Uri.parse( 'https://json-schema.org/draft/2020-12/schema', ); + static final draft7Dialects = { + Uri.parse('http://json-schema.org/draft-07/schema'), + Uri.parse('https://json-schema.org/draft-07/schema'), + }; static final _anchorPattern = RegExp(r'^[A-Za-z_][-A-Za-z0-9._]*$'); + static const applicatorVocabularyUri = + 'https://json-schema.org/draft/2020-12/vocab/applicator'; + static const unevaluatedVocabularyUri = + 'https://json-schema.org/draft/2020-12/vocab/unevaluated'; static const validationVocabularyUri = 'https://json-schema.org/draft/2020-12/vocab/validation'; static const formatAssertionVocabularyUri = @@ -443,6 +455,13 @@ final class _JsonSchemaCompiler { void _configureDialect(_ImportedNode node) { final dialect = resources[node.dialectUri]; if (dialect == null) { + if (draft7Dialects.contains(node.dialectUri)) { + node.applicatorVocabulary = true; + node.unevaluatedVocabulary = true; + node.validationVocabulary = true; + node.formatAssertion = assertFormats; + return; + } _fail( node, r'$schema', @@ -463,6 +482,10 @@ final class _JsonSchemaCompiler { ); } _checkRequiredVocabularies(node, r'$schema', vocabulary); + node.applicatorVocabulary = vocabulary.containsKey(applicatorVocabularyUri); + node.unevaluatedVocabulary = vocabulary.containsKey( + unevaluatedVocabularyUri, + ); node.validationVocabulary = vocabulary.containsKey(validationVocabularyUri); node.formatAssertion = assertFormats || vocabulary.containsKey(formatAssertionVocabularyUri); diff --git a/packages/ack/lib/src/json_schema/json_schema_formats.dart b/packages/ack/lib/src/json_schema/json_schema_formats.dart index 09e04562..95be9ac8 100644 --- a/packages/ack/lib/src/json_schema/json_schema_formats.dart +++ b/packages/ack/lib/src/json_schema/json_schema_formats.dart @@ -20,15 +20,25 @@ const _jsonSchemaFormats = { 'relative-json-pointer', 'regex', 'uuid', + 'int32', + 'int64', + 'uint32', + 'uint64', + 'float', + 'double', }; +final _jsonSchemaBigIntInt64Min = BigInt.parse('-9223372036854775808'); +final _jsonSchemaBigIntInt64Max = BigInt.parse('9223372036854775807'); +final _jsonSchemaBigIntUint64Max = BigInt.parse('18446744073709551615'); + bool _isSupportedJsonSchemaFormat(String format) => _jsonSchemaFormats.contains(format); bool _isValidJsonSchemaFormat(String format, String value) => switch (format) { 'date' => _isFullDate(value), 'time' => _isFullTime(value), - 'date-time' => _isDateTime(value), + 'date-time' => isValidRfc3339DateTime(value), 'duration' => _isDuration(value), 'email' => _isEmail(value, international: false), 'idn-email' => _isEmail(value, international: true), @@ -47,9 +57,49 @@ bool _isValidJsonSchemaFormat(String format, String value) => switch (format) { 'uuid' => RegExp( r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', ).hasMatch(value), + 'int32' || 'int64' || 'uint32' || 'uint64' || 'float' || 'double' => true, _ => false, }; +bool _isValidJsonSchemaNumericFormat(String format, num value) { + switch (format) { + case 'int32': + if (!value.isFinite || value % 1 != 0) return false; + return value >= -2147483648 && value <= 2147483647; + case 'uint32': + if (!value.isFinite || value % 1 != 0) return false; + return value >= 0 && value <= 4294967295; + case 'int64': + final intVal = _integralBigInt(value); + if (intVal == null) return false; + return intVal >= _jsonSchemaBigIntInt64Min && + intVal <= _jsonSchemaBigIntInt64Max; + case 'uint64': + final intVal = _integralBigInt(value); + if (intVal == null) return false; + return intVal >= BigInt.zero && intVal <= _jsonSchemaBigIntUint64Max; + case 'float': + if (!value.isFinite) return false; + final d = value.toDouble(); + return d.abs() <= 3.4028234663852886e+38; + case 'double': + return value.isFinite; + default: + // String formats do not constrain numeric instances. + return true; + } +} + +BigInt? _integralBigInt(num value) { + if (!value.isFinite || value % 1 != 0) return null; + if (value is int) return BigInt.from(value); + final (:coefficient, :scale) = decimalParts(value); + if (scale == 0) return coefficient; + final divisor = BigInt.from(10).pow(scale); + if (coefficient % divisor != BigInt.zero) return null; + return coefficient ~/ divisor; +} + bool _isFullDate(String value) { final match = RegExp(r'^([0-9]{4})-([0-9]{2})-([0-9]{2})$').firstMatch(value); if (match == null) return false; @@ -90,14 +140,6 @@ bool _isFullTime(String value) { return true; } -bool _isDateTime(String value) { - if (value.length < 12 || (value[10] != 'T' && value[10] != 't')) { - return false; - } - return _isFullDate(value.substring(0, 10)) && - _isFullTime(value.substring(11)); -} - bool _isDuration(String value) { if (RegExp(r'^P[0-9]+W$').hasMatch(value)) return true; final match = RegExp( diff --git a/packages/ack/lib/src/models/ack_model_adapter.dart b/packages/ack/lib/src/models/ack_model_adapter.dart index 177398a1..341ea16e 100644 --- a/packages/ack/lib/src/models/ack_model_adapter.dart +++ b/packages/ack/lib/src/models/ack_model_adapter.dart @@ -2,7 +2,7 @@ import '../schemas/schema.dart'; import '../schemas/extensions/ack_schema_extensions.dart'; import '../validation/schema_result.dart'; -/// Connects a generated immutable model to an Ack schema. +/// Adapter that connects a generated immutable model to an Ack schema. /// /// The schema owns boundary validation and codec behavior. Generated model /// classes only map between the schema's validated runtime value and their @@ -28,10 +28,10 @@ final class AckModelAdapter< final Model Function(Runtime value) _fromRuntime; final Runtime Function(Model value) _toRuntime; - /// Resolves the source schema for this model. + /// The source schema for this model. AckSchema get schema => _schema(); - /// Resolves a schema that decodes to this model. + /// A schema that decodes boundary values to this model. /// /// The source schema keeps ownership of boundary validation and encoding. AckSchema get modelSchema => diff --git a/packages/ack/lib/src/models/json_maybe.dart b/packages/ack/lib/src/models/json_maybe.dart index 8c3b67c7..9d09c182 100644 --- a/packages/ack/lib/src/models/json_maybe.dart +++ b/packages/ack/lib/src/models/json_maybe.dart @@ -2,8 +2,7 @@ import 'package:meta/meta.dart'; import '../utils/collection_utils.dart'; -/// Represents an optional, nullable JSON object property with three distinct -/// states: +/// An optional, nullable JSON object property with three distinct states. /// /// * [JsonAbsent] (`const JsonMaybe.absent()`): the key is omitted from the /// JSON map. @@ -19,13 +18,13 @@ import '../utils/collection_utils.dart'; sealed class JsonMaybe { const JsonMaybe(); - /// An omitted JSON property. + /// Creates an omitted JSON property marker. const factory JsonMaybe.absent() = JsonAbsent; - /// A present JSON property with [value] (which may be `null`). + /// Creates a present JSON property with [value] (which may be `null`). const factory JsonMaybe.value(T? value) = JsonValue; - /// A present JSON property with an explicit `null` value. + /// Creates a present JSON property with an explicit `null` value. const factory JsonMaybe.nullValue() = JsonValue.nullValue; /// Whether the key was present in the JSON map. diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 71cb6153..85e8b02b 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -300,18 +300,24 @@ sealed class AckSchemaModel { final class AckRawSchemaModel extends AckSchemaModel { const AckRawSchemaModel({ required this.schema, + Set> indexedSchemas = const {}, super.title, super.description, super.nullable, super.defaultValue, super.warnings, super.extensions, - }); + }) : _indexedSchemas = indexedSchemas; - AckRawSchemaModel._(_AckSchemaModelCommon common, {required this.schema}) - : super._(common); + AckRawSchemaModel._( + _AckSchemaModelCommon common, { + required this.schema, + required Set> indexedSchemas, + }) : _indexedSchemas = indexedSchemas, + super._(common); final Map schema; + final Set> _indexedSchemas; @override Map toJsonSchema() { @@ -328,6 +334,7 @@ final class AckRawSchemaModel extends AckSchemaModel { body, Uri.parse(body[r'$id'] as String), resourceKeywords, + _indexedSchemas, ); final resource = { for (final entry in relocated.entries) @@ -345,13 +352,18 @@ final class AckRawSchemaModel extends AckSchemaModel { @override AckRawSchemaModel _rebuildWithCommon(_AckSchemaModelCommon common) => - AckRawSchemaModel._(common, schema: schema); + AckRawSchemaModel._( + common, + schema: schema, + indexedSchemas: _indexedSchemas, + ); } Map _relocateRootPointers( Map source, Uri rootUri, Set retainedKeywords, + Set> indexedSchemas, ) { const mapKeywords = { r'$defs', @@ -374,17 +386,44 @@ Map _relocateRootPointers( }; const listKeywords = {'anyOf', 'allOf', 'oneOf', 'prefixItems'}; - Object? visit(Object? value, Uri base) { + late Object? Function(Object?, Uri) visit; + + Object? visitExtension(Object? value, Uri base) { + if (value is Map) { + if (indexedSchemas.contains(value)) return visit(value, base); + return { + for (final entry in value.entries) + entry.key: visitExtension(entry.value, base), + }; + } + if (value is List) { + return [for (final item in value) visitExtension(item, base)]; + } + return value; + } + + visit = (Object? value, Uri base) { if (value is! Map) return value; final declaredId = value[r'$id']; - final currentBase = declaredId is String - ? base.resolve(declaredId).removeFragment() - : base; + var currentBase = base; + if (declaredId is String) { + try { + currentBase = base.resolve(declaredId).removeFragment(); + } on FormatException { + // Dormant malformed $id values in unreachable definitions remain as-is. + } + } final rewritten = Map.from(value); for (final key in [r'$ref', r'$dynamicRef']) { final reference = rewritten[key]; if (reference is! String) continue; - final resolved = currentBase.resolve(reference); + final Uri resolved; + try { + resolved = currentBase.resolve(reference); + } on FormatException { + // Dormant malformed references are not evaluated by the importer. + continue; + } final fragment = resolved.fragment; if (resolved.removeFragment() != rootUri) continue; if (fragment.isEmpty) { @@ -417,8 +456,16 @@ Map _relocateRootPointers( ]; } } + for (final key in rewritten.keys.toList(growable: false)) { + if (mapKeywords.contains(key) || + childKeywords.contains(key) || + listKeywords.contains(key)) { + continue; + } + rewritten[key] = visitExtension(rewritten[key], currentBase); + } return rewritten; - } + }; return visit(source, rootUri)! as Map; } diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index a43abc0e..a884814a 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -19,7 +19,9 @@ extension AckSchemaModelExtension< Runtime extends Object > on AckSchema { - /// Preserves imported Draft 2020-12 resources, including dynamic references. + /// Converts this schema into an [AckSchemaModel], preserving imported Draft + /// 2020-12 resources including dynamic references. + /// /// Native Ack schemas continue to use their Draft-7 representation. AckSchemaModel toSchemaModel() => _SchemaModelBuilder(preserveImportedDialect: true).build(this); @@ -177,8 +179,8 @@ final class _SchemaModelBuilder { AckSchemaModel _imported(ImportedJsonSchema schema) { final importIndex = _importCount++; if (preserveImportedDialect) { + final indexedSchemas = HashSet>.identity(); if (_composingImports) { - final indexedSchemas = HashSet>.identity(); final document = schema.export2020Document( importIndex: importIndex, includeFluentNullability: false, @@ -188,6 +190,7 @@ final class _SchemaModelBuilder { schemas: [ AckRawSchemaModel( schema: _shareImportedResources(document, indexedSchemas), + indexedSchemas: indexedSchemas, ), ], description: schema.description, @@ -199,7 +202,11 @@ final class _SchemaModelBuilder { ); } return AckRawSchemaModel( - schema: schema.export2020Document(importIndex: importIndex), + schema: schema.export2020Document( + importIndex: importIndex, + onSchema: indexedSchemas.add, + ), + indexedSchemas: indexedSchemas, description: schema.description, nullable: schema.isNullable && @@ -244,12 +251,16 @@ final class _SchemaModelBuilder { Object? index(Object? value, Uri base, List<(Uri, List)> scopes) { final id = value is Map ? value[r'$id'] : null; if (id is String) { - base = base.resolve(id); - final address = scopes.isEmpty - ? base - : location(scopes.first.$1, scopes.first.$2); - _resourceLocations.putIfAbsent(base, () => address); - scopes = [...scopes, (base, [])]; + try { + base = base.resolve(id); + final address = scopes.isEmpty + ? base + : location(scopes.first.$1, scopes.first.$2); + _resourceLocations.putIfAbsent(base, () => address); + scopes = [...scopes, (base, [])]; + } on FormatException { + // Dormant malformed $id values in unreachable definitions remain as-is. + } } for (final (uri, path) in scopes) { final (resource, relativePath) = scopes.last; @@ -270,14 +281,22 @@ final class _SchemaModelBuilder { Object? normalize(Object? value, Uri base) { if (value is! Map) return value; final id = value[r'$id']; - if (id is String) base = base.resolve(id); + var resolvedValidId = false; + if (id is String) { + try { + base = base.resolve(id); + resolvedValidId = true; + } on FormatException { + // Dormant malformed $id values in unreachable definitions remain as-is. + } + } final result = _mapImportedChildren( value, (child, _) => normalize(child, base), indexedSchemas, ); if (indexedSchemas.contains(value)) indexedSchemas.add(result); - if (id is String) result[r'$id'] = base.toString(); + if (resolvedValidId) result[r'$id'] = base.toString(); for (final keyword in [r'$ref', r'$dynamicRef']) { final ref = result[keyword]; if (ref is String) { @@ -308,21 +327,29 @@ final class _SchemaModelBuilder { if (value is! Map) return value; final id = value[r'$id']; if (id is String) { - final uri = Uri.parse(id); - final existing = _importedResources[uri]; - if (existing != null) { - if (!_deepEquality.equals(existing, value)) { - throw ArgumentError('Conflicting imported schema resource "$uri".'); + final uri = Uri.tryParse(id); + if (uri != null) { + final existing = _importedResources[uri]; + if (existing != null) { + if (!_deepEquality.equals(existing, value)) { + throw ArgumentError( + 'Conflicting imported schema resource "$uri".', + ); + } + return { + r'$ref': _resourceLocations[uri].toString(), + }; } - return {r'$ref': _resourceLocations[uri].toString()}; + _importedResources[uri] = value; } - _importedResources[uri] = value; } - return _mapImportedChildren( + final rebuilt = _mapImportedChildren( value, (child, _) => share(child), indexedSchemas, ); + if (indexedSchemas.contains(value)) indexedSchemas.add(rebuilt); + return rebuilt; } return share(normalize(document, base))! as Map; @@ -587,8 +614,14 @@ AckSchemaModel _applyConstraints( for (final entry in _renderedKeywords(model).entries) entry.key: [entry.value], }; + final postNormalizerStart = schema is StringSchema + ? schema.firstPostNormalizerConstraintIndex + : null; final omittedConstraintKeys = []; - for (final constraint in schema.constraints) { + for (final (index, constraint) in schema.constraints.indexed) { + final projectConstraint = + projectKeywords && + (postNormalizerStart == null || index < postNormalizerStart); if (constraint is DateTimeConstraint) { next = _applyDateTimeConstraint(next, constraint); continue; @@ -596,7 +629,7 @@ AckSchemaModel _applyConstraints( if (constraint is JsonSchemaSpec) { final keywords = constraint.toJsonSchema(); - if (!projectKeywords) { + if (!projectConstraint) { if (keywords.isNotEmpty) { omittedConstraintKeys.add(constraint.constraintKey); } diff --git a/packages/ack/lib/src/schemas/boundary_schema.dart b/packages/ack/lib/src/schemas/boundary_schema.dart index bde39323..0d545f24 100644 --- a/packages/ack/lib/src/schemas/boundary_schema.dart +++ b/packages/ack/lib/src/schemas/boundary_schema.dart @@ -1,6 +1,7 @@ part of 'schema.dart'; -/// Validates with another schema while preserving the original boundary value. +/// Schema wrapper that validates with [inner] while preserving the original +/// boundary value. /// /// This is useful when callers need a composable schema for validating wire /// data without retaining any runtime values produced by nested codecs. diff --git a/packages/ack/lib/src/schemas/default_schema.dart b/packages/ack/lib/src/schemas/default_schema.dart index c1aa5cd2..23d6bbdb 100644 --- a/packages/ack/lib/src/schemas/default_schema.dart +++ b/packages/ack/lib/src/schemas/default_schema.dart @@ -1,8 +1,10 @@ part of 'schema.dart'; -/// Wraps another schema and supplies a runtime default when the input is -/// null on parse. Object encode injects encoded defaults for missing -/// default-wrapped fields. +/// Schema wrapper that supplies [defaultValue] when the parse input is +/// `null`. +/// +/// Object encoding also injects encoded defaults for missing default-wrapped +/// fields. @immutable final class DefaultSchema extends AckSchema diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index f4f0090d..862250ec 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -61,7 +61,7 @@ final class DiscriminatedObjectSchema 'Discriminated branches must be object-backed schemas.', ); } - // Union-owned discriminator (PR #107): if a branch declares the + // Union-owned discriminator: if a branch declares the // discriminator property, it must accept the branch label. Otherwise // the union synthesizes the literal automatically via [effectiveBranch]. final branchDiscriminator = base.properties[discriminatorKey]; @@ -298,7 +298,7 @@ final class DiscriminatedObjectSchema final discValue = discriminatorResult.getOrNull()!; // Route through `effectiveBranch` so branches authored without the - // discriminator property (per PR #107) still validate the literal at parse + // discriminator property still validate the literal at parse // time. The shared discriminator check guarantees the branch exists, so // `effectiveBranch` is always callable here. final effective = effectiveBranch(discValue); @@ -401,7 +401,7 @@ final class DiscriminatedObjectSchema String? matchedDiscValue; for (final discValue in schemas.keys) { // Select the branch via the effective schema (its literal discriminator - // gates selection per PR #107); the union itself writes the discriminator + // gates selection); the union itself writes the discriminator // onto the encoded boundary in [_encodeBranch]. final effective = effectiveBranch(discValue, optionalDiscriminator: true); final branchCtx = context.createChild( diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index d963ff2d..edf13037 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -21,9 +21,13 @@ final class ImportedJsonSchema extends AckSchema final root = compiler.addDocument(document, base); final externalRoots = <_ImportedNode>[]; for (final entry in documents.entries) { - externalRoots.add( - compiler.addDocument(entry.value, base.resolveUri(entry.key)), + final node = compiler.addDocument( + entry.value, + base.resolveUri(entry.key), ); + if (!identical(node, root) && !externalRoots.contains(node)) { + externalRoots.add(node); + } } for (final entry in _standardMetaSchemaDocuments.entries) { if (!compiler.resources.containsKey(entry.key)) { @@ -126,7 +130,10 @@ final class ImportedJsonSchema extends AckSchema if (exported is! Map) continue; onSchema?.call(exported); if (assertFormats && - indexed.dialectUri == _JsonSchemaCompiler.standardDialect && + (indexed.dialectUri == _JsonSchemaCompiler.standardDialect || + _JsonSchemaCompiler.draft7Dialects.contains( + indexed.dialectUri, + )) && (identical(indexed, node) || exported.containsKey(r'$schema'))) { exported[r'$schema'] = _assertionDialect.toString(); } @@ -144,10 +151,10 @@ final class ImportedJsonSchema extends AckSchema // compiler already rejects malformed references that are evaluated. continue; } - for (final external in _externalRoots) { - if (external.documentUri != external.baseUri && - resolved.removeFragment() == external.documentUri) { - exported[keyword] = external.baseUri + for (final resourceNode in [_root, ..._externalRoots]) { + if (resourceNode.documentUri != resourceNode.baseUri && + resolved.removeFragment() == resourceNode.documentUri) { + exported[keyword] = resourceNode.baseUri .replace( fragment: resolved.hasFragment ? resolved.fragment : null, ) @@ -161,7 +168,8 @@ final class ImportedJsonSchema extends AckSchema if (source is Map) ...source, r'$id': (syntheticId ?? node.baseUri).toString(), if (assertFormats && - node.dialectUri == _JsonSchemaCompiler.standardDialect) + (node.dialectUri == _JsonSchemaCompiler.standardDialect || + _JsonSchemaCompiler.draft7Dialects.contains(node.dialectUri))) r'$schema': _assertionDialect.toString(), if (source is bool) 'allOf': [source], }; @@ -328,6 +336,10 @@ final class ImportedJsonSchema extends AckSchema @override SchemaType get schemaType => SchemaType.any; + Map get _externalDocuments => { + for (final node in _externalRoots) node.documentUri: node.source, + }; + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -335,12 +347,19 @@ final class ImportedJsonSchema extends AckSchema return baseFieldsEqual(other) && assertFormats == other.assertFormats && - deepEquals(rawSchema, other.rawSchema); + _root.documentUri == other._root.documentUri && + deepEquals(rawSchema, other.rawSchema) && + deepEquals(_externalDocuments, other._externalDocuments); } @override - int get hashCode => - Object.hash(baseFieldsHashCode, assertFormats, deepHashCode(rawSchema)); + int get hashCode => Object.hash( + baseFieldsHashCode, + assertFormats, + _root.documentUri, + deepHashCode(rawSchema), + deepHashCode(_externalDocuments), + ); } final class _ImportViolation { @@ -455,59 +474,61 @@ _ImportEvaluation _evaluateImportedNode( } evaluated.include(result); } - if (node.allOf case final branches?) { - for (var i = 0; i < branches.length; i++) { - final result = _evaluateImportedNode(branches[i], value, currentScope); - if (result.error case final error?) { - return _ImportEvaluation(error.via(['allOf', '$i'])); + if (node.applicatorVocabulary) { + if (node.allOf case final branches?) { + for (var i = 0; i < branches.length; i++) { + final result = _evaluateImportedNode(branches[i], value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via(['allOf', '$i'])); + } + evaluated.include(result); } - evaluated.include(result); } - } - if (node.anyOf case final branches?) { - var success = false; - for (final branch in branches) { - final result = _evaluateImportedNode(branch, value, currentScope); - if (result.error == null) { - success = true; - evaluated.include(result); + if (node.anyOf case final branches?) { + var success = false; + for (final branch in branches) { + final result = _evaluateImportedNode(branch, value, currentScope); + if (result.error == null) { + success = true; + evaluated.include(result); + } } + if (!success) return fail('anyOf'); } - if (!success) return fail('anyOf'); - } - if (node.oneOf case final branches?) { - var successes = 0; - _ImportEvaluation? matched; - for (final branch in branches) { - final result = _evaluateImportedNode(branch, value, currentScope); - if (result.error == null) { - successes++; - matched = result; + if (node.oneOf case final branches?) { + var successes = 0; + _ImportEvaluation? matched; + for (final branch in branches) { + final result = _evaluateImportedNode(branch, value, currentScope); + if (result.error == null) { + successes++; + matched = result; + } } + if (successes != 1) return fail('oneOf'); + evaluated.include(matched!); } - if (successes != 1) return fail('oneOf'); - evaluated.include(matched!); - } - if (node.not case final target?) { - if (_evaluateImportedNode(target, value, currentScope).error == null) { - return fail('not'); + if (node.not case final target?) { + if (_evaluateImportedNode(target, value, currentScope).error == null) { + return fail('not'); + } } - } - if (node.ifNode case final condition?) { - final conditionResult = _evaluateImportedNode( - condition, - value, - currentScope, - ); - final passed = conditionResult.error == null; - if (passed) evaluated.include(conditionResult); - final branch = passed ? node.thenNode : node.elseNode; - if (branch != null) { - final result = _evaluateImportedNode(branch, value, currentScope); - if (result.error case final error?) { - return _ImportEvaluation(error.via([passed ? 'then' : 'else'])); + if (node.ifNode case final condition?) { + final conditionResult = _evaluateImportedNode( + condition, + value, + currentScope, + ); + final passed = conditionResult.error == null; + if (passed) evaluated.include(conditionResult); + final branch = passed ? node.thenNode : node.elseNode; + if (branch != null) { + final result = _evaluateImportedNode(branch, value, currentScope); + if (result.error case final error?) { + return _ImportEvaluation(error.via([passed ? 'then' : 'else'])); + } + evaluated.include(result); } - evaluated.include(result); } } switch (value) { @@ -530,6 +551,12 @@ _ImportEvaluation _evaluateImportedNode( return fail('exclusiveMaximum'); } } + if (node.formatAssertion) { + if (node.format case final format? + when !_isValidJsonSchemaNumericFormat(format, value)) { + return fail('format'); + } + } case String(): if (node.validationVocabulary) { // Counting code points allocates, so only do it when a bound exists. @@ -579,92 +606,100 @@ _ImportEvaluation _evaluateImportedNode( } } } - final properties = node.properties; - final patterns = node.patternProperties; - final additional = node.additionalProperties; - if (properties != null || patterns != null || additional != null) { - for (final MapEntry(:key, value: item) in value.entries) { - final propKey = key as String; - var matched = false; - if (properties?[propKey] case final target?) { - matched = true; - final result = _evaluateImportedNode(target, item, currentScope); - if (result.error case final error?) { - return _ImportEvaluation( - error.at(propKey, ['properties', propKey]), - ); - } - evaluated.properties.add(propKey); - } - if (patterns != null) { - for (final (rawPattern, regex, target) in patterns) { - if (!regex.hasMatch(propKey)) continue; + if (node.applicatorVocabulary) { + final properties = node.properties; + final patterns = node.patternProperties; + final additional = node.additionalProperties; + if (properties != null || patterns != null || additional != null) { + for (final MapEntry(:key, value: item) in value.entries) { + final propKey = key as String; + var matched = false; + if (properties?[propKey] case final target?) { matched = true; final result = _evaluateImportedNode(target, item, currentScope); if (result.error case final error?) { return _ImportEvaluation( - error.at(propKey, ['patternProperties', rawPattern]), + error.at(propKey, ['properties', propKey]), + ); + } + evaluated.properties.add(propKey); + } + if (patterns != null) { + for (final (rawPattern, regex, target) in patterns) { + if (!regex.hasMatch(propKey)) continue; + matched = true; + final result = _evaluateImportedNode( + target, + item, + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(propKey, ['patternProperties', rawPattern]), + ); + } + evaluated.properties.add(propKey); + } + } + if (!matched && additional != null) { + final result = _evaluateImportedNode( + additional, + item, + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(propKey, const ['additionalProperties']), ); } evaluated.properties.add(propKey); } } - if (!matched && additional != null) { + } + if (node.dependentSchemas case final dependencies?) { + for (final dependency in dependencies.entries) { + if (!value.containsKey(dependency.key)) continue; final result = _evaluateImportedNode( - additional, - item, + dependency.value, + value, currentScope, ); if (result.error case final error?) { return _ImportEvaluation( - error.at(propKey, const ['additionalProperties']), + error.via(['dependentSchemas', dependency.key]), ); } - evaluated.properties.add(propKey); - } - } - } - if (node.dependentSchemas case final dependencies?) { - for (final dependency in dependencies.entries) { - if (!value.containsKey(dependency.key)) continue; - final result = _evaluateImportedNode( - dependency.value, - value, - currentScope, - ); - if (result.error case final error?) { - return _ImportEvaluation( - error.via(['dependentSchemas', dependency.key]), - ); + evaluated.include(result); } - evaluated.include(result); } - } - if (node.propertyNames case final target?) { - for (final key in value.keys) { - final result = _evaluateImportedNode(target, key, currentScope); - if (result.error case final error?) { - return _ImportEvaluation( - error.atPropertyName(key as String, const ['propertyNames']), - ); + if (node.propertyNames case final target?) { + for (final key in value.keys) { + final result = _evaluateImportedNode(target, key, currentScope); + if (result.error case final error?) { + return _ImportEvaluation( + error.atPropertyName(key as String, const ['propertyNames']), + ); + } } } } - if (node.unevaluatedProperties case final target?) { - for (final entry in value.entries) { - final key = entry.key as String; - if (evaluated.properties.contains(key)) continue; - final result = _evaluateImportedNode( - target, - entry.value, - currentScope, - ); - if (result.error case final error?) { - return _ImportEvaluation( - error.at(key, const ['unevaluatedProperties']), + if (node.unevaluatedVocabulary) { + if (node.unevaluatedProperties case final target?) { + for (final entry in value.entries) { + final key = entry.key as String; + if (evaluated.properties.contains(key)) continue; + final result = _evaluateImportedNode( + target, + entry.value, + currentScope, ); + if (result.error case final error?) { + return _ImportEvaluation( + error.at(key, const ['unevaluatedProperties']), + ); + } + evaluated.properties.add(key); } - evaluated.properties.add(key); } } case List(): @@ -681,57 +716,71 @@ _ImportEvaluation _evaluateImportedNode( return fail('uniqueItems'); } } - final prefixItems = node.prefixItems ?? const <_ImportedNode>[]; - for (var i = 0; i < prefixItems.length && i < value.length; i++) { - final result = _evaluateImportedNode( - prefixItems[i], - value[i], - currentScope, - ); - if (result.error case final error?) { - return _ImportEvaluation(error.at('$i', ['prefixItems', '$i'])); - } - evaluated.items.add(i); - } - if (node.items case final target?) { - for (var i = prefixItems.length; i < value.length; i++) { - final result = _evaluateImportedNode(target, value[i], currentScope); + if (node.applicatorVocabulary) { + final prefixItems = node.prefixItems ?? const <_ImportedNode>[]; + for (var i = 0; i < prefixItems.length && i < value.length; i++) { + final result = _evaluateImportedNode( + prefixItems[i], + value[i], + currentScope, + ); if (result.error case final error?) { - return _ImportEvaluation(error.at('$i', const ['items'])); + return _ImportEvaluation(error.at('$i', ['prefixItems', '$i'])); } evaluated.items.add(i); } - } - if (node.contains case final target?) { - var matches = 0; - for (var i = 0; i < value.length; i++) { - if (_evaluateImportedNode(target, value[i], currentScope).error == - null) { - matches++; + if (node.items case final target?) { + for (var i = prefixItems.length; i < value.length; i++) { + final result = _evaluateImportedNode( + target, + value[i], + currentScope, + ); + if (result.error case final error?) { + return _ImportEvaluation(error.at('$i', const ['items'])); + } evaluated.items.add(i); } } - final minimum = node.validationVocabulary ? (node.minContains ?? 1) : 1; - final maximum = node.validationVocabulary ? node.maxContains : null; - if (matches < minimum) { - return fail( - node.validationVocabulary && node.minContains != null - ? 'minContains' - : 'contains', - ); + if (node.contains case final target?) { + var matches = 0; + for (var i = 0; i < value.length; i++) { + if (_evaluateImportedNode(target, value[i], currentScope).error == + null) { + matches++; + evaluated.items.add(i); + } + } + final minimum = node.validationVocabulary + ? (node.minContains ?? 1) + : 1; + final maximum = node.validationVocabulary ? node.maxContains : null; + if (matches < minimum) { + return fail( + node.validationVocabulary && node.minContains != null + ? 'minContains' + : 'contains', + ); + } + if (maximum != null && matches > maximum) return fail('maxContains'); } - if (maximum != null && matches > maximum) return fail('maxContains'); } - if (node.unevaluatedItems case final target?) { - for (var i = 0; i < value.length; i++) { - if (evaluated.items.contains(i)) continue; - final result = _evaluateImportedNode(target, value[i], currentScope); - if (result.error case final error?) { - return _ImportEvaluation( - error.at('$i', const ['unevaluatedItems']), + if (node.unevaluatedVocabulary) { + if (node.unevaluatedItems case final target?) { + for (var i = 0; i < value.length; i++) { + if (evaluated.items.contains(i)) continue; + final result = _evaluateImportedNode( + target, + value[i], + currentScope, ); + if (result.error case final error?) { + return _ImportEvaluation( + error.at('$i', const ['unevaluatedItems']), + ); + } + evaluated.items.add(i); } - evaluated.items.add(i); } } } diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index 43024e2c..094f5877 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -1,7 +1,7 @@ part of 'schema.dart'; -/// Defers resolving another schema until parse, validation, encode, or export -/// time. +/// Schema wrapper that defers resolving another schema until parse, +/// validation, encode, or export time. /// /// This enables recursive schema graphs where a child schema needs to refer /// back to an outer schema that is assigned after construction. diff --git a/packages/ack/lib/src/schemas/schema.dart b/packages/ack/lib/src/schemas/schema.dart index 59394b17..d37446ea 100644 --- a/packages/ack/lib/src/schemas/schema.dart +++ b/packages/ack/lib/src/schemas/schema.dart @@ -83,10 +83,10 @@ sealed class AckSchema { final List> _constraints; final List> _refinements; - /// Returns an unmodifiable view of the constraints for this schema. + /// An unmodifiable view of the constraints for this schema. List> get constraints => List.unmodifiable(_constraints); - /// Returns an unmodifiable view of the refinements for this schema. + /// An unmodifiable view of the refinements for this schema. List> get refinements => List.unmodifiable(_refinements); Iterable get _constraintsForEquality => _constraints; @@ -135,8 +135,10 @@ sealed class AckSchema { SchemaContext context, ); - /// Encodes a runtime value into a boundary value. The base class strips - /// `null` before calling this; subclasses receive a non-null [value]. + /// Encodes a runtime value into a boundary value. + /// + /// The base class strips `null` before calling this; subclasses receive a + /// non-null [value]. /// /// The default delegates to [encodeAsBoundary], which is correct for schemas /// where `Boundary == Runtime`. Schemas where the two types differ (codecs, @@ -234,9 +236,9 @@ sealed class AckSchema { ); } - /// Helper for schemas whose boundary == runtime: validates the runtime - /// value and, if it passes, returns it as the boundary value unchanged. - /// Only safe to call when `Boundary` and `Runtime` are the same type. + /// Validates [value] and returns it as the boundary value unchanged. + /// + /// Only safe to call when [Boundary] and [Runtime] are the same type. @protected SchemaResult encodeAsBoundary( Runtime value, @@ -247,7 +249,7 @@ sealed class AckSchema { return SchemaResult.ok(value as Boundary); } - /// Runtime-side non-nullable failure. + /// Returns a runtime-side non-nullable failure. @protected SchemaResult failNonNullable(SchemaContext context) { final constraintError = NonNullableConstraint().validate(null); @@ -259,15 +261,17 @@ sealed class AckSchema { ); } - /// Encode-side non-nullable failure. + /// Returns an encode-side non-nullable failure. @protected SchemaResult failNonNullableEncode(SchemaContext context) { return SchemaResult.fail(SchemaEncodeError.nonNullable(context: context)); } - /// Centralized null gate for the parse/validate paths. Returns null when - /// [inputValue] is non-null; otherwise returns Ok(null) if [acceptsNull] - /// is true or a non-nullable failure. + /// Handles `null` input for the parse and runtime-validation paths. + /// + /// Returns `null` when [inputValue] is non-null; otherwise returns + /// `SchemaResult.ok(null)` when [acceptsNull] is `true`, or a non-nullable + /// failure. @protected SchemaResult? handleNullInput( Object? inputValue, @@ -282,9 +286,11 @@ sealed class AckSchema { return failNonNullable(context); } - /// Whether `parse(null)` and `encode(null)` should accept null without - /// raising a non-nullable failure. Defaults to [isNullable]; subclasses with - /// branch-level null policies (e.g. [AnyOfSchema]) override this hook. + /// Whether `parse(null)` and `encode(null)` should accept `null` without + /// raising a non-nullable failure. + /// + /// Defaults to [isNullable]; subclasses with branch-level null policies (such + /// as [AnyOfSchema]) override this hook. @protected bool get acceptsNull => isNullable; @@ -322,8 +328,10 @@ sealed class AckSchema { } /// Wraps this schema in a [DefaultSchema] that supplies [defaultValue] when - /// the parse input is null. Object encode also injects encoded defaults for - /// missing default-wrapped fields. + /// the parse input is `null`. + /// + /// Object encoding also injects encoded defaults for missing default-wrapped + /// fields. DefaultSchema withDefault(Runtime defaultValue) { return DefaultSchema( inner: this, @@ -528,7 +536,7 @@ sealed class AckSchema { ); } - /// Computes hash code for base schema fields. + /// The hash code for the base schema fields. @protected int get baseFieldsHashCode { const iterableEq = IterableEquality(); diff --git a/packages/ack/lib/src/schemas/string_schema.dart b/packages/ack/lib/src/schemas/string_schema.dart index 5b464ab4..1d1f0b52 100644 --- a/packages/ack/lib/src/schemas/string_schema.dart +++ b/packages/ack/lib/src/schemas/string_schema.dart @@ -58,6 +58,10 @@ final class StringSchema extends AckSchema List<_StringNormalizerStep> normalizers = const [], }) : _normalizers = normalizers; + @internal + int? get firstPostNormalizerConstraintIndex => + _normalizers.isEmpty ? null : _normalizers.first.constraintIndex; + StringSchema _withNormalizer(_StringNormalizer normalizer) { return StringSchema._( isNullable: isNullable, @@ -165,13 +169,32 @@ final class StringSchema extends AckSchema List>? constraints, List>? refinements, }) { + final nextConstraints = constraints ?? this.constraints; + final nextRefinements = refinements ?? this.refinements; + final nextNormalizers = + _normalizers.isEmpty || (constraints == null && refinements == null) + ? _normalizers + : List<_StringNormalizerStep>.unmodifiable([ + for (final step in _normalizers) + _StringNormalizerStep( + normalizer: step.normalizer, + constraintIndex: step.constraintIndex.clamp( + 0, + nextConstraints.length, + ), + refinementIndex: step.refinementIndex.clamp( + 0, + nextRefinements.length, + ), + ), + ]); return StringSchema._( isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, - constraints: constraints ?? this.constraints, - refinements: refinements ?? this.refinements, - normalizers: _normalizers, + constraints: nextConstraints, + refinements: nextRefinements, + normalizers: nextNormalizers, ); } diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index 897b3847..86fc8be2 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -1,6 +1,6 @@ name: ack description: Schema validation and bidirectional codecs for Dart and Flutter -version: 2.0.0-beta.1 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues homepage: https://concepta.dev/ack diff --git a/packages/ack/test/annotations/ack_model_test.dart b/packages/ack/test/annotations/ack_model_test.dart index e854e3a1..99812e61 100644 --- a/packages/ack/test/annotations/ack_model_test.dart +++ b/packages/ack/test/annotations/ack_model_test.dart @@ -1,8 +1,6 @@ import 'package:ack/annotations.dart'; import 'package:test/test.dart'; -Object _customSchema() => Object(); - void main() { test( 'Schemable options are const and default to class-first conventions', @@ -47,20 +45,9 @@ void main() { }, ); - test('AckField accepts name, schema tear-off, and description', () { - const inferred = AckField( - name: 'wire_key', - schema: _customSchema, - description: 'Field description', - ); - expect(inferred.name, 'wire_key'); - expect(inferred.schema, same(_customSchema)); - expect(inferred.description, 'Field description'); - - const defaults = AckField(); - expect(defaults.name, isNull); - expect(defaults.schema, isNull); - expect(defaults.description, isNull); + test('AckKey accepts a positional wire key name', () { + const key = AckKey('wire_key'); + expect(key.name, 'wire_key'); }); test( diff --git a/packages/ack/test/annotations/dart_core_names_test.dart b/packages/ack/test/annotations/dart_core_names_test.dart index 4e963a88..fcde020c 100644 --- a/packages/ack/test/annotations/dart_core_names_test.dart +++ b/packages/ack/test/annotations/dart_core_names_test.dart @@ -6,7 +6,7 @@ import 'package:test/test.dart'; final class _Endpoint { const _Endpoint({required this.uri, required this.createdAt}); - @AckField(name: 'endpoint_uri') + @AckKey('endpoint_uri') @Validate.uri() final Uri uri; diff --git a/packages/ack/test/annotations/exports_test.dart b/packages/ack/test/annotations/exports_test.dart index 7c0c3207..d1c34a77 100644 --- a/packages/ack/test/annotations/exports_test.dart +++ b/packages/ack/test/annotations/exports_test.dart @@ -8,7 +8,8 @@ import 'own_types.dart'; final class Contact { const Contact(this.email); - @ack.AckField(name: 'email_address', description: 'Primary email') + /// @description Primary email + @ack.AckKey('email_address') @ack.Validate.email() final Email email; } @@ -17,7 +18,8 @@ final class Contact { final class SingleImportContact { const SingleImportContact(this.email, this.id); - @AckField(description: 'Contact email') + /// @description Contact email + @AckKey('email') @Validate.email() final Email email; @@ -29,7 +31,7 @@ void main() { test('package:ack/ack.dart exports the model and Validate annotations', () { const values = [ Schemable(caseStyle: AckCaseStyle.snake), - AckField(description: 'id'), + AckKey('id'), Validate.required(), Validate.optional(), Validate.notNull(), @@ -83,17 +85,16 @@ void main() { ); test( - 'a prefixed annotations import exposes Schemable, AckField, and Validate', + 'a prefixed annotations import exposes Schemable, AckKey, and Validate', () { const contact = Contact(Email('ada@example.com')); const schemable = ack.Schemable(name: 'Contact'); - const field = ack.AckField(name: 'email_address', description: 'Primary'); + const key = ack.AckKey('email_address'); const validate = ack.Validate.matches('^a'); expect(contact.email.address, 'ada@example.com'); expect(schemable.name, 'Contact'); - expect(field.name, 'email_address'); - expect(field.description, 'Primary'); + expect(key.name, 'email_address'); expect(validate, isA()); expect(validate.kind, 'matches'); expect(validate.value, '^a'); diff --git a/packages/ack/test/json_schema_import_api_test.dart b/packages/ack/test/json_schema_import_api_test.dart index 762f9b10..298f31b5 100644 --- a/packages/ack/test/json_schema_import_api_test.dart +++ b/packages/ack/test/json_schema_import_api_test.dart @@ -126,7 +126,7 @@ void main() { {'minLength': -1}, {r'$ref': 'missing.json'}, {r'$ref': '#'}, - {r'$schema': 'http://json-schema.org/draft-07/schema#'}, + {r'$schema': 'http://json-schema.org/draft-04/schema#'}, ]) { expect( () => Ack.fromJsonSchema(document), @@ -138,6 +138,23 @@ void main() { expect(never.safeParse('anything').isFail, isTrue); }); + test('Draft-07 dialect URIs are accepted by Ack.fromJsonSchema', () { + for (final dialectUri in [ + 'http://json-schema.org/draft-07/schema#', + 'http://json-schema.org/draft-07/schema', + 'https://json-schema.org/draft-07/schema#', + 'https://json-schema.org/draft-07/schema', + ]) { + final schema = Ack.fromJsonSchema({ + r'$schema': dialectUri, + 'type': 'string', + 'format': 'email', + }, assertFormats: true); + expect(schema.safeParse('alice@example.com').isOk, isTrue); + expect(schema.safeParse('not-an-email').isFail, isTrue); + } + }); + test('dynamic references export by default and reject Draft-7 lowering', () { final schema = Ack.fromJsonSchema({ r'$dynamicAnchor': 'node', diff --git a/packages/ack/test/json_schema_import_test.dart b/packages/ack/test/json_schema_import_test.dart index 94e58511..bac3b1f8 100644 --- a/packages/ack/test/json_schema_import_test.dart +++ b/packages/ack/test/json_schema_import_test.dart @@ -333,7 +333,7 @@ void main() { {r'$ref': '#/%FF'}, {r'$schema': null}, {r'$anchor': null}, - {r'$schema': 'http://json-schema.org/draft-07/schema#'}, + {r'$schema': 'http://json-schema.org/draft-04/schema#'}, ]) { expect( () => Ack.fromJsonSchema(document), @@ -1106,6 +1106,234 @@ void main() { expect(nested.value, ['a', 'b', 'a']); }, ); + + test( + 'supports Draft-07 dialect URIs under the supported subset and round-trips format assertions', + () { + final schema = Ack.fromJsonSchema({ + r'$schema': 'http://json-schema.org/draft-07/schema#', + 'type': 'object', + 'properties': { + 'email': {'type': 'string', 'format': 'email'}, + }, + 'unevaluatedProperties': false, + }, assertFormats: true); + + expect(schema.safeParse({'email': 'alice@example.com'}).isOk, isTrue); + expect( + schema.safeParse({'email': 'alice@example.com', 'extra': 123}).isFail, + isTrue, + ); + expect(schema.safeParse({'email': 'invalid'}).isFail, isTrue); + + final roundTripped = Ack.fromJsonSchema(schema.toJsonSchema()); + expect( + roundTripped.safeParse({'email': 'alice@example.com'}).isOk, + isTrue, + ); + expect( + roundTripped.safeParse({ + 'email': 'alice@example.com', + 'extra': 123, + }).isFail, + isTrue, + ); + expect(roundTripped.safeParse({'email': 'invalid'}).isFail, isTrue); + }, + ); + + test( + 'ImportedJsonSchema equality and hashCode include baseUri and external documents', + () { + final docA = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/a/root.json'), + documents: { + Uri.parse('https://example.test/a/other.json'): {'type': 'string'}, + }, + ); + final docB = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/a/root.json'), + documents: { + Uri.parse('https://example.test/a/other.json'): {'type': 'integer'}, + }, + ); + final docSameAsA = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/a/root.json'), + documents: { + Uri.parse('https://example.test/a/other.json'): {'type': 'string'}, + }, + ); + final docDifferentBase = Ack.fromJsonSchema( + {r'$ref': 'other.json'}, + baseUri: Uri.parse('https://example.test/b/root.json'), + documents: { + Uri.parse('https://example.test/b/other.json'): {'type': 'string'}, + }, + ); + + expect(docA, equals(docSameAsA)); + expect(docA.hashCode, equals(docSameAsA.hashCode)); + expect(docA, isNot(equals(docB))); + expect(docA, isNot(equals(docDifferentBase))); + }, + ); + + test( + 'custom vocabularies can disable applicator and unevaluated keywords', + () { + final withoutUnevaluated = Ack.fromJsonSchema( + { + r'$schema': 'https://example.test/meta/no-unevaluated', + 'type': 'object', + 'properties': { + 'a': {'type': 'string'}, + }, + 'unevaluatedProperties': false, + 'items': {'type': 'integer'}, + 'unevaluatedItems': false, + }, + documents: { + Uri.parse('https://example.test/meta/no-unevaluated'): { + r'$id': 'https://example.test/meta/no-unevaluated', + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + r'$vocabulary': { + 'https://json-schema.org/draft/2020-12/vocab/core': true, + 'https://json-schema.org/draft/2020-12/vocab/applicator': true, + 'https://json-schema.org/draft/2020-12/vocab/validation': true, + }, + }, + }, + ); + expect( + withoutUnevaluated.safeParse({'a': 'ok', 'extra': 1}).isOk, + isTrue, + ); + expect(withoutUnevaluated.safeParse({'a': 1}).isFail, isTrue); + + final withoutApplicator = Ack.fromJsonSchema( + { + r'$schema': 'https://example.test/meta/no-applicator', + 'type': 'object', + 'allOf': [ + {r'$ref': '#'}, + false, + ], + 'not': true, + 'properties': {'a': false}, + }, + documents: { + Uri.parse('https://example.test/meta/no-applicator'): { + r'$id': 'https://example.test/meta/no-applicator', + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + r'$vocabulary': { + 'https://json-schema.org/draft/2020-12/vocab/core': true, + 'https://json-schema.org/draft/2020-12/vocab/validation': true, + }, + }, + }, + ); + expect(withoutApplicator.safeParse({'a': 123}).isOk, isTrue); + expect(withoutApplicator.safeParse('not-an-object').isFail, isTrue); + }, + ); + + test( + 'export rewrites references targeting the root retrieval URI when root declares a different \$id', + () { + final schema = Ack.fromJsonSchema({ + r'$id': 'https://example.test/canonical/schema.json', + r'$defs': { + 'Value': {'type': 'string'}, + }, + r'$ref': 'https://example.test/retrieval/root.json#/\$defs/Value', + }, baseUri: Uri.parse('https://example.test/retrieval/root.json')); + + final exported = schema.toJsonSchema(); + expect( + exported[r'$ref'], + 'https://example.test/canonical/schema.json#/\$defs/Value', + ); + final reimported = Ack.fromJsonSchema(exported); + expect(reimported.safeParse('hello').isOk, isTrue); + expect(reimported.safeParse(42).isFail, isTrue); + }, + ); + + test( + 'assertFormats validates numeric formats on numbers and ignores them on strings', + () { + final int32Schema = Ack.fromJsonSchema({ + 'format': 'int32', + }, assertFormats: true); + expect(int32Schema.safeParse(2147483647).isOk, isTrue); + expect(int32Schema.safeParse(-2147483648).isOk, isTrue); + expect(int32Schema.safeParse(2147483648).isFail, isTrue); + expect(int32Schema.safeParse(1.5).isFail, isTrue); + expect(int32Schema.safeParse('not-a-number').isOk, isTrue); + + final uint64Schema = Ack.fromJsonSchema({ + 'format': 'uint64', + }, assertFormats: true); + expect(uint64Schema.safeParse(0).isOk, isTrue); + expect(uint64Schema.safeParse(-1).isFail, isTrue); + expect(uint64Schema.safeParse(18446744073709551616.0).isFail, isTrue); + + final floatSchema = Ack.fromJsonSchema({ + 'format': 'float', + }, assertFormats: true); + expect(floatSchema.safeParse(1.5).isOk, isTrue); + expect(floatSchema.safeParse(1e40).isFail, isTrue); + }, + ); + + test( + 'date-time format assertion enforces RFC 3339 leap-second calendar dates', + () { + final schema = Ack.fromJsonSchema({ + 'type': 'string', + 'format': 'date-time', + }, assertFormats: true); + + expect(schema.safeParse('1990-12-31T23:59:60Z').isOk, isTrue); + expect(schema.safeParse('1990-12-31T15:59:60-08:00').isOk, isTrue); + expect(schema.safeParse('2030-06-30T23:59:60Z').isFail, isTrue); + expect(schema.safeParse('2024-03-15T23:59:60Z').isFail, isTrue); + }, + ); + + test( + 'nullable imported root relocates root pointers inside referenced extension containers and ignores dormant malformed refs', + () { + final schema = Ack.fromJsonSchema({ + 'type': 'object', + 'properties': { + 'value': {'type': 'string'}, + 'viaExtension': {r'$ref': '#/x-ext/target'}, + }, + 'x-ext': { + 'target': {r'$ref': '#/properties/value'}, + }, + r'$defs': { + 'Dormant': {r'$ref': '#/%FF'}, + }, + }).nullable(); + + final exported = schema.toJsonSchema(); + final reimported = Ack.fromJsonSchema(exported); + expect(reimported.safeParse(null).isOk, isTrue); + expect( + reimported.safeParse({'value': 'a', 'viaExtension': 'b'}).isOk, + isTrue, + ); + expect( + reimported.safeParse({'value': 'a', 'viaExtension': 123}).isFail, + isTrue, + ); + }, + ); }); } diff --git a/packages/ack/test/schemas/extensions/string_transformers_test.dart b/packages/ack/test/schemas/extensions/string_transformers_test.dart index b3c6c810..655dcc47 100644 --- a/packages/ack/test/schemas/extensions/string_transformers_test.dart +++ b/packages/ack/test/schemas/extensions/string_transformers_test.dart @@ -372,7 +372,7 @@ void main() { }); test('exports clean JSON Schema without x-transformed', () { - final schema = Ack.string().trim().toLowerCase().minLength(2).email(); + final schema = Ack.string().minLength(2).email().trim().toLowerCase(); final jsonSchema = schema.toJsonSchema(); expect(jsonSchema['type'], 'string'); @@ -380,6 +380,41 @@ void main() { expect(jsonSchema['format'], 'email'); expect(jsonSchema.containsKey('x-transformed'), isFalse); }); + + test('omits post-normalization constraints from JSON Schema export', () { + final schema = Ack.string().minLength(1).trim().minLength(3).email(); + final model = schema.toSchemaModel(); + final jsonSchema = model.toJsonSchema(); + + expect(jsonSchema, {'type': 'string', 'minLength': 1}); + expect( + model.warnings, + contains( + isA() + .having( + (w) => w.code, + 'code', + 'codec_runtime_constraint_not_exported', + ) + .having((w) => w.context['constraints'], 'constraints', [ + 'string_min_length', + 'string_format_email', + ]), + ), + ); + }); + + test('copyWith clamps normalizer indices when constraints shrink', () { + final schema = Ack.string() + .minLength(2) + .refine((s) => s.isNotEmpty) + .trim() + .minLength(3) + .copyWith(constraints: const [], refinements: const []); + + expect(schema.parse(' a '), 'a'); + expect(schema.encode(' a '), 'a'); + }); }); }); } diff --git a/packages/ack_firebase_ai/CHANGELOG.md b/packages/ack_firebase_ai/CHANGELOG.md index af901055..7890114b 100644 --- a/packages/ack_firebase_ai/CHANGELOG.md +++ b/packages/ack_firebase_ai/CHANGELOG.md @@ -1,8 +1,8 @@ -## 2.0.0-beta.1 +## 2.0.0-alpha.1 ### Breaking -* Requires `ack: ^2.0.0-beta.1` and aligns with the coordinated Ack 2.0.0-beta.1 +* Requires `ack: ^2.0.0-alpha.1` and aligns with the coordinated Ack 2.0.0-alpha.1 release. ### Changed diff --git a/packages/ack_firebase_ai/README.md b/packages/ack_firebase_ai/README.md index c9c0cbf1..458a184c 100644 --- a/packages/ack_firebase_ai/README.md +++ b/packages/ack_firebase_ai/README.md @@ -23,8 +23,8 @@ Always validate model output with the same ACK schema after generation. Firebase ```yaml dependencies: - ack: ^2.0.0-beta.1 - ack_firebase_ai: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 + ack_firebase_ai: ^2.0.0-alpha.1 firebase_ai: ^3.12.2 ``` diff --git a/packages/ack_firebase_ai/pubspec.yaml b/packages/ack_firebase_ai/pubspec.yaml index fd8f2739..91f21d18 100644 --- a/packages/ack_firebase_ai/pubspec.yaml +++ b/packages/ack_firebase_ai/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_firebase_ai description: Firebase AI (Gemini) schema converter for the Ack validation library -version: 2.0.0-beta.1 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -17,7 +17,7 @@ environment: dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 flutter: sdk: flutter diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 3f4ae631..b65d3b97 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,18 +1,18 @@ -## 2.0.0-beta.1 +## 2.0.0-alpha.1 ### Breaking -* Requires `ack: ^2.0.0-beta.1`. +* Requires `ack: ^2.0.0-alpha.1`. * **Single-pass `.g.dart` builder (`#189`)**: `ack_generator:ack_generator` is now a `SharedPartBuilder` that emits `.g.dart` directly with generated `toJson()` / `fromJson()` helpers. The two-part `.ack.dart` and `.ack.g.dart` builders and runtime `json_serializable` delegation are removed. Replace `part '.ack.dart';` and `part '.ack.g.dart';` with a single `part '.g.dart';`. -* Remove legacy `@AckType`, `@AckInfer`, `@AckModel`, `*Type` extension-type - model generation, top-level `@Optional` / `@Required` / `@NotNull` / constraint - classes, and the legacy `@JsonKey(name: ...)` fallback in favor of - `@Schemable()`, `@Validate.*`, and `@AckField(name: ...)` (`#188`, `#189`, `#191`). +* Remove legacy `@AckType`, `@AckInfer`, `@AckModel`, `@AckField`, `*Type` + extension-type model generation, top-level `@Optional` / `@Required` / + `@NotNull` / constraint classes, and the legacy `@JsonKey(name: ...)` fallback + in favor of `@Schemable()`, `@Validate.*`, and `@AckKey('...')` (`#188`, `#189`, `#191`). ### Added @@ -23,14 +23,12 @@ `static final schema = _Schema;` on class-first models (`#191`). * Support `JsonMaybe` fields in `@Schemable()` class-first models for PATCH tri-state presence (`#148`). -* Support `@AckField(name: '...')` directly without `package:json_annotation` - (`#189`). +* Support `@AckKey('...')` directly without `package:json_annotation` (`#189`). * Support custom schema resolution on `@Schemable()` class fields via: - 1. `@AckField(schema: ...)` - 2. `@Schemable(schemas: [...])` on the class, enclosing sealed union, current + 1. `@Schemable(schemas: [...])` on the class, enclosing sealed union, current `library;` directive, or an imported `library;` directive - 3. Type-owned `T.schema` / `T.schema<...>()` - 4. Automatic detection of a unique top-level `AckSchema` + 2. Type-owned `T.schema` / `T.schema<...>()` + 3. Automatic detection of a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library (`#185`). * Support two-way `StringSchema` normalizers (`.trim()`, `.toLowerCase()`, `.toUpperCase()`) in schema-first `@Schemable()` models. diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index c7b1b914..abf20830 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -2,7 +2,7 @@ `ack_generator` supports two directions through `@Schemable()`: it turns a top-level Ack schema into an immutable model and derives an Ack -codec schema from a hand-written class. `@Schemable()`, `@AckField()`, and the +codec schema from a hand-written class. `@Schemable()`, `@AckKey()`, and the `@Validate.*` validation namespace are all exported from `package:ack/ack.dart`. @@ -178,11 +178,10 @@ void main() { A field of custom type `T` (including inside `List`, `Set`, `Map`, `JsonMaybe`, or generic type-owned schemas `Box`) resolves in priority order: -1. **`@AckField(schema: ...)`** on the field or constructor parameter; -2. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current +1. **`@Schemable(schemas: [...])`** on the class, enclosing union base, current `library;` directive, or an imported `library;` directive; -3. **Type-owned schema**: a static `T.schema` or `T.schema(...)` on the type; -4. **Automatic current-library detection**: a unique top-level +2. **Type-owned schema**: a static `T.schema` or `T.schema(...)` on the type; +3. **Automatic current-library detection**: a unique top-level `AckSchema` variable, getter, or zero-argument function in the current library. @@ -195,13 +194,14 @@ with a static `schema` or a `@Schemable(schemas: [...])` entry resolves to it instead of `Ack.enumValues`. A class may also expose its own generated codec with `static final schema = _accountSchema;` (or `AccountSchema.schema`). -Use `@Schemable(description: ...)` and `@AckField(description: ...)` for schema -descriptions. A single-line `@description` tag in a `///` or `/** */` doc -comment is a fallback. Only the text after the tag, on the same line, is exported. Untagged -prose never becomes schema data. Duplicate or blank tags fail generation. -Annotation text takes precedence over a tag. A class description applies to -both the object schema and model codec, including union bases and branches. -A field description applies at its property without changing a nested model. +Use `@Schemable(description: ...)` for class descriptions and a single-line +`@description` tag in a `///` or `/** */` doc comment on a class, field, or +constructor parameter for schema descriptions. Only the text after the tag, on +the same line, is exported. Untagged prose never becomes schema data. Duplicate +or blank tags fail generation. Annotation text on `@Schemable(description: ...)` +takes precedence over a class doc tag. A class description applies to both the +object schema and model codec, including union bases and branches. A field +description applies at its property without changing a nested model. The generator shares its type and constraint inference through `package:ack_generator/inference.dart`. A second generator can use `AckSchemaInference.inferType`, `applyConstraints`, and `applyDescription`. @@ -230,8 +230,7 @@ Generation rejects shapes without a useful static, encodable model contract: - one-way `.transform()` calls; use `.codec()` with an encoder; - nullable roots; - nullable `Ack.list` item schemas and automatically inferred `List` or - `Set` fields; make the collection nullable instead, or use an explicit - `@AckField(schema: ...)` codec for a different collection contract; + `Set` fields; make the collection or element type non-nullable; - `Ack.any()` and `Ack.map()` roots (both are supported as fields), `Ack.anyOf()`, and bare `Ack.instance()`; - anonymous inline object fields and unresolved dynamic schema factories; @@ -281,7 +280,7 @@ their concrete branches must also be final. Use `@Validate.optional()` or `@Validate.required()` to override inferred key presence, `@Validate.notNull()` to reject JSON `null` without requiring the key, `JsonMaybe` to distinguish omitted keys from explicit `null` on both parse and encode, and -`@AckField(name: 'wire_name')` to override a single field's JSON key. Validation +`@AckKey('wire_name')` to override a single field's JSON key. Validation annotations use `@Validate.*` (such as `@Validate.matches(pattern)` for `Ack.string().matches(pattern)`). See the [Model Code Generation guide](https://concepta.dev/documentation/ack/advanced/typesafe-schemas) diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 336cf99d..71b23021 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -259,7 +259,7 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { throw InvalidGenerationSource( '$path uses the JsonConverter ${type.getDisplayString()}, which ' 'would bypass Ack validation in the JSON part. Give the type a ' - 'static schema or use @AckField(schema: ...) instead.', + 'static schema or register one in @Schemable(schemas: [...]) instead.', element: owner, ); } @@ -726,18 +726,6 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { FormalParameterElement? parameter, FutureGeneratedType? futureType, }) async { - final override = _firstAnnotation( - ModelGraphBuilderBase.ackFieldChecker, - field, - parameter, - ); - if (override != null) { - final reader = ConstantReader(override); - if (!reader.read('schema').isNull) { - final base = await _customSchemas.escapeHatchExpression(field, reader); - return _applySugar(base, field, parameter); - } - } if (futureType != null) { _rejectNullableFutureCollectionElement(field, futureType.runtimeRef); final setListSchema = futureType.setListSchema; @@ -854,8 +842,7 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { throw InvalidGenerationSource( '${field.enclosingElement.name}.${field.name} uses nullable collection ' 'elements ($itemType). Ack.list does not support ' - 'nullable item schemas; make the element non-nullable or provide an ' - 'explicit @AckField(schema: ...) codec.', + 'nullable item schemas; make the element non-nullable.', element: field, ); } @@ -884,18 +871,9 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { FieldElement field, FormalParameterElement? parameter, ) { - final target = - ModelGraphBuilderBase.ackFieldChecker.hasAnnotationOfExact(field) - ? field - : (parameter != null && - ModelGraphBuilderBase.ackFieldChecker.hasAnnotationOfExact( - parameter, - ) - ? parameter - : field); return _inference.applyDescription( schema, - target, + field, sourceComment: _fieldDocumentationComment(field, parameter), ); } @@ -962,7 +940,7 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { if (type is InterfaceType) { throw InvalidGenerationSource( '$path uses unsupported $display. Declare a static schema on ' - '${type.element.name}, or set @AckField(schema: ...) on the field.', + '${type.element.name}, or register one in @Schemable(schemas: [...]).', element: field, ); } @@ -1105,24 +1083,6 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { ); } - final annotation = _firstAnnotation( - ModelGraphBuilderBase.ackFieldChecker, - field, - parameter, - ); - if (annotation != null) { - final reader = ConstantReader(annotation); - if (reader.read('name').isNull && - reader.read('schema').isNull && - reader.read('description').isNull) { - throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField() is a ' - 'no-op; set name, schema, or description.', - element: field, - ); - } - } - if (isJsonMaybe) { return hasRequired ? AckSchemaFieldPresence.required @@ -1372,30 +1332,30 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { if (parameter != null) { _rejectJsonKey(field.enclosingElement as ClassElement, parameter); } - final ackField = _firstAnnotation( - ModelGraphBuilderBase.ackFieldChecker, + final ackKey = _firstAnnotation( + ModelGraphBuilderBase.ackKeyChecker, field, parameter, ); - if (ackField == null) return null; - final nameReader = ConstantReader(ackField).read('name'); + if (ackKey == null) return null; + final nameReader = ConstantReader(ackKey).read('name'); if (nameReader.isNull) return null; - final ackFieldName = nameReader.stringValue; - if (ackFieldName.trim().isEmpty) { + final ackKeyName = nameReader.stringValue; + if (ackKeyName.trim().isEmpty) { throw InvalidGenerationSource( - '${field.enclosingElement.name}.${field.name} @AckField.name must ' + '${field.enclosingElement.name}.${field.name} @AckKey.name must ' 'not be blank.', element: field, ); } - return ackFieldName; + return ackKeyName; } void _rejectJsonKey(ClassElement owner, Element element) { if (!_jsonKeyChecker.hasAnnotationOfExact(element)) return; throw InvalidGenerationSource( '${owner.name}.${element.name} uses @JsonKey, which is not supported on ' - '${annotationName(owner)} classes in Ack 2.0. Use @AckField(name: ...) ' + '${annotationName(owner)} classes in Ack 2.0. Use @AckKey(...) ' 'instead.', element: element, ); diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart index e732b575..0b604e1c 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -21,9 +21,9 @@ typedef FutureGeneratedType = ({ String? setListSchema, }); -/// Resolves custom `@Schemable(schemas: ...)`, `@AckField(schema: ...)`, -/// auto-detected top-level schemas, type-owned static `.schema` declarations, -/// and future-generated schema-first types for [ClassModelGraphBuilder]. +/// Resolves custom `@Schemable(schemas: ...)`, auto-detected top-level schemas, +/// type-owned static `.schema` declarations, and future-generated schema-first +/// types for [ClassModelGraphBuilder]. final class CustomSchemaResolver { CustomSchemaResolver(this._base); @@ -210,8 +210,7 @@ final class CustomSchemaResolver { if (runtime is InterfaceType && isNonOverridableCoreType(runtime)) { throw InvalidGenerationSource( '$ownerLabel @Schemable(schemas: ...) cannot override core type ' - '${runtime.getDisplayString()}; use @AckField(schema: ' - '${function.name}) on the specific field instead.', + '${runtime.getDisplayString()}.', element: owner, ); } @@ -294,8 +293,7 @@ final class CustomSchemaResolver { .join(', '); throw InvalidGenerationSource( '$path has multiple imported library schemas for $display ($names). ' - 'Disambiguate with @Schemable(schemas: [...]) or ' - '@AckField(schema: ...).', + 'Disambiguate with @Schemable(schemas: [...]).', element: field, ); } @@ -340,8 +338,7 @@ final class CustomSchemaResolver { final names = matches.map((m) => m.declaration.name).join(', '); throw InvalidGenerationSource( '$path has multiple top-level schemas for $display in this library ' - '($names). Disambiguate with @Schemable(schemas: [...]) or ' - '@AckField(schema: ...).', + '($names). Disambiguate with @Schemable(schemas: [...]).', element: field, ); } @@ -400,71 +397,6 @@ final class CustomSchemaResolver { type.isDartCoreMap || _base.isJsonMaybeType(type); - Future escapeHatchExpression( - FieldElement field, - ConstantReader annotation, - ) async { - final function = annotation.read('schema').objectValue.toFunctionValue(); - final path = _fieldPath(field); - if (function is! TopLevelFunctionElement) { - throw InvalidGenerationSource( - '$path @AckField schema must be a const tear-off of a top-level ' - 'function.', - element: field, - ); - } - if (function.typeParameters.isNotEmpty) { - throw InvalidGenerationSource( - '$path @AckField schema function ${function.name} is generic, and the ' - 'generated call cannot keep its type arguments. Use a non-generic ' - 'function, or declare a static schema method on the field type.', - element: field, - ); - } - if (function.formalParameters.isNotEmpty || - !ModelGraphBuilderBase.ackSchemaChecker.isAssignableFromType( - function.returnType, - )) { - throw InvalidGenerationSource( - '$path @AckField top-level function must have type AckSchema ' - 'Function().', - element: field, - ); - } - final runtime = _base.ackSchemaRuntimeType(function.returnType); - final expectedFieldType = _base.unwrapJsonMaybeType(field.type); - if (runtime == null || - !_base.sameTypeIgnoringNullability(runtime, expectedFieldType)) { - throw InvalidGenerationSource( - '$path @AckField schema function ${function.name} produces ' - '${runtime?.getDisplayString() ?? 'an untyped value'}, but the field ' - 'type is ${expectedFieldType.getDisplayString()}.', - element: field, - todo: 'Return AckSchema<..., ${expectedFieldType.getDisplayString()}>.', - ); - } - final resolved = await _base.resolvedLibraryFor(function.library); - final bodyExpression = _base.declarationExpression(resolved, function); - if (bodyExpression == null) { - throw InvalidGenerationSource( - '$path @AckField function must have a statically resolvable ' - 'expression or single return.', - element: field, - ); - } - final oneWaySource = await _base.oneWayTransformSource(bodyExpression); - if (oneWaySource != null) { - throw InvalidGenerationSource( - '$path @AckField schema function ${function.name} reaches one-way ' - 'schema $oneWaySource. Migrate this .transform() path to .codec() ' - 'with an encoder.', - element: field, - ); - } - final prefix = _base.visiblePrefixFor(function, path: path, context: field); - return '${prefix == null ? '' : '$prefix.'}${function.name}()'; - } - DartType? inferredBoundaryType( DartType type, FieldElement field, { diff --git a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart index d44cc845..1dc8a126 100644 --- a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -63,8 +63,8 @@ abstract base class ModelGraphBuilderBase { annotations.Schemable, inPackage: 'ack', ); - static const ackFieldChecker = TypeChecker.typeNamed( - annotations.AckField, + static const ackKeyChecker = TypeChecker.typeNamed( + annotations.AckKey, inPackage: 'ack', ); static const ackSchemaChecker = TypeChecker.typeNamed( diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 1b71150b..1c5e1e30 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -10,7 +10,8 @@ import 'package:source_gen/source_gen.dart'; import '../utils/doc_comment_utils.dart'; import '../utils/string_literal.dart'; -/// Infers Ack schema expressions from resolved Dart types and annotations. +/// Inference helper that builds Ack schema expressions from resolved Dart +/// types and annotations. /// /// The caller resolves application model types and import prefixes. Ack owns /// scalar, collection, and constraint inference for each generator. @@ -23,16 +24,12 @@ final class AckSchemaInference { annotations.Validate, inPackage: 'ack', ); - static const _ackField = TypeChecker.typeNamed( - annotations.AckField, - inPackage: 'ack', - ); /// Infers a schema expression for [type]. /// /// [resolveNamed] returns a schema expression for an application type, - /// including an enum that declares its own schema. Return null when the type - /// has no supported model contract; an enum then uses `Ack.enumValues`. + /// including an enum that declares its own schema. Return `null` when the + /// type has no supported model contract; an enum then uses `Ack.enumValues`. Future inferType( DartType type, { required String Function(InterfaceType) visibleTypeName, @@ -370,7 +367,7 @@ final class AckSchemaInference { return '$schema$modifiers'; } - /// Adds an explicit Ack description or a tagged documentation description. + /// Adds a tagged documentation description (`/// @description ...`) to [schema]. /// /// Analyzer does not attach parameter doc comments to parameter elements. /// A function generator passes the comment from its source AST. @@ -379,9 +376,9 @@ final class AckSchemaInference { Element declaration, { String? sourceComment, }) { - final String? tag; + final String? description; try { - tag = parseDescriptionTag( + description = parseDescriptionTag( sourceComment ?? declaration.documentationComment, ); } on FormatException catch (error) { @@ -390,18 +387,6 @@ final class AckSchemaInference { element: declaration, ); } - final annotation = _ackField.firstAnnotationOfExact(declaration); - final value = annotation == null - ? null - : ConstantReader(annotation).read('description'); - final explicit = value == null || value.isNull ? null : value.stringValue; - if (explicit != null && explicit.trim().isEmpty) { - throw InvalidGenerationSource( - '${declaration.name} @AckField.description must not be blank.', - element: declaration, - ); - } - final description = explicit ?? tag; return description == null ? schema : '$schema.describe(${dartStringLiteral(description)})'; diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart b/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart index b89acb01..88432453 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_generation_exception.dart @@ -13,7 +13,7 @@ final class AckJsonSchemaDiagnostic { String toString() => '$pointer: $message'; } -/// Thrown when a JSON Schema document cannot generate Ack models. +/// Exception thrown when a JSON Schema document cannot generate Ack models. final class AckJsonSchemaGenerationException implements Exception { /// Creates an exception for [documentUri] with every [diagnostics] entry. AckJsonSchemaGenerationException(this.documentUri, this.diagnostics) diff --git a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart index f39fa101..c17ed4d8 100644 --- a/packages/ack_generator/lib/src/json_schema/json_schema_library.dart +++ b/packages/ack_generator/lib/src/json_schema/json_schema_library.dart @@ -10,10 +10,11 @@ import 'json_schema_generation_exception.dart'; /// How generated models treat properties a JSON Schema object doesn't declare. enum AckUnknownPropertiesMode { - /// Follow the schema: `additionalProperties: false` rejects unknown keys. + /// Follows the schema, where `additionalProperties: false` rejects unknown + /// keys. schema, - /// Accept and keep unknown keys on every object, for clients that must + /// Accepts and keeps unknown keys on every object, for clients that must /// tolerate additive server changes to strict schemas. preserve, } @@ -100,11 +101,11 @@ final class AckJsonSchemaLibraryOptions { /// URI and stored at package-local asset paths. final Map documentPaths; - /// Assert known formats even under the standard 2020-12 dialect. + /// Whether to assert known formats even under the standard 2020-12 dialect. final bool assertFormats; - /// `auto` retains typed v1 models where possible; `validated` uses exact - /// source-schema value wrappers for every model. + /// Mode for generated models (`auto` retains typed models where possible; + /// `validated` uses exact source-schema value wrappers for every model). final AckJsonSchemaModelMode modelMode; } @@ -387,14 +388,20 @@ final class _Translator { return diagnostics.isEmpty ? effective : null; } - /// Sets `additionalProperties: false` to `true` on every schema, walking - /// only keywords whose values are schemas. + /// Sets `additionalProperties: false` and `unevaluatedProperties: false` to + /// `true` on every schema, walking only keywords whose values are schemas. void _widen(Object? node, String pointer) { if (node is! Map) return; + var didWiden = false; if (node['additionalProperties'] == false) { node['additionalProperties'] = true; - widened.add(pointer); + didWiden = true; } + if (node['unevaluatedProperties'] == false) { + node['unevaluatedProperties'] = true; + didWiden = true; + } + if (didWiden) widened.add(pointer); for (final keyword in const [ r'$defs', 'definitions', @@ -518,7 +525,7 @@ final class _Translator { _fail(pointer, 'Keywords next to "\$ref" are not supported.'); return null; } - return _ref(node[r'$ref'], '$pointer/\$ref', owner); + return _ref(node[r'$ref'], '$pointer/\$ref', owner, describe); } for (final keyword in ['allOf', 'oneOf', 'not', 'if', 'then', 'else']) { if (keys.contains(keyword)) { @@ -593,7 +600,7 @@ final class _Translator { } final code = switch (single) { 'string' => _string(node, pointer), - 'integer' => _integer(node), + 'integer' => _integer(node, pointer), 'number' => _number(node), 'boolean' => 'ack.Ack.boolean()', _ => _array(node, pointer, owner, hoistName), @@ -602,7 +609,12 @@ final class _Translator { return _Expr(describe(code), nullable: nullable); } - _Expr? _ref(Object? ref, String pointer, String owner) { + _Expr? _ref( + Object? ref, + String pointer, + String owner, + String Function(String) describe, + ) { final match = ref is String ? RegExp(r'^#/\$defs/([A-Za-z0-9]+)$').firstMatch(ref) : null; @@ -616,7 +628,7 @@ final class _Translator { return null; } _edge(owner, name, pointer); - return _Expr(_variable(name), reference: name); + return _Expr(describe(_variable(name)), reference: name); } _Expr? _anyOf( @@ -671,7 +683,7 @@ final class _Translator { ); if (inner == null) return null; return _Expr( - inner.reference == null ? describe(inner.code) : inner.code, + describe(inner.code), nullable: nullable || inner.nullable, reference: inner.reference, ); @@ -799,12 +811,18 @@ final class _Translator { return _Expr(describe('ack.Ack.literal(${dartStringLiteral(value)})')); } + static bool _fitsInInt64(num value) => + value is int || + (value.isFinite && + value >= -9223372036854775808.0 && + value < 9223372036854775808.0); + int? _sizeBound(Map node, String keyword, String pointer) { final value = node[keyword]; if (value == null) return null; // The importer gate has already checked that the bound is integral. // Guard conversion as toInt() clamps values outside the integer range. - if (value is num && value.isFinite && value.toInt() == value) { + if (value is num && _fitsInInt64(value) && value.toInt() == value) { return value.toInt(); } _fail( @@ -831,23 +849,53 @@ final class _Translator { return buffer.toString(); } - String _integer(Map node) { + String? _integer(Map node, String pointer) { final buffer = StringBuffer('ack.Ack.integer()'); int? integral(Object? value) => - value is num && value == value.truncate() ? value.toInt() : null; + value is num && _fitsInInt64(value) && value == value.truncate() + ? value.toInt() + : null; if (node['minimum'] case final num n) { + if (!_fitsInInt64(n)) { + _fail( + '$pointer/minimum', + 'Bound cannot be represented as a Dart integer.', + ); + return null; + } buffer.write('.min(${integral(n) ?? n.ceil()})'); } if (node['exclusiveMinimum'] case final num n) { + if (!_fitsInInt64(n)) { + _fail( + '$pointer/exclusiveMinimum', + 'Bound cannot be represented as a Dart integer.', + ); + return null; + } final exact = integral(n); buffer.write( exact == null ? '.min(${n.ceil()})' : '.greaterThan($exact)', ); } if (node['maximum'] case final num n) { + if (!_fitsInInt64(n)) { + _fail( + '$pointer/maximum', + 'Bound cannot be represented as a Dart integer.', + ); + return null; + } buffer.write('.max(${integral(n) ?? n.floor()})'); } if (node['exclusiveMaximum'] case final num n) { + if (!_fitsInInt64(n)) { + _fail( + '$pointer/exclusiveMaximum', + 'Bound cannot be represented as a Dart integer.', + ); + return null; + } final exact = integral(n); buffer.write(exact == null ? '.max(${n.floor()})' : '.lessThan($exact)'); } @@ -1179,6 +1227,7 @@ String _emitFallback( ..writeln("import 'dart:convert';") ..writeln("import 'package:ack/ack.dart' as ack;") ..writeln() + ..writeln('// dart format off') ..writeln('final Object _jsonSchemaDocument =') ..writeln( ' jsonDecode(${dartStringLiteral(jsonEncode(document))}) as Object;', @@ -1193,6 +1242,7 @@ String _emitFallback( } buffer ..writeln('};') + ..writeln('// dart format on') ..writeln(); for (final (name, definition) in models) { final variable = _variable(name); @@ -1257,8 +1307,17 @@ String _commentText(String value) => value .replaceAll('\u2028', r'\u2028') .replaceAll('\u2029', r'\u2029'); -String _num(num value) => - value is int || value != value.truncate() ? '$value' : '${value.toInt()}'; +String _num(num value) { + if (value is int) { + return value.abs() <= 9007199254740992 ? '$value' : '${value.toDouble()}'; + } + if (value.isFinite && + value.abs() <= 9007199254740992.0 && + value == value.truncate()) { + return '${value.toInt()}'; + } + return '$value'; +} String _token(String key) => key.replaceAll('~', '~0').replaceAll('/', '~1'); diff --git a/packages/ack_generator/pubspec.yaml b/packages/ack_generator/pubspec.yaml index 5a0e7a31..d8b3c5d0 100644 --- a/packages/ack_generator/pubspec.yaml +++ b/packages/ack_generator/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_generator description: Generates immutable models from Ack schemas and validated schemas from Dart classes -version: 2.0.0-beta.1 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -24,7 +24,7 @@ dependencies: pub_semver: ^2.1.4 # Ack packages (versions are overridden locally by Melos) - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 dev_dependencies: build_runner: ^2.1.7 diff --git a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart index 5acfa9e1..1253a3ef 100644 --- a/packages/ack_generator/test/integration/class_first_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_runtime_build_test.dart @@ -177,16 +177,7 @@ AckSchema alphaItemSchema() => Ack.string() AckSchema betaItemSchema() => Ack.integer() .codec(decode: beta.Item.new, encode: (item) => item.value); -AckSchema, Map>> groupsSchema() => - Ack.object({}, additionalProperties: true) - .codec>>( - decode: (value) => value.map( - (key, item) => MapEntry(key, (item! as List).cast()), - ), - encode: (value) => value, - ); - -@Schemable() +@Schemable(schemas: [colorSchema]) final class Profile with _$ProfileAck { const Profile({ required this.name, @@ -203,7 +194,6 @@ final class Profile with _$ProfileAck { final String role; @Validate.uniqueItems() final Set tags; - @AckField(schema: colorSchema) final Color color; static final fromJson = ProfileSchema.fromJson; @@ -213,7 +203,7 @@ final class Profile with _$ProfileAck { final class Account with _$AccountAck { const Account({required this.firstName, required this.imageUrl}); final String firstName; - @AckField(name: 'avatar') + @AckKey('avatar') final Uri imageUrl; static final fromJson = AccountSchema.fromJson; @@ -271,12 +261,10 @@ final class NullDefault with _$NullDefaultAck { final String? label; } -@Schemable() +@Schemable(schemas: [alphaItemSchema, betaItemSchema]) final class ImportedPair with _$ImportedPairAck { const ImportedPair({required this.left, required this.right}); - @AckField(schema: alphaItemSchema) final alpha.Item left; - @AckField(schema: betaItemSchema) final beta.Item right; } @@ -291,7 +279,6 @@ final class ImmutableCollections with _$ImmutableCollectionsAck { final List> matrix; @Validate.uniqueItems() final Set labels; - @AckField(schema: groupsSchema) final Map> groups; } @@ -334,15 +321,29 @@ final class InferredNotNull with _$InferredNotNullAck { final String? label; } -AckSchema nullableNameSchema() => Ack.string().nullable(); +final class NameTag { + const NameTag(this.value); + final String value; + + @override + bool operator ==(Object other) => other is NameTag && other.value == value; -@Schemable() + @override + int get hashCode => value.hashCode; +} + +AckSchema nullableNameSchema() => + Ack.string().nullable().codec( + decode: NameTag.new, + encode: (value) => value.value, + ); + +@Schemable(schemas: [nullableNameSchema]) final class OverrideNotNull with _$OverrideNotNullAck { const OverrideNotNull({this.name}); @Validate.notNull() - @AckField(schema: nullableNameSchema) - final String? name; + final NameTag? name; } @Schemable() @@ -359,19 +360,13 @@ final class ParameterEntry { final String value; } -AckSchema, Map> -parameterMapSchema() => - Ack.object({}, additionalProperties: true).codec>( - decode: (value) => { - for (final entry in value.entries) - entry.key: ParameterEntry(entry.value! as String), - }, - encode: (value) => { - for (final entry in value.entries) entry.key: entry.value.value, - }, +AckSchema parameterEntrySchema() => + Ack.string().codec( + decode: ParameterEntry.new, + encode: (value) => value.value, ); -@Schemable() +@Schemable(schemas: [parameterEntrySchema]) final class CapabilityBinding with _$CapabilityBindingAck { CapabilityBinding({ required this.name, @@ -381,15 +376,11 @@ final class CapabilityBinding with _$CapabilityBindingAck { final String name; @Validate.optional() - @AckField(schema: parameterMapSchema) final Map parameters; static final fromJson = CapabilityBindingSchema.fromJson; } -AckSchema, Map> scoresSchema() => - Ack.map(Ack.integer().min(0)); - @Schemable() final class Envelope with _$EnvelopeAck { const Envelope({ @@ -414,7 +405,6 @@ final class Envelope with _$EnvelopeAck { final Map values; final Map metadata; final Map labels; - @AckField(schema: scoresSchema) final Map scores; } @@ -508,8 +498,7 @@ final class Board with _$BoardAck { const Board({required this.focus, required this.backlog, this.pinned}); /// The task to do next. - /// @description This tag has lower priority. - @AckField(description: 'The task to do next.') + /// @description The task to do next. final Task focus; final List backlog; final Task? pinned; @@ -876,9 +865,12 @@ void main() { expect(InferredNotNull().toJson(), {}); }); - test('NotNull wins over a nullable AckField schema override', () { + test('NotNull wins over a nullable custom schema', () { expect(OverrideNotNullSchema.parse({}).name, isNull); - expect(OverrideNotNullSchema.parse({'name': 'ada'}).name, 'ada'); + expect( + OverrideNotNullSchema.parse({'name': 'ada'}).name, + const NameTag('ada'), + ); expect(OverrideNotNullSchema.safeParse({'name': null}).isFail, isTrue); expect(OverrideNotNull().toJson(), {}); }); @@ -994,7 +986,7 @@ void main() { 'labels': {'env': 1}, }, { - 'scores': {'a': -1}, + 'scores': {'a': 'not-an-int'}, }, ]) { test('Envelope rejects invalid field values: $invalid', () { diff --git a/packages/ack_generator/test/integration/class_model_emitter_test.dart b/packages/ack_generator/test/integration/class_model_emitter_test.dart index 6faf7615..61f4a98c 100644 --- a/packages/ack_generator/test/integration/class_model_emitter_test.dart +++ b/packages/ack_generator/test/integration/class_model_emitter_test.dart @@ -81,33 +81,6 @@ final class Contact with _\$ContactAck { }, ); - test('an Ack.map schema function backs a Map field', () async { - await _build( - { - 'scores.dart': - ''' -$_imports -part 'scores.g.dart'; - -MapSchema scoresSchema() => Ack.map(Ack.integer().min(0)); - -@Schemable() -final class Board with _\$BoardAck { - const Board({required this.scores}); - - @AckField(schema: scoresSchema) - final Map scores; -} -''', - }, - outputs: { - 'test_pkg|lib/scores.g.dart': decodedMatches( - _containsCode("Ack.object({'scores': scoresSchema()})"), - ), - }, - ); - }); - test( 'collection constraints on a nested Set land on the outer set', () async { @@ -316,7 +289,7 @@ part 'escaped.g.dart'; final class Escaped with _\$EscapedAck { const Escaped({required this.value}); - @AckField(description: 'A\\tB') + /// @description A\tB final String value; } ''', @@ -615,13 +588,11 @@ final class Profile with _\$ProfileAck { }, ); - test( - 'emits escape-hatch schemas and built-in recursive type coverage', - () async { - await _build( - { - 'types.dart': - ''' + test('emits custom schemas and built-in recursive type coverage', () async { + await _build( + { + 'types.dart': + ''' $_imports part 'types.g.dart'; @@ -635,15 +606,9 @@ AckSchema colorSchema() => Ack.string().codec( encode: (color) => color.value, ).describe('Authored color.'); -AckSchema, Map> scoresSchema() => - Ack.object({}, additionalProperties: true).codec>( - decode: (value) => value.map((key, item) => MapEntry(key, item as int)), - encode: (value) => value, - ); - enum Role { admin, member } -@Schemable() +@Schemable(schemas: [colorSchema]) final class Record with _\$RecordAck { const Record({ required this.color, @@ -656,9 +621,7 @@ final class Record with _\$RecordAck { }); /// Untagged field prose does not replace the authored description. - @AckField(schema: colorSchema) final Color color; - @AckField(schema: scoresSchema) final Map scores; final Role role; final DateTime createdAt; @@ -667,29 +630,28 @@ final class Record with _\$RecordAck { final List> names; } ''', - }, - outputs: { - 'test_pkg|lib/types.g.dart': decodedMatches( - allOf([ - contains("'color': colorSchema()"), - isNot(contains("'color': colorSchema().describe(")), - contains("'scores': scoresSchema()"), - contains("'role': Ack.enumValues(Role.values)"), - contains("'createdAt': Ack.datetime()"), - contains("'website': Ack.uri()"), - contains("'timeout': Ack.duration()"), - contains("'names': Ack.list(Ack.list(Ack.string()))"), - contains('Map.unmodifiable('), - contains('List>.unmodifiable('), - contains('List.unmodifiable('), - ]), - ), - }, - ); - }, - ); + }, + outputs: { + 'test_pkg|lib/types.g.dart': decodedMatches( + allOf([ + contains("'color': colorSchema()"), + isNot(contains("'color': colorSchema().describe(")), + contains("'scores': Ack.map(Ack.integer())"), + contains("'role': Ack.enumValues(Role.values)"), + contains("'createdAt': Ack.datetime()"), + contains("'website': Ack.uri()"), + contains("'timeout': Ack.duration()"), + contains("'names': Ack.list(Ack.list(Ack.string()))"), + contains('Map.unmodifiable('), + contains('List>.unmodifiable('), + contains('List.unmodifiable('), + ]), + ), + }, + ); + }); - test('computes case-style and AckField(name:) schema keys once', () async { + test('computes case-style and AckKey schema keys once', () async { await _build( { 'account.dart': @@ -701,13 +663,13 @@ part 'account.g.dart'; final class Account with _\$AccountAck { const Account({ required this.firstName, - @AckField(name: 'avatar') required this.imageUrl, + @AckKey('avatar') required this.imageUrl, required this.handleName, }); final String firstName; final String imageUrl; - @AckField(name: 'handle') + @AckKey('handle') final String handleName; } ''', @@ -920,15 +882,22 @@ final class InferredNotNull with _\$InferredNotNullAck { final String? label; } -AckSchema nullableNameSchema() => Ack.string().nullable(); +final class NameTag { + const NameTag(this.value); + final String value; +} -@Schemable() +AckSchema nullableNameSchema() => Ack.string().nullable().codec( + decode: NameTag.new, + encode: (value) => value.value, +); + +@Schemable(schemas: [nullableNameSchema]) final class OverrideNotNull with _\$OverrideNotNullAck { const OverrideNotNull({this.name}); @Validate.notNull() - @AckField(schema: nullableNameSchema) - final String? name; + final NameTag? name; } @Schemable() @@ -953,20 +922,13 @@ final class ParameterEntry { final String value; } -AckSchema, Map> -parameterMapSchema() => - Ack.object({}, additionalProperties: true) - .codec>( - decode: (value) => { - for (final entry in value.entries) - entry.key: ParameterEntry(entry.value! as String), - }, - encode: (value) => { - for (final entry in value.entries) entry.key: entry.value.value, - }, - ); +AckSchema parameterEntrySchema() => + Ack.string().codec( + decode: ParameterEntry.new, + encode: (value) => value.value, + ); -@Schemable() +@Schemable(schemas: [parameterEntrySchema]) final class CapabilityBinding with _\$CapabilityBindingAck { CapabilityBinding({ required this.name, @@ -976,7 +938,6 @@ final class CapabilityBinding with _\$CapabilityBindingAck { final String name; @Validate.optional() - @AckField(schema: parameterMapSchema) final Map parameters; } @@ -1003,8 +964,14 @@ final class CapabilityBinding with _\$CapabilityBindingAck { contains("'value': Ack.string().nullable(value: false)"), isNot(contains("'value': Ack.string().optional()")), contains("'summary': Ack.string().nullable()"), - contains("'parameters': parameterMapSchema().optional()"), - isNot(contains('parameterMapSchema().optional().nullable(')), + contains( + "'parameters': Ack.map(parameterEntrySchema()).optional()", + ), + isNot( + contains( + 'Ack.map(parameterEntrySchema()).optional().nullable(', + ), + ), contains('Map? parameters'), contains('parameters: parameters ?? _source.parameters'), isNot(contains('parameters as Map?')), diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 8645aab7..7442ff92 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -74,7 +74,7 @@ final class Link with _\$LinkAck { [ 'Link.target uses the JsonConverter UriText, which would bypass Ack ' 'validation in the JSON part. Give the type a static schema or ' - 'use @AckField(schema: ...) instead.', + 'register one in @Schemable(schemas: [...]) instead.', ], ); await _expectFailure( @@ -91,7 +91,7 @@ final class Link with _\$LinkAck { [ 'Link uses the JsonConverter UriText, which would bypass Ack ' 'validation in the JSON part. Give the type a static schema or ' - 'use @AckField(schema: ...) instead.', + 'register one in @Schemable(schemas: [...]) instead.', ], ); }); @@ -235,17 +235,6 @@ final class Item with _\$ItemAck { ''', ['Item @Schemable.description must not be blank.'], ); - await _expectFailure( - ''' -@Schemable() -final class Item with _\$ItemAck { - const Item({required this.value}); - @AckField(description: '') - final String value; -} -''', - ['value @AckField.description must not be blank.'], - ); }); test('rejects duplicate class description tags at the class', () async { @@ -356,130 +345,6 @@ final class User with _\$UserAck { }, ); - test('rejects a static-method AckField escape hatch', () async { - await _expectFailure( - ''' -final class Schemas { - static AckSchema name() => Ack.string(); -} - -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(schema: Schemas.name) - final String name; -} -''', - ['@AckField', 'top-level', 'name'], - ); - }); - - test( - 'rejects an AckField schema whose runtime type is not the field type', - () async { - await _expectFailure( - ''' -AckSchema countSchema() => Ack.integer(); - -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(schema: countSchema) - final String name; -} -''', - [ - 'User.name @AckField schema function countSchema produces int, but ' - 'the field type is String.', - ], - ); - }, - ); - - test('rejects a generic AckField schema function', () async { - await _expectFailure( - ''' -final class Box { - const Box(this.value); - final T value; -} - -AckSchema> boxSchema() => - Ack.any().codec>( - decode: (value) => Box(value as T), - encode: (box) => box.value, - ); - -@Schemable() -final class User with _\$UserAck { - const User({required this.box}); - - @AckField(schema: boxSchema) - final Box box; -} -''', - [ - 'User.box @AckField schema function boxSchema is generic, and the ' - 'generated call cannot keep its type arguments.', - ], - ); - }); - - test('rejects an AckField function that does not return AckSchema', () async { - await _expectFailure( - ''' -String nameSchema() => 'not a schema'; - -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(schema: nameSchema) - final String name; -} -''', - ['@AckField', 'AckSchema', 'name'], - ); - }); - - test('rejects a one-way transform returned by AckField', () async { - await _expectFailure( - ''' -AckSchema normalizedSchema() => - Ack.string().transform((v) => v.trim()); - -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(schema: normalizedSchema) - final String name; -} -''', - ['User.name', 'normalizedSchema', '.transform()'], - ); - }); - - test('rejects a referenced one-way transform returned by AckField', () async { - await _expectFailure( - ''' -final normalized = Ack.string().transform((v) => v.trim()); -AckSchema normalizedSchema() => normalized; - -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(schema: normalizedSchema) - final String name; -} -''', - ['User.name', 'normalizedSchema', 'normalized', '.transform()'], - ); - }); - test('rejects dynamic map values and points to Object?', () async { await _expectFailure( ''' @@ -822,7 +687,7 @@ final class User with _\$UserAck { ''', [ 'User.name', - 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckField(name: ...) instead.', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckKey(...) instead.', ], ); }); @@ -839,7 +704,7 @@ final class User with _\$UserAck { ''', [ 'User.name', - 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckField(name: ...) instead.', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckKey(...) instead.', ], ); }); @@ -1234,58 +1099,40 @@ final class User { ); }); - test('rejects a no-op AckField', () async { + test('rejects blank @AckKey(...)', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { const User({required this.name}); - @AckField() + @AckKey(' ') final String name; } ''', - ['User.name', '@AckField()', 'no-op'], + ['User.name', '@AckKey.name must not be blank.'], ); }); - test('rejects blank @AckField(name: ...)', () async { + test('rejects combining @AckKey(...) and @JsonKey(name: ...)', () async { await _expectFailure( ''' @Schemable() final class User with _\$UserAck { const User({required this.name}); - @AckField(name: ' ') + @AckKey('a') + @JsonKey(name: 'b') final String name; } ''', - ['User.name', '@AckField.name must not be blank.'], + [ + 'User.name', + 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckKey(...) instead.', + ], ); }); - test( - 'rejects combining @AckField(name: ...) and @JsonKey(name: ...)', - () async { - await _expectFailure( - ''' -@Schemable() -final class User with _\$UserAck { - const User({required this.name}); - - @AckField(name: 'a') - @JsonKey(name: 'b') - final String name; -} -''', - [ - 'User.name', - 'uses @JsonKey, which is not supported on @Schemable classes in Ack 2.0. Use @AckField(name: ...) instead.', - ], - ); - }, - ); - test( 'rejects @Validate.optional() on a required constructor parameter', () async { @@ -1449,7 +1296,7 @@ final addressSchema = Ack.object({'city': Ack.string()}); @Schemable() final class Order with _\$OrderAck { const Order({ - @AckField(name: 'shipping_address') required this.shipping, + @AckKey('shipping_address') required this.shipping, @Validate.minItems(1) this.history = const [], }); diff --git a/packages/ack_generator/test/integration/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index 18b93058..8627763d 100644 --- a/packages/ack_generator/test/integration/inference_library_test.dart +++ b/packages/ack_generator/test/integration/inference_library_test.dart @@ -76,33 +76,6 @@ void widget({ ); }); - test('an explicit parameter description overrides its source tag', () async { - final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); - await readerWriter.testing.loadIsolateSources(); - await testBuilder( - const _InferenceProbe( - sourceComment: '/// @description Tag text.\n/// Following prose.', - ), - { - 'test_pkg|lib/widget.dart': ''' -import 'package:ack/annotations.dart'; - -void widget({ - @AckField(description: 'Explicit text.') - required String title, -}) {} -''', - }, - generateFor: const {'test_pkg|lib/widget.dart'}, - readerWriter: readerWriter, - outputs: { - 'test_pkg|lib/widget.probe': decodedMatches( - "Ack.string().describe('Explicit text.')", - ), - }, - ); - }); - test('a second generator infers a list of Dart enums', () async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); await readerWriter.testing.loadIsolateSources(); diff --git a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart index 6f2174b7..1c560b26 100644 --- a/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/json_schema_runtime_build_test.dart @@ -165,7 +165,7 @@ void main() { expect(Node.encode(node), tree); expect(node.toJson(), tree); expect(Node.safeParse({'value': 1, 'next': null}).isFail, isTrue); - expect(Node.safeParse({'value': 1, 'extra': true}).isFail, isTrue); + expect(Node.safeParse({'value': 1, 'extra': true}).isOk, isTrue); expect(Choice.safeParse('a@example.test').isOk, isTrue); expect(Choice.safeParse('not-an-email').isFail, isTrue); expect(Choice.safeParse(1.5).isOk, isTrue); diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index 86be83ea..523e6e93 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -481,7 +481,7 @@ final class Color { contains( contains( 'Section.color uses unsupported Color. Declare a static schema on ' - 'Color, or set @AckField(schema: ...) on the field.', + 'Color, or register one in @Schemable(schemas: [...]).', ), ), ); @@ -834,7 +834,7 @@ final class Color { contains( 'Theme.primary has multiple top-level schemas for Color in this ' 'library (hexColorSchema, namedColorSchema). Disambiguate with ' - '@Schemable(schemas: [...]) or @AckField(schema: ...).', + '@Schemable(schemas: [...]).', ), ), ); @@ -1012,9 +1012,7 @@ final class Post with _$PostAck { coreErrors, contains( contains( - 'Post @Schemable(schemas: ...) cannot override core type String; ' - 'use @AckField(schema: customStringSchema) on the specific field ' - 'instead.', + 'Post @Schemable(schemas: ...) cannot override core type String.', ), ), ); diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart index 691df3d6..f73c6df9 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping.schema.dart @@ -10,15 +10,13 @@ import 'dart:convert'; import 'package:ack/ack.dart' as ack; +// dart format off final Object _jsonSchemaDocument = - jsonDecode( - '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":false},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', - ) - as Object; + jsonDecode('{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":false},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":false},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}') as Object; final Map _jsonSchemaDocuments = { - Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): - _jsonSchemaDocument, + Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): _jsonSchemaDocument, }; +// dart format on final listingSchema = ack.Ack.fromJsonSchema( { diff --git a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart index 52bb94e3..c61b7d16 100644 --- a/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart +++ b/packages/ack_generator/test/json_schema/fixtures/mapping_preserve.schema.dart @@ -13,15 +13,13 @@ import 'dart:convert'; import 'package:ack/ack.dart' as ack; +// dart format off final Object _jsonSchemaDocument = - jsonDecode( - '{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":true},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":true},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}', - ) - as Object; + jsonDecode('{"\$schema":"https://json-schema.org/draft/2020-12/schema","\$defs":{"Listing":{"type":"object","description":"A listing.\\nWith a second line.","properties":{"key":{"type":"string","minLength":2,"maxLength":8,"description":"The key, with \'quotes\', \$dollars, and \\\\backslashes\\\\."},"code":{"type":"string","pattern":"^[A-Z]{2}-\\\\d+\$","examples":["AB-12"]},"rooms":{"type":"integer","minimum":1,"maximum":9},"floors":{"type":"integer","exclusiveMinimum":0,"exclusiveMaximum":4.5},"price":{"type":"number","minimum":0,"exclusiveMaximum":1000000.5},"active":{"type":"boolean"},"status":{"enum":["Active","Closed","Active",null]},"kind":{"const":"residential"},"tags":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":3,"uniqueItems":true},"notes":{"anyOf":[{"type":"string"},{"type":"null"}]},"nickname":{"type":["string","null"]},"streetNumber":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"number"}]},{"type":"null"}]},"extra":{},"labels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer"}},"meta":{"type":"object","additionalProperties":true},"address":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"],"additionalProperties":true},"photo":{"\$ref":"#/\$defs/Photo"},"cover":{"anyOf":[{"\$ref":"#/\$defs/Photo"},{"type":"null"}]},"photos":{"type":"array","items":{"\$ref":"#/\$defs/Photo"}}},"required":["key","rooms","active"],"additionalProperties":true},"Photo":{"type":"object","properties":{"url":{"type":"string"},"width":{"type":"integer"}},"required":["url"]}}}') as Object; final Map _jsonSchemaDocuments = { - Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): - _jsonSchemaDocument, + Uri.parse('package:ack_generator_fixtures/mapping.schema.json'): _jsonSchemaDocument, }; +// dart format on final listingSchema = ack.Ack.fromJsonSchema( { diff --git a/packages/ack_generator/test/json_schema/json_schema_library_test.dart b/packages/ack_generator/test/json_schema/json_schema_library_test.dart index 38eca476..4c6cc0eb 100644 --- a/packages/ack_generator/test/json_schema/json_schema_library_test.dart +++ b/packages/ack_generator/test/json_schema/json_schema_library_test.dart @@ -86,6 +86,10 @@ void main() { {'type': 'number', 'minimum': 0, 'exclusiveMaximum': 1.5}, 'ack.Ack.number().min(0).lessThan(1.5)', ), + 'a number with a bound beyond 2^53': ( + {'type': 'number', 'maximum': 1e20}, + 'ack.Ack.number().max(100000000000000000000.0)', + ), 'a boolean': ({'type': 'boolean'}, 'ack.Ack.boolean()'), 'a deduped enum with null': ( { @@ -205,6 +209,17 @@ void main() { 'type': 'object', 'properties': { 'photo': {r'$ref': r'#/$defs/Photo'}, + 'describedPhoto': { + r'$ref': r'#/$defs/Photo', + 'description': 'Primary photo', + }, + 'nullableDescribedPhoto': { + 'description': 'Optional backup photo', + 'anyOf': [ + {r'$ref': r'#/$defs/Photo'}, + {'type': 'null'}, + ], + }, }, }, 'Photo': {'type': 'object', 'properties': {}}, @@ -212,6 +227,18 @@ void main() { }).source, ); expect(source, contains("'photo':photoSchema.optional()")); + expect( + source, + contains( + "'describedPhoto':photoSchema.describe('Primaryphoto').optional()", + ), + ); + expect( + source, + contains( + "'nullableDescribedPhoto':photoSchema.describe('Optionalbackupphoto').nullable().optional()", + ), + ); }); test('models in document order with explicit names', () { @@ -253,6 +280,7 @@ void main() { r'#/$defs/Model', r'#/$defs/Model/properties/address', ]); + expect(library.source, contains('@ack.Schemable')); expect( library.source, contains(r'// - #/$defs/Model/properties/address'), @@ -262,6 +290,41 @@ void main() { expect(model['additionalProperties'], isTrue); }); + test('widens unevaluatedProperties: false in fallback models', () { + final library = _generate({ + r'$defs': { + 'Model': { + 'type': 'object', + 'properties': { + 'profile': { + 'type': 'object', + 'properties': { + 'bio': {'type': 'string'}, + }, + 'unevaluatedProperties': false, + }, + }, + 'unevaluatedProperties': false, + }, + }, + }, unknownProperties: AckUnknownPropertiesMode.preserve); + + expect(library.widenedPointers, [ + r'#/$defs/Model', + r'#/$defs/Model/properties/profile', + ]); + expect( + library.source, + contains(r'// - #/$defs/Model/properties/profile'), + ); + final model = + (library.effectiveDocument[r'$defs']! as Map)['Model']! as Map; + expect(model['unevaluatedProperties'], isTrue); + final profile = + (model['properties']! as Map)['profile']! as Map; + expect(profile['unevaluatedProperties'], isTrue); + }); + test('leaves the input document unchanged', () { final document = _model({ @@ -327,6 +390,15 @@ void main() { 'pattern': r'^[😀]{2}$', }, 'unrepresentable size bound': {'type': 'string', 'minLength': 1e30}, + 'clamped 2^63 size bound': { + 'type': 'string', + 'minLength': 9223372036854775808.0, + }, + 'unrepresentable integer bound': {'type': 'integer', 'maximum': 1e30}, + 'clamped 2^63 integer bound': { + 'type': 'integer', + 'maximum': 9223372036854775808.0, + }, 'composition': { 'allOf': [ {'type': 'string'}, diff --git a/packages/ack_generator/test/src/ack_exports_test.dart b/packages/ack_generator/test/src/ack_exports_test.dart index e134c95f..51bf3899 100644 --- a/packages/ack_generator/test/src/ack_exports_test.dart +++ b/packages/ack_generator/test/src/ack_exports_test.dart @@ -40,7 +40,7 @@ void main() { readerWriter: readerWriter, outputs: { 'test_pkg|lib/probe.exports': decodedMatches( - 'AckCaseStyle,AckField,AckUnknownPropertyPolicy,Schemable,Validate', + 'AckCaseStyle,AckKey,AckUnknownPropertyPolicy,Schemable,Validate', ), }, ); diff --git a/packages/ack_json_schema_builder/CHANGELOG.md b/packages/ack_json_schema_builder/CHANGELOG.md index 77b04041..f8106e02 100644 --- a/packages/ack_json_schema_builder/CHANGELOG.md +++ b/packages/ack_json_schema_builder/CHANGELOG.md @@ -1,8 +1,8 @@ -## 2.0.0-beta.1 +## 2.0.0-alpha.1 ### Breaking -* Requires `ack: ^2.0.0-beta.1`. +* Requires `ack: ^2.0.0-alpha.1`. * `toAckSchema()` now defaults to `assertFormats: false` (matching `Ack.fromJsonSchema()` and JSON Schema Draft 2020-12 default annotation semantics); pass `assertFormats: true` to enforce format assertions (`#171`). diff --git a/packages/ack_json_schema_builder/README.md b/packages/ack_json_schema_builder/README.md index 90d7d55b..182926cf 100644 --- a/packages/ack_json_schema_builder/README.md +++ b/packages/ack_json_schema_builder/README.md @@ -52,14 +52,14 @@ for the complete support matrix and reference/diagnostic behavior. ```yaml dependencies: - ack: ^2.0.0-beta.1 - ack_json_schema_builder: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 + ack_json_schema_builder: ^2.0.0-alpha.1 json_schema_builder: ^0.1.3 ``` ### Compatibility -Requires `ack: ^2.0.0-beta.1` and +Requires `ack: ^2.0.0-alpha.1` and `json_schema_builder: >=0.1.3 <1.0.0` as peer dependencies. Report [compatibility issues](https://github.com/btwld/ack/issues). diff --git a/packages/ack_json_schema_builder/pubspec.yaml b/packages/ack_json_schema_builder/pubspec.yaml index f2ca3c57..44f9d886 100644 --- a/packages/ack_json_schema_builder/pubspec.yaml +++ b/packages/ack_json_schema_builder/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_json_schema_builder description: JSON Schema Builder converter for the Ack validation library -version: 2.0.0-beta.1 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -14,7 +14,7 @@ environment: dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 json_schema_builder: ^0.1.3 dev_dependencies: diff --git a/packages/ack_mcp_dart/CHANGELOG.md b/packages/ack_mcp_dart/CHANGELOG.md index aef0dd82..a0ea9b6a 100644 --- a/packages/ack_mcp_dart/CHANGELOG.md +++ b/packages/ack_mcp_dart/CHANGELOG.md @@ -1,8 +1,8 @@ -## 2.0.0-beta.1 +## 2.0.0-alpha.1 ### Breaking -* Requires `ack: ^2.0.0-beta.1` and aligns with the coordinated Ack 2.0.0-beta.1 +* Requires `ack: ^2.0.0-alpha.1` and aligns with the coordinated Ack 2.0.0-alpha.1 release. * Remove `registerAckModelTool` from `AckMcpServer`; pass `Model.schema` (or `model.modelSchema`) directly to `registerAckTool(name, input: Model.schema, ...)`. diff --git a/packages/ack_mcp_dart/README.md b/packages/ack_mcp_dart/README.md index 442ca1fa..3974bb16 100644 --- a/packages/ack_mcp_dart/README.md +++ b/packages/ack_mcp_dart/README.md @@ -15,8 +15,8 @@ Transport, negotiation, cancellation, and lifecycle remain in `mcp_dart`. ```yaml dependencies: - ack: ^2.0.0-beta.1 - ack_mcp_dart: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 + ack_mcp_dart: ^2.0.0-alpha.1 mcp_dart: ^2.4.2 ``` @@ -141,7 +141,7 @@ New emitted keywords require revisiting this dialect assumption. | Component | Supported / exercised | | --- | --- | | Dart | `>=3.9.0 <4.0.0` | -| ACK | `^2.0.0-beta.1` | +| ACK | `^2.0.0-alpha.1` | | mcp_dart | `^2.4.2` (tests resolved against 2.4.2) | | MCP stable discovery | `2026-07-28` | | MCP initialization | `2025-11-25`, `2025-06-18` | diff --git a/packages/ack_mcp_dart/pubspec.yaml b/packages/ack_mcp_dart/pubspec.yaml index 772b4fc2..42e66d97 100644 --- a/packages/ack_mcp_dart/pubspec.yaml +++ b/packages/ack_mcp_dart/pubspec.yaml @@ -1,6 +1,6 @@ name: ack_mcp_dart description: MCP tool schemas and typed argument validation for Ack and mcp_dart -version: 2.0.0-beta.1 +version: 2.0.0-alpha.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues resolution: workspace @@ -14,7 +14,7 @@ environment: dependencies: - ack: ^2.0.0-beta.1 + ack: ^2.0.0-alpha.1 mcp_dart: ^2.4.2 dev_dependencies: diff --git a/scripts/src/workspace_packages.dart b/scripts/src/workspace_packages.dart index 88d7a32b..d51bdbec 100644 --- a/scripts/src/workspace_packages.dart +++ b/scripts/src/workspace_packages.dart @@ -9,6 +9,4 @@ const publishableAckPackages = [ /// First releases for packages added after the original workspace baseline. /// Earlier versions have no published API to compare against. -const ackPackageFirstReleases = { - 'ack_mcp_dart': '2.0.0-beta.1', -}; +const ackPackageFirstReleases = {'ack_mcp_dart': '1.3.0'}; diff --git a/test/scripts/api_check_test.dart b/test/scripts/api_check_test.dart index 557303cc..f738fa63 100644 --- a/test/scripts/api_check_test.dart +++ b/test/scripts/api_check_test.dart @@ -44,7 +44,7 @@ exit 23 ); }, skip: Platform.isWindows ? 'Uses POSIX test executables.' : false); - for (final baseline in ['1.1.0', '1.2.0', '2.0.0-beta.1']) { + for (final baseline in ['1.1.0', '1.2.0', '1.3.0']) { test( 'new adapter API baseline $baseline respects its first release', () async { @@ -61,11 +61,11 @@ exit 23 'PATH': '${fakeBin.path}:/usr/bin:/bin', }, ); - if (baseline != '2.0.0-beta.1') { + if (baseline != '1.3.0') { expect(result.exitCode, 0); expect( result.stdout, - contains('ack_mcp_dart first releases in 2.0.0-beta.1'), + contains('ack_mcp_dart first releases in 1.3.0'), ); expect(result.stderr, isEmpty); } else { From 8290120666541bca46abd721ff45d414cd681cd4 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 14 Oct 2026 14:26:11 -0400 Subject: [PATCH 28/29] feat(ack_generator): allow non-nullable Object override in class @Schemable(schemas: ...) --- .../src/analyzer/custom_schema_resolver.dart | 13 +++++++++--- .../lib/src/inference/schema_inference.dart | 3 ++- .../integration/type_owned_schema_test.dart | 20 +++++++++++++++++-- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart index 0b604e1c..4f7a270e 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -1,5 +1,6 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:source_gen/source_gen.dart'; @@ -199,7 +200,10 @@ final class CustomSchemaResolver { if (boundary == null || runtime == null || runtime is DynamicType || - (runtime is InterfaceType && _base.isCoreType(runtime, 'Object'))) { + (runtime is InterfaceType && + _base.isCoreType(runtime, 'Object') && + (owner is LibraryElement || + runtime.nullabilitySuffix != NullabilitySuffix.none))) { throw InvalidGenerationSource( '$ownerLabel @Schemable(schemas: ...) function ${function.name} must ' 'return a typed AckSchema for a specific runtime ' @@ -383,7 +387,9 @@ final class CustomSchemaResolver { bool isOverridableBuiltInScalar(InterfaceType type) => _base.isCoreType(type, 'DateTime') || _base.isCoreType(type, 'Uri') || - _base.isCoreType(type, 'Duration'); + _base.isCoreType(type, 'Duration') || + (_base.isCoreType(type, 'Object') && + type.nullabilitySuffix == NullabilitySuffix.none); bool isNonOverridableCoreType(InterfaceType type) => _base.isCoreType(type, 'String') || @@ -391,7 +397,8 @@ final class CustomSchemaResolver { _base.isCoreType(type, 'double') || _base.isCoreType(type, 'num') || _base.isCoreType(type, 'bool') || - _base.isCoreType(type, 'Object') || + (_base.isCoreType(type, 'Object') && + type.nullabilitySuffix != NullabilitySuffix.none) || type.isDartCoreList || type.isDartCoreSet || type.isDartCoreMap || diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index 1c5e1e30..d6876f82 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -42,7 +42,8 @@ final class AckSchemaInference { if (type is! InterfaceType) return unsupported(type); if (_core(type, 'DateTime') || _core(type, 'Uri') || - _core(type, 'Duration')) { + _core(type, 'Duration') || + (_core(type, 'Object') && !_nullable(type))) { final override = await resolveNamed(type); if (override != null) return override; } diff --git a/packages/ack_generator/test/integration/type_owned_schema_test.dart b/packages/ack_generator/test/integration/type_owned_schema_test.dart index 523e6e93..eba7e831 100644 --- a/packages/ack_generator/test/integration/type_owned_schema_test.dart +++ b/packages/ack_generator/test/integration/type_owned_schema_test.dart @@ -890,26 +890,42 @@ AckSchema altColorSchema() => Ack.string().codec( encode: (color) => color.hex, ); -@Schemable(schemas: [hexColorSchema, importedMoneySchema, epochDateTimeSchema]) +AckSchema cellValueSchema() => + Ack.anyOf([Ack.string().maxLength(2048), Ack.number(), Ack.boolean()]); + +@Schemable( + schemas: [ + hexColorSchema, + importedMoneySchema, + epochDateTimeSchema, + cellValueSchema, + ], +) final class Order with _$OrderAck { const Order({ required this.color, required this.total, required this.createdAt, required this.timestamps, + required this.cellValue, + this.anyJson, }); final Color color; final Money total; final DateTime createdAt; final List timestamps; + final Object cellValue; + final Object? anyJson; } ''', allOf( _containsCode("'color': hexColorSchema(),"), _containsCode("'total': importedMoneySchema(),"), _containsCode("'createdAt': epochDateTimeSchema(),"), - _containsCode("'timestamps': Ack.list(epochDateTimeSchema())"), + _containsCode("'timestamps': Ack.list(epochDateTimeSchema()),"), + _containsCode("'cellValue': cellValueSchema(),"), + _containsCode("'anyJson': Ack.any().optional().nullable()"), ), types: ''' import 'package:ack/ack.dart'; From 9678ac3a87e186c12e384c2bfdb2e9d2d9ebaa2a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 14 Oct 2026 14:40:58 -0400 Subject: [PATCH 29/29] feat(ack_generator): support scalar @Validate.* item constraints on List and Set fields --- .../lib/src/inference/schema_inference.dart | 99 +++++++++++++------ .../integration/inference_library_test.dart | 31 ++++++ 2 files changed, 102 insertions(+), 28 deletions(-) diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index d6876f82..fa254bda 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -92,16 +92,30 @@ final class AckSchemaInference { /// Applies constraint annotations declared on a field or parameter. String applyConstraints(String schema, Element declaration, DartType type) { var output = schema; - final isNumeric = - type is InterfaceType && - (_core(type, 'int') || _core(type, 'double') || _core(type, 'num')); - final isString = type is InterfaceType && _core(type, 'String'); final isCollection = type is InterfaceType && (type.isDartCoreList || type.isDartCoreSet); final isSet = type is InterfaceType && type.isDartCoreSet; + final scalarType = isCollection && type.typeArguments.length == 1 + ? type.typeArguments.single + : type; + final isNumeric = + scalarType is InterfaceType && + (_core(scalarType, 'int') || + _core(scalarType, 'double') || + _core(scalarType, 'num')); + final isString = scalarType is InterfaceType && _core(scalarType, 'String'); + final itemConstraints = []; final setConstraints = []; var hasUniqueConstraint = false; + void addScalarConstraint(String constraint) { + if (isCollection) { + itemConstraints.add(constraint); + } else { + output = '$output$constraint'; + } + } + void addCollectionConstraint(String constraint, {bool isUnique = false}) { if (isSet) { if (isUnique) hasUniqueConstraint = true; @@ -128,8 +142,9 @@ final class AckSchemaInference { isNumeric, '@Validate.minLength', ); - output = - '$output.min(${_number(declaration, '@Validate.min', value)})'; + addScalarConstraint( + '.min(${_number(declaration, '@Validate.min', value)})', + ); case 'max': _require( declaration, @@ -138,8 +153,9 @@ final class AckSchemaInference { isNumeric, '@Validate.maxLength', ); - output = - '$output.max(${_number(declaration, '@Validate.max', value)})'; + addScalarConstraint( + '.max(${_number(declaration, '@Validate.max', value)})', + ); case 'multipleOf': _require( declaration, @@ -148,8 +164,9 @@ final class AckSchemaInference { isNumeric, 'numeric field', ); - output = - '$output.multipleOf(${_number(declaration, '@Validate.multipleOf', value)})'; + addScalarConstraint( + '.multipleOf(${_number(declaration, '@Validate.multipleOf', value)})', + ); case 'positive': _require( declaration, @@ -158,7 +175,7 @@ final class AckSchemaInference { isNumeric, 'numeric field', ); - output = '$output.positive()'; + addScalarConstraint('.positive()'); case 'negative': _require( declaration, @@ -167,7 +184,7 @@ final class AckSchemaInference { isNumeric, 'numeric field', ); - output = '$output.negative()'; + addScalarConstraint('.negative()'); case 'minLength': _require( declaration, @@ -176,8 +193,9 @@ final class AckSchemaInference { isString, '@Validate.min', ); - output = - '$output.minLength(${value.getField('value')!.toIntValue()})'; + addScalarConstraint( + '.minLength(${value.getField('value')!.toIntValue()})', + ); case 'maxLength': _require( declaration, @@ -186,8 +204,9 @@ final class AckSchemaInference { isString, '@Validate.max', ); - output = - '$output.maxLength(${value.getField('value')!.toIntValue()})'; + addScalarConstraint( + '.maxLength(${value.getField('value')!.toIntValue()})', + ); case 'matches': _require( declaration, @@ -196,8 +215,9 @@ final class AckSchemaInference { isString, 'String field', ); - output = - '$output.matches(${dartStringLiteral(value.getField('value')!.toStringValue()!)})'; + addScalarConstraint( + '.matches(${dartStringLiteral(value.getField('value')!.toStringValue()!)})', + ); case 'notEmpty': _require( declaration, @@ -219,7 +239,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.email()'; + addScalarConstraint('.email()'); case 'url': _require( declaration, @@ -228,7 +248,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.url()'; + addScalarConstraint('.url()'); case 'uri': _require( declaration, @@ -237,7 +257,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.uri()'; + addScalarConstraint('.uri()'); case 'uuid': _require( declaration, @@ -246,7 +266,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.uuid()'; + addScalarConstraint('.uuid()'); case 'date': _require( declaration, @@ -255,7 +275,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.date()'; + addScalarConstraint('.date()'); case 'dateTime': _require( declaration, @@ -264,7 +284,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.datetime()'; + addScalarConstraint('.datetime()'); case 'ip': _require( declaration, @@ -282,9 +302,9 @@ final class AckSchemaInference { element: declaration, ); } - output = '$output.ip(version: $version)'; + addScalarConstraint('.ip(version: $version)'); } else { - output = '$output.ip()'; + addScalarConstraint('.ip()'); } case 'ipv4': _require( @@ -294,7 +314,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.ipv4()'; + addScalarConstraint('.ipv4()'); case 'ipv6': _require( declaration, @@ -303,7 +323,7 @@ final class AckSchemaInference { isString, 'String field', ); - output = '$output.ipv6()'; + addScalarConstraint('.ipv6()'); case 'minItems': _require( declaration, @@ -338,6 +358,9 @@ final class AckSchemaInference { } } } + if (itemConstraints.isNotEmpty) { + output = _appendListItemConstraints(output, itemConstraints.join()); + } if (setConstraints.isNotEmpty) { if (isSet && !hasUniqueConstraint) { // A Set cannot retain duplicate values after decoding. Validate the @@ -349,6 +372,26 @@ final class AckSchemaInference { return output; } + String _appendListItemConstraints(String schema, String modifiers) { + const prefix = 'final schema = '; + final unit = parseString( + content: '$prefix$schema;', + throwIfDiagnostics: false, + ).unit; + final declaration = unit.declarations.single as TopLevelVariableDeclaration; + Expression? current = declaration.variables.variables.single.initializer; + while (current is MethodInvocation) { + if (current.methodName.name == 'list' && + current.argumentList.arguments.length == 1) { + final itemArg = current.argumentList.arguments.single; + final end = itemArg.end - prefix.length; + return '${schema.substring(0, end)}$modifiers${schema.substring(end)}'; + } + current = current.target; + } + return '$schema$modifiers'; + } + /// Inserts [modifiers] before the codec that ends [schema], or appends them /// when [schema] is the list schema that the set codec will wrap. String _beforeOuterCodec(String schema, String modifiers) { diff --git a/packages/ack_generator/test/integration/inference_library_test.dart b/packages/ack_generator/test/integration/inference_library_test.dart index 8627763d..67af42da 100644 --- a/packages/ack_generator/test/integration/inference_library_test.dart +++ b/packages/ack_generator/test/integration/inference_library_test.dart @@ -148,4 +148,35 @@ void widget({@Validate.maxItems(3) required Set> cells}) {} }, ); }); + + test( + 'a second generator applies scalar item constraints and collection constraints on List', + () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + const _InferenceProbe(parameterName: 'weekdays'), + { + 'test_pkg|lib/widget.dart': ''' +import 'package:ack/annotations.dart'; + +void widget({ + @Validate.min(1) + @Validate.max(7) + @Validate.minItems(1) + @Validate.uniqueItems() + required List weekdays, +}) {} +''', + }, + generateFor: const {'test_pkg|lib/widget.dart'}, + readerWriter: readerWriter, + outputs: { + 'test_pkg|lib/widget.probe': decodedMatches( + 'Ack.list(Ack.integer().min(1).max(7)).minItems(1).unique()', + ), + }, + ); + }, + ); }